diff --git a/AGENTS.md b/AGENTS.md index 5682bba5..68d4ce9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,10 @@ interpreter bindings on the workspace's single `@stellar/stellar-sdk` copy — the #72 dual-SDK hazard). Perch contract addresses are derived, not deployed by nido — see DEPLOYED.md "Perch canonical deployment" and `src/policyDoc/deployment.ts`; frozen golden vectors live in -`src/policyDoc/testdata/`. +`src/policyDoc/testdata/`. The frontend's doc surface lives under +`packages/frontend/src/lib/policy/` (three-tier read, doc builder drafts, +the dApp delegate-doc request contract) — each module's header comment is +the reference. ## Frontend Design Export diff --git a/package-lock.json b/package-lock.json index 9052104b..bfb194a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22581,6 +22581,7 @@ "@noble/hashes": "^2.2.0", "@noir-lang/noir_js": "1.0.0-beta.18", "@scure/bip39": "^2.2.0", + "@stellar-registry/perch-interpreter": "^0.1.0", "@stellar/stellar-sdk": "^15.1.0", "astro": "^5.3.0", "buffer": "^6.0.3" diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 77bb655b..3d6ba3b6 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -22,6 +22,7 @@ "@nidohq/spending-limit-policy": "*", "@nidohq/status-message": "*", "@nidohq/stellar-wallets-kit-module": "*", + "@stellar-registry/perch-interpreter": "^0.1.0", "@stellar/stellar-sdk": "^15.1.0", "astro": "^5.3.0", "buffer": "^6.0.3" diff --git a/packages/frontend/src/components/PolicyBuilder.ts b/packages/frontend/src/components/PolicyBuilder.ts new file mode 100644 index 00000000..1d58cd32 --- /dev/null +++ b/packages/frontend/src/components/PolicyBuilder.ts @@ -0,0 +1,724 @@ +// The policy builder: compose a policy-document update and apply it. +// +// DOC-ONLY by ruling: every policy write goes through the account's +// one-transaction `apply_doc` surface (`buildApplyDocTx`). Two tabs: +// +// - **Session key** — the canned v1 template: one scoped session key (a +// delegated signer restricted to one contract's named functions, with an +// expiry) plus an optional cumulative spending cap, UPSERTED into the +// account's applied document. +// - **Admin keys** — enroll another ADMIN key (a brand-new passkey created +// via the WebAuthn ceremony, or a pasted external/delegated key) as a +// policy-free self-admin rule — the same shape the contract's anti-brick +// check requires — or remove one (never the last: the account must keep +// an admin authority, and the contract would refuse such a document). +// +// `apply_doc` replaces the whole document, so every submit is an update +// against a loaded BASELINE (the applied doc, or the admin baseline +// on a first apply), and every preview shows the full merged document +// (canonical JSON + doc_hash) AND the diff against the applied one — +// exactly what changes — before the user confirms. Validation and the +// merge/diff logic live in the pure libs (lib/policy/docDraft, +// lib/policy/docDiff); submission goes through the account's existing +// passkey signing path (signAndSubmit). This module never signs silently. + +import { + buildApplyDocTx, + canonicalJson, + createSessionPasskey, + docHash, + parsePolicyDocJson, + type PolicyDoc, +} from '@nidohq/passkey-sdk'; +import { Networks } from '@stellar/stellar-sdk'; +import { esc } from '../lib/html.js'; +import { toast } from '../lib/toast.js'; +import { RPC_URL } from '../lib/network.js'; +import { fetchDefaultRuleAuthInfo, fetchVerifierAddress } from '../lib/policyChainFetch.js'; +import { fetchAppliedDocJson, fetchDocSurface, toHex } from '../lib/policy/docPolicyFetch.js'; +import { stroopsFromXlm, PERIOD_LEDGERS } from '../lib/spendingLimitParams.js'; +import { signAndSubmit } from '../lib/primaryPasskeySigner.js'; +import { + addAdminKey, + adminRules, + nextAdminRuleName, + adminBaseline, + removeAdminRule, + upsertSessionRule, + validateAdminKeyDraft, + validateSessionDocDraft, + type AdminKeyDraft, + type SessionDocDraft, +} from '../lib/policy/docDraft.js'; +import { diffPolicyDocs } from '../lib/policy/docDiff.js'; +import { bytesToHex, truncate } from '../lib/policy/policyView.js'; +import { summarizeDoc } from '../lib/policy/docView.js'; +import { renderDocDiffHtml, renderDocPreviewHtml } from './PolicyInspector.js'; + +const NETWORK_PASSPHRASE = Networks.TESTNET; + +interface BuilderOptions { + account: string; + /** Called after a document is successfully applied, so the page can refresh. */ + onSubmitted?: () => void; +} + +type BuilderTab = 'session' | 'admin'; + +export function mountPolicyBuilder(container: HTMLElement, opts: BuilderOptions): void { + // The merge baseline for every submit and the diff's "before" side: + // the applied document, or — on a first apply — the synthesized + // admin baseline (apply_doc replaces EVERY rule and refuses docs + // without a policy-free self-admin rule, so the account's own passkey + // must ride along from the start). + let baselineDoc: PolicyDoc | null = null; + /** True when nothing is applied yet (diff renders all-new). */ + let isFirstApply = false; + /** Human-readable reason the baseline could not be established — the + * builder fails closed rather than applying over an unknown state. */ + let baselineBlocked: string | null = null; + let baselineLoaded = false; + + let tab: BuilderTab = 'session'; + /** Admin tab: the rule name staged for removal, or null (add mode). */ + let pendingRemoval: string | null = null; + + let sessionWrap: HTMLElement; + let adminWrap: HTMLElement; + + const baselineReady: Promise = (async () => { + try { + const surface = await fetchDocSurface(opts.account); + if (!surface.supported) { + baselineBlocked = + "This account's contract has no policy-document surface — it cannot take document updates."; + } else if (surface.appliedDocHash !== null) { + const recovered = await fetchAppliedDocJson(opts.account, toHex(surface.appliedDocHash)); + if (recovered !== null) baselineDoc = parsePolicyDocJson(recovered.json); + else { + baselineBlocked = + 'The applied policy document could not be read — cannot build a safe update.'; + } + } else { + // First apply: anchor the anti-brick admin rule on the account's + // live primary passkey (the constructor default rule). + const info = await fetchDefaultRuleAuthInfo(opts.account); + const passkey = info.externalSigners[0]; + if (passkey === undefined) { + baselineBlocked = + "Could not read the account's primary passkey — a first document must carry it (anti-brick)."; + } else { + baselineDoc = adminBaseline( + { verifier: passkey.verifier, publicKeyHex: bytesToHex(passkey.publicKey) }, + NETWORK_PASSPHRASE, + ); + isFirstApply = true; + } + } + } catch (e) { + baselineBlocked = `Could not read the account's policy state: ${e instanceof Error ? e.message : String(e)}`; + } finally { + baselineLoaded = true; + } + })(); + + // After a successful apply the new document IS the applied one. + function adoptApplied(doc: PolicyDoc): void { + baselineDoc = doc; + isFirstApply = false; + pendingRemoval = null; + } + + function render(): void { + container.innerHTML = ` +
+ + +
+
+
`; + sessionWrap = container.querySelector('#pol-session-wrap')!; + adminWrap = container.querySelector('#pol-admin-wrap')!; + renderSessionForm(); + renderAdminPanel(); + const sessionTab = container.querySelector('#pol-tab-session')!; + const adminTab = container.querySelector('#pol-tab-admin')!; + const applyTab = () => { + sessionWrap.hidden = tab !== 'session'; + adminWrap.hidden = tab !== 'admin'; + sessionTab.className = `btn sm ${tab === 'session' ? 'soft' : 'ghost'}`; + adminTab.className = `btn sm ${tab === 'admin' ? 'soft' : 'ghost'}`; + sessionTab.setAttribute('aria-selected', String(tab === 'session')); + adminTab.setAttribute('aria-selected', String(tab === 'admin')); + }; + sessionTab.addEventListener('click', () => { tab = 'session'; applyTab(); }); + adminTab.addEventListener('click', () => { tab = 'admin'; applyTab(); }); + applyTab(); + } + + // ==== Session-key tab ====================================================== + + function renderSessionForm(): void { + sessionWrap.innerHTML = ` +
+

+ The template: one scoped session key — a delegated key + limited to one contract's named functions, with an optional expiry and + spending cap. Saved into the account's policy document + (one apply_doc transaction), so this page can show exactly + what you approved, verified against the hash stored on chain. +

+ + + + + + + + + + + + +
+ What changes +
Reading the applied document…
+
+ +
+ Document preview (after this update) +
+ doc_hash + — +
+
—
+
+ + + +
+ +
+

+
`; + wireSessionForm(); + } + + function collectDraft(): SessionDocDraft { + const q = (sel: string) => sessionWrap.querySelector(sel); + const expiryOn = q('input[name="doc-expiry-on"]')?.checked ?? false; + const capOn = q('input[name="doc-cap-on"]')?.checked ?? false; + + let notAfterLedger: number | null = null; + if (expiryOn) { + const n = Number(q('input[name="doc-expiry-ledger"]')?.value); + notAfterLedger = Number.isFinite(n) ? n : NaN; + } + let cap: SessionDocDraft['cap'] = null; + if (capOn) { + const xlm = q('input[name="doc-cap-xlm"]')?.value ?? ''; + const period = (q('select[name="doc-cap-period"]')?.value ?? + 'day') as keyof typeof PERIOD_LEDGERS; + let stroops = '0'; + try { + stroops = stroopsFromXlm(xlm).toString(); + } catch { + stroops = '0'; + } + cap = { stroops, periodLedgers: PERIOD_LEDGERS[period] }; + } + return { + name: q('input[name="doc-name"]')?.value ?? '', + signer: { + kind: 'delegated', + address: q('input[name="doc-signer"]')?.value.trim() ?? '', + }, + targetContract: q('input[name="doc-contract"]')?.value.trim() ?? '', + functionsInput: q('input[name="doc-functions"]')?.value ?? '', + notAfterLedger, + cap, + }; + } + + /** The merged document for the current form state, or null while the + * form is invalid or the baseline is unavailable. */ + function mergedFromForm(): PolicyDoc | null { + if (baselineDoc === null) return null; + const draft = collectDraft(); + if (!validateSessionDocDraft(draft).ok) return null; + try { + return upsertSessionRule(baselineDoc, draft, NETWORK_PASSPHRASE).doc; + } catch { + return null; + } + } + + function updateSessionPreview(): void { + const hashEl = sessionWrap.querySelector('#pol-doc-prev-hash')!; + const docEl = sessionWrap.querySelector('#pol-doc-prev-doc')!; + const diffEl = sessionWrap.querySelector('#pol-doc-prev-diff')!; + if (!baselineLoaded) { + diffEl.textContent = 'Reading the applied document…'; + } else if (baselineBlocked !== null) { + diffEl.textContent = baselineBlocked; + } + const merged = mergedFromForm(); + if (merged === null) { + hashEl.textContent = '—'; + docEl.textContent = '—'; + if (baselineLoaded && baselineBlocked === null) { + diffEl.textContent = 'Fill in the template to see what would change.'; + } + return; + } + const hash = docHash(merged); + hashEl.textContent = hash; + docEl.innerHTML = renderDocPreviewHtml(summarizeDoc(merged, hash), canonicalJson(merged)); + // The diff's "before" side is the APPLIED doc — on a first apply that + // is nothing, so everything (admin rule included) renders as new. + diffEl.innerHTML = renderDocDiffHtml(diffPolicyDocs(isFirstApply ? null : baselineDoc, merged)); + } + + function showSessionErrors(errors: string[]): void { + showErrorsIn(sessionWrap, '#pol-doc-errors', errors); + } + + async function submitSession(): Promise { + const draft = collectDraft(); + const check = validateSessionDocDraft(draft); + if (!check.ok) { + showSessionErrors(check.errors); + return; + } + showSessionErrors([]); + await applyUpdate({ + wrap: sessionWrap, + submitSel: '#pol-doc-submit', + statusSel: '#pol-doc-status', + showErrors: showSessionErrors, + buildDoc: () => upsertSessionRule(baselineDoc!, draft, NETWORK_PASSPHRASE).doc, + successToast: 'Policy document applied.', + rerender: () => { + renderSessionForm(); + updateSessionPreview(); + }, + }); + } + + function wireSessionForm(): void { + const q = (sel: string) => sessionWrap.querySelector(sel); + q('input[name="doc-expiry-on"]')?.addEventListener('change', (e) => { + const on = (e.target as HTMLInputElement).checked; + const box = q('#pol-doc-expiry-fields'); + if (box) box.hidden = !on; + updateSessionPreview(); + }); + q('input[name="doc-cap-on"]')?.addEventListener('change', (e) => { + const on = (e.target as HTMLInputElement).checked; + const box = q('#pol-doc-cap-fields'); + if (box) box.hidden = !on; + updateSessionPreview(); + }); + sessionWrap.querySelectorAll('input, select').forEach((el) => { + el.addEventListener('input', updateSessionPreview); + }); + sessionWrap.querySelector('form')?.addEventListener('submit', (e) => { + e.preventDefault(); + void submitSession(); + }); + updateSessionPreview(); + } + + // ==== Admin-keys tab ======================================================= + + function adminKeyRows(): string { + if (baselineDoc === null) return ''; + const admins = adminRules(baselineDoc); + const signerById = new Map(baselineDoc.signers.map((s) => [s.id, s])); + return admins + .map((r) => { + const id = r.principals.type === 'self-authenticating' ? '' : r.principals.signers[0]; + const decl = id !== undefined ? signerById.get(id) : undefined; + const detail = + decl === undefined + ? '' + : 'address' in decl + ? truncate(decl.address) + : truncate(decl.key, 8, 8); + const last = admins.length <= 1; + const removeBtn = last + ? `Last admin — cannot remove (the account would lose its admin authority).` + : ``; + return `
+ ${esc(r.name)} + "${esc(id ?? '')}" + ${esc(detail)} + + ${removeBtn} +
`; + }) + .join(''); + } + + function renderAdminPanel(): void { + const defaultName = baselineDoc !== null ? nextAdminRuleName(baselineDoc) : 'admin-2'; + adminWrap.innerHTML = ` +
+

+ Any of the keys below can act for this account on + its own — each holds independent full authority (one policy-free + self-admin rule per key in the document). Add a backup passkey or + another device's key; remove one when it should no longer control + the account. +

+ +
Admin keys — any may act
+
${baselineLoaded && baselineDoc !== null ? adminKeyRows() : `${esc(baselineBlocked ?? 'Reading the applied document…')}`}
+ +
+
Add an admin key
+
+ + +
+ + +
+ +
+ Removing admin key rule "${esc(pendingRemoval ?? '')}" — + review the change below, then apply. + +
+ +
+ What changes +
—
+
+ +
+ Document preview (after this update) +
+ doc_hash + — +
+
—
+
+ + + +
+ +
+

+
`; + wireAdminPanel(); + } + + /** The pasted-or-pending admin draft. In new-passkey mode the key does + * not exist until submit runs the WebAuthn ceremony, so this returns + * null there (the preview explains instead). */ + function collectAdminDraft(): AdminKeyDraft | null { + const q = (sel: string) => adminWrap.querySelector(sel); + const source = q('input[name="adm-source"]:checked')?.value ?? 'new-passkey'; + if (source !== 'paste') return null; + const kind = q('select[name="adm-kind"]')?.value ?? 'passkey'; + const name = q('input[name="adm-name"]')?.value ?? ''; + if (kind === 'delegated') { + return { + name, + signer: { kind: 'delegated', address: q('input[name="adm-address"]')?.value.trim() ?? '' }, + }; + } + return { + name, + signer: { + kind: 'passkey', + verifier: q('input[name="adm-verifier"]')?.value.trim() ?? '', + publicKeyHex: q('input[name="adm-pubkey"]')?.value.trim() ?? '', + }, + }; + } + + function updateAdminPreview(): void { + const hashEl = adminWrap.querySelector('#pol-adm-prev-hash')!; + const docEl = adminWrap.querySelector('#pol-adm-prev-doc')!; + const diffEl = adminWrap.querySelector('#pol-adm-prev-diff')!; + const showNone = (msg: string) => { + hashEl.textContent = '—'; + docEl.textContent = '—'; + diffEl.textContent = msg; + }; + if (!baselineLoaded) return showNone('Reading the applied document…'); + if (baselineBlocked !== null || baselineDoc === null) { + return showNone(baselineBlocked ?? 'Unavailable.'); + } + + let merged: PolicyDoc; + if (pendingRemoval !== null) { + try { + merged = removeAdminRule(baselineDoc, pendingRemoval, NETWORK_PASSPHRASE); + } catch (e) { + return showNone(e instanceof Error ? e.message : String(e)); + } + } else { + const draft = collectAdminDraft(); + if (draft === null) { + // New-passkey mode: the key material appears at submit time. + const name = adminWrap.querySelector('input[name="adm-name"]')?.value ?? 'admin'; + return showNone( + `A new passkey will be created in this device's authenticator when you submit, then enrolled as admin rule "${name}". The exact document (and its hash) appears at the confirm step.`, + ); + } + if (!validateAdminKeyDraft(draft, baselineDoc).ok) { + return showNone('Fill in the key to see what would change.'); + } + try { + merged = addAdminKey(baselineDoc, draft, NETWORK_PASSPHRASE).doc; + } catch (e) { + return showNone(e instanceof Error ? e.message : String(e)); + } + } + const hash = docHash(merged); + hashEl.textContent = hash; + docEl.innerHTML = renderDocPreviewHtml(summarizeDoc(merged, hash), canonicalJson(merged)); + diffEl.innerHTML = renderDocDiffHtml(diffPolicyDocs(isFirstApply ? null : baselineDoc, merged)); + } + + function showAdminErrors(errors: string[]): void { + showErrorsIn(adminWrap, '#pol-adm-errors', errors); + } + + async function submitAdmin(): Promise { + await baselineReady; + if (baselineDoc === null || baselineBlocked !== null) { + showAdminErrors([baselineBlocked ?? 'The policy baseline is unavailable.']); + return; + } + + if (pendingRemoval !== null) { + const rule = pendingRemoval; + let doc: PolicyDoc; + try { + doc = removeAdminRule(baselineDoc, rule, NETWORK_PASSPHRASE); + } catch (e) { + showAdminErrors([e instanceof Error ? e.message : String(e)]); + return; + } + showAdminErrors([]); + await applyUpdate({ + wrap: adminWrap, + submitSel: '#pol-adm-submit', + statusSel: '#pol-adm-status', + showErrors: showAdminErrors, + buildDoc: () => doc, + successToast: `Admin key rule "${rule}" removed.`, + rerender: () => { + renderAdminPanel(); + updateAdminPreview(); + }, + }); + return; + } + + // Add path: in new-passkey mode, run the WebAuthn ceremony NOW (the + // submit click is the user activation), then enroll the fresh key. + const q = (sel: string) => adminWrap.querySelector(sel); + const source = q('input[name="adm-source"]:checked')?.value ?? 'new-passkey'; + let draft = collectAdminDraft(); + if (source === 'new-passkey') { + const status = q('#pol-adm-status'); + try { + if (status) status.textContent = 'Creating the new passkey…'; + const created = await createSessionPasskey({ + rpId: window.location.hostname, + rpName: window.location.host, + userName: `admin-key:${opts.account}`, + }); + if (status) status.textContent = 'Resolving the verifier…'; + const verifier = await fetchVerifierAddress(opts.account); + draft = { + name: q('input[name="adm-name"]')?.value ?? '', + signer: { kind: 'passkey', verifier, publicKeyHex: bytesToHex(created.publicKey) }, + }; + } catch (e) { + showAdminErrors([ + `Passkey creation failed: ${e instanceof Error ? e.message : String(e)}`, + ]); + if (status) status.textContent = ''; + return; + } + } + if (draft === null) return; + + const check = validateAdminKeyDraft(draft, baselineDoc); + if (!check.ok) { + showAdminErrors(check.errors); + return; + } + showAdminErrors([]); + const built = addAdminKey(baselineDoc, draft, NETWORK_PASSPHRASE).doc; + await applyUpdate({ + wrap: adminWrap, + submitSel: '#pol-adm-submit', + statusSel: '#pol-adm-status', + showErrors: showAdminErrors, + buildDoc: () => built, + successToast: 'Admin key enrolled.', + rerender: () => { + renderAdminPanel(); + updateAdminPreview(); + }, + }); + } + + function wireAdminPanel(): void { + const q = (sel: string) => adminWrap.querySelector(sel); + adminWrap.querySelectorAll('input[name="adm-source"]').forEach((el) => { + el.addEventListener('change', () => { + const paste = q('input[name="adm-source"]:checked')?.value === 'paste'; + const box = q('#pol-adm-paste'); + if (box) box.hidden = !paste; + updateAdminPreview(); + }); + }); + q('select[name="adm-kind"]')?.addEventListener('change', () => { + const delegated = q('select[name="adm-kind"]')?.value === 'delegated'; + const verifier = q('input[name="adm-verifier"]'); + const pubkey = q('input[name="adm-pubkey"]'); + const address = q('input[name="adm-address"]'); + if (verifier) verifier.hidden = delegated; + if (pubkey) pubkey.hidden = delegated; + if (address) address.hidden = !delegated; + updateAdminPreview(); + }); + adminWrap.querySelectorAll('.pol-adm-remove').forEach((btn) => { + btn.addEventListener('click', () => { + pendingRemoval = btn.dataset.rule ?? null; + renderAdminPanel(); + updateAdminPreview(); + }); + }); + q('#pol-adm-cancel-removal')?.addEventListener('click', () => { + pendingRemoval = null; + renderAdminPanel(); + updateAdminPreview(); + }); + adminWrap.querySelectorAll('input, select').forEach((el) => { + el.addEventListener('input', updateAdminPreview); + }); + adminWrap.querySelector('form')?.addEventListener('submit', (e) => { + e.preventDefault(); + void submitAdmin(); + }); + updateAdminPreview(); + } + + // ==== Shared apply plumbing ================================================ + + function showErrorsIn(wrap: HTMLElement, sel: string, errors: string[]): void { + const box = wrap.querySelector(sel); + if (!box) return; + if (errors.length === 0) { + box.hidden = true; + box.innerHTML = ''; + return; + } + box.hidden = false; + box.innerHTML = `
    ${errors.map((e) => `
  • ${esc(e)}
  • `).join('')}
`; + } + + /** Build the doc (against the LOADED baseline), submit one apply_doc + * through the passkey signing path, and refresh on success. */ + async function applyUpdate(args: { + wrap: HTMLElement; + submitSel: string; + statusSel: string; + showErrors: (errors: string[]) => void; + buildDoc: () => PolicyDoc; + successToast: string; + rerender: () => void; + }): Promise { + const submitBtn = args.wrap.querySelector(args.submitSel); + const status = args.wrap.querySelector(args.statusSel); + if (submitBtn) submitBtn.disabled = true; + const setStatus = (t: string) => { + if (status) status.textContent = t; + }; + + try { + setStatus('Reading the applied document…'); + await baselineReady; + if (baselineBlocked !== null || baselineDoc === null) { + args.showErrors([baselineBlocked ?? 'The policy baseline is unavailable.']); + setStatus(''); + return; + } + const doc = args.buildDoc(); + + setStatus('Building the apply_doc transaction…'); + const tx = await buildApplyDocTx(doc, { + account: opts.account, + rpcUrl: RPC_URL, + networkPassphrase: NETWORK_PASSPHRASE, + }); + await signAndSubmit({ + account: opts.account, + operation: tx.operations[0]!, + onProgress: (p) => setStatus(`${p.phase}${p.detail ? `: ${p.detail}` : ''}…`), + }); + + toast(args.successToast); + setStatus(''); + adoptApplied(doc); + args.rerender(); + opts.onSubmitted?.(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + args.showErrors([msg]); + setStatus(''); + } finally { + if (submitBtn) submitBtn.disabled = false; + } + } + + render(); + void baselineReady.then(() => { + updateSessionPreview(); + renderAdminPanel(); + updateAdminPreview(); + }); +} diff --git a/packages/frontend/src/components/PolicyInspector.ts b/packages/frontend/src/components/PolicyInspector.ts new file mode 100644 index 00000000..09408ec7 --- /dev/null +++ b/packages/frontend/src/components/PolicyInspector.ts @@ -0,0 +1,442 @@ +// Renders the full list of a smart account's context rules — the general +// "what can happen on this account and who authorizes it" view — plus, for +// accounts with an applied perch policy document, the LOSSLESS document view +// (the doc's own rule names, signer ids, and function lists, verified +// against the stored on-chain doc_hash). Pure DOM from the pure display +// models in lib/policy/{policyView,docView}; the page fetches the data. + +import type { ChainRule } from '@nidohq/passkey-sdk'; +import { esc } from '../lib/html.js'; +import { summarizeRule, type RuleView, type SignerView, type PolicyView } from '../lib/policy/policyView.js'; +import type { DocRuleView, DocSignerView, DocViewModel } from '../lib/policy/docView.js'; +import type { DocJsonSource } from '../lib/policy/docPolicyFetch.js'; +import type { DocDiff } from '../lib/policy/docDiff.js'; +import { describeDocRule } from '../lib/policy/docView.js'; + +export interface InspectorContext { + /** policy contract address → registry label. */ + known?: ReadonlyMap; + /** current ledger sequence, for expiry classification. */ + currentLedger?: number | null; +} + +function signerRow(s: SignerView): string { + const icon = s.kind === 'passkey' ? '🔑' : '👤'; + return `
  • + + ${esc(s.label)} + ${esc(s.detail)} +
  • `; +} + +function policyChip(p: PolicyView): string { + const cls = p.known ? 'pol-chip known' : 'pol-chip custom'; + return `${esc(p.label)} · ${esc(p.short)}`; +} + +function expiryBadge(view: RuleView): string { + const { state, label } = view.expiry; + if (state === 'none') return ''; + const cls = state === 'expired' ? 'pol-badge danger' : 'pol-badge'; + return `${esc(label)}`; +} + +function ruleCard(view: RuleView): string { + const badges = [ + view.isDefault ? 'Primary authority' : '', + view.gated ? 'Conditions apply' : '', + expiryBadge(view), + ] + .filter(Boolean) + .join(''); + + const scopeDetail = view.scope.detail + ? `${esc(view.scope.detail)}` + : ''; + + const signers = view.signers.length + ? `
      ${view.signers.map(signerRow).join('')}
    ` + : `

    No signers — authorized entirely by attached conditions.

    `; + + const policies = view.policies.length + ? `
    ${view.policies.map(policyChip).join('')}
    ` + : ''; + + return `
    +
    +
    + +

    ${esc(view.name || '(unnamed)')}

    +
    +
    ${badges}
    +
    +

    ${esc(view.permission)}

    +
    +
    + Scope + ${esc(view.scope.label)} ${scopeDetail} +
    +
    + Signers (${view.signers.length}) + ${signers} +
    + ${ + policies + ? `
    Conditions${policies}
    ` + : '' + } +
    +
    `; +} + +// --- Document view (tiers a/b) --------------------------------------------- + +function docSignerRow(s: DocSignerView): string { + const icon = s.kind === 'passkey' ? '🔑' : '👤'; + return `
  • + + "${esc(s.id)}" + ${esc(s.kindLabel)} + ${esc(s.detail)} +
  • `; +} + +function docExpiryBadge(rule: DocRuleView, currentLedger: number | null): string { + if (rule.notAfterLedger === null) return 'No expiry'; + const expired = currentLedger !== null && currentLedger >= rule.notAfterLedger; + const cls = expired ? 'pol-badge danger' : 'pol-badge'; + const label = expired + ? `Expired at ledger ${rule.notAfterLedger}` + : `Stops at ledger ${rule.notAfterLedger}`; + return `${esc(label)}`; +} + +function docRuleCard(rule: DocRuleView, currentLedger: number | null): string { + const functions = rule.functions + ? `
    ${rule.functions.map((f) => `${esc(f)}`).join('')}
    ` + : `Any function`; + const capRow = rule.cap + ? `
    + Spending cap + ${esc(formatStroops(rule.cap.limit))} XLM per ${rule.cap.periodLedgers.toLocaleString()} ledgers (rolling) +
    ` + : ''; + const argsBadge = rule.hasArgConstraints + ? 'Argument conditions' + : ''; + + return `
    +
    +
    + +

    ${esc(rule.name)}

    +
    +
    ${argsBadge}${docExpiryBadge(rule, currentLedger)}
    +
    +

    ${esc(rule.permission)}

    +
    + ${ + rule.contract + ? `
    + Contract + ${esc(rule.contract)} +
    ` + : '' + } +
    + Signers + ${rule.signerIds.map((id) => `"${esc(id)}"`).join('')} ${esc(rule.quorumLabel)} +
    +
    + Functions + ${functions} +
    + ${capRow} +
    +
    `; +} + +/** Render `limit` stroops as a short XLM amount (display only). */ +function formatStroops(stroops: string): string { + try { + const v = BigInt(stroops); + const whole = v / 10_000_000n; + const frac = v % 10_000_000n; + if (frac === 0n) return whole.toString(); + return `${whole}.${frac.toString().padStart(7, '0').replace(/0+$/, '')}`; + } catch { + return stroops; + } +} + +export interface DocRenderContext { + /** Current ledger sequence, for expiry classification. */ + currentLedger?: number | null; + /** Where the doc JSON was recovered from (storage = the lossless on-chain + * copy; events = event history). */ + source?: DocJsonSource | null; + /** The document's canonical JSON. When present, the doc view offers a + * raw-JSON toggle: pretty-printed for reading, with a copy button the + * page wires (id `pol-doc-copy-json`) that should copy THIS exact + * canonical string — the bytes whose sha256 is the stored doc_hash. */ + canonicalJson?: string; + /** True for a fresh doc-surface account: the rendered document is the + * SYNTHESIZED baseline (the account's current effective policy), not an + * applied document — the first policy edit applies it and the label + * drops. Changes the badge + lead copy; the hash shown is the identity + * the document WOULD store. */ + unapplied?: boolean; +} + +/** The doc view's raw-JSON toggle: pretty-printed for reading (the rule + * cards stay the default view), copy button for the exact canonical + * string. Empty when no canonical JSON was provided. */ +function rawJsonToggle(canonical: string | undefined): string { + if (canonical === undefined) return ''; + let pretty: string; + try { + pretty = JSON.stringify(JSON.parse(canonical), null, 2); + } catch { + pretty = canonical; + } + return `
    + Raw document JSON +
    + + Pretty-printed for reading. Copy gives the exact canonical bytes — + their sha256 is the doc_hash above. + + +
    +
    ${esc(pretty)}
    +
    `; +} + +/** The consolidated "Admin keys" card: every admin rule folded into ONE + * list — each is independent full authority, so any listed key may act. + * Rule names ride along small (they're the revoke handle in the builder). */ +function adminKeysCard(adminViews: DocRuleView[], signers: DocSignerView[]): string { + if (adminViews.length === 0) return ''; + const byId = new Map(signers.map((s) => [s.id, s])); + const rows = adminViews + .map((r) => { + const s = r.signerIds[0] !== undefined ? byId.get(r.signerIds[0]) : undefined; + const icon = s?.kind === 'passkey' ? '🔑' : '👤'; + return `
  • + + "${esc(s?.id ?? r.signerIds[0] ?? '')}" + ${esc(s?.kindLabel ?? '')} + ${esc(s?.detail ?? '')} + rule ${esc(r.name)} +
  • `; + }) + .join(''); + return `
    +
    +
    + +

    Admin keys

    +
    +
    Full authority
    +
    +

    Any of these ${adminViews.length === 1 ? 'keys' : `${adminViews.length} keys`} can act for this account on its own — each holds independent full authority.

    +
    + Keys (${adminViews.length}) +
      ${rows}
    +
    +
    `; +} + +/** Render the verified policy document (tiers a/b) into `container`. */ +export function renderDocPolicy( + container: HTMLElement, + model: DocViewModel, + ctx: DocRenderContext = {}, +): void { + const currentLedger = ctx.currentLedger ?? null; + const unapplied = ctx.unapplied === true; + const tierBadge = unapplied + ? 'Not yet applied' + : 'Verified · lossless'; + const sourceBadge = unapplied + ? '' + : ctx.source === 'storage' + ? 'Stored on chain' + : ctx.source === 'events' + ? 'From event history' + : ''; + + container.innerHTML = ` +
    +
    +
    + +

    What was approved for this account

    +
    +
    ${tierBadge}${sourceBadge}
    +
    +

    + ${ + unapplied + ? "The account's current effective policy, shown as the document it would become — nothing has been applied as a document yet. Your first policy edit applies exactly this (plus your change)." + : 'This is the document itself — names and all — checked byte-for-byte against the hash the account stores on chain.' + } +

    +
    + ${unapplied ? 'Document hash (once applied)' : 'Document hash'} + ${esc(model.docHashShort)} +
    +
    + Signers (${model.signers.length}) +
      ${model.signers.map(docSignerRow).join('')}
    +
    + ${rawJsonToggle(ctx.canonicalJson)} +
    + ${adminKeysCard(model.rules.filter((r) => r.isAdmin), model.signers)} + ${model.rules.filter((r) => !r.isAdmin).map((r) => docRuleCard(r, currentLedger)).join('')}`; +} + +// --- Compact document preview ---------------------------------------------- + +/** + * Render a document as a COMPACT, readable preview — for the builder's and + * delegate-doc's "document after this update" panels. Same display model as + * the inspector's full doc view (`summarizeDoc`), condensed: signer chips, + * one mini-card per rule (name, permission sentence, function/cap/expiry + * facts), and the canonical JSON tucked behind a details toggle rather than + * dumped raw. Pure string builder, no DOM. + */ +export function renderDocPreviewHtml(model: DocViewModel, canonical: string): string { + const signerChips = model.signers + .map( + (s) => + `${s.kind === 'passkey' ? '🔑' : '👤'} "${esc(s.id)}" · ${esc(s.detail)}`, + ) + .join(''); + + const ruleCard = (r: DocRuleView): string => { + const facts: string[] = []; + if (r.functions !== null) { + facts.push( + `Functions: ${r.functions.map((f) => `${esc(f)}`).join(', ')}`, + ); + } + if (r.cap !== null) { + facts.push( + `Cap: ${esc(r.cap.limit)} stroops / ${r.cap.periodLedgers.toLocaleString()} ledgers`, + ); + } + facts.push( + `${r.notAfterLedger === null ? 'No expiry' : `Stops at ledger ${r.notAfterLedger.toLocaleString()}`}`, + ); + return `
    +
    + ${esc(r.name)} + ${esc(r.scopeLabel)} +
    +

    ${esc(r.permission)}

    +
    ${facts.join('')}
    +
    `; + }; + + return `
    +
    ${signerChips}
    + ${model.rules.map(ruleCard).join('')} +
    + Raw document JSON +
    ${esc(canonical)}
    +
    +
    `; +} + +// --- Document-update diff -------------------------------------------------- + +/** + * Render a DocDiff (lib/policy/docDiff) as an HTML string — the "what + * changes if you apply this" panel shown before any apply_doc confirm. + * Shared by the policy-page builder, the delegate-doc request page, and + * /sign/. Pure string builder, no DOM. + */ +export function renderDocDiffHtml(diff: DocDiff): string { + if (diff.firstApply) { + return `
    +

    First document + This account has no applied document — every rule below is newly granted.

    +
    `; + } + if (diff.identical) { + return `
    +

    No changes + This document is identical to the one already applied.

    +
    `; + } + + const parts: string[] = []; + + for (const r of diff.rulesAdded) { + const v = describeDocRule(r); + parts.push(`
    + + added +
    ${esc(r.name)}
    ${esc(v.permission)}
    +
    `); + } + for (const r of diff.rulesRemoved) { + const v = describeDocRule(r); + parts.push(`
    + − removed +
    ${esc(r.name)}
    ${esc(v.permission)}
    +
    `); + } + for (const m of diff.rulesModified) { + parts.push(`
    + ± changed +
    ${esc(m.name)} +
      ${m.changes.map((c) => `
    • ${esc(c)}
    • `).join('')}
    +
    +
    `); + } + for (const s of diff.signers) { + const badge = + s.kind === 'added' + ? '+ key' + : s.kind === 'removed' + ? '− key' + : '± key'; + const detail = 'address' in s.decl ? s.decl.address : s.decl.key; + const label = + s.kind === 'rekeyed' ? 'now declares a different key' : s.kind === 'added' ? 'newly declared' : 'no longer declared'; + parts.push(`
    + ${badge} +
    "${esc(s.decl.id)}" ${esc(label)} +
    ${esc(detail)}
    +
    +
    `); + } + if (diff.unchangedRuleNames.length > 0) { + parts.push(`

    + Unchanged: ${diff.unchangedRuleNames.map((n) => esc(n)).join(', ')}.

    `); + } + + return `
    +

    + Applying replaces the account's document. This update changes:

    + ${parts.join('')} +
    `; +} + +/** Render (or re-render) the rule list into `container`. */ +export function renderPolicyList( + container: HTMLElement, + rules: ChainRule[], + ctx: InspectorContext = {}, +): void { + if (rules.length === 0) { + container.innerHTML = `

    No policy rules found on this account.

    `; + return; + } + const views = rules + .slice() + .sort((a, b) => a.ruleId - b.ruleId) + .map((r) => summarizeRule(r, { known: ctx.known, currentLedger: ctx.currentLedger })); + container.innerHTML = views.map(ruleCard).join(''); +} diff --git a/packages/frontend/src/lib/policy/docDiff.test.ts b/packages/frontend/src/lib/policy/docDiff.test.ts new file mode 100644 index 00000000..3d3d31d8 --- /dev/null +++ b/packages/frontend/src/lib/policy/docDiff.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect } from 'vitest'; +import { Networks } from '@stellar/stellar-sdk'; +import { buildPolicyDoc, scopedSessionKeyDoc } from '@nidohq/passkey-sdk'; +import { diffPolicyDocs, diffRuleFields } from './docDiff.js'; +import { adminBaseline, upsertSessionRule, type SessionDocDraft } from './docDraft.js'; + +const TARGET = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; +const TARGET2 = 'CDVVRZAVXTUQLS5LCGUP3H26RGOIUFKNE2UEJ6CAWYMBWY5LNORF6POX'; +const VERIFIER = 'CD4IF75DNQJKCT35PAJAQDPW3K337EK6SJZDMQEVLXAH65K7ZVZMLXYN'; +const G1 = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; +const G2 = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; +const OWNER_KEY = '04' + 'ab'.repeat(64); + +const baseDoc = buildPolicyDoc({ + signers: [ + { id: 'admin', kind: 'passkey', verifier: VERIFIER, publicKey: OWNER_KEY }, + { id: 'ops', kind: 'delegated', address: G1 }, + ], + permissions: [ + { name: 'pay', on: { contract: TARGET }, by: ['admin'], functions: ['transfer'], until: 9000 }, + { name: 'ops', on: { contract: TARGET2 }, by: ['ops'] }, + ], +}); + +describe('diffPolicyDocs', () => { + it('marks a first apply as all-new', () => { + const d = diffPolicyDocs(null, baseDoc); + expect(d.firstApply).toBe(true); + expect(d.rulesAdded.map((r) => r.name)).toEqual(['pay', 'ops']); + expect(d.signers.map((s) => s.kind)).toEqual(['added', 'added']); + expect(d.identical).toBe(false); + }); + + it('reports identical docs as a no-op', () => { + const d = diffPolicyDocs(baseDoc, baseDoc); + expect(d.identical).toBe(true); + expect(d.unchangedRuleNames).toEqual(['pay', 'ops']); + }); + + it('classifies added, removed, and modified rules by name', () => { + const next = buildPolicyDoc({ + signers: [ + { id: 'admin', kind: 'passkey', verifier: VERIFIER, publicKey: OWNER_KEY }, + { id: 'session', kind: 'delegated', address: G2 }, + ], + permissions: [ + // 'pay' modified: functions widen, expiry moves. + { name: 'pay', on: { contract: TARGET }, by: ['admin'], functions: ['transfer', 'approve'], until: 12000 }, + // 'ops' removed; 'session' added. + { name: 'session', on: { contract: TARGET2 }, by: ['session'], until: 500 }, + ], + }); + const d = diffPolicyDocs(baseDoc, next); + expect(d.firstApply).toBe(false); + expect(d.rulesAdded.map((r) => r.name)).toEqual(['session']); + expect(d.rulesRemoved.map((r) => r.name)).toEqual(['ops']); + expect(d.rulesModified).toHaveLength(1); + expect(d.rulesModified[0].name).toBe('pay'); + expect(d.rulesModified[0].changes.join('\n')).toContain('functions: transfer → transfer, approve'); + expect(d.rulesModified[0].changes.join('\n')).toContain('expiry: ledger 9000 → ledger 12000'); + // Signer churn: 'session' added, 'ops' removed. + expect(d.signers).toEqual([ + { decl: { id: 'session', address: G2 }, kind: 'added' }, + { decl: { id: 'ops', address: G1 }, kind: 'removed' }, + ]); + }); + + it('surfaces a rekeyed signer on rules that reference it', () => { + const next = buildPolicyDoc({ + signers: [ + { id: 'admin', kind: 'passkey', verifier: VERIFIER, publicKey: OWNER_KEY }, + { id: 'ops', kind: 'delegated', address: G2 }, // same id, new key + ], + permissions: [ + { name: 'pay', on: { contract: TARGET }, by: ['admin'], functions: ['transfer'], until: 9000 }, + { name: 'ops', on: { contract: TARGET2 }, by: ['ops'] }, + ], + }); + const d = diffPolicyDocs(baseDoc, next); + expect(d.signers).toEqual([{ decl: { id: 'ops', address: G2 }, kind: 'rekeyed' }]); + expect(d.rulesModified).toHaveLength(1); + expect(d.rulesModified[0].name).toBe('ops'); + expect(d.rulesModified[0].changes[0]).toContain('signer "ops" now declares a different key'); + }); +}); + +describe('renderDocDiffHtml', () => { + it('renders a first apply as all-new and an update with its change kinds', async () => { + const { renderDocDiffHtml } = await import('../../components/PolicyInspector.js'); + const first = renderDocDiffHtml(diffPolicyDocs(null, baseDoc)); + expect(first).toContain('First document'); + + const draft: SessionDocDraft = { + name: 'session', + signer: { kind: 'delegated' as const, address: G2 }, + targetContract: TARGET2, + functionsInput: '', + notAfterLedger: null, + cap: null, + }; + const { doc } = upsertSessionRule(baseDoc, draft, Networks.TESTNET); + const update = renderDocDiffHtml(diffPolicyDocs(baseDoc, doc)); + expect(update).toContain('+ added'); + expect(update).toContain('session'); + expect(update).toContain('Unchanged: pay, ops'); + + const noop = renderDocDiffHtml(diffPolicyDocs(baseDoc, baseDoc)); + expect(noop).toContain('No changes'); + }); +}); + +describe('diffRuleFields', () => { + it('reports scope, cap, and quorum changes', () => { + const a = scopedSessionKeyDoc({ sessionAddress: G1, targetContract: TARGET }).rules[0]; + const b = { + ...scopedSessionKeyDoc({ + sessionAddress: G1, + targetContract: TARGET2, + cap: { limitStroops: 5_0000000n, periodLedgers: 17280 }, + }).rules[0], + }; + const changes = diffRuleFields(a, b); + expect(changes.join('\n')).toContain('scope:'); + expect(changes.join('\n')).toContain('cap: no cap → 50000000 stroops per 17280 ledgers'); + }); +}); + +describe('upsertSessionRule', () => { + const draft: SessionDocDraft = { + name: 'session', + signer: { kind: 'delegated' as const, address: G2 }, + targetContract: TARGET2, + functionsInput: 'udpate_message', + notAfterLedger: 700, + cap: null, + }; + + it('first apply: upserts into the owner-admin baseline (anti-brick rule rides along)', () => { + const baseline = adminBaseline( + { verifier: VERIFIER, publicKeyHex: OWNER_KEY }, + Networks.TESTNET, + ); + const { doc, signerId } = upsertSessionRule(baseline, draft, Networks.TESTNET); + expect(signerId).toBe('session'); + expect(doc.rules.map((r) => r.name)).toEqual(['admin', 'session']); + // The admin rule is the policy-free self-admin shape the contract's + // DocAdminLockout check requires. + expect(doc.rules[0].scope).toEqual({ type: 'self-admin' }); + expect(doc.rules[0].functions).toBeUndefined(); + expect(doc.rules[0].cap).toBeUndefined(); + expect(doc.signers.map((s) => s.id)).toEqual(['admin', 'session']); + // Everything renders as newly granted on the first apply. + const d = diffPolicyDocs(null, doc); + expect(d.firstApply).toBe(true); + expect(d.rulesAdded.map((r) => r.name)).toEqual(['admin', 'session']); + }); + + it('appends to an existing doc, keeping its rules and signers', () => { + const { doc, signerId } = upsertSessionRule(baseDoc, draft, Networks.TESTNET); + expect(signerId).toBe('session'); + expect(doc.rules.map((r) => r.name)).toEqual(['pay', 'ops', 'session']); + expect(doc.signers.map((s) => s.id)).toEqual(['admin', 'ops', 'session']); + const d = diffPolicyDocs(baseDoc, doc); + expect(d.rulesAdded.map((r) => r.name)).toEqual(['session']); + expect(d.rulesRemoved).toEqual([]); + expect(d.rulesModified).toEqual([]); + }); + + it('reuses an existing declaration for the same key', () => { + const { doc, signerId } = upsertSessionRule( + baseDoc, + { ...draft, signer: { kind: 'delegated' as const, address: G1 } }, + Networks.TESTNET, + ); + expect(signerId).toBe('ops'); + expect(doc.signers.map((s) => s.id)).toEqual(['admin', 'ops']); + }); + + it('allocates a fresh id on collision and prunes orphaned signers on replace', () => { + // First install a 'session' rule for G2, then replace it with one for a + // different key: the old declaration must not linger unreferenced. + const first = upsertSessionRule(baseDoc, draft, Networks.TESTNET).doc; + const G3 = 'GCS7RFDDWSU2S2KYWEZDGHDGYUHM2VZWMCDTFP7ZS3MK7RY2VUXQ5D67'; + const second = upsertSessionRule(first, { ...draft, signer: { kind: 'delegated' as const, address: G3 } }, Networks.TESTNET); + expect(second.signerId).toBe('session-2'); + expect(second.doc.signers.map((s) => s.id)).toEqual(['admin', 'ops', 'session-2']); + const d = diffPolicyDocs(first, second.doc); + expect(d.rulesModified.map((m) => m.name)).toEqual(['session']); + expect(d.signers.map((s) => [s.decl.id, s.kind])).toEqual([ + ['session-2', 'added'], + ['session', 'removed'], + ]); + }); + + it('refuses a cross-network update', () => { + const bound = adminBaseline( + { verifier: VERIFIER, publicKeyHex: OWNER_KEY }, + Networks.TESTNET, + ); + expect(() => upsertSessionRule(bound, draft, 'Other Net')).toThrow(/bound to/); + }); +}); + +describe('legacy owner→admin migration (captain naming ruling)', () => { + it('renames owner to admin on the next composed update and shows it in the diff', () => { + const legacy = buildPolicyDoc({ + signers: [{ id: 'owner', kind: 'passkey', verifier: VERIFIER, publicKey: OWNER_KEY }], + permissions: [{ name: 'admin', on: 'self-admin', by: ['owner'] }], + }); + const draft: SessionDocDraft = { + name: 'session', + signer: { kind: 'delegated', address: G2 }, + targetContract: TARGET2, + functionsInput: '', + notAfterLedger: null, + cap: null, + }; + const { doc } = upsertSessionRule(legacy, draft, Networks.TESTNET); + // The update carries the rename: no 'owner' id survives. + expect(doc.signers.map((s) => s.id)).toEqual(['admin', 'session']); + expect(doc.rules[0].principals).toEqual({ type: 'all', signers: ['admin'] }); + // And the diff against the still-legacy applied doc RENDERS the rename. + const d = diffPolicyDocs(legacy, doc); + expect(d.signers.map((c) => [c.decl.id, c.kind])).toContainEqual(['admin', 'added']); + expect(d.signers.map((c) => [c.decl.id, c.kind])).toContainEqual(['owner', 'removed']); + expect(d.rulesModified.map((m) => m.name)).toEqual(['admin']); + expect(d.rulesModified[0].changes.join(' ')).toContain('owner'); + expect(d.rulesModified[0].changes.join(' ')).toContain('admin'); + }); + + it('never renames when a distinct admin id already exists (guard)', () => { + const G3 = 'GCS7RFDDWSU2S2KYWEZDGHDGYUHM2VZWMCDTFP7ZS3MK7RY2VUXQ5D67'; + const mixed = buildPolicyDoc({ + signers: [ + { id: 'owner', kind: 'passkey', verifier: VERIFIER, publicKey: OWNER_KEY }, + { id: 'admin', kind: 'delegated', address: G3 }, + ], + permissions: [ + { name: 'admin', on: 'self-admin', by: ['owner'] }, + { name: 'admin-b', on: 'self-admin', by: ['admin'] }, + ], + }); + const draft: SessionDocDraft = { + name: 'session', + signer: { kind: 'delegated', address: G2 }, + targetContract: TARGET2, + functionsInput: '', + notAfterLedger: null, + cap: null, + }; + const { doc } = upsertSessionRule(mixed, draft, Networks.TESTNET); + expect(doc.signers.map((s) => s.id)).toEqual(['owner', 'admin', 'session']); + }); +}); diff --git a/packages/frontend/src/lib/policy/docDiff.ts b/packages/frontend/src/lib/policy/docDiff.ts new file mode 100644 index 00000000..daa9c95e --- /dev/null +++ b/packages/frontend/src/lib/policy/docDiff.ts @@ -0,0 +1,175 @@ +// Pure diff between two perch policy documents (canonical wire forms, as +// returned by `parsePolicyDoc[Json]`): what changes when `next` replaces +// `prev` as an account's applied document. +// +// apply_doc is a whole-document write — every update REPLACES the applied +// document — so before confirming, the user must see exactly what moves: +// rules added / removed / modified (name-level, with field-level detail on +// modified rules) and signer declarations added / removed. Rule identity is +// the rule NAME (unique within a valid doc — lowering enforces it); signer +// identity is the declaration id. + +import type { PolicyDoc, Rule, SignerDecl } from '@nidohq/passkey-sdk'; +import { truncate } from './policyView.js'; + +export interface RuleModification { + name: string; + before: Rule; + after: Rule; + /** Human-readable field-level change lines, one per changed field. */ + changes: string[]; +} + +export interface SignerChange { + decl: SignerDecl; + /** 'added' | 'removed' | 'rekeyed' — rekeyed means the same id now + * declares a different key/address (shown once, on the new decl). */ + kind: 'added' | 'removed' | 'rekeyed'; +} + +export interface DocDiff { + /** True when there is no currently applied document — everything is new. */ + firstApply: boolean; + signers: SignerChange[]; + rulesAdded: Rule[]; + rulesRemoved: Rule[]; + rulesModified: RuleModification[]; + /** Rules present in both docs with no changes. */ + unchangedRuleNames: string[]; + /** True when the documents are canonically identical (no-op apply). */ + identical: boolean; +} + +function signerKey(s: SignerDecl): string { + return 'address' in s ? `delegated:${s.address}` : `external:${s.verifier}:${s.key}`; +} + +function signerLabel(s: SignerDecl): string { + return 'address' in s ? truncate(s.address) : truncate(s.key, 8, 8); +} + +function sameStringArray(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean { + if (a === undefined || b === undefined) return a === b; + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +/** Field-level change lines for one renamed-to-itself rule pair. */ +export function diffRuleFields(before: Rule, after: Rule): string[] { + const changes: string[] = []; + + const scopeOf = (r: Rule) => (r.scope.type === 'contract' ? r.scope.address : 'this account'); + if (scopeOf(before) !== scopeOf(after)) { + changes.push(`scope: ${truncate(scopeOf(before))} → ${truncate(scopeOf(after))}`); + } + + const pb = before.principals; + const pa = after.principals; + const sigsOf = (p: Rule['principals']) => (p.type === 'self-authenticating' ? [] : p.signers); + if (!sameStringArray(sigsOf(pb), sigsOf(pa))) { + changes.push(`signers: ${sigsOf(pb).join(', ') || '(none)'} → ${sigsOf(pa).join(', ') || '(none)'}`); + } + const quorumOf = (p: Rule['principals']) => + p.type === 'threshold' ? `${p.m} of ${p.signers.length}` : p.type; + if (quorumOf(pb) !== quorumOf(pa)) { + changes.push(`quorum: ${quorumOf(pb)} → ${quorumOf(pa)}`); + } + + if (!sameStringArray(before.functions, after.functions)) { + const show = (f: readonly string[] | undefined) => (f === undefined ? 'any function' : f.join(', ')); + changes.push(`functions: ${show(before.functions)} → ${show(after.functions)}`); + } + + const expB = before['not-after-ledger']; + const expA = after['not-after-ledger']; + if (expB !== expA) { + const show = (v: number | undefined) => (v === undefined ? 'no expiry' : `ledger ${v}`); + changes.push(`expiry: ${show(expB)} → ${show(expA)}`); + } + + const capOf = (r: Rule) => + r.cap === undefined ? 'no cap' : `${r.cap.limit} stroops per ${r.cap['period-ledgers']} ledgers`; + if (capOf(before) !== capOf(after)) { + changes.push(`cap: ${capOf(before)} → ${capOf(after)}`); + } + + const argsOf = (r: Rule) => JSON.stringify(r.args ?? null); + if (argsOf(before) !== argsOf(after)) { + changes.push('argument conditions changed'); + } + + return changes; +} + +/** Diff `next` against the currently applied `prev` (null = first apply). */ +export function diffPolicyDocs(prev: PolicyDoc | null, next: PolicyDoc): DocDiff { + if (prev === null) { + return { + firstApply: true, + signers: next.signers.map((decl) => ({ decl, kind: 'added' as const })), + rulesAdded: [...next.rules], + rulesRemoved: [], + rulesModified: [], + unchangedRuleNames: [], + identical: false, + }; + } + + // Signers: by id first (rekey detection), then by full identity. + const prevById = new Map(prev.signers.map((s) => [s.id, s])); + const nextById = new Map(next.signers.map((s) => [s.id, s])); + const signers: SignerChange[] = []; + for (const s of next.signers) { + const old = prevById.get(s.id); + if (old === undefined) signers.push({ decl: s, kind: 'added' }); + else if (signerKey(old) !== signerKey(s)) signers.push({ decl: s, kind: 'rekeyed' }); + } + for (const s of prev.signers) { + if (!nextById.has(s.id)) signers.push({ decl: s, kind: 'removed' }); + } + + const prevRules = new Map(prev.rules.map((r) => [r.name, r])); + const nextRules = new Map(next.rules.map((r) => [r.name, r])); + const rulesAdded: Rule[] = []; + const rulesRemoved: Rule[] = []; + const rulesModified: RuleModification[] = []; + const unchangedRuleNames: string[] = []; + + for (const r of next.rules) { + const old = prevRules.get(r.name); + if (old === undefined) { + rulesAdded.push(r); + continue; + } + const changes = diffRuleFields(old, r); + // A rule whose own fields are identical still changes meaning when a + // signer id it references was rekeyed — surface that as a field line. + const rekeyed = signers.filter( + (c) => + c.kind === 'rekeyed' && + r.principals.type !== 'self-authenticating' && + r.principals.signers.includes(c.decl.id), + ); + for (const c of rekeyed) { + changes.push(`signer "${c.decl.id}" now declares a different key (${signerLabel(c.decl)})`); + } + if (changes.length > 0) rulesModified.push({ name: r.name, before: old, after: r, changes }); + else unchangedRuleNames.push(r.name); + } + for (const r of prev.rules) { + if (!nextRules.has(r.name)) rulesRemoved.push(r); + } + + return { + firstApply: false, + signers, + rulesAdded, + rulesRemoved, + rulesModified, + unchangedRuleNames, + identical: + signers.length === 0 && + rulesAdded.length === 0 && + rulesRemoved.length === 0 && + rulesModified.length === 0, + }; +} diff --git a/packages/frontend/src/lib/policy/docDraft.test.ts b/packages/frontend/src/lib/policy/docDraft.test.ts new file mode 100644 index 00000000..442f628b --- /dev/null +++ b/packages/frontend/src/lib/policy/docDraft.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from 'vitest'; +import { Networks } from '@stellar/stellar-sdk'; +import { canonicalJson, docHash } from '@nidohq/passkey-sdk'; +import { + addAdminKey, + adminRules, + draftToDoc, + isAdminRule, + nextAdminRuleName, + adminBaseline, + parseFunctionsInput, + removeAdminRule, + upsertSessionRule, + validateAdminKeyDraft, + validateSessionDocDraft, + type AdminKeyDraft, + type SessionDocDraft, +} from './docDraft.js'; + +const SESSION_G = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; +const TARGET = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; +const VERIFIER = 'CD4IF75DNQJKCT35PAJAQDPW3K337EK6SJZDMQEVLXAH65K7ZVZMLXYN'; +const OWNER_KEY = '04' + 'ab'.repeat(64); +const G2 = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; + +function draft(overrides: Partial = {}): SessionDocDraft { + return { + name: 'status-session', + signer: { kind: 'delegated' as const, address: SESSION_G }, + targetContract: TARGET, + functionsInput: 'update_message', + notAfterLedger: 5000, + cap: { stroops: '1000000000', periodLedgers: 17280 }, + ...overrides, + }; +} + +describe('parseFunctionsInput', () => { + it('splits on commas and whitespace, dropping empties', () => { + expect(parseFunctionsInput('a, b c,,')).toEqual(['a', 'b', 'c']); + }); + it('returns undefined for empty input (any function)', () => { + expect(parseFunctionsInput(' ')).toBeUndefined(); + }); +}); + +describe('validateSessionDocDraft', () => { + it('accepts the filled template', () => { + expect(validateSessionDocDraft(draft())).toEqual({ ok: true, errors: [] }); + }); + + it('rejects a bad session address, target, and function name', () => { + const r = validateSessionDocDraft( + draft({ signer: { kind: 'delegated' as const, address: 'nope' }, targetContract: 'also-no', functionsInput: 'bad-fn!' }), + ); + expect(r.ok).toBe(false); + expect(r.errors).toHaveLength(3); + }); + + it('rejects a zero cap and a negative expiry', () => { + const r = validateSessionDocDraft( + draft({ cap: { stroops: '0', periodLedgers: 17280 }, notAfterLedger: -1 }), + ); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => e.includes('cap'))).toBe(true); + expect(r.errors.some((e) => e.includes('Expiry'))).toBe(true); + }); +}); + +describe('draftToDoc', () => { + it('builds the template doc with cap, functions, expiry, and network', () => { + const doc = draftToDoc(draft(), Networks.TESTNET); + expect(doc.network).toBe(Networks.TESTNET); + expect(doc.signers).toEqual([{ id: 'session', address: SESSION_G }]); + const rule = doc.rules[0]; + expect(rule.name).toBe('status-session'); + expect(rule.functions).toEqual(['update_message']); + expect(rule['not-after-ledger']).toBe(5000); + expect(rule.cap).toEqual({ limit: '1000000000', 'period-ledgers': 17280 }); + // The doc is canonicalizable + hashable — the identity the chain stores. + expect(docHash(doc)).toMatch(/^[0-9a-f]{64}$/); + expect(canonicalJson(doc)).toContain('"status-session"'); + }); + + it('omits functions, expiry, and cap when unset', () => { + const doc = draftToDoc( + draft({ functionsInput: '', notAfterLedger: null, cap: null }), + Networks.TESTNET, + ); + const rule = doc.rules[0]; + expect(rule.functions).toBeUndefined(); + expect(rule['not-after-ledger']).toBeUndefined(); + expect(rule.cap).toBeUndefined(); + }); +}); + +describe('admin keys', () => { + const baseline = adminBaseline( + { verifier: VERIFIER, publicKeyHex: OWNER_KEY }, + Networks.TESTNET, + ); + // A realistic base: owner admin + one dApp session rule. + const base = upsertSessionRule( + baseline, + { + name: 'session', + signer: { kind: 'delegated' as const, address: SESSION_G }, + targetContract: TARGET, + functionsInput: 'udpate_message', + notAfterLedger: 900, + cap: null, + }, + Networks.TESTNET, + ).doc; + + const delegatedDraft: AdminKeyDraft = { + name: 'admin-2', + signer: { kind: 'delegated', address: G2 }, + }; + + it('classifies admin rules (policy-free self-admin only)', () => { + expect(base.rules.filter(isAdminRule).map((r) => r.name)).toEqual(['admin']); + expect(adminRules(base)).toHaveLength(1); + expect(nextAdminRuleName(base)).toBe('admin-2'); + }); + + describe('validateAdminKeyDraft', () => { + it('accepts a fresh delegated key', () => { + expect(validateAdminKeyDraft(delegatedDraft, base)).toEqual({ ok: true, errors: [] }); + }); + + it('rejects a duplicate rule name instead of replacing the rule', () => { + const r = validateAdminKeyDraft({ ...delegatedDraft, name: 'session' }, base); + expect(r.ok).toBe(false); + expect(r.errors[0]).toContain('already has a rule named'); + }); + + it('rejects a malformed address and pasted passkey fields', () => { + expect( + validateAdminKeyDraft( + { name: 'a2', signer: { kind: 'delegated', address: 'nope' } }, + base, + ).errors[0], + ).toContain('not a valid'); + const r = validateAdminKeyDraft( + { name: 'a2', signer: { kind: 'passkey', verifier: 'bad', publicKeyHex: 'xyz' } }, + base, + ); + expect(r.ok).toBe(false); + expect(r.errors).toHaveLength(2); + }); + + it('rejects a key that is already an admin', () => { + const r = validateAdminKeyDraft( + { name: 'a2', signer: { kind: 'passkey', verifier: VERIFIER, publicKeyHex: OWNER_KEY } }, + base, + ); + expect(r.ok).toBe(false); + expect(r.errors[0]).toContain('already an admin'); + }); + }); + + describe('addAdminKey', () => { + it('adds the signer (id admin-2, mirroring the default rule name) and its own self-admin rule', () => { + const { doc, signerId } = addAdminKey(base, delegatedDraft, Networks.TESTNET); + // Naming ruling: founder keeps `owner`; added admins are admin-2, admin-3, … + expect(signerId).toBe('admin-2'); + expect(doc.signers.map((s) => s.id)).toEqual(['admin', 'session', 'admin-2']); + expect(doc.rules.map((r) => r.name)).toEqual(['admin', 'session', 'admin-2']); + expect(adminRules(doc).map((r) => r.name)).toEqual(['admin', 'admin-2']); + // Each admin has its OWN rule (all = N-of-N, never co-signing). + const added = doc.rules.find((r) => r.name === 'admin-2')!; + expect(added.principals).toEqual({ type: 'all', signers: ['admin-2'] }); + }); + + it('allocates the next admin-N id for each further key', () => { + const withOne = addAdminKey(base, delegatedDraft, Networks.TESTNET).doc; + const G3 = 'GCS7RFDDWSU2S2KYWEZDGHDGYUHM2VZWMCDTFP7ZS3MK7RY2VUXQ5D67'; + const { doc, signerId } = addAdminKey( + withOne, + { name: 'admin-3', signer: { kind: 'delegated', address: G3 } }, + Networks.TESTNET, + ); + expect(signerId).toBe('admin-3'); + expect(nextAdminRuleName(doc)).toBe('admin-4'); + expect(adminRules(doc)).toHaveLength(3); + }); + }); + + describe('removeAdminRule', () => { + const twoAdmins = addAdminKey(base, delegatedDraft, Networks.TESTNET).doc; + + it('removes an admin rule and prunes its now-orphaned signer', () => { + const doc = removeAdminRule(twoAdmins, 'admin-2', Networks.TESTNET); + expect(doc.rules.map((r) => r.name)).toEqual(['admin', 'session']); + expect(doc.signers.map((s) => s.id)).toEqual(['admin', 'session']); + }); + + it('refuses to remove the last admin rule', () => { + expect(() => removeAdminRule(base, 'admin', Networks.TESTNET)).toThrow(/last admin key/); + }); + + it('refuses non-admin and unknown rules', () => { + expect(() => removeAdminRule(twoAdmins, 'session', Networks.TESTNET)).toThrow( + /not an admin rule/, + ); + expect(() => removeAdminRule(twoAdmins, 'ghost', Networks.TESTNET)).toThrow(/no rule named/); + }); + }); +}); + +describe('passkey session signers (legacy delegate flow)', () => { + const draft: SessionDocDraft = { + name: 'session-key', + signer: { kind: 'passkey', verifier: VERIFIER, publicKeyHex: '04' + 'b0'.repeat(64) }, + targetContract: TARGET, + functionsInput: '', + notAfterLedger: null, + cap: null, + }; + + it('validates and builds an external-signer session doc', () => { + expect(validateSessionDocDraft(draft)).toEqual({ ok: true, errors: [] }); + const doc = draftToDoc(draft, Networks.TESTNET); + expect(doc.signers).toEqual([ + { id: 'session', verifier: VERIFIER, key: '04' + 'b0'.repeat(64) }, + ]); + expect(doc.rules[0].name).toBe('session-key'); + }); + + it('rejects a bad verifier and non-hex key', () => { + const r = validateSessionDocDraft({ + ...draft, + signer: { kind: 'passkey', verifier: 'nope', publicKeyHex: 'zz' }, + }); + expect(r.ok).toBe(false); + expect(r.errors).toHaveLength(2); + }); +}); diff --git a/packages/frontend/src/lib/policy/docDraft.ts b/packages/frontend/src/lib/policy/docDraft.ts new file mode 100644 index 00000000..89e20bfd --- /dev/null +++ b/packages/frontend/src/lib/policy/docDraft.ts @@ -0,0 +1,473 @@ +// Pure validation + document construction for the policy builder's DOC mode +// (sibling of policyDraft.ts, which backs the raw add_context_rule mode). +// +// Doc mode emits a perch PolicyDoc — for the showcase, the canned v1 +// template: one scoped session key (a delegated signer restricted to one +// contract's named functions, with an expiry) optionally composed with a +// cumulative spending cap. The pure layer validates the form draft, builds +// the document via the SDK, and decides which apply route the transaction +// takes; the component does the RPC + signing. + +import { buildPolicyDoc, parsePolicyDoc, scopedSessionKeyDoc, type PolicyDoc } from '@nidohq/passkey-sdk'; +import { isContractAddress, isStellarAddress, MAX_RULE_NAME_LEN } from './policyDraft.js'; + +/** Soroban symbol constraints for function names (SCSymbol: [A-Za-z0-9_], + * max 32 bytes). Checked client-side so a typo'd function list fails with a + * real message instead of a doomed simulation. */ +const FN_NAME_RE = /^[A-Za-z0-9_]{1,32}$/; + +/** The session signer: a delegated address the dApp holds, or a WebAuthn + * passkey (the dApp-origin session passkey of the legacy delegate flow). */ +export type SessionSignerDraft = + | { kind: 'delegated'; address: string } + | { kind: 'passkey'; verifier: string; publicKeyHex: string }; + +export interface SessionDocDraft { + /** Rule name (becomes the doc rule's name — shown losslessly on read). */ + name: string; + /** The session signer the rule authorizes. */ + signer: SessionSignerDraft; + /** The one contract the key may call. */ + targetContract: string; + /** Raw comma/space-separated function-name input; empty = any function. */ + functionsInput: string; + /** Exclusive not-after ledger, or null for no expiry. */ + notAfterLedger: number | null; + /** Cumulative cap, or null for none. */ + cap: { stroops: string; periodLedgers: number } | null; +} + +/** Parse the functions input to a doc function list. Empty input means the + * rule allows any function (the doc omits `functions`). */ +export function parseFunctionsInput(raw: string): string[] | undefined { + const names = raw + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return names.length > 0 ? names : undefined; +} + +export interface DocValidationResult { + ok: boolean; + errors: string[]; +} + +export function validateSessionDocDraft(draft: SessionDocDraft): DocValidationResult { + const errors: string[] = []; + + const name = draft.name.trim(); + if (name.length === 0) errors.push('Give the rule a name.'); + else if (new TextEncoder().encode(name).length > MAX_RULE_NAME_LEN) { + errors.push(`Name must be at most ${MAX_RULE_NAME_LEN} bytes.`); + } + + if (draft.signer.kind === 'delegated') { + if (!isStellarAddress(draft.signer.address.trim())) { + errors.push('Session key is not a valid C- or G-address.'); + } + } else { + if (!isContractAddress(draft.signer.verifier.trim())) { + errors.push('Session-key verifier is not a valid C-address.'); + } + const hex = draft.signer.publicKeyHex.trim(); + if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0 || hex.length === 0) { + errors.push('Session public key is not valid hex.'); + } + } + if (!isContractAddress(draft.targetContract.trim())) { + errors.push('Target contract is not a valid C-address.'); + } + + for (const fn of parseFunctionsInput(draft.functionsInput) ?? []) { + if (!FN_NAME_RE.test(fn)) { + errors.push(`Function "${fn}" is not a valid contract function name.`); + } + } + + if (draft.notAfterLedger !== null) { + if (!Number.isInteger(draft.notAfterLedger) || draft.notAfterLedger <= 0) { + errors.push('Expiry ledger must be a positive integer.'); + } + } + + if (draft.cap !== null) { + let stroops: bigint | null = null; + try { + stroops = BigInt(draft.cap.stroops); + } catch { + stroops = null; + } + if (stroops === null || stroops <= 0n) errors.push('Spending cap must be a positive amount.'); + if (!Number.isInteger(draft.cap.periodLedgers) || draft.cap.periodLedgers <= 0) { + errors.push('Spending-cap window must be a positive number of ledgers.'); + } + } + + return { ok: errors.length === 0, errors }; +} + +/** Build the template document from a validated draft. Delegated signers + * route through the SDK's canned scopedSessionKeyDoc; passkey signers (the + * legacy delegate flow's dApp-origin session passkey) compose the same + * one-rule shape via buildPolicyDoc with an external signer declaration. + * Precondition: `validateSessionDocDraft(draft).ok`. */ +export function draftToDoc(draft: SessionDocDraft, networkPassphrase: string): PolicyDoc { + const functions = parseFunctionsInput(draft.functionsInput); + if (draft.signer.kind === 'delegated') { + return scopedSessionKeyDoc({ + sessionAddress: draft.signer.address.trim(), + targetContract: draft.targetContract.trim(), + ...(functions !== undefined ? { functions } : {}), + ...(draft.notAfterLedger !== null ? { notAfterLedger: draft.notAfterLedger } : {}), + ...(draft.cap !== null + ? { + cap: { + limitStroops: BigInt(draft.cap.stroops), + periodLedgers: draft.cap.periodLedgers, + }, + } + : {}), + network: networkPassphrase, + name: draft.name.trim(), + signerId: 'session', + }); + } + return buildPolicyDoc({ + network: networkPassphrase, + signers: [ + { + id: 'session', + kind: 'passkey', + verifier: draft.signer.verifier.trim(), + publicKey: draft.signer.publicKeyHex.trim().toLowerCase(), + }, + ], + permissions: [ + { + name: draft.name.trim(), + on: { contract: draft.targetContract.trim() }, + by: ['session'], + ...(functions !== undefined ? { functions } : {}), + ...(draft.notAfterLedger !== null ? { until: draft.notAfterLedger } : {}), + ...(draft.cap !== null + ? { cap: { limit: draft.cap.stroops, 'period-ledgers': draft.cap.periodLedgers } } + : {}), + }, + ], + }); +} + +/** The account's primary passkey, as read off its live auth rule (the + * constructor default rule before the first apply; the doc's own admin + * rule afterwards — but then the applied doc IS the baseline and this is + * not needed). */ +export interface AdminPasskey { + /** WebAuthn verifier contract the account trusts. */ + verifier: string; + /** SEC1 uncompressed P-256 public key, hex. */ + publicKeyHex: string; +} + +/** + * The FIRST-APPLY baseline: a document holding only the anti-brick admin + * rule — the account's own passkey, signer id `admin`, with policy-free + * self-admin authority. + * `apply_doc` replaces EVERY rule (the constructor's default passkey rule + * included) and refuses documents without a policy-free self-admin rule + * (`DocAdminLockout`), so a first apply must never submit a session rule + * alone: it upserts into this baseline instead. + */ +export function adminBaseline(admin: AdminPasskey, networkPassphrase: string): PolicyDoc { + return buildPolicyDoc({ + network: networkPassphrase, + signers: [ + { id: 'admin', kind: 'passkey', verifier: admin.verifier, publicKey: admin.publicKeyHex }, + ], + permissions: [{ name: 'admin', on: 'self-admin', by: ['admin'] }], + }); +} + +/** + * Upsert the template's session rule into `base` — the account's currently + * applied document, or {@link ownerAdminBaseline} on a first apply. + * `apply_doc` is a whole-document write: it REPLACES the applied set, so an + * update must carry the base document forward with the new rule merged in + * (and a base is REQUIRED — a standalone session doc would trip the + * contract's `DocAdminLockout` anti-brick check). + * + * - Same rule name exists → the new rule replaces it (a "modified" rule in + * the diff). + * - Signer reuse: an existing declaration for the SAME key is reused; an id + * collision with a DIFFERENT key allocates "session-2", "session-3", …. + * - Signer declarations no longer referenced by any rule are pruned. + * + * Precondition: `validateSessionDocDraft(draft).ok`. Throws when the base + * doc is bound to a different network than the draft targets. + */ +export function upsertSessionRule( + current: PolicyDoc, + draft: SessionDocDraft, + networkPassphrase: string, +): { doc: PolicyDoc; signerId: string } { + const template = draftToDoc(draft, networkPassphrase); + assertSameNetwork(current, networkPassphrase); + + const { signers: merged, signerId } = mergeSignerDecl(current.signers, template.signers[0]); + const rule = { + ...template.rules[0], + principals: { type: 'all' as const, signers: [signerId] }, + }; + const rules = current.rules.some((r) => r.name === rule.name) + ? current.rules.map((r) => (r.name === rule.name ? rule : r)) + : [...current.rules, rule]; + + const doc = rebuildDoc(current, merged, rules, networkPassphrase); + // rebuildDoc may have applied the legacy owner→admin rename under us. + return { doc, signerId: signerId === 'owner' && !doc.signers.some((s) => s.id === 'owner') ? 'admin' : signerId }; +} + +// --- Shared doc-merge helpers ---------------------------------------------- + +type WireSigner = PolicyDoc['signers'][number]; +type WireRule = PolicyDoc['rules'][number]; + +function assertSameNetwork(doc: PolicyDoc, networkPassphrase: string): void { + if (doc.network !== undefined && doc.network !== networkPassphrase) { + throw new Error( + `policy doc: the applied document is bound to "${doc.network}" but this update targets "${networkPassphrase}"`, + ); + } +} + +/** Structural identity of a signer declaration (id excluded). */ +function signerKeyOf(s: WireSigner): string { + return 'address' in s ? `delegated:${s.address}` : `external:${s.verifier}:${s.key}`; +} + +/** Merge one declaration into a signer list: reuse an existing declaration + * for the SAME key; on an id collision with a DIFFERENT key allocate + * "-2", "-3", …. */ +function mergeSignerDecl( + signers: readonly WireSigner[], + decl: WireSigner, +): { signers: WireSigner[]; signerId: string } { + const sameKey = signers.find((s) => signerKeyOf(s) === signerKeyOf(decl)); + if (sameKey !== undefined) return { signers: [...signers], signerId: sameKey.id }; + const taken = new Set(signers.map((s) => s.id)); + let signerId = decl.id; + for (let n = 2; taken.has(signerId); n++) signerId = `${decl.id}-${n}`; + return { signers: [...signers, { ...decl, id: signerId }], signerId }; +} + +/** + * Legacy-id migration: early spike documents declared the founder signer as + * `owner`; the captain's naming ruling is `admin` only ("confusing for new + * users that there are both"). Guarded rename — applied only when `owner` + * exists and no distinct `admin` signer does, so a rename can never merge + * two different keys. Pure; every composed update flows through it (via + * rebuildDoc), so the rename lands with the user's NEXT doc update and + * shows up in the diff preview as the owner → admin signer change. + */ +export function renameLegacyOwner(doc: PolicyDoc): PolicyDoc { + const hasOwner = doc.signers.some((s) => s.id === 'owner'); + const hasAdmin = doc.signers.some((s) => s.id === 'admin'); + if (!hasOwner || hasAdmin) return doc; + return { + ...doc, + signers: doc.signers.map((s) => (s.id === 'owner' ? { ...s, id: 'admin' } : s)), + rules: doc.rules.map((r) => + r.principals.type === 'self-authenticating' + ? r + : { + ...r, + principals: { + ...r.principals, + signers: r.principals.signers.map((id) => (id === 'owner' ? 'admin' : id)), + }, + }, + ), + }; +} + +/** Drop declarations no rule references, migrate legacy ids, then + * re-validate through the schema so a malformed merge fails closed here, + * not at the compiler. */ +function rebuildDoc( + base: PolicyDoc, + signers: readonly WireSigner[], + rules: readonly WireRule[], + networkPassphrase: string, +): PolicyDoc { + const referenced = new Set( + rules.flatMap((r) => (r.principals.type === 'self-authenticating' ? [] : r.principals.signers)), + ); + return parsePolicyDoc( + renameLegacyOwner({ + ...base, + network: networkPassphrase, + signers: signers.filter((s) => referenced.has(s.id)), + rules, + } as PolicyDoc), + ); +} + +// --- Admin keys ------------------------------------------------------------- + +/** An ADMIN rule is the anti-brick shape: policy-free, cap-free self-admin + * authority (bare `all` principals, no functions, no args, no cap, no + * expiry — full account authority for its signer). */ +export function isAdminRule(rule: WireRule): boolean { + return ( + rule.scope.type === 'self-admin' && + rule.principals.type === 'all' && + rule.functions === undefined && + rule.args === undefined && + rule.cap === undefined && + rule['not-after-ledger'] === undefined + ); +} + +/** The document's admin rules, in doc order. */ +export function adminRules(doc: PolicyDoc): WireRule[] { + return doc.rules.filter(isAdminRule); +} + +/** Next free "admin-N" slot (N ≥ 2 — the founder's ids are `owner` / + * `admin`), free as BOTH a rule name and a signer id, so by default the + * added admin's rule name mirrors its signer id. */ +export function nextAdminRuleName(doc: PolicyDoc): string { + const taken = new Set([...doc.rules.map((r) => r.name), ...doc.signers.map((s) => s.id)]); + for (let n = 2; ; n++) { + if (!taken.has(`admin-${n}`)) return `admin-${n}`; + } +} + +/** The new admin signer: a passkey (WebAuthn ceremony or pasted key) or a + * delegated address. */ +export interface AdminKeyDraft { + /** Rule name for the new admin rule. */ + name: string; + signer: + | { kind: 'passkey'; verifier: string; publicKeyHex: string } + | { kind: 'delegated'; address: string }; +} + +export function validateAdminKeyDraft(draft: AdminKeyDraft, base: PolicyDoc): DocValidationResult { + const errors: string[] = []; + + const name = draft.name.trim(); + if (name.length === 0) errors.push('Give the admin rule a name.'); + else if (new TextEncoder().encode(name).length > MAX_RULE_NAME_LEN) { + errors.push(`Name must be at most ${MAX_RULE_NAME_LEN} bytes.`); + } else if (base.rules.some((r) => r.name === name)) { + // Never silently REPLACE an existing rule from the admin form — a + // colliding name must be an explicit error, not a surprise overwrite. + errors.push(`The document already has a rule named "${name}" — pick another name.`); + } + + if (draft.signer.kind === 'delegated') { + if (!isStellarAddress(draft.signer.address.trim())) { + errors.push('Admin key is not a valid C- or G-address.'); + } + } else { + if (!isContractAddress(draft.signer.verifier.trim())) { + errors.push('Verifier is not a valid C-address.'); + } + if (!/^[0-9a-fA-F]{2,512}$/.test(draft.signer.publicKeyHex.trim()) || draft.signer.publicKeyHex.trim().length % 2 !== 0) { + errors.push('Public key is not valid hex.'); + } + } + + // Refuse enrolling a key that already holds admin authority. + if (errors.length === 0) { + const decl = adminDraftToDecl(draft, 'probe'); + const existing = base.signers.find((s) => signerKeyOf(s) === signerKeyOf(decl)); + if ( + existing !== undefined && + adminRules(base).some( + (r) => r.principals.type !== 'self-authenticating' && r.principals.signers.includes(existing.id), + ) + ) { + errors.push('This key is already an admin on the account.'); + } + } + + return { ok: errors.length === 0, errors }; +} + +function adminDraftToDecl(draft: AdminKeyDraft, id: string): WireSigner { + return draft.signer.kind === 'delegated' + ? { id, address: draft.signer.address.trim() } + : { + id, + verifier: draft.signer.verifier.trim(), + key: draft.signer.publicKeyHex.trim().toLowerCase(), + }; +} + +/** + * Add an admin key: the signer declaration plus its own policy-free + * self-admin rule (each admin key gets its OWN rule — `all` principals are + * N-of-N, so sharing one rule would require both keys to co-sign). + * Precondition: `validateAdminKeyDraft(draft, base).ok`. + */ +export function addAdminKey( + base: PolicyDoc, + draft: AdminKeyDraft, + networkPassphrase: string, +): { doc: PolicyDoc; signerId: string } { + assertSameNetwork(base, networkPassphrase); + // Naming convention (captain's ruling): the founder keeps `owner`; added + // admins are `admin-2`, `admin-3`, … — never a bare `admin` id colliding + // with the founder's rule name. mergeSignerDecl still reuses an existing + // declaration when the KEY already exists. + const { signers, signerId } = mergeSignerDecl( + base.signers, + adminDraftToDecl(draft, nextAdminRuleName(base)), + ); + const rule: WireRule = { + name: draft.name.trim(), + scope: { type: 'self-admin' }, + principals: { type: 'all', signers: [signerId] }, + }; + const doc = rebuildDoc(base, signers, [...base.rules, rule], networkPassphrase); + // rebuildDoc may have applied the legacy owner→admin rename under us. + return { + doc, + signerId: signerId === 'owner' && !doc.signers.some((s) => s.id === 'owner') ? 'admin' : signerId, + }; +} + +/** + * Remove an admin rule (and prune its signer if nothing else references + * it). Refuses to remove the LAST admin rule — the contract's + * `DocAdminLockout` anti-brick check would reject the document anyway, so + * the refusal surfaces here with a human-readable reason instead of a + * failed simulation. + */ +export function removeAdminRule( + base: PolicyDoc, + ruleName: string, + networkPassphrase: string, +): PolicyDoc { + assertSameNetwork(base, networkPassphrase); + const target = base.rules.find((r) => r.name === ruleName); + if (target === undefined) { + throw new Error(`policy doc: no rule named "${ruleName}"`); + } + if (!isAdminRule(target)) { + throw new Error(`policy doc: rule "${ruleName}" is not an admin rule`); + } + if (adminRules(base).length <= 1) { + throw new Error( + 'policy doc: cannot remove the last admin key — the account would have no admin authority (the contract refuses such documents)', + ); + } + return rebuildDoc( + base, + base.signers, + base.rules.filter((r) => r.name !== ruleName), + networkPassphrase, + ); +} + diff --git a/packages/frontend/src/lib/policy/docPolicyFetch.test.ts b/packages/frontend/src/lib/policy/docPolicyFetch.test.ts new file mode 100644 index 00000000..016d8669 --- /dev/null +++ b/packages/frontend/src/lib/policy/docPolicyFetch.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { docJsonFromEventValue, toHex } from './docPolicyFetch.js'; + +const JSON_TEXT = '{"version":1,"signers":[],"rules":[]}'; +const JSON_BYTES = new TextEncoder().encode(JSON_TEXT); + +describe('docJsonFromEventValue', () => { + it('decodes the generated event map shape { doc_json: Bytes }', () => { + expect(docJsonFromEventValue({ doc_json: JSON_BYTES })).toBe(JSON_TEXT); + }); + + it('decodes a bare bytes payload', () => { + expect(docJsonFromEventValue(JSON_BYTES)).toBe(JSON_TEXT); + }); + + it('rejects payloads without bytes', () => { + expect(docJsonFromEventValue(null)).toBeNull(); + expect(docJsonFromEventValue('not-bytes')).toBeNull(); + expect(docJsonFromEventValue({ other: 1 })).toBeNull(); + }); + + it('rejects invalid UTF-8 rather than yielding replacement chars', () => { + expect(docJsonFromEventValue(new Uint8Array([0xff, 0xfe, 0x80]))).toBeNull(); + }); +}); + +describe('toHex', () => { + it('lowercase-hex-encodes with zero padding', () => { + expect(toHex(new Uint8Array([0, 1, 0xab, 0xff]))).toBe('0001abff'); + }); +}); diff --git a/packages/frontend/src/lib/policy/docPolicyFetch.ts b/packages/frontend/src/lib/policy/docPolicyFetch.ts new file mode 100644 index 00000000..23549db2 --- /dev/null +++ b/packages/frontend/src/lib/policy/docPolicyFetch.ts @@ -0,0 +1,274 @@ +// Chain reads for the perch doc layer: the smart account's `apply_doc` +// surface (`applied_doc_hash` / `doc_rule_ids` / `get_applied_doc`), the +// `DocApplied` event fallback, and the interpreter's per-rule programs — +// composed into the SDK's three-tier `readPolicy`. +// +// Every read here is simulate-only / RPC-only; nothing signs. All reads are +// tolerant of accounts WITHOUT the doc surface (pre-`apply_doc` wasm): they +// resolve to "no document", which `readPolicy` classifies as tier c and the +// inspector renders as the raw rule cards. + +import { rpc, Contract, scValToNative, xdr } from '@stellar/stellar-sdk'; +import { Buffer } from 'buffer'; +import { + DOC_APPLIED_EVENT, + perchTestnetAddresses, + readPolicy, + type ChainRule, + type PolicyDoc, + type ReadPolicyResult, +} from '@nidohq/passkey-sdk'; +import { fetchDefaultRuleAuthInfo } from '../policyChainFetch.js'; +import { adminBaseline } from './docDraft.js'; +import { Client as InterpreterClient } from '@stellar-registry/perch-interpreter'; +import { fetchRegistryAddress, simulateView } from '../policyChainFetch.js'; +import { RPC_URL, NETWORK_PASSPHRASE } from '../network.js'; + +/** How far back the `DocApplied` event fallback scans. Testnet RPC keeps + * roughly a day of events; ask for a bit less so the request never starts + * before retention (which errors rather than clamping). */ +const EVENT_LOOKBACK_LEDGERS = 16000; + +/** Where the applied doc's JSON was recovered from. `storage` is the + * lossless on-chain copy (`get_applied_doc`); `events` is the `DocApplied` + * event history, which only reaches back as far as RPC retention. */ +export type DocJsonSource = 'storage' | 'events'; + +export interface DocSurface { + /** False when the account predates the `apply_doc` surface (the probe + * simulation failed) — such accounts always read as tier c. */ + supported: boolean; + /** Stored canonical hash, or null if no document was ever applied. */ + appliedDocHash: Uint8Array | null; + /** Rule ids of the doc-managed rules, in document order. */ + docRuleIds: number[]; +} + +/** Probe the account's doc surface (`applied_doc_hash` + `doc_rule_ids`). */ +export async function fetchDocSurface(account: string): Promise { + let server: rpc.Server; + let contract: Contract; + let appliedDocHash: Uint8Array | null; + try { + // Construction inside the try: an account string the SDK rejects (bad + // checksum) must read as "no doc surface", not escape as a throw. + server = new rpc.Server(RPC_URL); + contract = new Contract(account); + const rv = await simulateView(server, contract, 'applied_doc_hash'); + const native = scValToNative(rv) as Uint8Array | Buffer | null | undefined; + appliedDocHash = native == null ? null : new Uint8Array(native); + } catch { + // No `applied_doc_hash` on this account (older wasm) — or the RPC is + // down, in which case every other read on the page fails loudly anyway. + return { supported: false, appliedDocHash: null, docRuleIds: [] }; + } + let docRuleIds: number[] = []; + try { + const rv = await simulateView(server, contract, 'doc_rule_ids'); + docRuleIds = (scValToNative(rv) as number[]).map(Number); + } catch { + docRuleIds = []; + } + return { supported: true, appliedDocHash, docRuleIds }; +} + +/** Decode a `DocApplied` event's data payload to the doc JSON string. + * Exported for tests. The generated event data is `{ doc_json: Bytes }`; + * accept a bare bytes payload too, so a wire-shape drift degrades to "still + * works" rather than "silently tier c". */ +export function docJsonFromEventValue(native: unknown): string | null { + // Realm-safe bytes check: scValToNative yields Buffer, and test + // environments (jsdom) hand over Uint8Arrays from another realm, so a + // plain `instanceof` misses both. + const isBytes = (v: unknown): v is Uint8Array => + v instanceof Uint8Array || Object.prototype.toString.call(v) === '[object Uint8Array]'; + const bytes = isBytes(native) + ? native + : native != null && typeof native === 'object' && isBytes((native as Record).doc_json) + ? ((native as Record).doc_json as Uint8Array) + : null; + if (bytes === null) return null; + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return null; + } +} + +/** Lowercase hex of raw bytes (no deps — small enough to keep local). */ +export function toHex(bytes: Uint8Array): string { + let out = ''; + for (const b of bytes) out += b.toString(16).padStart(2, '0'); + return out; +} + +export interface RecoveredDocJson { + json: string; + source: DocJsonSource; +} + +/** + * Recover the applied document's JSON. Prefers the lossless on-chain copy + * (the `get_applied_doc` view, stored by `apply_doc`); falls back to the + * account's latest `DocApplied` event whose `doc_hash` topic matches the + * stored hash. Returns null when neither path reaches a document (readPolicy + * then classifies the account as tier c). + */ +export async function fetchAppliedDocJson( + account: string, + storedHashHex: string, +): Promise { + let server: rpc.Server; + try { + server = new rpc.Server(RPC_URL); + } catch { + return null; + } + + // Lossless path: the canonical doc JSON in persistent storage. + try { + const rv = await simulateView(server, new Contract(account), 'get_applied_doc'); + const native = scValToNative(rv) as Uint8Array | Buffer | null | undefined; + if (native != null) { + return { json: Buffer.from(native).toString('utf8'), source: 'storage' }; + } + } catch { + // View absent (wasm predates doc-JSON storage) — fall through to events. + } + + // Event fallback: newest DocApplied whose doc_hash topic == stored hash. + try { + const latest = await server.getLatestLedger(); + const startLedger = Math.max(1, latest.sequence - EVENT_LOOKBACK_LEDGERS); + const resp = await server.getEvents({ + startLedger, + filters: [ + { + type: 'contract', + contractIds: [account], + topics: [[xdr.ScVal.scvSymbol(DOC_APPLIED_EVENT).toXDR('base64'), '*']], + }, + ], + limit: 100, + }); + for (const ev of [...resp.events].reverse()) { + const hashTopic = ev.topic[1] !== undefined ? (scValToNative(ev.topic[1]) as Uint8Array) : null; + if (hashTopic === null || toHex(new Uint8Array(hashTopic)) !== storedHashHex) continue; + const json = docJsonFromEventValue(scValToNative(ev.value)); + if (json !== null) return { json, source: 'events' }; + } + } catch { + // Retention exceeded / RPC hiccup — no document recoverable. + } + return null; +} + +/** Fetch the interpreter's install params for each doc-managed rule, keyed + * by rule id. Missing/unreadable entries are simply absent (readPolicy + * treats absence as "cannot check program content", not as drift). */ +export async function fetchInterpreterPrograms( + account: string, + ruleIds: number[], +): Promise> { + if (ruleIds.length === 0) return {}; + const client = new InterpreterClient({ + contractId: perchTestnetAddresses().interpreter, + networkPassphrase: NETWORK_PASSPHRASE, + rpcUrl: RPC_URL, + }); + const out: Record = {}; + await Promise.all( + ruleIds.map(async (id) => { + try { + const tx = await client.get_program({ smart_account: account, context_rule_id: id }); + const params = tx.result; + if (params) { + out[id] = { program: params.program, docHash: new Uint8Array(params.doc_hash) }; + } + } catch { + // Rule carries no interpreter program (or the read failed) — skip. + } + }), + ); + return out; +} + +export interface DocPolicyRead { + result: ReadPolicyResult; + /** Where the tier-a/b document came from, or null for tier c. */ + docSource: DocJsonSource | null; + /** Whether the account exposes the `apply_doc` surface at all. */ + surfaceSupported: boolean; + /** True when the surface exists but NO document has ever been applied — + * a fresh account. The page then renders the synthesized baseline + * (adminBaseline over the default rule's passkey, the SAME baseline + * every first-apply flow composes against) as the effective policy, + * labeled not-yet-applied. */ + unapplied: boolean; +} + +/** + * The full doc-layer read for the policy page: probe the surface, recover + * the document, fetch programs, and classify via the SDK's `readPolicy`. + */ +export async function readDocPolicy( + account: string, + chainRules: ChainRule[], +): Promise { + const surface = await fetchDocSurface(account); + const storedHex = surface.appliedDocHash === null ? null : toHex(surface.appliedDocHash); + const recovered = storedHex === null ? null : await fetchAppliedDocJson(account, storedHex); + const programs = + surface.docRuleIds.length > 0 + ? await fetchInterpreterPrograms(account, surface.docRuleIds) + : {}; + const spendingLimitAddress = await fetchRegistryAddress('spending-limit-policy').catch( + () => undefined, + ); + + const result = readPolicy({ + chainRules, + appliedDocHash: surface.appliedDocHash, + // View-first, matching the SDK's input surface: the on-chain canonical + // copy is `storedDocJson`; an event-recovered doc is the fallback field. + ...(recovered?.source === 'storage' ? { storedDocJson: recovered.json } : {}), + ...(recovered?.source === 'events' ? { eventDocJson: recovered.json } : {}), + decompileCtx: { + account, + interpreterAddress: perchTestnetAddresses().interpreter, + ...(spendingLimitAddress !== undefined ? { spendingLimitAddress } : {}), + programs, + }, + }); + return { + result, + docSource: result.tier === 'decompiled' ? null : (recovered?.source ?? null), + surfaceSupported: surface.supported, + unapplied: surface.supported && surface.appliedDocHash === null, + }; +} + +/** + * The synthesized FIRST-APPLY baseline document for a fresh doc-surface + * account: the founder admin rule over the default rule's live passkey — + * byte-identical to what the builder and both delegate pages compose + * against on a first apply, so the policy page's "effective policy" + * rendering and the flows agree on one baseline. Null when the passkey + * cannot be read (the flows fail closed on the same condition). + */ +export async function fetchUnappliedBaseline( + account: string, + networkPassphrase: string, +): Promise { + try { + const info = await fetchDefaultRuleAuthInfo(account); + const passkey = info.externalSigners[0]; + if (passkey === undefined) return null; + return adminBaseline( + { verifier: passkey.verifier, publicKeyHex: toHex(passkey.publicKey) }, + networkPassphrase, + ); + } catch { + return null; + } +} diff --git a/packages/frontend/src/lib/policy/docRequest.test.ts b/packages/frontend/src/lib/policy/docRequest.test.ts new file mode 100644 index 00000000..6d1f3124 --- /dev/null +++ b/packages/frontend/src/lib/policy/docRequest.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest'; +import { buildSessionGrantOperation, parseDocDelegateParams } from './docRequest.js'; + +const TARGET = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; +const SIGNER = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; + +function params(overrides: Record = {}): URLSearchParams { + const base: Record = { + origin: 'https://dapp.example', + target: TARGET, + signer: SIGNER, + functions: 'udpate_message', + duration: '24h', + label: 'status-note-session', + return: 'https://dapp.example/page?x=1', + }; + const p = new URLSearchParams(); + for (const [k, v] of Object.entries({ ...base, ...overrides })) { + if (v !== null) p.set(k, v); + } + return p; +} + +describe('parseDocDelegateParams', () => { + it('parses a complete request', () => { + const r = parseDocDelegateParams(params()); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.req).toMatchObject({ + origin: 'https://dapp.example', + target: TARGET, + signer: SIGNER, + functions: ['udpate_message'], + duration: '24h', + label: 'status-note-session', + limitStroops: null, + }); + }); + + it('defaults duration to 30d and label to "session"', () => { + const r = parseDocDelegateParams(params({ duration: 'bogus', label: null })); + expect(r.ok && r.req.duration).toBe('30d'); + expect(r.ok && r.req.label).toBe('session'); + }); + + it('treats absent functions as any-function', () => { + const r = parseDocDelegateParams(params({ functions: null })); + expect(r.ok && r.req.functions).toBeUndefined(); + }); + + it('parses a decimal-XLM limit to stroops and tolerates a malformed one', () => { + const good = parseDocDelegateParams(params({ limit: '2.5', limit_period: 'week' })); + expect(good.ok && good.req.limitStroops).toBe('25000000'); + expect(good.ok && good.req.limitPeriod).toBe('week'); + const bad = parseDocDelegateParams(params({ limit: 'lots' })); + expect(bad.ok && bad.req.limitStroops).toBeNull(); + }); + + it.each([ + ['origin', 'Missing origin'], + ['target', 'Invalid target'], + ['signer', 'Invalid session signer'], + ['return', 'Missing return'], + ] as const)('rejects a missing/invalid %s', (key, msg) => { + const r = parseDocDelegateParams(params({ [key]: key === 'origin' || key === 'return' ? null : 'nope' })); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error).toContain(msg); + }); +}); + +describe('buildSessionGrantOperation (doc-only regression)', () => { + const VERIFIER = 'CD4IF75DNQJKCT35PAJAQDPW3K337EK6SJZDMQEVLXAH65K7ZVZMLXYN'; + const OWNER_KEY = '04' + 'ab'.repeat(64); + const SESSION_KEY = '04' + 'b0'.repeat(64); + + it('emits an apply-policy-doc descriptor — never add_context_rule — for the passkey grant', async () => { + const { Networks } = await import('@stellar/stellar-sdk'); + const { parsePolicyDocJson } = await import('@nidohq/passkey-sdk'); + const { adminBaseline } = await import('./docDraft.js'); + const baseline = adminBaseline( + { verifier: VERIFIER, publicKeyHex: OWNER_KEY }, + Networks.TESTNET, + ); + const op = buildSessionGrantOperation({ + baseline, + isFirstApply: true, + draft: { + name: 'session-key', + signer: { kind: 'passkey', verifier: VERIFIER, publicKeyHex: SESSION_KEY }, + targetContract: TARGET, + functionsInput: '', + notAfterLedger: 5145276, + cap: null, + }, + networkPassphrase: Networks.TESTNET, + expiryLabel: '24 hours', + }); + // The invariant the captain's live failure proved missing: the grant is + // a whole-document apply, not a per-rule mutator call. + expect(op.type).toBe('apply-policy-doc'); + expect(op.prevDocJson).toBeUndefined(); // first apply + const doc = parsePolicyDocJson(op.docJson); + // The merged doc carries the anti-brick admin rule AND the session rule + // for the dApp's passkey, declared against the account's verifier. + expect(doc.rules.map((r) => r.name)).toEqual(['admin', 'session-key']); + expect(doc.signers).toContainEqual({ + id: 'session', + verifier: VERIFIER, + key: SESSION_KEY.toLowerCase(), + }); + const rule = doc.rules.find((r) => r.name === 'session-key')!; + expect(rule['not-after-ledger']).toBe(5145276); + }); + + it('carries prevDocJson on an update and replaces a same-named rule', async () => { + const { Networks } = await import('@stellar/stellar-sdk'); + const { parsePolicyDocJson } = await import('@nidohq/passkey-sdk'); + const { adminBaseline, upsertSessionRule } = await import('./docDraft.js'); + const { diffPolicyDocs } = await import('./docDiff.js'); + const baseline = adminBaseline( + { verifier: VERIFIER, publicKeyHex: OWNER_KEY }, + Networks.TESTNET, + ); + const draft = (key: string) => ({ + name: 'session-key', + signer: { kind: 'passkey' as const, verifier: VERIFIER, publicKeyHex: key }, + targetContract: TARGET, + functionsInput: '', + notAfterLedger: null, + cap: null, + }); + const applied = upsertSessionRule(baseline, draft(SESSION_KEY), Networks.TESTNET).doc; + const NEW_KEY = '04' + 'c1'.repeat(64); + const op = buildSessionGrantOperation({ + baseline: applied, + isFirstApply: false, + draft: draft(NEW_KEY), + networkPassphrase: Networks.TESTNET, + expiryLabel: 'Until revoked', + }); + expect(op.type).toBe('apply-policy-doc'); + expect(op.prevDocJson).toBeDefined(); + // Re-delegation semantics: the session-key rule is REPLACED, not stacked. + const doc = parsePolicyDocJson(op.docJson); + expect(doc.rules.filter((r) => r.name === 'session-key')).toHaveLength(1); + const d = diffPolicyDocs(parsePolicyDocJson(op.prevDocJson!), doc); + expect(d.rulesModified.map((m) => m.name)).toEqual(['session-key']); + }); +}); diff --git a/packages/frontend/src/lib/policy/docRequest.ts b/packages/frontend/src/lib/policy/docRequest.ts new file mode 100644 index 00000000..9c540615 --- /dev/null +++ b/packages/frontend/src/lib/policy/docRequest.ts @@ -0,0 +1,137 @@ +// Pure parsing/validation of a dApp's scoped-session-key DOC request — the +// URL-param contract of /security/delegate-doc/ (the doc-based sibling of +// /security/delegate/). Kept pure so the param contract is unit-tested +// without a browser; the page does the RPC + handoff. +// +// Params: `origin` (dApp origin), `target` (C…), `signer` (the delegated +// session key, C… or G… — the dApp holds this key), optional `functions` +// (comma-separated), `duration` (24h|7d|30d|none), optional `limit` (decimal +// XLM) + `limit_period`, optional `label` (rule name), `return` (URL). + +import { canonicalJson, type PolicyDoc } from '@nidohq/passkey-sdk'; +import { isContractAddress, isStellarAddress } from './policyDraft.js'; +import { parseFunctionsInput, upsertSessionRule, type SessionDocDraft } from './docDraft.js'; +import { PERIOD_LEDGERS, stroopsFromXlm, type LimitPeriod } from '../spendingLimitParams.js'; +import type { OperationDescriptor } from '../signing/signRequest.js'; + +export const DOC_DURATIONS: Record = { + '24h': 17280, + '7d': 17280 * 7, + '30d': 17280 * 30, + none: null, +}; + +export const DOC_DURATION_LABEL: Record = { + '24h': '24 hours', + '7d': '7 days', + '30d': '30 days', + none: 'Until revoked', +}; + +export interface DocDelegateRequest { + origin: string; + target: string; + /** The delegated session signer the dApp holds (C… or G…). */ + signer: string; + /** Allowed function names; undefined = any function. */ + functions: string[] | undefined; + /** Requested duration key (validated against DOC_DURATIONS). */ + duration: keyof typeof DOC_DURATIONS; + /** Requested cap in stroops (from the decimal-XLM `limit` param), or null. + * A malformed limit is treated as "no cap" — never block the flow on it. */ + limitStroops: string | null; + limitPeriod: LimitPeriod; + /** Rule name (doc rule + display), default "session". */ + label: string; + returnUrl: string; +} + +export type DocDelegateParse = + | { ok: true; req: DocDelegateRequest } + | { ok: false; error: string }; + +export function parseDocDelegateParams(params: URLSearchParams): DocDelegateParse { + const origin = params.get('origin') ?? ''; + const target = params.get('target') ?? ''; + const signer = params.get('signer') ?? ''; + const returnUrl = params.get('return') ?? ''; + + if (!origin) return { ok: false, error: 'Missing origin parameter.' }; + if (!target || !isContractAddress(target)) { + return { ok: false, error: 'Invalid target contract address.' }; + } + if (!signer || !isStellarAddress(signer)) { + return { ok: false, error: 'Invalid session signer (expected a C… or G… address).' }; + } + if (!returnUrl) return { ok: false, error: 'Missing return URL.' }; + try { + new URL(returnUrl); + } catch { + return { ok: false, error: 'Malformed return URL.' }; + } + + const functions = parseFunctionsInput(params.get('functions') ?? ''); + + const durationParam = params.get('duration') ?? '30d'; + const duration = durationParam in DOC_DURATIONS ? durationParam : '30d'; + + const limitParam = params.get('limit'); + let limitStroops: string | null = null; + if (limitParam) { + try { + limitStroops = stroopsFromXlm(limitParam).toString(); + } catch { + limitStroops = null; + } + } + const periodParam = params.get('limit_period') ?? 'day'; + const limitPeriod: LimitPeriod = + periodParam in PERIOD_LEDGERS ? (periodParam as LimitPeriod) : 'day'; + + const label = (params.get('label') ?? '').trim() || 'session'; + + return { + ok: true, + req: { + origin, + target, + signer, + functions, + duration, + limitStroops, + limitPeriod, + label, + returnUrl, + }, + }; +} + +/** + * The ONE way a session grant becomes a signable operation: upsert the + * session rule into the loaded baseline and wrap the merged document in an + * `apply-policy-doc` descriptor. Every policy write is an `apply_doc` — a + * grant flow must never emit `add_context_rule` (the doc-only contract + * hard-refuses it outside the recovery-completion window). Both delegate + * pages (passkey and delegated-key requests) build their /sign/ handoff + * through this helper, so the invariant is testable in one place. + * + * Precondition: `validateSessionDocDraft(draft).ok` and a loaded baseline + * (the applied doc, or the owner-admin baseline on a first apply). + */ +export function buildSessionGrantOperation(args: { + baseline: PolicyDoc; + /** True when nothing is applied yet — no prevDocJson (all-new diff). */ + isFirstApply: boolean; + draft: SessionDocDraft; + networkPassphrase: string; + /** Human-readable expiry label (e.g. "24 hours"). */ + expiryLabel: string; +}): Extract { + const { doc } = upsertSessionRule(args.baseline, args.draft, args.networkPassphrase); + return { + type: 'apply-policy-doc', + docJson: canonicalJson(doc), + ...(args.isFirstApply ? {} : { prevDocJson: canonicalJson(args.baseline) }), + expiryLabel: args.expiryLabel, + }; +} diff --git a/packages/frontend/src/lib/policy/docView.test.ts b/packages/frontend/src/lib/policy/docView.test.ts new file mode 100644 index 00000000..458dad61 --- /dev/null +++ b/packages/frontend/src/lib/policy/docView.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; +import { buildPolicyDoc, docHash, scopedSessionKeyDoc } from '@nidohq/passkey-sdk'; +import { describeDocRule, describeDocSigner, summarizeDoc } from './docView.js'; + +const TARGET = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; +const VERIFIER = 'CD4IF75DNQJKCT35PAJAQDPW3K337EK6SJZDMQEVLXAH65K7ZVZMLXYN'; +const SESSION_G = 'GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZ'; +const OWNER_KEY = '04' + 'ab'.repeat(64); + +describe('describeDocSigner', () => { + it('labels a delegated signer with its doc id and address', () => { + const v = describeDocSigner({ id: 'session', address: SESSION_G }); + expect(v.id).toBe('session'); + expect(v.kind).toBe('delegated'); + expect(v.full).toBe(SESSION_G); + expect(v.detail).toContain('…'); + }); + + it('labels a passkey signer with its doc id and truncated key', () => { + const v = describeDocSigner({ id: 'owner', verifier: VERIFIER, key: OWNER_KEY }); + expect(v.id).toBe('owner'); + expect(v.kind).toBe('passkey'); + expect(v.full).toBe(OWNER_KEY); + }); +}); + +describe('describeDocRule', () => { + const doc = scopedSessionKeyDoc({ + sessionAddress: SESSION_G, + targetContract: TARGET, + functions: ['update_message'], + notAfterLedger: 5000, + cap: { limitStroops: 100_0000000n, periodLedgers: 17280 }, + name: 'status-session', + }); + + it('keeps the doc rule name, functions, expiry, and cap', () => { + const v = describeDocRule(doc.rules[0]); + expect(v.name).toBe('status-session'); + expect(v.contract).toBe(TARGET); + expect(v.functions).toEqual(['update_message']); + expect(v.notAfterLedger).toBe(5000); + expect(v.cap).toEqual({ limit: '1000000000', periodLedgers: 17280 }); + expect(v.signerIds).toEqual(['session']); + }); + + it('writes the permission sentence with the doc names', () => { + const v = describeDocRule(doc.rules[0]); + expect(v.permission).toContain('"session"'); + expect(v.permission).toContain('update_message'); + }); + + it('treats absent functions as any-function and absent expiry as none', () => { + const bare = scopedSessionKeyDoc({ sessionAddress: SESSION_G, targetContract: TARGET }); + const v = describeDocRule(bare.rules[0]); + expect(v.functions).toBeNull(); + expect(v.notAfterLedger).toBeNull(); + expect(v.cap).toBeNull(); + expect(v.permission).toContain('any function'); + }); + + it('labels a threshold quorum', () => { + const doc2 = buildPolicyDoc({ + signers: [ + { id: 'a', kind: 'delegated', address: SESSION_G }, + { id: 'b', kind: 'passkey', verifier: VERIFIER, publicKey: OWNER_KEY }, + ], + permissions: [{ name: 'ops', on: { contract: TARGET }, by: ['a', 'b'] }], + }); + // requestToPolicyDoc only emits `all` principals; patch to threshold to + // exercise the label without hand-writing a whole doc. + const rule = { ...doc2.rules[0], principals: { type: 'threshold' as const, signers: ['a', 'b'], m: 1 } }; + const v = describeDocRule(rule); + expect(v.quorumLabel).toBe('Any 1 of 2'); + expect(v.permission).toContain('any 1 of'); + }); +}); + +describe('summarizeDoc', () => { + const doc = scopedSessionKeyDoc({ + sessionAddress: SESSION_G, + targetContract: TARGET, + functions: ['update_message'], + name: 'status-session', + }); + const hash = docHash(doc); + + it('carries the doc hash badge, signers, and rules', () => { + const m = summarizeDoc(doc, hash); + expect(m.docHash).toBe(hash); + expect(m.docHashShort).toContain('…'); + expect(m.signers).toHaveLength(1); + expect(m.rules).toHaveLength(1); + }); +}); + +describe('renderDocPreviewHtml', () => { + it('renders rule cards with the doc names and tucks the JSON behind a toggle', async () => { + const { renderDocPreviewHtml } = await import('../../components/PolicyInspector.js'); + const { canonicalJson } = await import('@nidohq/passkey-sdk'); + const doc = scopedSessionKeyDoc({ + sessionAddress: SESSION_G, + targetContract: TARGET, + functions: ['update_message'], + notAfterLedger: 5000, + name: 'status-session', + }); + const html = renderDocPreviewHtml(summarizeDoc(doc, docHash(doc)), canonicalJson(doc)); + expect(html).toContain('status-session'); + expect(html).toContain('update_message'); + expect(html).toContain('"session"'); // the doc's own signer id + expect(html).toContain('Stops at ledger 5,000'); + // Raw JSON is available but folded, not dumped. + expect(html).toContain(' { + it('marks admin rules in the display model', async () => { + const { Networks } = await import('@stellar/stellar-sdk'); + const { adminBaseline } = await import('./docDraft.js'); + const baseline = adminBaseline( + { verifier: VERIFIER, publicKeyHex: OWNER_KEY }, + Networks.TESTNET, + ); + expect(describeDocRule(baseline.rules[0]).isAdmin).toBe(true); + const session = scopedSessionKeyDoc({ sessionAddress: SESSION_G, targetContract: TARGET }); + expect(describeDocRule(session.rules[0]).isAdmin).toBe(false); + }); + + it('folds all admin rules into ONE Admin-keys card in the doc view', async () => { + const { Networks } = await import('@stellar/stellar-sdk'); + const { addAdminKey, adminBaseline } = await import('./docDraft.js'); + const { renderDocPolicy } = await import('../../components/PolicyInspector.js'); + const { docHash: hashOf } = await import('@nidohq/passkey-sdk'); + const baseline = adminBaseline( + { verifier: VERIFIER, publicKeyHex: OWNER_KEY }, + Networks.TESTNET, + ); + const twoAdmins = addAdminKey( + baseline, + { name: 'admin-2', signer: { kind: 'delegated', address: SESSION_G } }, + Networks.TESTNET, + ).doc; + const el = document.createElement('div'); + renderDocPolicy(el, summarizeDoc(twoAdmins, hashOf(twoAdmins)), {}); + // ONE consolidated card listing both keys ("any may act"), no per-rule + // admin cards. + expect(el.querySelectorAll('[data-doc-admins]')).toHaveLength(1); + const card = el.querySelector('[data-doc-admins]')!; + expect(card.textContent).toContain('"admin"'); + expect(card.textContent).toContain('"admin-2"'); + expect(card.textContent).toContain('Any of these'); + expect(el.querySelectorAll('[data-doc-rule]')).toHaveLength(0); + }); +}); + +describe('new-account (unapplied baseline) doc view', () => { + it('renders the synthesized baseline labeled not-yet-applied, with cards and the raw toggle', async () => { + const { Networks } = await import('@stellar/stellar-sdk'); + const { adminBaseline } = await import('./docDraft.js'); + const { renderDocPolicy } = await import('../../components/PolicyInspector.js'); + const { canonicalJson, docHash: hashOf } = await import('@nidohq/passkey-sdk'); + // The new-account fixture: no applied doc; the page synthesizes the + // SAME baseline every first-apply flow composes against. + const baseline = adminBaseline( + { verifier: VERIFIER, publicKeyHex: OWNER_KEY }, + Networks.TESTNET, + ); + const el = document.createElement('div'); + renderDocPolicy(el, summarizeDoc(baseline, hashOf(baseline)), { + canonicalJson: canonicalJson(baseline), + unapplied: true, + }); + expect(el.textContent).toContain('Not yet applied'); + expect(el.textContent).toContain('current effective policy'); + expect(el.textContent).toContain('Document hash (once applied)'); + // The admin list renders (founder as "admin") and the raw toggle exists. + expect(el.querySelectorAll('[data-doc-admins]')).toHaveLength(1); + expect(el.textContent).toContain('"admin"'); + expect(el.textContent).toContain('Raw document JSON'); + expect(el.querySelector('#pol-doc-copy-json')).not.toBeNull(); + // An applied doc must NOT carry the unapplied label. + const applied = document.createElement('div'); + renderDocPolicy(applied, summarizeDoc(baseline, hashOf(baseline)), { + canonicalJson: canonicalJson(baseline), + source: 'storage', + }); + expect(applied.textContent).toContain('Verified · lossless'); + expect(applied.textContent).not.toContain('Not yet applied'); + }); +}); diff --git a/packages/frontend/src/lib/policy/docView.ts b/packages/frontend/src/lib/policy/docView.ts new file mode 100644 index 00000000..e9a80b2e --- /dev/null +++ b/packages/frontend/src/lib/policy/docView.ts @@ -0,0 +1,163 @@ +// Pure display model for a perch policy DOCUMENT (tiers a/b of the SDK's +// three-tier `readPolicy`), sibling of policyView.ts (which models raw chain +// rules — tier c). +// +// The point of the doc view is losslessness: the document carries the names +// its author wrote — signer ids, rule names, function lists — so the +// inspector can show *those* instead of reverse-engineered chain state. Keep +// everything here pure (no RPC, no DOM) so it unit-tests like policyView. + +import type { PolicyDoc, Rule, SignerDecl } from '@nidohq/passkey-sdk'; +import { renameLegacyOwner } from './docDraft.js'; +import { truncate } from './policyView.js'; + +/** One declared signer, displayed with the doc's own id as the label. */ +export interface DocSignerView { + id: string; + kind: 'passkey' | 'delegated'; + /** Short kind label, e.g. "Passkey" / "Delegated key". */ + kindLabel: string; + /** Truncated identifier (address or hex key). */ + detail: string; + /** Full identifier for copy/verify. */ + full: string; +} + +export function describeDocSigner(decl: SignerDecl): DocSignerView { + if ('address' in decl) { + return { + id: decl.id, + kind: 'delegated', + kindLabel: 'Delegated key', + detail: truncate(decl.address), + full: decl.address, + }; + } + return { + id: decl.id, + kind: 'passkey', + kindLabel: 'Passkey', + detail: truncate(decl.key, 8, 8), + full: decl.key, + }; +} + +export interface DocRuleView { + name: string; + /** "One contract" / "This account" heading. */ + scopeLabel: string; + /** Target contract address, or null for self-admin. */ + contract: string | null; + /** The doc's own signer ids for this rule, in doc order. */ + signerIds: string[]; + /** "All must sign" / "Any m of n". */ + quorumLabel: string; + /** Named functions the rule allows, or null for "any function". */ + functions: string[] | null; + /** Exclusive not-after ledger from the doc, or null for no expiry. */ + notAfterLedger: number | null; + /** Cumulative cap, if the rule carries one. */ + cap: { limit: string; periodLedgers: number } | null; + /** True when the rule constrains args (rendered as a badge only — arg + * predicates have no compact prose form yet). */ + hasArgConstraints: boolean; + /** True for the ADMIN shape: policy-free cap-free self-admin authority + * (bare `all` principals, no functions/args/cap/expiry). The inspector + * folds these into one "Admin keys" list — any admin may act. */ + isAdmin: boolean; + /** Plain-language sentence of what this rule permits. */ + permission: string; +} + +export function describeDocRule(rule: Rule): DocRuleView { + const contract = rule.scope.type === 'contract' ? rule.scope.address : null; + const scopeLabel = contract === null ? 'This account' : 'One contract'; + const signerIds = rule.principals.type === 'self-authenticating' ? [] : rule.principals.signers; + const quorumLabel = + rule.principals.type === 'threshold' + ? `Any ${rule.principals.m} of ${signerIds.length}` + : signerIds.length > 1 + ? 'All must sign' + : 'One key'; + const functions = rule.functions !== undefined ? [...rule.functions] : null; + const notAfterLedger = rule['not-after-ledger'] ?? null; + const cap = + rule.cap !== undefined + ? { limit: rule.cap.limit, periodLedgers: rule.cap['period-ledgers'] } + : null; + + const isAdmin = + rule.scope.type === 'self-admin' && + rule.principals.type === 'all' && + rule.functions === undefined && + rule.args === undefined && + rule.cap === undefined && + rule['not-after-ledger'] === undefined; + + return { + name: rule.name, + scopeLabel, + contract, + signerIds, + quorumLabel, + functions, + notAfterLedger, + cap, + hasArgConstraints: rule.args !== undefined, + isAdmin, + permission: docPermissionSentence({ rule, contract, signerIds, functions }), + }; +} + +function signerPhrase(rule: Rule, signerIds: string[]): string { + if (signerIds.length === 0) return 'No signer'; + if (signerIds.length === 1) return `"${signerIds[0]}"`; + const names = signerIds.map((s) => `"${s}"`).join(', '); + if (rule.principals.type === 'threshold') { + return `any ${rule.principals.m} of ${names}`; + } + return `${names} together`; +} + +function docPermissionSentence(args: { + rule: Rule; + contract: string | null; + signerIds: string[]; + functions: string[] | null; +}): string { + const who = signerPhrase(args.rule, args.signerIds); + const what = + args.functions === null + ? 'call any function' + : args.functions.length === 1 + ? `call ${args.functions[0]}` + : `call ${args.functions.join(', ')}`; + const where = args.contract === null ? 'on this account' : `on ${truncate(args.contract)}`; + return `${who} can ${what} ${where}.`; +} + +export interface DocViewModel { + /** Lowercase-hex canonical doc hash (== the stored on-chain hash). */ + docHash: string; + /** Truncated hash for the badge. */ + docHashShort: string; + signers: DocSignerView[]; + rules: DocRuleView[]; +} + +/** Build the display model for a hash-verified document (tier a — under + * doc-only there is no drift tier: the live rules ARE the doc's lowering). */ +export function summarizeDoc(doc: PolicyDoc, docHashHex: string): DocViewModel { + // Display-layer id migration: a legacy applied doc may still declare the + // founder as `owner` until its next update rewrites it — render it as + // `admin` so the two names never show at once (renameLegacyOwner's guard + // keeps distinct owner/admin keys apart). Display only; the hash shown is + // still the STORED doc's identity. + const display = renameLegacyOwner(doc); + return { + docHash: docHashHex, + docHashShort: truncate(docHashHex, 8, 8), + signers: display.signers.map(describeDocSigner), + rules: display.rules.map(describeDocRule), + }; +} diff --git a/packages/frontend/src/lib/policy/policyDraft.test.ts b/packages/frontend/src/lib/policy/policyDraft.test.ts new file mode 100644 index 00000000..0f26705d --- /dev/null +++ b/packages/frontend/src/lib/policy/policyDraft.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest'; +import { + validateDraft, + buildContextTypeArg, + buildSignerArgs, + buildAddContextRuleArgs, + spendingLimitPlan, + isContractAddress, + isStellarAddress, + isHex, + type RuleDraft, +} from './policyDraft'; + +const C_ADDR = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; +const VERIFIER = 'CCYWLNWRYDCAEM2A2EMTWAMIGWESQGUJNDTRRFIOS5CBPRO54EZ27ABG'; +const G_ADDR = 'GCY63ZN3C232UXXWENGF5I3PUHSYLR45MKCXS53MI3NSCWBFERWKHEPH'; + +function base(overrides: Partial = {}): RuleDraft { + return { + name: 'ci-publish', + scope: { kind: 'call-contract', contract: C_ADDR }, + signers: [{ kind: 'delegated', address: G_ADDR }], + ...overrides, + }; +} + +describe('address/hex guards', () => { + it('accepts a valid C-address and rejects junk', () => { + expect(isContractAddress(C_ADDR)).toBe(true); + expect(isContractAddress(G_ADDR)).toBe(false); + expect(isContractAddress('nope')).toBe(false); + }); + it('isStellarAddress accepts C and G', () => { + expect(isStellarAddress(C_ADDR)).toBe(true); + expect(isStellarAddress(G_ADDR)).toBe(true); + }); + it('isHex requires even-length hex', () => { + expect(isHex('04ab')).toBe(true); + expect(isHex('abc')).toBe(false); + expect(isHex('xy')).toBe(false); + }); +}); + +describe('validateDraft', () => { + it('accepts a well-formed delegated-signer rule', () => { + expect(validateDraft(base())).toEqual({ ok: true, errors: [] }); + }); + + it('requires a name', () => { + const r = validateDraft(base({ name: ' ' })); + expect(r.ok).toBe(false); + expect(r.errors).toContain('Give the rule a name.'); + }); + + it('rejects an over-long name', () => { + const r = validateDraft(base({ name: 'x'.repeat(40) })); + expect(r.ok).toBe(false); + expect(r.errors.some((e) => e.includes('at most'))).toBe(true); + }); + + it('requires a valid contract for call-contract scope', () => { + const r = validateDraft(base({ scope: { kind: 'call-contract', contract: 'bad' } })); + expect(r.errors.some((e) => e.includes('valid C-address'))).toBe(true); + }); + + it('allows default scope with no contract', () => { + expect(validateDraft(base({ scope: { kind: 'default' } })).ok).toBe(true); + }); + + it('requires at least one signer', () => { + const r = validateDraft(base({ signers: [] })); + expect(r.errors).toContain('Add at least one signer.'); + }); + + it('validates a passkey signer verifier + hex key', () => { + const good = validateDraft( + base({ signers: [{ kind: 'passkey', verifier: VERIFIER, publicKeyHex: '04aabb' }] }), + ); + expect(good.ok).toBe(true); + const bad = validateDraft( + base({ signers: [{ kind: 'passkey', verifier: 'nope', publicKeyHex: 'zz' }] }), + ); + expect(bad.ok).toBe(false); + expect(bad.errors.length).toBe(2); + }); + + it('rejects a non-positive spending limit', () => { + const r = validateDraft(base({ spendingLimit: { stroops: '0', periodLedgers: 100 } })); + expect(r.errors).toContain('Spending limit must be a positive amount.'); + }); + + it('rejects a non-positive expiry ledger', () => { + const r = validateDraft(base({ validUntilLedger: -5 })); + expect(r.errors.some((e) => e.includes('Expiry ledger'))).toBe(true); + }); +}); + +describe('argument construction', () => { + it('maps default scope to the Default tag', () => { + expect(buildContextTypeArg(base({ scope: { kind: 'default' } }))).toEqual({ + tag: 'Default', + values: [], + }); + }); + + it('maps call-contract scope to the CallContract tag with the address', () => { + expect(buildContextTypeArg(base())).toEqual({ tag: 'CallContract', values: [C_ADDR] }); + }); + + it('maps signer kinds to External/Delegated tags', () => { + const args = buildSignerArgs( + base({ + signers: [ + { kind: 'delegated', address: G_ADDR }, + { kind: 'passkey', verifier: VERIFIER, publicKeyHex: '04aabb' }, + ], + }), + ); + expect(args[0]).toEqual({ tag: 'Delegated', values: [G_ADDR] }); + expect(args[1]).toEqual({ tag: 'External', values: [VERIFIER, '04aabb'] }); + }); + + it('spendingLimitPlan parses stroops to bigint or returns null', () => { + expect(spendingLimitPlan(base())).toBeNull(); + expect(spendingLimitPlan(base({ spendingLimit: { stroops: '1000', periodLedgers: 17280 } }))).toEqual({ + stroops: 1000n, + periodLedgers: 17280, + }); + }); + + it('buildAddContextRuleArgs assembles the full binding argument', () => { + const args = buildAddContextRuleArgs(base({ validUntilLedger: 500 })); + expect(args).toEqual({ + context_type: { tag: 'CallContract', values: [C_ADDR] }, + name: 'ci-publish', + valid_until: 500, + signers: [{ tag: 'Delegated', values: [G_ADDR] }], + }); + }); +}); diff --git a/packages/frontend/src/lib/policy/policyDraft.ts b/packages/frontend/src/lib/policy/policyDraft.ts new file mode 100644 index 00000000..95b53d62 --- /dev/null +++ b/packages/frontend/src/lib/policy/policyDraft.ts @@ -0,0 +1,179 @@ +// Pure validation + argument construction for the policy builder. +// +// The builder UI collects a RuleDraft; this module validates it and lowers it to +// the exact `add_context_rule` argument shape the smart-account binding expects +// (minus the policies map, which needs address resolution + ScVal encoding the +// page does). Kept pure so the rules that decide what's a valid policy are +// unit-tested without a wallet or RPC. + +/** Client-side mirror of OZ's on-chain context-rule name limit. The chain + * rejects longer names; catching it here gives a real error instead of a + * failed simulation. */ +export const MAX_RULE_NAME_LEN = 32; +/** OZ verifier key_data cap (bytes). */ +export const MAX_SIGNER_KEY_BYTES = 256; + +export type ScopeKind = 'default' | 'call-contract'; + +export interface DraftSigner { + kind: 'passkey' | 'delegated'; + /** delegated: the C- or G-address that authorizes via its own require_auth. */ + address?: string; + /** passkey: the verifier contract that checks the signature. */ + verifier?: string; + /** passkey: hex-encoded public key the verifier understands. */ + publicKeyHex?: string; +} + +export interface SpendingLimitDraft { + /** Limit in stroops, as a decimal string (bigint-parseable). */ + stroops: string; + /** Rolling window length in ledgers. */ + periodLedgers: number; +} + +export interface RuleDraft { + name: string; + scope: { kind: ScopeKind; contract?: string }; + signers: DraftSigner[]; + spendingLimit?: SpendingLimitDraft | null; + /** Expiry ledger sequence (not a timestamp), or null for no expiry. */ + validUntilLedger?: number | null; +} + +/** Shape check for a Soroban contract address (C-strkey). Checksum is verified + * on-chain; this catches obvious typos before a doomed simulation. */ +export function isContractAddress(s: string): boolean { + return /^C[A-Z2-7]{55}$/.test(s); +} + +/** Shape check for any Stellar address usable as a delegated signer (C or G). */ +export function isStellarAddress(s: string): boolean { + return /^[CG][A-Z2-7]{55}$/.test(s); +} + +export function isHex(s: string): boolean { + return s.length > 0 && s.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(s); +} + +export interface ValidationResult { + ok: boolean; + errors: string[]; +} + +export function validateDraft(draft: RuleDraft): ValidationResult { + const errors: string[] = []; + + const name = draft.name?.trim() ?? ''; + if (name.length === 0) errors.push('Give the rule a name.'); + else if (new TextEncoder().encode(name).length > MAX_RULE_NAME_LEN) { + errors.push(`Name must be at most ${MAX_RULE_NAME_LEN} bytes.`); + } + + if (draft.scope.kind === 'call-contract') { + const c = draft.scope.contract?.trim() ?? ''; + if (!c) errors.push('Choose the contract this rule applies to.'); + else if (!isContractAddress(c)) errors.push('Contract address is not a valid C-address.'); + } + + if (!draft.signers || draft.signers.length === 0) { + errors.push('Add at least one signer.'); + } else { + draft.signers.forEach((s, i) => { + const n = i + 1; + if (s.kind === 'delegated') { + if (!s.address || !isStellarAddress(s.address.trim())) { + errors.push(`Signer ${n}: delegated address is not a valid C- or G-address.`); + } + } else { + if (!s.verifier || !isContractAddress(s.verifier.trim())) { + errors.push(`Signer ${n}: verifier is not a valid C-address.`); + } + const hex = s.publicKeyHex?.trim() ?? ''; + if (!isHex(hex)) errors.push(`Signer ${n}: public key is not valid hex.`); + else if (hex.length / 2 > MAX_SIGNER_KEY_BYTES) { + errors.push(`Signer ${n}: public key exceeds ${MAX_SIGNER_KEY_BYTES} bytes.`); + } + } + }); + } + + if (draft.spendingLimit) { + let stroops: bigint | null = null; + try { + stroops = BigInt(draft.spendingLimit.stroops); + } catch { + stroops = null; + } + if (stroops == null || stroops <= 0n) errors.push('Spending limit must be a positive amount.'); + if (!Number.isInteger(draft.spendingLimit.periodLedgers) || draft.spendingLimit.periodLedgers <= 0) { + errors.push('Spending-limit window must be a positive number of ledgers.'); + } + } + + if (draft.validUntilLedger != null) { + if (!Number.isInteger(draft.validUntilLedger) || draft.validUntilLedger <= 0) { + errors.push('Expiry ledger must be a positive integer.'); + } + } + + return { ok: errors.length === 0, errors }; +} + +// --- Argument construction (only call after validateDraft passes) ---------- + +export type ContextTypeArg = + | { tag: 'Default'; values: readonly [] } + | { tag: 'CallContract'; values: readonly [string] }; + +export function buildContextTypeArg(draft: RuleDraft): ContextTypeArg { + if (draft.scope.kind === 'call-contract') { + return { tag: 'CallContract', values: [draft.scope.contract!.trim()] as const }; + } + return { tag: 'Default', values: [] as const }; +} + +export type SignerArg = + | { tag: 'External'; values: readonly [string, string] } // [verifier, publicKeyHex] + | { tag: 'Delegated'; values: readonly [string] }; + +export function buildSignerArgs(draft: RuleDraft): SignerArg[] { + return draft.signers.map((s) => + s.kind === 'delegated' + ? ({ tag: 'Delegated', values: [s.address!.trim()] as const } as const) + : ({ tag: 'External', values: [s.verifier!.trim(), s.publicKeyHex!.trim()] as const } as const), + ); +} + +export interface SpendingLimitPlan { + stroops: bigint; + periodLedgers: number; +} + +/** The spending-limit policy to attach, or null. The page resolves the policy + * address from the registry and builds its param ScVal. */ +export function spendingLimitPlan(draft: RuleDraft): SpendingLimitPlan | null { + if (!draft.spendingLimit) return null; + return { + stroops: BigInt(draft.spendingLimit.stroops), + periodLedgers: draft.spendingLimit.periodLedgers, + }; +} + +export interface AddContextRuleArgs { + context_type: ContextTypeArg; + name: string; + valid_until: number | undefined; + signers: SignerArg[]; +} + +/** Lower a validated draft to the binding arguments (policies added by the page). + * Precondition: `validateDraft(draft).ok`. */ +export function buildAddContextRuleArgs(draft: RuleDraft): AddContextRuleArgs { + return { + context_type: buildContextTypeArg(draft), + name: draft.name.trim(), + valid_until: draft.validUntilLedger ?? undefined, + signers: buildSignerArgs(draft), + }; +} diff --git a/packages/frontend/src/lib/policy/policyView.test.ts b/packages/frontend/src/lib/policy/policyView.test.ts new file mode 100644 index 00000000..57ecb83f --- /dev/null +++ b/packages/frontend/src/lib/policy/policyView.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import type { ChainRule } from '@nidohq/passkey-sdk'; +import { + truncate, + bytesToHex, + describeSigner, + describePolicy, + describeScope, + describeExpiry, + summarizeRule, +} from './policyView'; + +const PASSKEY_PUB = new Uint8Array([0x04, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x11, 0x22]); +const ADDR_C = 'CCA7QAA6OD6LQJTU2MKN6EAS5I52QIFPAYMMQYSU7KHWTGT26AN6N2AL'; +const ADDR_G = 'GCY63ZN3C232UXXWENGF5I3PUHSYLR45MKCXS53MI3NSCWBFERWKHEPH'; +const POLICY_ADDR = 'CCYWLNWRYDCAEM2A2EMTWAMIGWESQGUJNDTRRFIOS5CBPRO54EZ27ABG'; + +describe('truncate', () => { + it('shortens long identifiers and leaves short ones alone', () => { + expect(truncate(ADDR_C)).toBe('CCA7QA…N6N2AL'); + expect(truncate('short')).toBe('short'); + }); +}); + +describe('bytesToHex', () => { + it('lowercase, zero-padded', () => { + expect(bytesToHex(new Uint8Array([0x00, 0x0f, 0xff]))).toBe('000fff'); + }); +}); + +describe('describeSigner', () => { + it('labels a delegated signer with its address', () => { + expect(describeSigner({ kind: 'delegated', address: ADDR_G })).toEqual({ + kind: 'delegated', + label: 'Delegated key', + detail: truncate(ADDR_G), + full: ADDR_G, + }); + }); + + it('labels an external signer as a passkey with its hex key', () => { + const v = describeSigner({ kind: 'external', verifier: POLICY_ADDR, publicKey: PASSKEY_PUB }); + expect(v.kind).toBe('passkey'); + expect(v.label).toBe('Passkey'); + expect(v.full).toBe(bytesToHex(PASSKEY_PUB)); + }); +}); + +describe('describePolicy', () => { + const known = new Map([[POLICY_ADDR, 'Spending limit']]); + it('uses the registry label for a known policy', () => { + expect(describePolicy(POLICY_ADDR, known)).toMatchObject({ label: 'Spending limit', known: true }); + }); + it('falls back to "Custom policy" for an unknown address', () => { + expect(describePolicy(ADDR_C, known)).toMatchObject({ label: 'Custom policy', known: false }); + }); +}); + +describe('describeScope', () => { + it('default → any contract', () => { + expect(describeScope({ kind: 'default' })).toEqual({ kind: 'default', label: 'Any contract', detail: null }); + }); + it('call-contract carries the target address', () => { + expect(describeScope({ kind: 'call-contract', contract: ADDR_C })).toEqual({ + kind: 'call-contract', + label: 'One contract', + detail: ADDR_C, + }); + }); + it('create-contract → contract creation', () => { + expect(describeScope({ kind: 'create-contract', wasm: new Uint8Array() })).toEqual({ + kind: 'create-contract', + label: 'Contract creation', + detail: null, + }); + }); +}); + +describe('describeExpiry', () => { + it('no valid_until → no expiry', () => { + expect(describeExpiry(null, 100)).toEqual({ state: 'none', label: 'No expiry' }); + }); + it('current ledger past valid_until → expired', () => { + expect(describeExpiry(50, 100)).toEqual({ state: 'expired', label: 'Expired at ledger 50' }); + }); + it('current ledger before valid_until → active', () => { + expect(describeExpiry(200, 100).state).toBe('active'); + }); + it('unknown current ledger keeps a set expiry active, never guesses expired', () => { + expect(describeExpiry(50, null).state).toBe('active'); + }); +}); + +function rule(partial: Partial): ChainRule { + return { + ruleId: 1, + contextType: { kind: 'default' }, + name: 'rule', + signers: [], + policies: [], + validUntil: null, + ...partial, + }; +} + +describe('summarizeRule', () => { + it('marks rule 0 default as the primary authority', () => { + const v = summarizeRule(rule({ ruleId: 0, name: 'default', contextType: { kind: 'default' } })); + expect(v.isDefault).toBe(true); + expect(v.permission).toContain('primary authority'); + }); + + it('writes a scoped-call permission sentence with signer count', () => { + const v = summarizeRule( + rule({ + ruleId: 2, + name: 'ci-publish', + contextType: { kind: 'call-contract', contract: ADDR_C }, + signers: [ + { kind: 'external', verifier: POLICY_ADDR, publicKey: PASSKEY_PUB }, + { kind: 'delegated', address: ADDR_G }, + ], + }), + ); + expect(v.permission).toContain('Any of 2 keys'); + expect(v.permission).toContain(truncate(ADDR_C)); + expect(v.gated).toBe(false); + }); + + it('flags a gated rule and names its attached policies', () => { + const v = summarizeRule( + rule({ + contextType: { kind: 'call-contract', contract: ADDR_C }, + signers: [{ kind: 'delegated', address: ADDR_G }], + policies: [POLICY_ADDR], + }), + { known: new Map([[POLICY_ADDR, 'Spending limit']]) }, + ); + expect(v.gated).toBe(true); + expect(v.policies[0].label).toBe('Spending limit'); + expect(v.permission).toContain('subject to: Spending limit'); + }); + + it('classifies expiry against the current ledger', () => { + const v = summarizeRule(rule({ validUntil: 10 }), { currentLedger: 99 }); + expect(v.expiry.state).toBe('expired'); + }); +}); diff --git a/packages/frontend/src/lib/policy/policyView.ts b/packages/frontend/src/lib/policy/policyView.ts new file mode 100644 index 00000000..da048243 --- /dev/null +++ b/packages/frontend/src/lib/policy/policyView.ts @@ -0,0 +1,183 @@ +// Pure display model for a smart account's on-chain policy (its context rules). +// +// The Security page renders curated cards (recovery, session keys); this module +// backs the general Policy inspector, which shows EVERY context rule the way OZ +// stores it — scope, signers, attached policies, expiry — plus a plain-language +// sentence of what each rule permits. Everything here is pure so it unit-tests +// without RPC or a browser; the page supplies the fetched data. + +import type { ChainRule, ChainSigner } from '@nidohq/passkey-sdk'; + +/** Truncate a long identifier (C-address, hex key) to `head…tail`. */ +export function truncate(s: string, head = 6, tail = 6): string { + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} + +/** Lowercase hex of raw key bytes. */ +export function bytesToHex(bytes: Uint8Array): string { + let out = ''; + for (const b of bytes) out += b.toString(16).padStart(2, '0'); + return out; +} + +export interface SignerView { + kind: 'passkey' | 'delegated'; + /** Short human label for the signer type. */ + label: string; + /** Truncated identifier for display. */ + detail: string; + /** Full identifier (address, or hex public key) for copy/verify. */ + full: string; +} + +export function describeSigner(signer: ChainSigner): SignerView { + if (signer.kind === 'delegated') { + return { + kind: 'delegated', + label: 'Delegated key', + detail: truncate(signer.address), + full: signer.address, + }; + } + const hex = bytesToHex(signer.publicKey); + return { + kind: 'passkey', + label: 'Passkey', + detail: truncate(hex, 8, 8), + full: hex, + }; +} + +export interface PolicyView { + address: string; + short: string; + /** Registry name if known, else "Custom policy". */ + label: string; + known: boolean; +} + +export function describePolicy(address: string, known: ReadonlyMap): PolicyView { + const label = known.get(address); + return { + address, + short: truncate(address), + label: label ?? 'Custom policy', + known: label != null, + }; +} + +export interface ScopeView { + kind: ChainRule['contextType']['kind']; + /** Short heading, e.g. "One contract". */ + label: string; + /** Address (call-contract) or null. */ + detail: string | null; +} + +export function describeScope(contextType: ChainRule['contextType']): ScopeView { + switch (contextType.kind) { + case 'default': + return { kind: 'default', label: 'Any contract', detail: null }; + case 'call-contract': + return { kind: 'call-contract', label: 'One contract', detail: contextType.contract }; + case 'create-contract': + return { kind: 'create-contract', label: 'Contract creation', detail: null }; + } +} + +export interface ExpiryView { + state: 'none' | 'active' | 'expired'; + label: string; +} + +/** Classify a rule's `valid_until` ledger against the current ledger. + * `currentLedger` null (unknown) keeps a set expiry as "active" rather than + * guessing — we never show "expired" without evidence. */ +export function describeExpiry(validUntil: number | null, currentLedger: number | null): ExpiryView { + if (validUntil == null) return { state: 'none', label: 'No expiry' }; + if (currentLedger != null && currentLedger > validUntil) { + return { state: 'expired', label: `Expired at ledger ${validUntil}` }; + } + return { state: 'active', label: `Expires at ledger ${validUntil}` }; +} + +export interface RuleView { + ruleId: number; + name: string; + scope: ScopeView; + signers: SignerView[]; + policies: PolicyView[]; + expiry: ExpiryView; + /** Plain-language statement of what the rule allows. */ + permission: string; + /** True when policies are attached — extra conditions gate the rule. */ + gated: boolean; + /** Rule 0 is the account's own default authority. */ + isDefault: boolean; +} + +export interface SummarizeOptions { + /** address → registry label for attached policy contracts. */ + known?: ReadonlyMap; + /** Current ledger sequence, for expiry classification. */ + currentLedger?: number | null; +} + +/** Build the display model for one context rule. */ +export function summarizeRule(rule: ChainRule, opts: SummarizeOptions = {}): RuleView { + const known = opts.known ?? new Map(); + const currentLedger = opts.currentLedger ?? null; + const scope = describeScope(rule.contextType); + const signers = rule.signers.map(describeSigner); + const policies = rule.policies.map((p) => describePolicy(p, known)); + const expiry = describeExpiry(rule.validUntil, currentLedger); + const gated = policies.length > 0; + const isDefault = rule.ruleId === 0 && scope.kind === 'default'; + + return { + ruleId: rule.ruleId, + name: rule.name, + scope, + signers, + policies, + expiry, + gated, + isDefault, + permission: permissionSentence({ scope, signers, policies, isDefault }), + }; +} + +function whoCanSign(signers: SignerView[]): string { + if (signers.length === 0) return 'No signer'; + if (signers.length === 1) return 'One key'; + return `Any of ${signers.length} keys`; +} + +function scopePhrase(scope: ScopeView): string { + switch (scope.kind) { + case 'default': + return 'call any function on any contract'; + case 'call-contract': + return `call ${scope.detail ? truncate(scope.detail) : 'one contract'}`; + case 'create-contract': + return 'create contracts'; + } +} + +function permissionSentence(args: { + scope: ScopeView; + signers: SignerView[]; + policies: PolicyView[]; + isDefault: boolean; +}): string { + const base = `${whoCanSign(args.signers)} can ${scopePhrase(args.scope)}`; + if (args.isDefault) { + return `${base} — this is the account's primary authority.`; + } + if (args.policies.length > 0) { + const names = args.policies.map((p) => p.label).join(', '); + return `${base}, subject to: ${names}.`; + } + return `${base}.`; +} diff --git a/packages/frontend/src/lib/signing/operationBuilders.test.ts b/packages/frontend/src/lib/signing/operationBuilders.test.ts index c312b0ab..84418ddd 100644 --- a/packages/frontend/src/lib/signing/operationBuilders.test.ts +++ b/packages/frontend/src/lib/signing/operationBuilders.test.ts @@ -10,11 +10,11 @@ * REGISTRY_FALLBACKS["name-registry"] address when the RPC is unreachable, * so buildOperation returns a valid xdr.Operation without any mock needed. * - * - `add-context-rule` / `remove-context-rule`: SmartAccountClient.add_context_rule - * and .remove_context_rule call simulateTransaction against RPC, which will - * fail in jsdom. These branches are verified by code inspection and covered - * indirectly through integration tests — they are NOT unit-tested here - * (no vi.stubGlobal fetch mock exists for them). + * - `add-context-rule` / `remove-context-rule`: DOC-ONLY — both branches + * THROW offline (the account has no general rule mutators), which IS + * the unit-tested behavior below: the regression guard against the + * legacy delegate flow emitting a per-rule install (the live + * Error(Contract, #19) failure). * * - `transfer`: buildSendOperation is pure (no network), so the round-trip * test runs without any mock. @@ -76,6 +76,43 @@ describe("buildOperation", () => { }); }); + describe("doc-only guards (regression: no legacy mutator escapes)", () => { + it("refuses the add-context-rule descriptor with doc-only guidance", async () => { + await expect( + buildOperation( + { + type: "add-context-rule", + target: TOKEN, + signerPublicKeyHex: "04" + "b0".repeat(64), + verifierAddress: TO, + validUntil: 5145276, + limit: null, + label: "session-key", + }, + C1, + ), + ).rejects.toThrow(/doc-only.*apply_doc/); + }); + + it("refuses the remove-context-rule descriptor with doc-only guidance", async () => { + await expect( + buildOperation({ type: "remove-context-rule", ruleId: 3, target: TOKEN }, C1), + ).rejects.toThrow(/doc-only/); + }); + }); + + describe("apply-policy-doc", () => { + // The apply path ends in a network simulation (SmartAccountClient), so + // like add-context-rule the happy path is not unit-tested here. The + // OFFLINE guard IS: a doc that doesn't parse throws before any client + // is constructed. + it("refuses malformed doc JSON", async () => { + await expect( + buildOperation({ type: "apply-policy-doc", docJson: "{\"nope\":true}" }, C1), + ).rejects.toThrow(); + }); + }); + describe("register — dispatches correctly", () => { it("returns an invokeHostFunction xdr.Operation", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); diff --git a/packages/frontend/src/lib/signing/operationBuilders.ts b/packages/frontend/src/lib/signing/operationBuilders.ts index 0afa282c..01bf11e3 100644 --- a/packages/frontend/src/lib/signing/operationBuilders.ts +++ b/packages/frontend/src/lib/signing/operationBuilders.ts @@ -10,18 +10,20 @@ * * register → account/index.astro runNameClaim (invokeContractFunction) * transfer → lib/transfer/buildSend.ts buildSendOperation - * add-context-rule → security/delegate/index.astro approveBtn handler - * remove-context-rule → passkey-sdk policyBlocks/scopedSessionKey.ts buildRevoke - * (via SmartAccountClient.remove_context_rule) + * apply-policy-doc → the ONE policy write (buildApplyDocTx); both + * delegate pages and the policy builder route here + * + * DOC-ONLY: the add-context-rule / remove-context-rule descriptors THROW — + * the account has no general rule mutators (add_context_rule is hard-gated + * to the zk-recovery completion window), so a stale stashed request must + * fail with guidance here rather than as Error(Contract, #19) on-chain. */ import { Address, Operation, Networks, nativeToScVal, xdr } from "@stellar/stellar-sdk"; -import { Client as SmartAccountClient } from "@nidohq/smart-account"; -import { extractXdrOperations, hex2buf } from "@nidohq/passkey-sdk"; +import { buildApplyDocTx, parsePolicyDocJson } from "@nidohq/passkey-sdk"; import type { OperationDescriptor } from "./signRequest"; import { buildSendOperation } from "../transfer/buildSend.js"; import { fetchRegistryAddress } from "../policyChainFetch.js"; -import { spendingLimitParamsScVal } from "../spendingLimitParams.js"; import { RPC_URL } from "../network.js"; const NETWORK_PASSPHRASE = Networks.TESTNET; @@ -69,42 +71,30 @@ export async function buildOperation( } case "add-context-rule": { - // Mirror: security/delegate/index.astro approveBtn handler (~lines 296-314). - // Constructs the SmartAccountClient and calls add_context_rule with the - // same shape: context_type CallContract, External signer, optional policies. - const client = new SmartAccountClient({ - contractId: account, - networkPassphrase: NETWORK_PASSPHRASE, - rpcUrl: RPC_URL, - }); - - // Spending limit: non-null `limit` → policies map with one entry. - // No limit (null/undefined) → empty map (byte-identical to pre-limit behavior). - let policies = new Map>(); - if (d.limit != null) { - const policyAddr = await fetchRegistryAddress("spending-limit-policy"); - policies = new Map([ - [policyAddr, spendingLimitParamsScVal(BigInt(d.limit.stroops), d.limit.periodLedgers)], - ]); - } + // DOC-ONLY: the account has no general add_context_rule (it is + // hard-gated to the zk-recovery completion window — DocOnlyWritePath, + // Error(Contract, #19) on-chain). Session grants are policy-document + // updates: /security/delegate/ composes them via + // buildSessionGrantOperation → apply-policy-doc. Reaching this branch + // means a STALE stashed SignRequest from before the doc-only rewrite. + throw new Error( + "doc-only: session grants are policy-document updates (apply_doc), not add_context_rule — restart the delegation from the dApp", + ); + } - const assembled = await client.add_context_rule({ - context_type: { tag: "CallContract", values: [d.target] as readonly [string] }, - name: d.label ?? "session-key", - valid_until: d.validUntil ?? undefined, - signers: [ - { - tag: "External" as const, - values: [d.verifierAddress, hex2buf(d.signerPublicKeyHex) as Buffer] as readonly [ - string, - Buffer, - ], - }, - ], - policies, + case "apply-policy-doc": { + // Mirror: components/PolicyBuilder.ts submit. The doc is parsed fresh + // from the canonical JSON in the descriptor (never trusted as a live + // object) and applied through the account's `apply_doc` surface — + // the ONLY policy write path (doc-only ruling; the per-rule + // add_context_rule lowering for docs is gone). + const doc = parsePolicyDocJson(d.docJson); + const tx = await buildApplyDocTx(doc, { + account, + rpcUrl: RPC_URL, + networkPassphrase: NETWORK_PASSPHRASE, }); - - return extractXdrOperations(assembled, "add-context-rule")[0]!; + return tx.operations[0]!; } case "remove-context-rule": { diff --git a/packages/frontend/src/lib/signing/signRequest.ts b/packages/frontend/src/lib/signing/signRequest.ts index a12bba2b..586eb6ee 100644 --- a/packages/frontend/src/lib/signing/signRequest.ts +++ b/packages/frontend/src/lib/signing/signRequest.ts @@ -14,6 +14,24 @@ export type OperationDescriptor = expiryLabel?: string; } | { type: "remove-context-rule"; ruleId: number; target: string } + | { + /** Apply a perch policy document (the doc-based session-grant flow). + * The doc is the source of truth — scope, signers, functions, expiry, + * and cap all live in `docJson`, so nothing here is editable at /sign/ + * (adjustments happen on the delegate-doc page, before the handoff). + * Every policy write is an `apply_doc` — a whole-document REPLACE — + * so `docJson` is the full updated document, and `prevDocJson` lets + * /sign/ show exactly what changes vs the currently applied one. */ + type: "apply-policy-doc"; + /** Canonical doc JSON — the exact bytes `apply_doc` receives (and the + * bytes whose sha256 is the doc_hash shown to the user). */ + docJson: string; + /** Canonical JSON of the CURRENTLY applied document at request-build + * time; absent on a first apply. Display-only (the diff panel). */ + prevDocJson?: string; + /** Human-readable expiry label (e.g. "24 hours"), as for add-context-rule. */ + expiryLabel?: string; + } | { type: "raw-xdr"; xdr: string }; export type SignKind = diff --git a/packages/frontend/src/lib/transfer/review.ts b/packages/frontend/src/lib/transfer/review.ts index cc7e8f00..f932e711 100644 --- a/packages/frontend/src/lib/transfer/review.ts +++ b/packages/frontend/src/lib/transfer/review.ts @@ -148,4 +148,9 @@ export function renderGenericOp(op: OpSummary): string { return `
    ${renderGenericOpLine(op)}
    `; } -export { renderSessionGrant, type SessionGrantScope } from "./sessionGrantReview.js"; +export { + renderSessionGrant, + renderDocSessionGrant, + type SessionGrantScope, + type DocSessionGrantView, +} from "./sessionGrantReview.js"; diff --git a/packages/frontend/src/lib/transfer/sessionGrantReview.ts b/packages/frontend/src/lib/transfer/sessionGrantReview.ts index 0cd2af5a..6aee9d5a 100644 --- a/packages/frontend/src/lib/transfer/sessionGrantReview.ts +++ b/packages/frontend/src/lib/transfer/sessionGrantReview.ts @@ -35,3 +35,44 @@ export function renderSessionGrant( ${row("On contract", `${esc(shortAddr(op.target))}`)} `; } + +/** The doc-based session grant (an apply-policy-doc SignRequest): same + * review-card conventions as renderSessionGrant, plus the doc-only rows — + * the rule's own name, the named functions, and the doc_hash the account + * will store on chain. */ +export interface DocSessionGrantView { + origin: string; + ruleName: string; + target: string; + /** The delegated session signer (C… or G…). */ + signer: string; + /** Named functions, or null for "any function". */ + functions: string[] | null; + expiryLabel: string; + capStroops: string | null; + capPeriod: LimitPeriod; + /** Lowercase-hex canonical doc_hash. */ + docHash: string; +} + +export function renderDocSessionGrant(v: DocSessionGrantView): string { + const cap = + v.capStroops == null + ? `Any amount (no cap)` + : `Up to ${esc(stroopsToXlm(BigInt(v.capStroops)))} XLM ${esc(PERIOD_LABEL[v.capPeriod])}`; + const fns = + v.functions == null + ? `Any function` + : v.functions.map((f) => `${esc(f)}`).join(", "); + return `
    + ${row("Action", "Grant an app a scoped session key", true)} + ${row("App", `${esc(v.origin)}`)} + ${row("Rule name", `${esc(v.ruleName)}`)} + ${row("App's key", `${esc(shortAddr(v.signer))}`)} + ${row("On contract", `${esc(shortAddr(v.target))}`)} + ${row("Functions", fns)} + ${row("Can spend", cap)} + ${row("Expires", esc(v.expiryLabel))} + ${row("Document hash", `${esc(v.docHash.slice(0, 8))}…${esc(v.docHash.slice(-8))}`)} +
    `; +} diff --git a/packages/frontend/src/pages/account/policy/index.astro b/packages/frontend/src/pages/account/policy/index.astro new file mode 100644 index 00000000..a433b725 --- /dev/null +++ b/packages/frontend/src/pages/account/policy/index.astro @@ -0,0 +1,255 @@ +--- +// Policy inspector + builder. Shows every context rule on the account (scope, +// signers, attached policies, expiry) and lets the owner add a new rule. The +// curated Security page stays the friendly front door for recovery/session +// keys; this is the full, general view. +import NidoLayout from '../../../layouts/NidoLayout.astro'; +import Toast from '../../../components/Toast.astro'; +import PasskeySheet from '../../../components/PasskeySheet.astro'; +import Icon from '../../../components/Icon.astro'; +--- + +
    +
    + + + +
    Policy
    +
    + +
    +

    Account policy

    +

    + Every rule that decides who may do what on this account. +

    + + + +
    Loading…
    + + + +
    + +
    +
    + +
    + +
    +

    + Grant a key permission to act on this account — optionally scoped to one + contract, capped by a spending limit, or time-boxed with an expiry. +

    +
    +
    +
    +
    +
    + + + +
    + + + + diff --git a/packages/frontend/src/pages/security/delegate-doc/index.astro b/packages/frontend/src/pages/security/delegate-doc/index.astro new file mode 100644 index 00000000..de83eabb --- /dev/null +++ b/packages/frontend/src/pages/security/delegate-doc/index.astro @@ -0,0 +1,483 @@ +--- +// The DOC-based sibling of /security/delegate/: a dApp requests a SCOPED +// SESSION KEY as a perch policy-document update. The dApp holds a delegated +// key (C… or G…) and asks for it to be allowed on one contract's named +// functions, time-boxed, optionally spend-capped. +// +// Policy writes are DOC-ONLY: the wallet reads the account's currently +// applied document, upserts the requested session rule into it (apply_doc +// replaces the whole document, so the update carries existing rules +// forward), previews the merged document's canonical JSON + doc_hash AND +// the exact diff against the applied one — rules added / removed / modified +// — then hands off to /sign/ with an apply-policy-doc descriptor. +// +// The origin / return-URL validation (anti-redirect-abuse) mirrors +// /security/delegate/ verbatim; the query-param contract is parsed by the +// pure lib lib/policy/docRequest. +import NidoLayout from '../../../layouts/NidoLayout.astro'; +import Toast from '../../../components/Toast.astro'; +import PasskeySheet from '../../../components/PasskeySheet.astro'; +import Icon from '../../../components/Icon.astro'; +--- + +
    +
    + + + +

    Let an app in?

    + +
    + +
    +
    +
    +
    + +
    +
    +
    App requests a scoped session key
    +
    +
    +
    +

    + The app holds its own key and asks to use it with the contract below — + only the listed functions, only for a while. What you approve becomes + part of your account's policy document, so your policy page can + always show exactly this, verified against a hash stored on chain. +

    +
    + + +
    + + + + + +
    + + +
    +
    Reading the applied document…
    +
    + + +
    +
    + doc_hash + — +
    +
    —
    +

    + After applying, this document is always visible on + your Policy page. +

    +
    + +
    + + +
    +

    +
    +
    + + + +
    + + + + diff --git a/packages/frontend/src/pages/security/delegate/index.astro b/packages/frontend/src/pages/security/delegate/index.astro index 26455600..7e1a336e 100644 --- a/packages/frontend/src/pages/security/delegate/index.astro +++ b/packages/frontend/src/pages/security/delegate/index.astro @@ -1,10 +1,25 @@ --- -// Nido reskin of the session-key delegation approval page. The ENTIRE - diff --git a/packages/frontend/src/pages/security/index.astro b/packages/frontend/src/pages/security/index.astro index 07888c21..8a4f6ef7 100644 --- a/packages/frontend/src/pages/security/index.astro +++ b/packages/frontend/src/pages/security/index.astro @@ -127,6 +127,27 @@ import Nest from '../../components/Nest.astro'; + + +
    diff --git a/packages/frontend/src/pages/sign/index.astro b/packages/frontend/src/pages/sign/index.astro index 364772de..c47e709d 100644 --- a/packages/frontend/src/pages/sign/index.astro +++ b/packages/frontend/src/pages/sign/index.astro @@ -193,8 +193,28 @@ import PasskeySheet from '../../components/PasskeySheet.astro'; #limit-none { accent-color: var(--acc); } + +