feat: VTA management console, proof-of-presence gating, and a registry-derived core #137
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/" | |
| # 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 |