feat(setup): ask what the wallet is for, and where it keeps its settings #199
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| # A superseded run on the same PR tells you nothing worth waiting for. | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| jobs: | |
| verify: | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # package.json declares engines.node >=24, so prove it on the floor | |
| # (24, Active LTS) as well as on Current, rather than only on whatever | |
| # the dev happens to have. Keep the spread: a single version hides | |
| # version-dependent bugs. The Node 20 runner previously caught a test | |
| # that leaked an event-loop handle, which the newer runner exited past. | |
| node: ["24", "26"] | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - uses: actions/setup-node@v5 | |
| with: | |
| node-version: ${{ matrix.node }} | |
| cache: npm | |
| - run: npm ci | |
| # Deliberately runs from a cold checkout with no build state. Every | |
| # workspace typechecks against its dependencies' emitted `dist`, so a | |
| # missing build step surfaces here as "cannot find module" in source that | |
| # is perfectly correct. `tsc -b` walks the project references and builds | |
| # them in order; this job is what keeps that wiring honest. | |
| - name: Lint (typecheck, builds project references) | |
| run: npm run lint | |
| - name: Build | |
| run: npm run build | |
| - name: Test | |
| run: npm test | |
| # The MV3 service worker may not call dynamic `import()` (HTML spec; see | |
| # w3c/ServiceWorker#1356), so background.js must stay a single | |
| # self-contained bundle. Rollup silently emits extra chunks if the | |
| # `codeSplitting: false` output option is lost in a future upgrade, and | |
| # the failure would land at runtime in users' browsers rather than here. | |
| - name: Assert MV3 worker is a single self-contained bundle | |
| run: | | |
| bundle=packages/extension/dist/background.js | |
| test -f "$bundle" || { echo "::error::$bundle was not emitted"; exit 1; } | |
| if grep -qE '\bimport\s*\(' "$bundle"; then | |
| echo "::error::$bundle contains a dynamic import(); MV3 service workers cannot load it" | |
| exit 1 | |
| fi | |
| echo "OK: $bundle is a single bundle with no dynamic import()" | |
| # `@openvtc/pnm-core/admin` is operator surface — granting authority at an | |
| # agent, revoking it, destroying contexts. The task URIs are the tell: | |
| # they only appear in a bundle that pulled the module in. | |
| # | |
| # The management console (`manager.html`) administers the agent, so it | |
| # imports the module deliberately. Every *wallet* surface — the service | |
| # worker, the content and page-world scripts, the popup, the confirm | |
| # window, the offscreen document, the options page — still must not, and | |
| # the way it would arrive is someone importing from the package root | |
| # instead of the subpath, or Rollup hoisting a shared chunk. | |
| # | |
| # Hence: banned everywhere in dist/ **except** `manager.js`. Phrased as an | |
| # exclusion rather than a list of permitted files so it keeps holding as | |
| # entries are added. `vite.config.manager.ts` builds the console alone | |
| # with `codeSplitting: false`, which is what makes "exactly one file may | |
| # contain this" a structural property rather than a convention. | |
| - name: Assert agent-administration surface is confined to the console | |
| run: | | |
| for task in 'acl/grant/0.1' 'acl/revoke/0.1' 'acl/update/0.1' 'contexts/delete/1.0' 'keys/create/0.1' 'keys/sign/0.1' 'policy/upsert/0.2' 'device/wipe/0.1' 'config/patch/0.1' 'vta/did-templates/create/2.0' 'consent/approver-set/1.0' 'keys/import/0.1' 'did-management/did/delete/0.1' 'vta/services/enable/1.0' 'vta/services/disable/1.0' 'vta/credentials/issue/0.1' 'vta/credentials/revoke/0.1'; do | |
| leaked=$(grep -rlF "$task" packages/extension/dist/ | grep -v '^packages/extension/dist/manager\.js$' || true) | |
| if [ -n "$leaked" ]; then | |
| echo "::error::$leaked contains $task — @openvtc/pnm-core/admin must not be reachable from any wallet surface (check for a root-barrel import, or a shared chunk)" | |
| exit 1 | |
| fi | |
| done | |
| echo "OK: admin task URIs appear only in manager.js" | |
| # A second, stricter guard — and the difference from the one above is the | |
| # point. | |
| # | |
| # That guard is about *authority*: `admin/*` grants and revokes it, and | |
| # the console is deliberately the one surface that holds it, so it names | |
| # `manager.js` as an exception. | |
| # | |
| # These tasks are about *material*. `vta/seeds/export-mnemonic/1.0` | |
| # returns a BIP-39 mnemonic — the seed every derived key in the agent | |
| # comes from — and `list`/`rotate` are the rest of that family's surface. | |
| # There is no browser context that should be able to ask for them, so this | |
| # guard has **no exception**: not the console, not the wallet, nowhere in | |
| # `dist/`. | |
| # | |
| # It exists because the alternative is an omission, and an omission is | |
| # indistinguishable from not having got to it yet. Someone reasonable | |
| # could add a seeds pane next year and no one would know it was refused on | |
| # purpose. This is what says so. | |
| # | |
| # `vault/release/0.1` is deliberately NOT here: it releases a secret to a | |
| # site the human just approved, which is the wallet's whole job. | |
| - name: Assert no key-material surface ships at all | |
| run: | | |
| for task in 'vta/seeds/list/1.0' 'vta/seeds/rotate/1.0' 'vta/seeds/export-mnemonic/1.0'; do | |
| found=$(grep -rlF "$task" packages/extension/dist/ || true) | |
| if [ -n "$found" ]; then | |
| echo "::error::$found contains $task — this family returns key material and must not ship in any extension bundle, the console included. See CLAUDE.md." | |
| exit 1 | |
| fi | |
| done | |
| echo "OK: no key-material task URIs anywhere in dist/" | |
| # A third guard, and the narrowest reason of the three. | |
| # | |
| # The `persona/*` family has two halves. The attribute pool and the | |
| # profiles over it are AGENT-scoped: they sit above every trust context, | |
| # and the agent gates them on an unscoped holder credential — Admin with | |
| # unrestricted scope, `require_super_admin` and deliberately not | |
| # `role == Admin`. `@openvtc/pnm-core/persona` implements only the | |
| # context-scoped half, because a *wallet's* holder identity is scoped to a | |
| # context and every one of these ten would come back forbidden there. | |
| # | |
| # So this is not about authority leaking (guard one) or key material | |
| # shipping (guard two). It is about the ONE-WAY BOUNDARY the family is | |
| # built around: a context never reads the holder's pool, it is handed a | |
| # materialised copy. A wallet bundle that named one of these URIs would | |
| # be a context-scoped surface reaching upwards, which is the exact shape | |
| # the design exists to prevent. | |
| # | |
| # **This guard used to have no exception, and the comment here said why:** | |
| # a persona-management pane belongs beside the console's other | |
| # holder-scoped panes, so the refusal was recorded as "not yet" rather | |
| # than "never", and whoever built that pane would have to change this line | |
| # — the change being the review. That pane now exists | |
| # (`src/manager/panes/persona.tsx`, backed by | |
| # `@openvtc/pnm-core/admin`'s `persona.ts`), so the guard is **narrowed, | |
| # not deleted**, in the same shape as the admin one above: banned | |
| # everywhere in `dist/` *except* `manager.js`. | |
| # | |
| # The console may hold this and a wallet may not, because the two are not | |
| # the same kind of caller. The console administers the agent — its | |
| # operator can hold the unscoped holder credential these tasks require — | |
| # while every wallet surface acts as a party *inside* a context. That is | |
| # the same distinction the `admin/*` guard rests on, and the reason both | |
| # can name exactly one permitted file rather than being relaxed. | |
| # | |
| # `codeSplitting: false` on the console build is what makes "exactly one | |
| # file" structural; the assertion below fails if it is ever lost. | |
| - name: Assert the holder-scoped half of persona is confined to the console | |
| run: | | |
| for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do | |
| leaked=$(grep -rlF "$task" packages/extension/dist/ | grep -v '^packages/extension/dist/manager\.js$' || true) | |
| if [ -n "$leaked" ]; then | |
| echo "::error::$leaked contains $task — this half of persona/* reads or writes the holder's attribute pool, which sits above every trust context. A wallet surface naming it is the boundary being crossed the wrong way; only the console may. Check for an import of @openvtc/pnm-core/admin from a wallet entry, or a shared chunk. See packages/core/src/admin/persona.ts." | |
| exit 1 | |
| fi | |
| done | |
| echo "OK: holder-scoped persona task URIs appear only in manager.js" | |
| # …and the other direction, which the guard above cannot see. | |
| # | |
| # Narrowing it to "except manager.js" means a leak now has two shapes, not | |
| # one. The first is a wallet surface gaining these URIs, which is what | |
| # that guard catches. The second is the console *losing* them — an import | |
| # dropped in a refactor, a pane deleted, a tree-shake that takes the | |
| # module out because nothing references it any more. The result is a | |
| # persona pane whose buttons do nothing, and it is silent: a smaller | |
| # bundle and a green build. | |
| # | |
| # This is the same non-vacuity problem the seeds guard documents from the | |
| # other side — there, an unreferenced export never reaches `dist/` and the | |
| # guard correctly stays quiet. Here that behaviour is the failure, so the | |
| # presence is asserted rather than assumed. | |
| - name: Assert the console still carries the persona surface it is the exception for | |
| run: | | |
| bundle=packages/extension/dist/manager.js | |
| for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do | |
| if ! grep -qF "$task" "$bundle"; then | |
| echo "::error::$bundle no longer contains $task. The guard above permits it here precisely because the console's persona pane calls it; a build without it is a pane that cannot do what it offers. If the pane genuinely dropped this task, remove it from BOTH lists in the same change." | |
| exit 1 | |
| fi | |
| done | |
| echo "OK: the console carries all ten holder-scoped persona tasks" | |
| # The console's isolation rests on it being one self-contained file: the | |
| # guard above names exactly one exception, so a second chunk would be a | |
| # file nothing checks. Losing `codeSplitting: false` in a future upgrade | |
| # is silent otherwise. | |
| - name: Assert the console is a single self-contained bundle | |
| run: | | |
| bundle=packages/extension/dist/manager.js | |
| test -f "$bundle" || { echo "::error::$bundle was not emitted — did the manager build run?"; exit 1; } | |
| extra=$(ls packages/extension/dist/manager-split-*.js 2>/dev/null || true) | |
| if [ -n "$extra" ]; then | |
| echo "::error::the console emitted extra chunks ($extra); codeSplitting: false was lost and the admin guard now has unchecked files" | |
| exit 1 | |
| fi | |
| echo "OK: manager.js is a single bundle" | |
| # Build the Chrome Web Store upload artefact from the dist/ that the | |
| # Build step just produced (scripts/package.mjs re-stages it; it does | |
| # not rebuild). Doing this every run means a submission is never the | |
| # first time the packaging path is exercised. | |
| - name: Package Web Store zip | |
| working-directory: packages/extension | |
| run: node scripts/package.mjs | |
| # Two ways a package passes CI and then fails at upload, both silent: | |
| # | |
| # - a `key` field, which the Store's "+ New item" upload rejects | |
| # outright (it issues its own key). `dist/` carries one on purpose so | |
| # local unpacked installs hold a stable ID; only the zip must not. | |
| # - a version that drifted from package.json, which the Store rejects | |
| # as non-increasing against the last accepted upload. | |
| - name: Assert Web Store zip is uploadable | |
| working-directory: packages/extension | |
| run: | | |
| version=$(node -p "require('./package.json').version") | |
| zip="release/vta-wallet-${version}.zip" | |
| test -f "$zip" || { echo "::error::$zip was not produced"; exit 1; } | |
| manifest=$(unzip -p "$zip" manifest.json) | |
| if [ "$(echo "$manifest" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).key ?? ''")" != "" ]; then | |
| echo "::error::$zip manifest contains a 'key' field; the Web Store rejects it on a new item" | |
| exit 1 | |
| fi | |
| got=$(echo "$manifest" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version") | |
| if [ "$got" != "$version" ]; then | |
| echo "::error::packaged manifest version $got != package.json version $version" | |
| exit 1 | |
| fi | |
| if [ "$(echo "$manifest" | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).content_scripts ? 'yes' : ''")" = "yes" ]; then | |
| echo "::error::manifest declares content_scripts; the page provider is registered dynamically for granted origins only (src/content-registration.ts). A static match would re-grant blanket host access and double-inject." | |
| exit 1 | |
| fi | |
| # The wallet writes nothing into the browser on a site's behalf, and | |
| # that is the claim docs/web-store-review.md makes to a reviewer. It | |
| # holds for exactly as long as nobody re-adds the permission, so | |
| # assert it rather than trust it: `cookies` is the permission a | |
| # reviewer looks hardest at, and its cost is an extended review. | |
| if [ "$(echo "$manifest" | node -p "(JSON.parse(require('fs').readFileSync(0,'utf8')).permissions ?? []).includes('cookies') ? 'yes' : ''")" = "yes" ]; then | |
| echo "::error::manifest requests the 'cookies' permission; the legacy password-site login that needed it was removed (see README 'The wallet writes nothing into your browser')." | |
| exit 1 | |
| fi | |
| # Checked against dist/, not src/: the built bundle has no comments, | |
| # so prose *about* the removed path (README, code comments) doesn't | |
| # trip it, while a real call site would. | |
| if grep -rl "chrome\.cookies" dist/ >/dev/null 2>&1; then | |
| echo "::error::the built bundle calls chrome.cookies; the wallet must not write to the cookie jar" | |
| grep -rl "chrome\.cookies" dist/ | |
| exit 1 | |
| fi | |
| test -f dist/content.js || { echo "::error::dist/content.js missing; chrome.scripting registers it by path"; exit 1; } | |
| echo "OK: $zip is version $version, no 'key', no static content_scripts, no cookie access" | |
| # One upload only — actions/upload-artifact errors on a duplicate name, | |
| # and the zip is identical across the Node matrix. | |
| - name: Upload Web Store zip | |
| if: matrix.node == '26' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: vta-wallet-extension | |
| path: packages/extension/release/*.zip | |
| if-no-files-found: error |