diff --git a/.github/workflows/example-preview.yml b/.github/workflows/example-preview.yml index 0615740c..dc89c16b 100644 --- a/.github/workflows/example-preview.yml +++ b/.github/workflows/example-preview.yml @@ -11,6 +11,7 @@ on: types: [opened, synchronize, reopened] paths: - "examples/status-message-dapp/**" + - "examples/perch-authz-console/**" - ".github/workflows/example-preview.yml" # The example exercises the wallet's connect / delegation-handover / # session-signing flows, so a wallet or SDK PR needs the example preview @@ -60,6 +61,7 @@ jobs: npm run build -w @nidohq/passkey-sdk npm run build -w @nidohq/stellar-wallets-kit-module npm run build -w status_message + npm run build -w @nidohq/testkit - name: Build the example (apex base) run: npx vite build @@ -69,6 +71,15 @@ jobs: run: echo "/* /index.html 200" > dist/_redirects working-directory: examples/status-message-dapp + # Extra examples nested under their own subpath in the same deploy. + # Relative base (--base=./) so each loads correctly from its subpath. + - name: Build perch-authz-console (nested) + run: npm run build:preview + working-directory: examples/perch-authz-console + + - name: Nest extra examples under the deploy + run: cp -r examples/perch-authz-console/dist examples/status-message-dapp/dist/perch-authz-console + # Deploy to a branch alias of the mysoroban project. The custom-domain # wildcard (*.mysoroban.xyz) only serves the project's PRODUCTION branch, # so for the example we use wrangler's branch-alias *.pages.dev URL — the @@ -101,7 +112,10 @@ jobs: const previewUrl = process.env.PREVIEW_URL || `https://example-pr-${prNum}.mysoroban.pages.dev`; const liveUrl = `https://${context.repo.owner}.github.io/${context.repo.repo}/`; - const body = `**Example dApp preview deployed!**\n\n${previewUrl}\n\nThe \`status-message\` example (testnet), wallet = THIS PR's preview (https://${prNum}.nido.fyi). The live home is ${liveUrl} once merged.`; + const body = `**Example dApp preview deployed!**\n\n` + + `- \`status-message\` — ${previewUrl}\n` + + `- \`perch-authz-console\` — ${previewUrl.replace(/\/?$/, '/')}perch-authz-console/\n\n` + + `status-message runs on testnet, wallet = THIS PR's preview (https://${prNum}.nido.fyi); perch-authz-console runs fully local. The live home is ${liveUrl} once merged.`; const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1d0c4fce..6c8b97c2 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,14 +1,21 @@ -name: Deploy Example to GitHub Pages +name: Deploy examples to GitHub Pages -# Deploys examples/status-message-dapp/ as a live testnet demo at the GitHub -# Pages project URL for this repository. Independent of the Cloudflare deploy of -# the main Nido frontend (see deploy.yml / preview.yml). +# Deploys the repo's example dApps as a live testnet demo at the GitHub Pages +# project URL. status-message-dapp is the home (project root); each additional +# example is nested under its own subpath. To add an example: build it into a +# subfolder of the uploaded site (see the "extra examples" step). Independent of +# the Cloudflare deploy of the main Nido frontend (deploy.yml / preview.yml). +# +# // → status-message-dapp +# //perch-authz-console/ → perch × Nido authorization console on: push: branches: [main] paths: - "examples/status-message-dapp/**" + - "examples/perch-authz-console/**" + - "packages/**" - ".github/workflows/pages.yml" workflow_dispatch: @@ -17,14 +24,11 @@ permissions: pages: write id-token: write -# Allow one concurrent deployment; don't cancel an in-progress publish. concurrency: group: pages cancel-in-progress: false -# Build-time config for the live demo. All public (no secrets). The committed -# client is bound to the testnet contract id; these point the frontend at -# testnet and at the production Nido passkey wallet. +# Build-time config for the status-message live demo. All public (no secrets). env: PAGES_BASE_PATH: "/${{ github.event.repository.name }}/" PUBLIC_STELLAR_NETWORK: TESTNET @@ -47,24 +51,36 @@ jobs: - name: Install dependencies run: npm ci - # The example imports these workspace packages, which publish built dist/. - # The status_message client is committed as source and built here too. + # Workspace packages the examples import (each publishes a built dist/). - name: Build workspace dependencies run: | npm run build -w @nidohq/passkey-sdk npm run build -w @nidohq/stellar-wallets-kit-module npm run build -w status_message + npm run build -w @nidohq/testkit - - name: Build the example (GitHub Pages base) + # --- home: status-message-dapp at the project root --- + - name: Build status-message-dapp (GitHub Pages base) run: npm run build:pages working-directory: examples/status-message-dapp - # GitHub Pages serves 404.html for unmatched paths; making it the SPA - # shell lets client-side routes under the project path survive a direct reload. + # GitHub Pages serves 404.html for unmatched paths; making it the SPA shell + # lets status-message's client-side routes survive a direct reload. - name: SPA fallback run: cp dist/index.html dist/404.html working-directory: examples/status-message-dapp + # --- extra examples: each nested under its own subpath --- + # Relative base (--base=./) so the bundle loads correctly from the subpath. + - name: Build perch-authz-console (nested) + run: npm run build:preview + working-directory: examples/perch-authz-console + + - name: Nest extra examples under the site + run: | + cp -r examples/perch-authz-console/dist \ + examples/status-message-dapp/dist/perch-authz-console + - uses: actions/configure-pages@v5 - uses: actions/upload-pages-artifact@v3 diff --git a/examples/perch-authz-console/.gitignore b/examples/perch-authz-console/.gitignore new file mode 100644 index 00000000..ea2a1c6f --- /dev/null +++ b/examples/perch-authz-console/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +artifacts/ +test-results/ +playwright-report/ +.playwright/ +*.local diff --git a/examples/perch-authz-console/README.md b/examples/perch-authz-console/README.md new file mode 100644 index 00000000..0d3b57e2 --- /dev/null +++ b/examples/perch-authz-console/README.md @@ -0,0 +1,62 @@ +# perch × Nido — Authorization Console + +A dApp that **logs in a local-key Nido smart account** (no passkey), visualizes +its perch authorization policy, simulates `__check_auth` locally, and lets you +build more complex policies — including a **post-quantum ML-DSA** signer. + +Everything is real and local: the account C-address, the signatures, the perch +`doc_hash`, and the allow/deny verdicts all come from +[`@nidohq/testkit`](../../packages/testkit) (nidohq/nido#188). No network, no +contracts deployed. + +## Run it + +```sh +npm install # from the repo root (workspaces) +npm run dev -w perch-authz-console +``` + +## What it shows + +- **Wallet login including Nido** — a `NidoLocalModule` implementing the + `@creit.tech/stellar-wallets-kit` `ModuleInterface`, so Nido is a wallet any + kit dApp can connect — here with a **local key** instead of a passkey. +- **Every verifier** — one signer each for **secp256r1** (the real + `webauthn-verifier`, driven by a local P-256 key), **ed25519**, and + **ML-DSA-65** (post-quantum). The last two, and perch on-chain, are simulated + ahead of their contracts (ML-DSA groundwork: nido#143). +- **Reachable calls** — what each key can actually do, derived from the policy. +- **Simulate `__check_auth`** — pick a call + signers → Kleene verdict + trace. +- **Build a policy** — add rules (scope, functions, arg predicates, spend cap); + the `doc_hash` updates live. +- **Attenuate** — narrow a rule with the fail-closed subset check + (`reachable(child) ⊆ reachable(parent)`); widening is refused. + +## Verify (browser snapshots) + +```sh +npx playwright install chromium +npm run test:e2e -w perch-authz-console # drives the full flow, writes artifacts/*.png +``` + +## Deploy + +- `npm run build` — local/apex build (base `/`). +- `npm run build:preview` — relative base (`./`), used when nested under a subpath. + +This example is hosted **alongside** `status-message-dapp` by the shared example +workflows — no per-example workflow: + +- `.github/workflows/pages.yml` — the GitHub Pages home; this example lives at + `//perch-authz-console/` (status-message stays at the root). +- `.github/workflows/example-preview.yml` — the per-PR Cloudflare preview, which + deploys both examples and comments both URLs. + +Add another example by nesting its build in those two workflows the same way. + +## Roadmap + +The simulator is a faithful TS model. Next, behind the same call: run the real +`soroban-env` in the browser (wasmi) backed by +[rs-soroban-sdk#1657](https://github.com/stellar/rs-soroban-sdk/pull/1657)'s +local-storage cache — lazy testnet pulls, otherwise fully offline. diff --git a/examples/perch-authz-console/index.html b/examples/perch-authz-console/index.html new file mode 100644 index 00000000..9b977dd2 --- /dev/null +++ b/examples/perch-authz-console/index.html @@ -0,0 +1,14 @@ + + + + + + perch × Nido — Authorization Console + + + + +
+ + + diff --git a/examples/perch-authz-console/package.json b/examples/perch-authz-console/package.json new file mode 100644 index 00000000..e23a37c9 --- /dev/null +++ b/examples/perch-authz-console/package.json @@ -0,0 +1,26 @@ +{ + "name": "perch-authz-console", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "build:pages": "tsc && vite build --base=/${REPO_NAME:-nido}/", + "build:preview": "tsc && vite build --base=./", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "@creit.tech/stellar-wallets-kit": "^2.2.0", + "@nidohq/testkit": "*", + "@noble/hashes": "^2.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vite-plugin-node-polyfills": "^0.25.0" + } +} diff --git a/examples/perch-authz-console/playwright.config.ts b/examples/perch-authz-console/playwright.config.ts new file mode 100644 index 00000000..98ce7d48 --- /dev/null +++ b/examples/perch-authz-console/playwright.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + reporter: [['list']], + use: { + baseURL: 'http://localhost:5177', + viewport: { width: 1200, height: 1400 }, + }, + webServer: { + command: 'npx vite --port 5177 --strictPort', + url: 'http://localhost:5177', + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}); diff --git a/examples/perch-authz-console/src/main.ts b/examples/perch-authz-console/src/main.ts new file mode 100644 index 00000000..4751d615 --- /dev/null +++ b/examples/perch-authz-console/src/main.ts @@ -0,0 +1,433 @@ +// perch × Nido — Authorization Console +// +// A dApp that logs in a *local-key* Nido account (via the NidoLocalModule, a +// @creit.tech/stellar-wallets-kit module), visualizes its perch policy and +// reachable calls, simulates authorization locally with @nidohq/testkit, and +// lets you build more complex policies — including a post-quantum (ML-DSA) +// signer. No passkey, no network. + +import { + simulateCheckAuth, + reachableCalls, + isNarrowing, + rule as mkRule, + contract, + isSelf, + docHash, + VERIFIERS, + type Algorithm, + type LocalAccount, + type PolicyDoc, + type Rule, + type SimArg, +} from '@nidohq/testkit'; +import { NidoLocalModule } from './nidoLocalModule'; + +const REGISTRY = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; + +const module = new NidoLocalModule(); +let account: LocalAccount | null = null; +let baseline: PolicyDoc | null = null; // the grant we attenuate against +let ledger = 54_000_000; +let seq = 0; + +const app = document.getElementById('app')!; +const el = (t: K, cls?: string, text?: string): HTMLElementTagNameMap[K] => { + const e = document.createElement(t); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; +}; +const short = (a: string) => (a.length > 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a); + +function verifierClass(algorithm: Algorithm): string { + if (algorithm === 'ml-dsa-65') return 'pq'; + return VERIFIERS[algorithm].onChain ? 'onchain' : 'sim'; +} + +function refresh(): void { + if (account) account = { ...account, docHash: docHash(account.policy) }; + render(); +} + +function logLine(v: 'ok' | 'bad' | 'info', msg: string): void { + seq++; + const ol = document.getElementById('log-list'); + if (!ol) return; + const li = el('li'); + li.append(el('span', 'mono', `#${String(seq).padStart(2, '0')}`)); + const vv = el('span', `v ${v}`, v === 'ok' ? '✓' : v === 'bad' ? '✕' : '·'); + li.append(vv); + const m = el('span'); m.innerHTML = msg; li.append(m); + ol.insertBefore(li, ol.firstChild); +} + +// ---- header + shell ---- + +function header(): HTMLElement { + const h = el('header'); + const left = el('div'); + const mark = el('div', 'mark'); + const g = el('div', 'glyph'); + g.innerHTML = ``; + mark.append(g, el('span', 'kicker', 'perch × nido · authorization console')); + left.append(mark); + left.append(el('h1', undefined, "Log in a Nido account, see what its keys can do, and narrow it")); + const lede = el('p', 'lede'); + lede.innerHTML = + 'A local-key Nido smart account — no passkey. Its authorization is a perch policy; the console shows what each key reaches and lets you build more. Everything runs locally via @nidohq/testkit.'; + left.append(lede); + h.append(left); + + const themeBtn = el('button', 'theme-btn', '◐ Theme'); + themeBtn.type = 'button'; + themeBtn.addEventListener('click', () => { + const root = document.documentElement; + const cur = root.getAttribute('data-theme') || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + root.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark'); + }); + h.append(themeBtn); + return h; +} + +function connectHero(): HTMLElement { + const hero = el('div', 'connect-hero'); + hero.append(el('div', 'kicker', 'stellar wallets kit · module')); + const t = el('h1'); t.style.fontSize = 'var(--s2)'; t.textContent = 'Connect a Nido account'; + hero.append(t); + const p = el('p'); + p.innerHTML = + 'Nido is a @creit.tech/stellar-wallets-kit wallet. This connects a local-key one — three signers across three verifiers (secp256r1, ed25519, and post-quantum ML-DSA-65) — with no WebAuthn.'; + hero.append(p); + const btn = el('button', 'act', 'Connect with Nido (local key)'); + btn.type = 'button'; + btn.id = 'connect'; + btn.addEventListener('click', connect); + hero.append(btn); + return hero; +} + +async function connect(): Promise { + await module.getAddress(); // the kit login handshake + account = module.account; + baseline = structuredClone(account.policy); + render(); + logLine('info', `connected Nido account ${short(account.address)} — 3 signers, 3 rules.`); +} + +// ---- identity + panels (after connect) ---- + +function identityBar(a: LocalAccount): HTMLElement { + const box = el('div', 'identity'); + const f1 = el('div', 'field'); + f1.append(el('span', 'label', 'Nido smart account')); + f1.append(el('span', 'addr', a.address)); + f1.append(el('span', 'label', 'doc_hash · sha-256 of the canonical perch policy')); + const hash = el('span', 'hash'); hash.id = 'hash'; hash.textContent = a.docHash; hash.title = a.docHash; + f1.append(hash); + box.append(f1); + const f2 = el('div', 'field'); f2.style.textAlign = 'right'; + f2.append(el('span', 'label', 'Current ledger')); + const l = el('span', 'mono'); l.style.fontSize = 'var(--s1)'; l.style.fontWeight = '600'; l.textContent = ledger.toLocaleString(); + f2.append(l); + box.append(f2); + return box; +} + +function panel(title: string, bodyBuilder: (body: HTMLElement) => void, right?: string): HTMLElement { + const p = el('section', 'panel'); + const h = el('h2'); h.append(document.createTextNode(title)); + if (right) { const r = el('span', 'mono'); r.style.color = 'var(--faint)'; r.textContent = right; h.append(r); } + p.append(h); + const body = el('div', 'panel-body'); + bodyBuilder(body); + p.append(body); + return p; +} + +function signersPanel(a: LocalAccount): HTMLElement { + return panel('Signers — one per verifier', (body) => { + const list = el('div', 'signers'); + for (const s of a.signers) { + const info = VERIFIERS[s.algorithm]; + const card = el('div', 'signer'); + const top = el('div', 'top'); + top.append(el('span', 'id', s.id)); + const badge = el('span', `vbadge ${verifierClass(s.algorithm)}`, info.label + (info.onChain ? '' : ' · sim')); + top.append(badge); + card.append(top); + card.append(el('div', 'pk', `verifier ${short(s.verifier)} · key ${short(s.publicKeyHex)}`)); + if (info.note) { const n = el('div', 'note'); n.textContent = info.note; card.append(n); } + list.append(card); + } + body.append(list); + }); +} + +function ruleCard(r: Rule): HTMLElement { + const card = el('div', 'rule'); + const head = el('div', 'rule-head'); + head.append(el('span', 'rule-name', r.name)); + card.append(head); + const scopeRow = el('div', 'rule-row'); + scopeRow.append(el('span', 'rk', 'scope')); + scopeRow.append(el('span', 'chip scope', r.scope.type === 'self-admin' ? 'self-admin' : short(r.scope.address))); + if (r.principals.type === 'all') for (const id of r.principals.signers) scopeRow.append(el('span', 'chip', `by ${id}`)); + card.append(scopeRow); + const fnRow = el('div', 'rule-row'); + fnRow.append(el('span', 'rk', 'calls')); + if (r.functions) r.functions.forEach((f) => fnRow.append(el('span', 'chip fn', `${f}()`))); + else fnRow.append(el('span', 'chip any', 'any function')); + card.append(fnRow); + if ((r.args && r.args.length) || r['not-after-ledger'] !== undefined || r.cap) { + const lim = el('div', 'rule-row'); + lim.append(el('span', 'rk', 'limits')); + (r.args ?? []).forEach((c) => lim.append(el('span', 'chip', `arg[${c.index}] ${c.pred.type}`))); + if (r['not-after-ledger'] !== undefined) lim.append(el('span', 'chip expiry', `expires @ ${r['not-after-ledger'].toLocaleString()}`)); + if (r.cap) lim.append(el('span', 'chip cap', `≤ ${r.cap.limit} / ${r.cap['period-ledgers']} · spending_limit`)); + card.append(lim); + } + return card; +} + +function rulesPanel(a: LocalAccount): HTMLElement { + return panel('Policy — what is granted', (body) => { + for (const r of a.policy.rules) body.append(ruleCard(r)); + }); +} + +function reachPanel(a: LocalAccount): HTMLElement { + const reach = reachableCalls(a.policy); + return panel('Reachable calls — what it can do', (body) => { + for (const rs of reach) { + const row = el('div', 'reach-row'); + const who = el('div', 'reach-who'); + who.append(el('span', 'chip', rs.rule)); + who.append(el('span', 'arrow', '→')); + who.append(el('span', 'chip scope', rs.scope === 'self-admin' ? 'self-admin' : short(rs.scope))); + row.append(who); + const fns = el('div', 'reach-fns'); + if (rs.functions.kind === 'any') fns.append(el('span', 'chip any', 'any function')); + else rs.functions.functions.forEach((f) => fns.append(el('span', 'chip fn', `${f}()`))); + row.append(fns); + body.append(row); + } + }); +} + +// ---- simulate ---- + +function simulatePanel(a: LocalAccount): HTMLElement { + return panel('Try it — simulate __check_auth', (body) => { + const controls = el('div', 'controls'); + const targetSel = el('select'); targetSel.id = 'sim-target'; + ([['registry', 'registry contract'], ['self', 'self-admin (this account)']] as const).forEach(([v, t]) => { const o = el('option'); o.value = v; o.textContent = t; targetSel.append(o); }); + const fnSel = el('select'); fnSel.id = 'sim-fn'; + ['publish_hash', 'publish', 'set_admin'].forEach((f) => { const o = el('option'); o.value = f; o.textContent = `${f}()`; fnSel.append(o); }); + const authorSel = el('select'); authorSel.id = 'sim-author'; + ([['self', 'author = self'], ['other', 'author = someone else']] as const).forEach(([v, t]) => { const o = el('option'); o.value = v; o.textContent = t; authorSel.append(o); }); + const signerWrap = el('div', 'controls'); signerWrap.style.gap = '.4rem'; + for (const s of a.signers) { + const lab = el('label'); lab.style.display = 'inline-flex'; lab.style.gap = '.3rem'; lab.style.fontSize = 'var(--s-1)'; + const cb = el('input'); cb.type = 'checkbox'; cb.value = s.id; cb.className = 'sim-signer'; if (s.id === 'ci') cb.checked = true; + lab.append(cb, document.createTextNode(s.id)); + signerWrap.append(lab); + } + controls.append(mkFld('target', targetSel), mkFld('function', fnSel), mkFld('author', authorSel), mkFld('signed by', signerWrap)); + const run = el('button', 'act', 'Simulate'); run.type = 'button'; run.id = 'sim-run'; + controls.append(run); + body.append(controls); + + const verdict = el('div', 'verdict info'); verdict.id = 'sim-verdict'; + verdict.append(el('span', 'vi', '·')); + const vt = el('span'); vt.id = 'sim-verdict-txt'; vt.textContent = 'Pick a call and simulate the ci-publish policy on the registry.'; + verdict.append(vt); + body.append(verdict); + + run.addEventListener('click', () => runSimulation(a)); + }); +} + +function runSimulation(a: LocalAccount): void { + const fn = (document.getElementById('sim-fn') as HTMLSelectElement).value; + const author = (document.getElementById('sim-author') as HTMLSelectElement).value; + const signedBy = Array.from(document.querySelectorAll('.sim-signer:checked')).map((c) => c.value); + const target = (document.getElementById('sim-target') as HTMLSelectElement).value; + const authorAddr = author === 'self' ? a.address : otherAddress(); + const args: SimArg[] = [{ type: 'u32', value: 0 }, { type: 'address', value: authorAddr }]; + const ctx = + target === 'self' + ? { contract: a.address, fn, ledger } // self-admin scope + : { contract: REGISTRY, fn, args, ledger }; + const res = simulateCheckAuth(a, ctx, signedBy); + const v = document.getElementById('sim-verdict')!; + const kind = res.verdict === 'allow' ? 'ok' : res.verdict === 'deny' ? 'bad' : 'info'; + v.className = `verdict ${kind}`; + v.querySelector('.vi')!.textContent = res.verdict === 'allow' ? '✓' : res.verdict === 'deny' ? '✕' : '·'; + const label = res.verdict === 'allow' ? 'Authorized' : res.verdict === 'deny' ? 'Denied' : 'No rule applies'; + document.getElementById('sim-verdict-txt')!.innerHTML = + `${label} — ${res.reasons.join('; ')}` + + (res.matchedRule ? ` [rule ${res.matchedRule}, digest ${res.authDigest.slice(0, 10)}…]` : ''); + if (kind === 'bad') { v.classList.remove('shake'); void v.offsetWidth; v.classList.add('shake'); } + logLine(kind === 'ok' ? 'ok' : kind === 'bad' ? 'bad' : 'info', `${fn}() by [${signedBy.join(', ') || '∅'}], author=${author} → ${res.verdict}`); +} + +// A deterministic non-self contract address, for the "author = someone else" case. +const OTHER_ADDRESS = 'CAPS4YALJ6I4D3NDMRG5JZGDAAT266PSPLSHIITGUKBXUVAH5SUPZQKE'; +function otherAddress(): string { + return OTHER_ADDRESS; +} + +// ---- policy builder ---- + +function builderPanel(a: LocalAccount): HTMLElement { + return panel('Build a more complex policy — add a rule', (body) => { + const c = el('div', 'controls'); + const name = el('input'); name.type = 'text'; name.id = 'b-name'; name.placeholder = 'rule name'; name.value = 'ci-publish-2'; + const scope = el('select'); scope.id = 'b-scope'; + ([['contract', 'contract scope'], ['self-admin', 'self-admin']] as const).forEach(([v, t]) => { const o = el('option'); o.value = v; o.textContent = t; scope.append(o); }); + const addr = el('input'); addr.type = 'text'; addr.id = 'b-addr'; addr.value = REGISTRY; addr.style.width = '18ch'; + const signer = el('select'); signer.id = 'b-signer'; + a.signers.forEach((s) => { const o = el('option'); o.value = s.id; o.textContent = `by ${s.id}`; signer.append(o); }); + const fns = el('input'); fns.type = 'text'; fns.id = 'b-fns'; fns.placeholder = 'publish, publish_hash'; fns.value = 'publish'; + const self = el('input'); self.type = 'checkbox'; self.id = 'b-self'; self.checked = true; + const selfLab = el('label'); selfLab.style.fontSize = 'var(--s-1)'; selfLab.append(self, document.createTextNode(' arg[1] = self')); + const cap = el('input'); cap.type = 'text'; cap.id = 'b-cap'; cap.placeholder = 'cap limit (optional)'; cap.style.width = '12ch'; + c.append(mkFld('name', name), mkFld('scope', scope), mkFld('address', addr), mkFld('signed by', signer), mkFld('functions', fns), mkFld('', selfLab), mkFld('cap', cap)); + const add = el('button', 'act', '+ Add rule'); add.type = 'button'; add.id = 'b-add'; + c.append(add); + body.append(c); + body.append(hintNote()); + add.addEventListener('click', () => addRule(a)); + }); +} + +function addRule(a: LocalAccount): void { + const name = (document.getElementById('b-name') as HTMLInputElement).value.trim() || `rule-${a.policy.rules.length}`; + const scopeType = (document.getElementById('b-scope') as HTMLSelectElement).value; + const addr = (document.getElementById('b-addr') as HTMLInputElement).value.trim(); + const signerId = (document.getElementById('b-signer') as HTMLSelectElement).value; + const fns = (document.getElementById('b-fns') as HTMLInputElement).value.split(',').map((s) => s.trim()).filter(Boolean); + const self = (document.getElementById('b-self') as HTMLInputElement).checked; + const capVal = (document.getElementById('b-cap') as HTMLInputElement).value.trim(); + const newRule = mkRule({ + name, + scope: scopeType === 'self-admin' ? { type: 'self-admin' } : contract(addr), + signedBy: [signerId], + functions: fns.length ? fns : undefined, + args: self ? [{ index: 1, pred: isSelf() }] : undefined, + cap: capVal ? { limit: capVal, 'period-ledgers': 17_280 } : undefined, + }); + a.policy.rules.push(newRule); + refresh(); + const h = document.getElementById('hash'); if (h) { h.classList.remove('flash'); void h.offsetWidth; h.classList.add('flash'); } + logLine('info', `added rule ${name} — policy now ${a.policy.rules.length} rules; doc_hash changed.`); +} + +// ---- attenuate ---- + +function attenuatePanel(a: LocalAccount): HTMLElement { + return panel('Attenuate — narrow a rule safely', (body) => { + const c = el('div', 'controls'); + const ruleSel = el('select'); ruleSel.id = 'a-rule'; + a.policy.rules.filter((r) => r.functions).forEach((r) => { const o = el('option'); o.value = r.name; o.textContent = r.name; ruleSel.append(o); }); + const fns = el('input'); fns.type = 'text'; fns.id = 'a-fns'; fns.placeholder = 'new functions'; fns.value = 'publish'; + c.append(mkFld('rule', ruleSel), mkFld('narrow functions to', fns)); + const apply = el('button', 'act ghost', 'Apply (must be a narrowing)'); apply.type = 'button'; apply.id = 'a-apply'; + c.append(apply); + body.append(c); + const v = el('div', 'verdict info'); v.id = 'a-verdict'; + v.append(el('span', 'vi', '·')); + const vt = el('span'); vt.id = 'a-verdict-txt'; + vt.innerHTML = 'Attenuation is fail-closed: a change is applied only if reachable(child) ⊆ reachable(parent). Try widening (add a function) — it will be refused.'; + v.append(vt); + body.append(v); + apply.addEventListener('click', () => applyNarrowing(a)); + }); +} + +function applyNarrowing(a: LocalAccount): void { + const ruleName = (document.getElementById('a-rule') as HTMLSelectElement).value; + const newFns = (document.getElementById('a-fns') as HTMLInputElement).value.split(',').map((s) => s.trim()).filter(Boolean); + const proposed: PolicyDoc = structuredClone(a.policy); + const r = proposed.rules.find((x) => x.name === ruleName); + if (!r) return; + r.functions = newFns; + const check = isNarrowing(a.policy, proposed); + if (check.ok) { + account = { ...a, policy: proposed, docHash: docHash(proposed) }; + render(); // rebuild first, then set the verdict on the fresh node + const nv = document.getElementById('a-verdict')!; + nv.className = 'verdict ok'; + nv.querySelector('.vi')!.textContent = '✓'; + document.getElementById('a-verdict-txt')!.innerHTML = `Verified narrowing — ${ruleName} → [${newFns.join(', ')}]. reachable(child) ⊆ reachable(parent) ✓`; + logLine('ok', `attenuated ${ruleName} → [${newFns.join(', ')}] (verified ⊆ parent).`); + return; + } + { + const v = document.getElementById('a-verdict')!; + v.className = 'verdict bad'; + v.querySelector('.vi')!.textContent = '✕'; + document.getElementById('a-verdict-txt')!.innerHTML = `Refused — not a narrowing. ${check.reason}. The grant is unchanged.`; + v.classList.remove('shake'); void v.offsetWidth; v.classList.add('shake'); + logLine('bad', `widening refused on ${ruleName}: ${check.reason}.`); + } +} + +// ---- helpers + render ---- + +function mkFld(label: string, control: HTMLElement): HTMLElement { + const l = el('label', 'fld'); + if (label) l.append(el('span', undefined, label)); + l.append(control); + return l; +} + +function hintNote(): HTMLElement { + const n = el('div', 'note'); + n.innerHTML = + 'Adding rules authors the policy (an owner action) — the doc_hash changes. Use the Attenuate panel to narrow an existing grant with the fail-closed subset check.'; + return n; +} + +function log(): HTMLElement { + return panel('Change log', (body) => { + const ol = el('ol'); ol.id = 'log-list'; + body.append(ol); + }); +} + +function footer(): HTMLElement { + const f = el('footer'); + f.innerHTML = + 'Built on @nidohq/testkit (nidohq/nido#188) and perch. The account, signatures, doc_hash, and verdicts are real and local; ed25519 / ML-DSA verifiers and perch-on-chain are simulated ahead of their contracts (ML-DSA: #143). Roadmap: soroban-env in the browser + rs-soroban-sdk#1657.'; + return f; +} + +function render(): void { + app.replaceChildren(); + const wrap = el('div', 'wrap'); + wrap.append(header()); + if (!account) { + wrap.append(connectHero()); + } else { + wrap.append(identityBar(account)); + const grid = el('div', 'grid'); + const colA = el('div'); colA.style.display = 'flex'; colA.style.flexDirection = 'column'; colA.style.gap = '1.1rem'; + colA.append(signersPanel(account), rulesPanel(account)); + const colB = el('div'); colB.style.display = 'flex'; colB.style.flexDirection = 'column'; colB.style.gap = '1.1rem'; + colB.append(reachPanel(account), simulatePanel(account)); + grid.append(colA, colB); + wrap.append(grid); + const stack = el('div'); stack.style.display = 'flex'; stack.style.flexDirection = 'column'; stack.style.gap = '1.1rem'; stack.style.marginTop = '1.1rem'; + stack.append(builderPanel(account), attenuatePanel(account), log()); + wrap.append(stack); + } + wrap.append(footer()); + app.append(wrap); +} + +render(); + +// expose for the e2e test / console poking +(window as unknown as { __console: unknown }).__console = { connect, get account() { return account; }, module }; diff --git a/examples/perch-authz-console/src/nidoLocalModule.ts b/examples/perch-authz-console/src/nidoLocalModule.ts new file mode 100644 index 00000000..9a0dbd91 --- /dev/null +++ b/examples/perch-authz-console/src/nidoLocalModule.ts @@ -0,0 +1,122 @@ +// A @creit.tech/stellar-wallets-kit module that connects a *local-key* Nido +// account — the passkey-free counterpart to @nidohq/stellar-wallets-kit-module. +// It generates local signers for every verifier (secp256r1, ed25519, ML-DSA-65) +// and derives the Nido account from them, so any dApp using the kit can "log in +// with Nido" in a test or demo without a WebAuthn ceremony. +// +// getAddress returns the derived C-address; signMessage signs locally. +// Submitting real transactions is out of scope for this simulation demo — those +// throw with a pointer to simulateCheckAuth. + +import { ModuleType } from '@creit.tech/stellar-wallets-kit'; +import { + localSigner, + createLocalAccount, + contract, + isSelf, + rule, + TESTNET_PASSPHRASE, + type LocalAccount, + type LocalSigner, +} from '@nidohq/testkit'; + +const REGISTRY = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; + +// A small teal "perch" bird icon (data URI — CSP-safe, no remote fetch). +const ICON = + 'data:image/svg+xml;utf8,' + + encodeURIComponent( + ``, + ); + +/** Build the default multi-verifier account: an admin (secp256r1), a CI key + * (ed25519), and a post-quantum key (ML-DSA-65), with a starter policy. */ +export function buildDefaultAccount(): { account: LocalAccount; signers: LocalSigner[] } { + const admin = localSigner({ id: 'admin', algorithm: 'secp256r1' }); + const ci = localSigner({ id: 'ci', algorithm: 'ed25519' }); + const pq = localSigner({ id: 'pq', algorithm: 'ml-dsa-65' }); + const account = createLocalAccount({ + signers: [admin, ci, pq], + rules: [ + rule({ name: 'admin-root', scope: { type: 'self-admin' }, signedBy: ['admin'] }), + rule({ + name: 'ci-publish', + scope: contract(REGISTRY), + signedBy: ['ci'], + functions: ['publish', 'publish_hash'], + args: [{ index: 1, pred: isSelf() }], + notAfterLedger: 55_000_000, + }), + rule({ name: 'pq-admin', scope: { type: 'self-admin' }, signedBy: ['pq'] }), + ], + }); + return { account, signers: [admin, ci, pq] }; +} + +export class NidoLocalModule { + readonly moduleType = ModuleType.HOT_WALLET; + readonly productId = 'nido-local'; + readonly productName = 'Nido (local key)'; + readonly productUrl = 'https://nido.fyi'; + readonly productIcon = ICON; + + private state = buildDefaultAccount(); + + /** The connected account — read by the app after getAddress. */ + get account(): LocalAccount { + return this.state.account; + } + get signers(): LocalSigner[] { + return this.state.signers; + } + + /** Replace the account (e.g. after the policy builder edits it). */ + setAccount(account: LocalAccount): void { + this.state = { account, signers: account.signers }; + } + + async isAvailable(): Promise { + return true; + } + + async getNetwork(): Promise<{ network: string; networkPassphrase: string }> { + return { network: 'TESTNET', networkPassphrase: TESTNET_PASSPHRASE }; + } + + async getAddress(): Promise<{ address: string }> { + return { address: this.state.account.address }; + } + + async signMessage(message: string): Promise<{ signedMessage: string; signerAddress: string }> { + // Local signing with the primary (admin) signer over the message bytes. + const bytes = new TextEncoder().encode(message); + const digest = new Uint8Array(32); + digest.set(bytes.slice(0, 32)); + const first = this.state.signers[0]!; + const sig = first.signAuth(digest); + const signedMessage = sig.kind === 'raw' ? bytesToHex(sig.bytes) : bytesToHex(sig.assertion.signature); + return { signedMessage, signerAddress: this.state.account.address }; + } + + async signTransaction(): Promise { + throw new Error( + 'Nido (local key) is a simulation wallet — it does not submit transactions. ' + + 'Use simulateCheckAuth() to check authorization locally.', + ); + } + + async signAuthEntry(): Promise { + throw new Error( + 'Nido (local key) is a simulation wallet — signAuthEntry is not supported. ' + + 'Use simulateCheckAuth() to check authorization locally.', + ); + } + + async disconnect(): Promise { + /* stateless local wallet */ + } +} + +function bytesToHex(b: Uint8Array): string { + return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); +} diff --git a/examples/perch-authz-console/src/styles.css b/examples/perch-authz-console/src/styles.css new file mode 100644 index 00000000..836a67f6 --- /dev/null +++ b/examples/perch-authz-console/src/styles.css @@ -0,0 +1,114 @@ +:root { + color-scheme: light dark; + --bg: #edf0f4; --surface: #fff; --surface-2: #f5f7fa; --line: #e1e6ed; --line-strong: #cbd3dd; + --ink: #182029; --muted: #58626f; --faint: #8791a0; + --accent: #0c8b99; --accent-soft: #e1f1f3; + --allow: #167a54; --allow-bg: #e6f2ec; --deny: #c22c3a; --deny-bg: #f9e8ea; + --cap: #9e6410; --cap-bg: #f5ecda; --pq: #6b40b8; --pq-bg: #ece4f7; + --shadow: 0 1px 2px rgba(23,32,45,.06), 0 6px 20px rgba(23,32,45,.06); + --ring: 0 0 0 3px color-mix(in srgb, var(--accent) 34%, transparent); + --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; + --s-1: .78rem; --s0: .925rem; --s1: 1.05rem; --s2: 1.3rem; --s3: 1.8rem; + --radius: 11px; --radius-sm: 7px; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1219; --surface: #151c26; --surface-2: #101722; --line: #232d3a; --line-strong: #33404f; + --ink: #e7edf4; --muted: #97a2b1; --faint: #69747f; + --accent: #3ecad8; --accent-soft: #12262b; + --allow: #46c58a; --allow-bg: #12271d; --deny: #f0727a; --deny-bg: #291418; + --cap: #e0a94a; --cap-bg: #271e10; --pq: #b18cf0; --pq-bg: #1e1830; + --shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 26px rgba(0,0,0,.34); + } +} +:root[data-theme="light"] { --bg:#edf0f4; --surface:#fff; --surface-2:#f5f7fa; --line:#e1e6ed; --line-strong:#cbd3dd; --ink:#182029; --muted:#58626f; --faint:#8791a0; --accent:#0c8b99; --accent-soft:#e1f1f3; --allow:#167a54; --allow-bg:#e6f2ec; --deny:#c22c3a; --deny-bg:#f9e8ea; --cap:#9e6410; --cap-bg:#f5ecda; --pq:#6b40b8; --pq-bg:#ece4f7; --shadow:0 1px 2px rgba(23,32,45,.06),0 6px 20px rgba(23,32,45,.06); } +:root[data-theme="dark"] { --bg:#0d1219; --surface:#151c26; --surface-2:#101722; --line:#232d3a; --line-strong:#33404f; --ink:#e7edf4; --muted:#97a2b1; --faint:#69747f; --accent:#3ecad8; --accent-soft:#12262b; --allow:#46c58a; --allow-bg:#12271d; --deny:#f0727a; --deny-bg:#291418; --cap:#e0a94a; --cap-bg:#271e10; --pq:#b18cf0; --pq-bg:#1e1830; --shadow:0 1px 2px rgba(0,0,0,.3),0 8px 26px rgba(0,0,0,.34); } + +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: var(--s0); line-height: 1.5; -webkit-font-smoothing: antialiased; } +.wrap { max-width: 1120px; margin: 0 auto; padding: clamp(1rem,3vw,2.4rem) clamp(1rem,3vw,2rem) 4rem; } +.mono { font-family: var(--mono); } + +header { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; } +.mark { display: flex; align-items: center; gap: .6rem; } +.glyph { width: 30px; height: 30px; flex: none; border-radius: 8px; background: color-mix(in srgb, var(--accent) 15%, var(--surface)); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line)); display: grid; place-items: center; color: var(--accent); } +.kicker { font-family: var(--mono); font-size: var(--s-1); letter-spacing: .14em; text-transform: uppercase; color: var(--accent); } +h1 { font-size: var(--s3); line-height: 1.1; margin: .55rem 0 .3rem; letter-spacing: -.02em; text-wrap: balance; } +.lede { color: var(--muted); max-width: 62ch; margin: 0; } +.theme-btn { font-family: var(--mono); font-size: var(--s-1); color: var(--muted); background: var(--surface); border: 1px solid var(--line); border-radius: 99px; padding: .45rem .8rem; cursor: pointer; } +.theme-btn:hover { border-color: var(--line-strong); color: var(--ink); } + +.identity { margin-top: 1.6rem; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); display: grid; grid-template-columns: 1fr auto; gap: .3rem 1.4rem; padding: 1rem 1.2rem; align-items: center; } +.field { display: flex; flex-direction: column; gap: .2rem; min-width: 0; } +.label { font-family: var(--mono); font-size: var(--s-1); letter-spacing: .1em; text-transform: uppercase; color: var(--faint); } +.addr { font-family: var(--mono); font-size: var(--s-1); color: var(--muted); word-break: break-all; } +.hash { font-family: var(--mono); font-size: var(--s0); color: var(--accent); font-weight: 600; word-break: break-all; transition: background .5s ease; border-radius: 5px; padding: 1px 3px; margin: -1px -3px; } +.hash.flash { background: color-mix(in srgb, var(--accent) 24%, transparent); } + +.grid { margin-top: 1.1rem; display: grid; grid-template-columns: 1fr 1fr; gap: 1.1rem; align-items: start; } +@media (max-width: 880px) { .grid { grid-template-columns: 1fr; } .identity { grid-template-columns: 1fr; } } +.panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; } +.panel > h2 { font-size: var(--s-1); font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--muted); margin: 0; padding: .85rem 1.1rem; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; gap: .5rem; align-items: center; } +.panel-body { padding: 1rem 1.1rem; display: flex; flex-direction: column; gap: .8rem; } + +.signers { display: flex; flex-direction: column; gap: .55rem; } +.signer { border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); padding: .6rem .75rem; display: flex; flex-direction: column; gap: .3rem; } +.signer .top { display: flex; align-items: center; gap: .5rem; justify-content: space-between; } +.signer .id { font-weight: 650; } +.vbadge { font-family: var(--mono); font-size: .72rem; padding: .12rem .45rem; border-radius: 5px; white-space: nowrap; } +.vbadge.onchain { color: var(--allow); background: var(--allow-bg); } +.vbadge.sim { color: var(--cap); background: var(--cap-bg); } +.vbadge.pq { color: var(--pq); background: var(--pq-bg); } +.signer .pk { font-family: var(--mono); font-size: .72rem; color: var(--faint); word-break: break-all; } + +.rule { position: relative; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); padding: .8rem .9rem; overflow: hidden; } +.rule::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--allow); } +.rule-head { display: flex; justify-content: space-between; align-items: baseline; gap: .5rem; margin-bottom: .4rem; } +.rule-name { font-weight: 650; font-size: var(--s1); } +.rule-row { display: flex; flex-wrap: wrap; gap: .3rem .45rem; align-items: center; margin-top: .3rem; } +.rk { font-family: var(--mono); font-size: var(--s-1); color: var(--faint); text-transform: uppercase; letter-spacing: .08em; min-width: 4em; } +.chip { font-family: var(--mono); font-size: var(--s-1); padding: .16rem .48rem; border-radius: 6px; background: var(--surface); border: 1px solid var(--line-strong); color: var(--ink); white-space: nowrap; } +.chip.scope { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); color: var(--accent); } +.chip.fn { background: var(--allow-bg); border-color: color-mix(in srgb, var(--allow) 30%, var(--line)); color: var(--allow); } +.chip.any { color: var(--muted); } +.chip.cap, .chip.expiry { background: var(--cap-bg); border-color: color-mix(in srgb, var(--cap) 32%, var(--line)); color: var(--cap); } + +.reach-row { display: flex; flex-direction: column; gap: .35rem; padding: .65rem .8rem; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); } +.reach-who { display: flex; align-items: center; gap: .45rem; font-size: var(--s-1); } +.reach-who .arrow { color: var(--faint); } +.reach-fns { display: flex; flex-wrap: wrap; gap: .3rem; } + +.verdict { display: flex; gap: .55rem; align-items: flex-start; padding: .7rem .8rem; border-radius: var(--radius-sm); font-size: var(--s-1); border: 1px solid; } +.verdict .vi { font-family: var(--mono); font-weight: 700; flex: none; } +.verdict.ok { background: var(--allow-bg); border-color: color-mix(in srgb, var(--allow) 30%, transparent); color: var(--allow); } +.verdict.bad { background: var(--deny-bg); border-color: color-mix(in srgb, var(--deny) 32%, transparent); color: var(--deny); } +.verdict.info { background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); color: var(--accent); } +.verdict.bad.shake { animation: shake .32s ease; } +@keyframes shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-4px)} 75%{transform:translateX(4px)} } + +.controls { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; } +label.fld { display: flex; flex-direction: column; gap: .25rem; font-size: var(--s-1); color: var(--muted); } +select, input[type="text"], input[type="number"] { font-family: var(--mono); font-size: var(--s-1); padding: .4rem .55rem; border: 1px solid var(--line-strong); border-radius: 7px; background: var(--surface); color: var(--ink); } +select:focus-visible, input:focus-visible, button:focus-visible { outline: none; box-shadow: var(--ring); border-color: var(--accent); } +button.act { font-family: var(--sans); font-size: var(--s-1); font-weight: 600; cursor: pointer; background: var(--accent); color: #fff; border: 1px solid transparent; border-radius: 8px; padding: .55rem .9rem; } +button.act.ghost { background: var(--surface); color: var(--ink); border-color: var(--line-strong); } +button.act.ghost:hover { border-color: var(--accent); } +button.act:active { transform: translateY(1px); } + +.connect-hero { margin-top: 1.6rem; text-align: center; padding: 3rem 1.5rem; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); } +.connect-hero p { color: var(--muted); max-width: 48ch; margin: .5rem auto 1.5rem; } + +.log ol { list-style: none; margin: 0; padding: 0; } +.log li { display: grid; grid-template-columns: auto auto 1fr; gap: .7rem; align-items: baseline; padding: .5rem 1.1rem; border-top: 1px solid var(--line); font-size: var(--s-1); } +.log li:first-child { border-top: none; } +.log .v { font-family: var(--mono); font-weight: 700; } +.log .v.ok { color: var(--allow); } .log .v.bad { color: var(--deny); } .log .v.info { color: var(--accent); } +.log .h { font-family: var(--mono); color: var(--muted); } + +.note { font-size: var(--s-1); color: var(--muted); } +.note b { color: var(--cap); } +footer { margin-top: 2rem; color: var(--faint); font-size: var(--s-1); text-align: center; } +footer .mono { color: var(--muted); } +a { color: var(--accent); } +@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } } diff --git a/examples/perch-authz-console/tests/e2e.spec.ts b/examples/perch-authz-console/tests/e2e.spec.ts new file mode 100644 index 00000000..4d9631d8 --- /dev/null +++ b/examples/perch-authz-console/tests/e2e.spec.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; + +// Drives the full flow and captures browser snapshots — the visual proof the +// example works end to end on real testkit output. +test('connect → visualize → simulate → attenuate', async ({ page }) => { + await page.goto('/'); + + // 1. Connect screen + await expect(page.getByRole('heading', { name: 'Connect a Nido account' })).toBeVisible(); + await page.screenshot({ path: 'artifacts/01-connect.png', fullPage: true }); + + // 2. Connect the local-key Nido account + await page.getByRole('button', { name: /Connect with Nido/ }).click(); + + // A real derived C-address and a real 64-hex doc_hash appear. + const hash = page.locator('#hash'); + await expect(hash).toBeVisible(); + await expect(hash).toHaveText(/^[0-9a-f]{64}$/); + await expect(page.locator('.addr').first()).toHaveText(/^C[A-Z2-7]{55}$/); + + // All three verifiers, including the post-quantum ML-DSA signer. + await expect(page.getByText('WebAuthn / secp256r1')).toBeVisible(); + await expect(page.getByText('ed25519 · sim')).toBeVisible(); + await expect(page.getByText('ML-DSA-65 (post-quantum) · sim')).toBeVisible(); + await page.screenshot({ path: 'artifacts/02-console.png', fullPage: true }); + + // 3. Simulate: the ci key may publish as self → allow. + await page.locator('#sim-run').click(); + await expect(page.locator('#sim-verdict')).toHaveClass(/verdict ok/); + + // ...but not set_admin → deny. + await page.locator('#sim-fn').selectOption('set_admin'); + await page.locator('#sim-run').click(); + await expect(page.locator('#sim-verdict')).toHaveClass(/verdict bad/); + await page.screenshot({ path: 'artifacts/03-simulate.png', fullPage: true }); + + // ...but set_admin on the *self-admin* scope, signed by admin, is authorized + // by the account's own admin rule → allow. (And pq alone authorizes it via + // the ML-DSA pq-admin rule — the simulator tries every matching rule.) + await page.locator('#sim-target').selectOption('self'); + await page.locator('.sim-signer[value="admin"]').check(); + await page.locator('#sim-run').click(); + await expect(page.locator('#sim-verdict')).toHaveClass(/verdict ok/); + await page.screenshot({ path: 'artifacts/03b-self-admin.png', fullPage: true }); + + // 4. Build a more complex policy: add a rule; doc_hash changes. + const before = await hash.textContent(); + await page.locator('#b-add').click(); + await expect(hash).not.toHaveText(before ?? ''); + + // 5. Attenuate: narrow ci-publish → verified ⊆ parent. + await page.locator('#a-apply').click(); + await expect(page.locator('#a-verdict')).toHaveClass(/verdict ok/); + + // Attempt to widen → refused, fail-closed. + await page.locator('#a-fns').fill('publish, publish_hash, set_admin'); + await page.locator('#a-apply').click(); + await expect(page.locator('#a-verdict')).toHaveClass(/verdict bad/); + await page.screenshot({ path: 'artifacts/04-attenuate.png', fullPage: true }); +}); diff --git a/examples/perch-authz-console/tsconfig.json b/examples/perch-authz-console/tsconfig.json new file mode 100644 index 00000000..8abbc30e --- /dev/null +++ b/examples/perch-authz-console/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/examples/perch-authz-console/vite.config.ts b/examples/perch-authz-console/vite.config.ts new file mode 100644 index 00000000..2de43280 --- /dev/null +++ b/examples/perch-authz-console/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; + +// base is left default ('/') for local dev + Cloudflare/apex previews; the +// GitHub Pages build passes --base=// explicitly (see build:pages). +export default defineConfig({ + plugins: [ + // @stellar/stellar-sdk (via @nidohq/testkit) expects a Buffer global. + nodePolyfills({ include: ['buffer'], globals: { Buffer: true } }), + ], + build: { target: 'esnext' }, +}); diff --git a/package-lock.json b/package-lock.json index d944df8d..966046ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "packages/*", "packages/contract-bindings/*", "examples/status-message-dapp", - "examples/status-message-dapp/packages/*" + "examples/status-message-dapp/packages/*", + "examples/perch-authz-console" ], "devDependencies": { "@noble/curves": "^2.2.0", @@ -19,6 +20,110 @@ "vitest": "^4.1.7" } }, + "examples/perch-authz-console": { + "version": "0.1.0", + "dependencies": { + "@creit.tech/stellar-wallets-kit": "^2.2.0", + "@nidohq/testkit": "*", + "@noble/hashes": "^2.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vite-plugin-node-polyfills": "^0.25.0" + } + }, + "examples/perch-authz-console/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "examples/perch-authz-console/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "examples/status-message-dapp": { "version": "0.1.0", "dependencies": { @@ -17592,6 +17697,10 @@ "node": ">= 0.10" } }, + "node_modules/perch-authz-console": { + "resolved": "examples/perch-authz-console", + "link": true + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -22720,7 +22829,8 @@ "dependencies": { "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", - "@noble/post-quantum": "^0.7.0" + "@noble/post-quantum": "^0.7.0", + "@stellar/stellar-sdk": "^15.1.0" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/package.json b/package.json index 28751616..004e014d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "packages/*", "packages/contract-bindings/*", "examples/status-message-dapp", - "examples/status-message-dapp/packages/*" + "examples/status-message-dapp/packages/*", + "examples/perch-authz-console" ], "scripts": { "build:packages": "npm run build -w @nidohq/smart-account -w @nidohq/multisig-policy -w @nidohq/spending-limit-policy -w @nidohq/webauthn-verifier -w @nidohq/factory -w @nidohq/status-message -w @nidohq/zk-recovery -w @nidohq/passkey-sdk -w @nidohq/stellar-wallets-kit-module", diff --git a/packages/testkit/src/checkauth.ts b/packages/testkit/src/checkauth.ts index cd308ec9..76cd59db 100644 --- a/packages/testkit/src/checkauth.ts +++ b/packages/testkit/src/checkauth.ts @@ -74,18 +74,17 @@ function argSatisfies(pred: ArgPred, arg: SimArg | undefined, account: LocalAcco } } -export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signedBy: string[]): SimResult { - const ledger = ctx.ledger ?? 0; +/** Evaluate one rule (at index `idx`) against the call: digest → authenticate + * signers → expiry / function / args / signer-floor. */ +function evalRule( + account: LocalAccount, + ctx: SimContext, + ledger: number, + rule: Rule, + idx: number, + signedBy: string[], +): SimResult { const reasons: string[] = []; - - // 1. Find the rule whose scope this call falls under. - const idx = account.policy.rules.findIndex((r) => scopeMatches(r, account, ctx)); - if (idx < 0) { - return { verdict: 'abstain', authDigest: '', reasons: ['no rule applies to this call'], signerChecks: [] }; - } - const rule = account.policy.rules[idx]!; - - // 2. Digest bound to this rule; authenticate the signers that signed it. const payload = sha256(new TextEncoder().encode(stableContext(ctx))); const digest = computeAuthDigest(payload, [idx]); const signerChecks = signedBy.map((id) => { @@ -94,7 +93,6 @@ export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signed return { id, verifier: s.verifier, ok: verifySignature(s.algorithm, digest, s.publicKey, s.signAuth(digest)) }; }); const authenticated = new Set(signerChecks.filter((c) => c.ok).map((c) => c.id)); - const base: Omit = { authDigest: bytesToHex(digest), matchedRule: rule.name, @@ -106,35 +104,49 @@ export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signed return { verdict: 'deny', ...base }; }; - // 3. Expiry (perch "dead at or after"; OZ valid_until is inclusive one below). + // Expiry (perch "dead at or after"). const notAfter = rule['not-after-ledger']; if (notAfter !== undefined && ledger >= notAfter) return deny(`rule expired (ledger ${ledger} ≥ ${notAfter})`); - - // 4. Function allowlist. + // Function allowlist. if (rule.functions && !rule.functions.includes(ctx.fn)) { return deny(`function ${ctx.fn}() not in [${rule.functions.join(', ')}]`); } - - // 5. Argument predicates. + // Argument predicates. for (const c of rule.args ?? []) { - if (!argSatisfies(c.pred, ctx.args?.[c.index], account)) { - return deny(`arg[${c.index}] fails ${c.pred.type}`); - } + if (!argSatisfies(c.pred, ctx.args?.[c.index], account)) return deny(`arg[${c.index}] fails ${c.pred.type}`); } - - // 6. Signer sufficiency: perch injects MinSigners(n) = every referenced signer - // (N-of-N), the on-chain floor when a policy is attached. + // Signer sufficiency: perch injects MinSigners(n) = every referenced signer. if (rule.principals.type === 'all') { const missing = rule.principals.signers.filter((id) => !authenticated.has(id)); if (missing.length) return deny(`missing signature from [${missing.join(', ')}]`); } - - // 7. Cumulative cap is a stateful sibling policy — not evaluable from a single - // call. Surface it rather than silently ignore. + // Cumulative cap is a stateful sibling policy — surface it, not per-call. if (rule.cap) { reasons.push(`cap ≤ ${rule.cap.limit} / ${rule.cap['period-ledgers']} ledgers applies (stateful; not checked per-call)`); } - reasons.push('authorized'); return { verdict: 'allow', ...base }; } + +export function simulateCheckAuth(account: LocalAccount, ctx: SimContext, signedBy: string[]): SimResult { + const ledger = ctx.ledger ?? 0; + + // Every rule this call could fall under (OZ lets the caller nominate a rule + // via context_rule_ids; the sim tries them all and authorizes if any rule + // does — matching "can these signers authorize this call?"). + const matching: Array<[Rule, number]> = []; + account.policy.rules.forEach((r, i) => { + if (scopeMatches(r, account, ctx)) matching.push([r, i]); + }); + if (matching.length === 0) { + return { verdict: 'abstain', authDigest: '', reasons: ['no rule applies to this call'], signerChecks: [] }; + } + + let firstDeny: SimResult | null = null; + for (const [rule, idx] of matching) { + const res = evalRule(account, ctx, ledger, rule, idx, signedBy); + if (res.verdict === 'allow') return res; + if (!firstDeny) firstDeny = res; + } + return firstDeny as SimResult; +} diff --git a/packages/testkit/src/testkit.test.ts b/packages/testkit/src/testkit.test.ts index c14b070a..0d21c258 100644 --- a/packages/testkit/src/testkit.test.ts +++ b/packages/testkit/src/testkit.test.ts @@ -88,6 +88,31 @@ describe('simulateCheckAuth — the ci-publish policy', () => { }); }); +describe('simulateCheckAuth — tries every matching rule', () => { + const admin = localSigner({ id: 'admin', algorithm: 'secp256r1' }); + const pq = localSigner({ id: 'pq', algorithm: 'ml-dsa-65' }); + const acct = createLocalAccount({ + signers: [admin, pq], + rules: [ + rule({ name: 'admin-root', scope: { type: 'self-admin' }, signedBy: ['admin'] }), + rule({ name: 'pq-admin', scope: { type: 'self-admin' }, signedBy: ['pq'] }), + ], + }); + + it('authorizes a self-admin call via a non-first rule (the ML-DSA signer)', () => { + // set_admin on self-admin, signed only by pq → admin-root denies (missing + // admin) but pq-admin authorizes. Must not stop at the first matching rule. + const res = simulateCheckAuth(acct, { contract: acct.address, fn: 'set_admin' }, ['pq']); + expect(res.verdict).toBe('allow'); + expect(res.matchedRule).toBe('pq-admin'); + }); + + it('denies when no matching rule is satisfied', () => { + const res = simulateCheckAuth(acct, { contract: acct.address, fn: 'set_admin' }, []); + expect(res.verdict).toBe('deny'); + }); +}); + describe('perch analysis — reachable + attenuation', () => { const acct = createLocalAccount({ signers: [localSigner({ id: 'ci', algorithm: 'ed25519' })],