From af32be7e36c440444a038e68dc756e7e3433e357 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Sat, 8 Aug 2026 17:03:24 -0400 Subject: [PATCH 01/14] feat(frontend): policy inspector + builder for a smart account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Policy page (/account/policy) that visualizes every context rule on a smart account and a builder for adding new rules. Inspector: fetches all context rules (fetchAllChainRules) and renders each as a card — scope (any contract / one contract / contract creation), signers (passkeys vs delegated keys), attached policy conditions (labeled from the registry where known), and expiry classified against the current ledger. Each rule gets a plain-language sentence of what it permits, and rule 0 is flagged as the account's primary authority. Builder: composes a new context rule (scope, one or more passkey/delegated signers, optional spending-limit policy, optional expiry), validates it, lowers it to the smart-account add_context_rule arguments, and submits through the account's existing passkey signing path (signAndSubmit). No silent signing — the on-chain write always goes through the user's passkey. The display model (policyView) and draft validation/lowering (policyDraft) are pure and unit-tested (34 tests); the Security page stays the curated front door for recovery/session keys, this is the general view. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011iRUGB6K1XCEsdHoxEzsbP --- .../frontend/src/components/PolicyBuilder.ts | 290 ++++++++++++++++++ .../src/components/PolicyInspector.ts | 100 ++++++ .../src/lib/policy/policyDraft.test.ts | 140 +++++++++ .../frontend/src/lib/policy/policyDraft.ts | 179 +++++++++++ .../src/lib/policy/policyView.test.ts | 148 +++++++++ .../frontend/src/lib/policy/policyView.ts | 183 +++++++++++ .../src/pages/account/policy/index.astro | 162 ++++++++++ 7 files changed, 1202 insertions(+) create mode 100644 packages/frontend/src/components/PolicyBuilder.ts create mode 100644 packages/frontend/src/components/PolicyInspector.ts create mode 100644 packages/frontend/src/lib/policy/policyDraft.test.ts create mode 100644 packages/frontend/src/lib/policy/policyDraft.ts create mode 100644 packages/frontend/src/lib/policy/policyView.test.ts create mode 100644 packages/frontend/src/lib/policy/policyView.ts create mode 100644 packages/frontend/src/pages/account/policy/index.astro diff --git a/packages/frontend/src/components/PolicyBuilder.ts b/packages/frontend/src/components/PolicyBuilder.ts new file mode 100644 index 00000000..594d9632 --- /dev/null +++ b/packages/frontend/src/components/PolicyBuilder.ts @@ -0,0 +1,290 @@ +// The policy builder: compose a new context rule and add it to the account. +// +// Collects a RuleDraft from the form, validates it with the pure lib, lowers it +// to the smart-account binding's add_context_rule arguments, attaches an +// optional spending-limit policy, and submits through the account's existing +// passkey signing path (signAndSubmit). The actual on-chain write always goes +// through the user's passkey ceremony — this module never signs silently. + +import { Client as SmartAccountClient } from '@nidohq/smart-account'; +import { extractXdrOperations, hex2buf } 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 { fetchRegistryAddress } from '../lib/policyChainFetch.js'; +import { spendingLimitParamsScVal, stroopsFromXlm, PERIOD_LEDGERS } from '../lib/spendingLimitParams.js'; +import { signAndSubmit } from '../lib/primaryPasskeySigner.js'; +import { + validateDraft, + buildAddContextRuleArgs, + spendingLimitPlan, + type RuleDraft, + type DraftSigner, +} from '../lib/policy/policyDraft.js'; + +const NETWORK_PASSPHRASE = Networks.TESTNET; + +interface BuilderOptions { + account: string; + /** Called after a rule is successfully added, so the page can refresh. */ + onSubmitted?: () => void; +} + +export function mountPolicyBuilder(container: HTMLElement, opts: BuilderOptions): void { + // Local signer draft state; the row inputs are the source of truth on submit. + let signers: DraftSigner[] = [{ kind: 'passkey' }]; + + function render(): void { + container.innerHTML = ` +
+ + +
+ Scope + + + +
+ +
Signers
+
+ + + + + + + + + + +
+ +
+

+
`; + renderSignerRows(); + wire(); + } + + function renderSignerRows(): void { + const rows = container.querySelector('#pol-signer-rows'); + if (!rows) return; + rows.innerHTML = signers + .map((s, i) => { + const passkey = s.kind === 'passkey'; + return `
+ + ${ + passkey + ? ` + ` + : `` + } + ${signers.length > 1 ? `` : ''} +
`; + }) + .join(''); + + rows.querySelectorAll('.pol-signer-kind').forEach((sel) => { + sel.addEventListener('change', () => { + const i = Number(sel.dataset.i); + readSignersFromDom(); + signers[i] = { kind: sel.value as DraftSigner['kind'] }; + renderSignerRows(); + }); + }); + rows.querySelectorAll('.pol-sig-remove').forEach((btn) => { + btn.addEventListener('click', () => { + readSignersFromDom(); + signers.splice(Number(btn.dataset.i), 1); + renderSignerRows(); + }); + }); + } + + /** Read current input values back into the signers state (preserve on re-render). */ + function readSignersFromDom(): void { + const rows = container.querySelectorAll('.pol-signer-row'); + rows.forEach((row) => { + const i = Number(row.dataset.i); + const kind = row.querySelector('.pol-signer-kind')?.value as DraftSigner['kind']; + if (kind === 'delegated') { + signers[i] = { kind, address: row.querySelector('.pol-sig-addr')?.value.trim() }; + } else { + signers[i] = { + kind: 'passkey', + verifier: row.querySelector('.pol-sig-verifier')?.value.trim(), + publicKeyHex: row.querySelector('.pol-sig-pubkey')?.value.trim(), + }; + } + }); + } + + function collectDraft(): RuleDraft { + readSignersFromDom(); + const q = (sel: string) => container.querySelector(sel); + const scopeKind = + q('input[name="scope"]:checked')?.value === 'default' ? 'default' : 'call-contract'; + const limitOn = q('input[name="limit-on"]')?.checked ?? false; + const expiryOn = q('input[name="expiry-on"]')?.checked ?? false; + + const draft: RuleDraft = { + name: q('input[name="name"]')?.value ?? '', + scope: + scopeKind === 'call-contract' + ? { kind: 'call-contract', contract: q('input[name="contract"]')?.value ?? '' } + : { kind: 'default' }, + signers: signers.map((s) => ({ ...s })), + }; + + if (limitOn) { + const xlm = q('input[name="limit-xlm"]')?.value ?? ''; + const period = (q('select[name="limit-period"]')?.value ?? 'day') as keyof typeof PERIOD_LEDGERS; + let stroops = '0'; + try { + stroops = stroopsFromXlm(xlm).toString(); + } catch { + stroops = '0'; + } + draft.spendingLimit = { stroops, periodLedgers: PERIOD_LEDGERS[period] }; + } + if (expiryOn) { + const n = Number(q('input[name="expiry-ledger"]')?.value); + draft.validUntilLedger = Number.isFinite(n) ? n : NaN; + } + return draft; + } + + function showErrors(errors: string[]): void { + const box = container.querySelector('#pol-errors'); + if (!box) return; + if (errors.length === 0) { + box.hidden = true; + box.innerHTML = ''; + return; + } + box.hidden = false; + box.innerHTML = `
    ${errors.map((e) => `
  • ${esc(e)}
  • `).join('')}
`; + } + + async function submit(): Promise { + const draft = collectDraft(); + const check = validateDraft(draft); + if (!check.ok) { + showErrors(check.errors); + return; + } + showErrors([]); + + const submitBtn = container.querySelector('#pol-submit'); + const status = container.querySelector('#pol-status'); + if (submitBtn) submitBtn.disabled = true; + const setStatus = (t: string) => { + if (status) status.textContent = t; + }; + + try { + const args = buildAddContextRuleArgs(draft); + + // Attach the optional spending-limit policy (address resolved from the + // registry, params ScVal-encoded — same path the delegate flow uses). + const policies = new Map>(); + const plan = spendingLimitPlan(draft); + if (plan) { + setStatus('Resolving spending-limit policy…'); + const policyAddr = await fetchRegistryAddress('spending-limit-policy'); + policies.set(policyAddr, spendingLimitParamsScVal(plan.stroops, plan.periodLedgers)); + } + + const client = new SmartAccountClient({ + contractId: opts.account, + networkPassphrase: NETWORK_PASSPHRASE, + rpcUrl: RPC_URL, + }); + + setStatus('Building transaction…'); + const assembled = await client.add_context_rule({ + context_type: + args.context_type.tag === 'CallContract' + ? { tag: 'CallContract', values: args.context_type.values as readonly [string] } + : { tag: 'Default', values: void 0 as unknown as undefined }, + name: args.name, + valid_until: args.valid_until, + signers: args.signers.map((s) => + s.tag === 'External' + ? { + tag: 'External' as const, + values: [s.values[0], hex2buf(s.values[1]) as Buffer] as readonly [string, Buffer], + } + : { tag: 'Delegated' as const, values: [s.values[0]] as readonly [string] }, + ), + policies, + }); + + const operation = extractXdrOperations(assembled, 'add-context-rule')[0]!; + + await signAndSubmit({ + account: opts.account, + operation, + onProgress: (p) => setStatus(`${p.phase}${p.detail ? `: ${p.detail}` : ''}…`), + }); + + toast('Policy rule added.'); + setStatus(''); + // Reset the form and refresh the inspector. + signers = [{ kind: 'passkey' }]; + render(); + opts.onSubmitted?.(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showErrors([msg]); + setStatus(''); + } finally { + if (submitBtn) submitBtn.disabled = false; + } + } + + function wire(): void { + container.querySelector('#pol-add-signer')?.addEventListener('click', () => { + readSignersFromDom(); + signers.push({ kind: 'passkey' }); + renderSignerRows(); + }); + container.querySelector('input[name="limit-on"]')?.addEventListener('change', (e) => { + const on = (e.target as HTMLInputElement).checked; + const box = container.querySelector('#pol-limit-fields'); + if (box) box.hidden = !on; + }); + container.querySelector('input[name="expiry-on"]')?.addEventListener('change', (e) => { + const on = (e.target as HTMLInputElement).checked; + const box = container.querySelector('#pol-expiry-fields'); + if (box) box.hidden = !on; + }); + container.querySelector('form')?.addEventListener('submit', (e) => { + e.preventDefault(); + void submit(); + }); + } + + render(); +} diff --git a/packages/frontend/src/components/PolicyInspector.ts b/packages/frontend/src/components/PolicyInspector.ts new file mode 100644 index 00000000..65fbca91 --- /dev/null +++ b/packages/frontend/src/components/PolicyInspector.ts @@ -0,0 +1,100 @@ +// Renders the full list of a smart account's context rules — the general +// "what can happen on this account and who authorizes it" view. Pure DOM from +// the pure display model in lib/policy/policyView; 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'; + +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}
    ` + : '' + } +
    +
    `; +} + +/** 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/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/pages/account/policy/index.astro b/packages/frontend/src/pages/account/policy/index.astro new file mode 100644 index 00000000..9276ab1d --- /dev/null +++ b/packages/frontend/src/pages/account/policy/index.astro @@ -0,0 +1,162 @@ +--- +// 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. +

    +
    +
    +
    +
    +
    + + + +
    + + + + From 605eda5894a9a19110594396113eab33ea0fab60 Mon Sep 17 00:00:00 2001 From: Willem Wyndham Date: Thu, 10 Sep 2026 00:07:59 -0400 Subject: [PATCH 02/14] feat(frontend): perch-powered policy inspector + doc-mode builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy page now runs the SDK's three-tier readPolicy: - tier a: an applied document recovered losslessly (get_applied_doc storage view first, DocApplied event history as fallback) and verified against the stored doc_hash renders as the document itself — the doc's own rule names, signer ids, and function lists, with a doc_hash badge and a provenance badge (stored on chain / from event history). - tier b: same view plus the SDK's drift findings, rendered prominently. - tier c: the existing raw rule cards, unchanged. The builder gains a doc mode (default) with the canned v1 template — a scoped session key (delegated signer, one contract's named functions, expiry) plus an optional cumulative spending cap — with a live canonical JSON + doc_hash preview. Uncapped docs apply through the account's one-transaction apply_doc surface; capped docs (and accounts without the surface) install per-rule via the SDK's client-side lowering, where the cap rides the stock spending-limit policy. The raw add_context_rule form stays as the second mode. New pure libs (docView, docDraft, docPolicyFetch helpers) carry the display models, validation, and route choice; all vitest-covered. @stellar-registry/perch-interpreter joins the frontend deps for the typed get_program read (stellar-sdk pinned by the root override). --- package-lock.json | 1 + packages/frontend/package.json | 1 + .../frontend/src/components/PolicyBuilder.ts | 361 ++++++++++++++++-- .../src/components/PolicyInspector.ts | 151 +++++++- .../frontend/src/lib/policy/docDraft.test.ts | 98 +++++ packages/frontend/src/lib/policy/docDraft.ts | 138 +++++++ .../src/lib/policy/docPolicyFetch.test.ts | 31 ++ .../frontend/src/lib/policy/docPolicyFetch.ts | 228 +++++++++++ .../frontend/src/lib/policy/docView.test.ts | 103 +++++ packages/frontend/src/lib/policy/docView.ts | 152 ++++++++ .../src/pages/account/policy/index.astro | 46 ++- 11 files changed, 1281 insertions(+), 29 deletions(-) create mode 100644 packages/frontend/src/lib/policy/docDraft.test.ts create mode 100644 packages/frontend/src/lib/policy/docDraft.ts create mode 100644 packages/frontend/src/lib/policy/docPolicyFetch.test.ts create mode 100644 packages/frontend/src/lib/policy/docPolicyFetch.ts create mode 100644 packages/frontend/src/lib/policy/docView.test.ts create mode 100644 packages/frontend/src/lib/policy/docView.ts 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 index 594d9632..ed67fa8b 100644 --- a/packages/frontend/src/components/PolicyBuilder.ts +++ b/packages/frontend/src/components/PolicyBuilder.ts @@ -1,18 +1,39 @@ -// The policy builder: compose a new context rule and add it to the account. +// The policy builder: compose a new policy and add it to the account. // -// Collects a RuleDraft from the form, validates it with the pure lib, lowers it -// to the smart-account binding's add_context_rule arguments, attaches an -// optional spending-limit policy, and submits through the account's existing -// passkey signing path (signAndSubmit). The actual on-chain write always goes -// through the user's passkey ceremony — this module never signs silently. +// Two modes: +// +// - **Session key (document)** — the canned perch-doc template: one scoped +// session key (a delegated signer restricted to one contract's named +// functions, with an expiry) plus an optional cumulative spending cap. +// Emits a perch PolicyDoc; applies through the account's one-transaction +// `apply_doc` surface when it has one (and the doc is uncapped — the +// hybrid contract refuses capped docs), else installs per-rule via the +// SDK's client-side lowering. The policy page then shows the LOSSLESS +// document view — names and all — verified against the stored doc_hash. +// - **Raw rule** — the original form: one OZ context rule straight through +// `add_context_rule` (scope, signers, optional spending limit, expiry). +// +// Both modes validate with the pure libs (lib/policy/docDraft, +// lib/policy/policyDraft) and submit through the account's existing passkey +// signing path (signAndSubmit). This module never signs silently. import { Client as SmartAccountClient } from '@nidohq/smart-account'; -import { extractXdrOperations, hex2buf } from '@nidohq/passkey-sdk'; +import { + buildApplyDocTx, + buildDocInstallTxs, + canonicalJson, + docHash, + extractXdrOperations, + hex2buf, + lowerDoc, + perchTestnetAddresses, +} 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 { fetchRegistryAddress } from '../lib/policyChainFetch.js'; +import { fetchDocSurface } from '../lib/policy/docPolicyFetch.js'; import { spendingLimitParamsScVal, stroopsFromXlm, PERIOD_LEDGERS } from '../lib/spendingLimitParams.js'; import { signAndSubmit } from '../lib/primaryPasskeySigner.js'; import { @@ -22,6 +43,13 @@ import { type RuleDraft, type DraftSigner, } from '../lib/policy/policyDraft.js'; +import { + chooseApplyRoute, + docHasCap, + draftToDoc, + validateSessionDocDraft, + type SessionDocDraft, +} from '../lib/policy/docDraft.js'; const NETWORK_PASSPHRASE = Networks.TESTNET; @@ -31,12 +59,297 @@ interface BuilderOptions { onSubmitted?: () => void; } +type BuilderMode = 'doc' | 'raw'; + export function mountPolicyBuilder(container: HTMLElement, opts: BuilderOptions): void { - // Local signer draft state; the row inputs are the source of truth on submit. + let mode: BuilderMode = 'doc'; + // Whether this account exposes `apply_doc` — probed once, lazily, for the + // route hint and the submit-time route choice. + let docSurfacePromise: Promise | null = null; + const hasDocSurface = (): Promise => + (docSurfacePromise ??= fetchDocSurface(opts.account) + .then((s) => s.supported) + .catch(() => false)); + + // Local signer draft state for the RAW form; the row inputs are the source + // of truth on submit. let signers: DraftSigner[] = [{ kind: 'passkey' }]; + let docWrap: HTMLElement; + let rawWrap: HTMLElement; + function render(): void { container.innerHTML = ` +
    + + +
    +
    +
    `; + docWrap = container.querySelector('#pol-doc-wrap')!; + rawWrap = container.querySelector('#pol-raw-wrap')!; + renderDocForm(); + renderRawForm(); + renderSignerRows(); + wireRawForm(); + const docTab = container.querySelector('#pol-mode-doc')!; + const rawTab = container.querySelector('#pol-mode-raw')!; + const applyMode = () => { + docWrap.hidden = mode !== 'doc'; + rawWrap.hidden = mode !== 'raw'; + docTab.className = `btn sm ${mode === 'doc' ? 'soft' : 'ghost'}`; + rawTab.className = `btn sm ${mode === 'raw' ? 'soft' : 'ghost'}`; + docTab.setAttribute('aria-selected', String(mode === 'doc')); + rawTab.setAttribute('aria-selected', String(mode === 'raw')); + }; + docTab.addEventListener('click', () => { mode = 'doc'; applyMode(); }); + rawTab.addEventListener('click', () => { mode = 'raw'; applyMode(); }); + applyMode(); + } + + // --- Doc mode: the scoped-session-key template --------------------------- + + function renderDocForm(): void { + docWrap.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 as a policy document, so this page can show + exactly what you approved (names included), verified against the hash + the account stores on chain. +

    + + + + + + + + + + + + +
    + Document preview +
    + doc_hash + — +
    +
    —
    +

    +
    + + + +
    + +
    +

    +
    `; + wireDocForm(); + } + + function collectDocDraft(): SessionDocDraft { + const q = (sel: string) => docWrap.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 ?? '', + sessionAddress: q('input[name="doc-signer"]')?.value.trim() ?? '', + targetContract: q('input[name="doc-contract"]')?.value.trim() ?? '', + functionsInput: q('input[name="doc-functions"]')?.value ?? '', + notAfterLedger, + cap, + }; + } + + function updateDocPreview(): void { + const hashEl = docWrap.querySelector('#pol-doc-prev-hash')!; + const jsonEl = docWrap.querySelector('#pol-doc-prev-json')!; + const routeEl = docWrap.querySelector('#pol-doc-prev-route')!; + const draft = collectDocDraft(); + const check = validateSessionDocDraft(draft); + if (!check.ok) { + hashEl.textContent = '—'; + jsonEl.textContent = '—'; + routeEl.textContent = ''; + return; + } + try { + const doc = draftToDoc(draft, NETWORK_PASSPHRASE); + hashEl.textContent = docHash(doc); + jsonEl.textContent = canonicalJson(doc); + const capped = docHasCap(doc); + routeEl.textContent = capped + ? 'Installs per-rule: the cap rides the stock spending-limit policy next to the interpreter (the one-transaction apply_doc path refuses capped docs).' + : 'Applies in one transaction via apply_doc when this account supports it; falls back to a per-rule install otherwise.'; + void hasDocSurface().then((supported) => { + if (capped || supported) return; + routeEl.textContent = + 'This account has no apply_doc surface — the document installs per-rule (one transaction per rule).'; + }); + } catch (e) { + hashEl.textContent = '—'; + jsonEl.textContent = `Cannot build document: ${e instanceof Error ? e.message : String(e)}`; + routeEl.textContent = ''; + } + } + + function showDocErrors(errors: string[]): void { + const box = docWrap.querySelector('#pol-doc-errors'); + if (!box) return; + if (errors.length === 0) { + box.hidden = true; + box.innerHTML = ''; + return; + } + box.hidden = false; + box.innerHTML = `
      ${errors.map((e) => `
    • ${esc(e)}
    • `).join('')}
    `; + } + + async function submitDoc(): Promise { + const draft = collectDocDraft(); + const check = validateSessionDocDraft(draft); + if (!check.ok) { + showDocErrors(check.errors); + return; + } + showDocErrors([]); + + const submitBtn = docWrap.querySelector('#pol-doc-submit'); + const status = docWrap.querySelector('#pol-doc-status'); + if (submitBtn) submitBtn.disabled = true; + const setStatus = (t: string) => { + if (status) status.textContent = t; + }; + + try { + const doc = draftToDoc(draft, NETWORK_PASSPHRASE); + setStatus('Checking the account’s apply_doc surface…'); + const route = chooseApplyRoute({ + hasDocSurface: await hasDocSurface(), + docHasCap: docHasCap(doc), + }); + + if (route === 'apply-doc') { + 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('Policy document applied.'); + } else { + setStatus('Lowering the document…'); + const lowered = lowerDoc(doc, { account: opts.account }); + const spendingLimitAddress = lowered.usesSpendingLimit + ? await fetchRegistryAddress('spending-limit-policy') + : undefined; + const steps = await buildDocInstallTxs(lowered, { + account: opts.account, + rpcUrl: RPC_URL, + networkPassphrase: NETWORK_PASSPHRASE, + interpreterAddress: perchTestnetAddresses().interpreter, + ...(spendingLimitAddress !== undefined ? { spendingLimitAddress } : {}), + }); + for (const [i, step] of steps.entries()) { + setStatus(`Installing rule ${i + 1} of ${steps.length} ("${step.ruleName}")…`); + await signAndSubmit({ + account: opts.account, + operation: step.operations[0]!, + onProgress: (p) => setStatus(`${p.phase}${p.detail ? `: ${p.detail}` : ''}…`), + }); + } + toast(`Installed ${steps.length} policy rule${steps.length === 1 ? '' : 's'} from the document.`); + } + + setStatus(''); + renderDocForm(); + opts.onSubmitted?.(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + showDocErrors([msg]); + setStatus(''); + } finally { + if (submitBtn) submitBtn.disabled = false; + } + } + + function wireDocForm(): void { + const q = (sel: string) => docWrap.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; + updateDocPreview(); + }); + 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; + updateDocPreview(); + }); + docWrap.querySelectorAll('input, select').forEach((el) => { + el.addEventListener('input', updateDocPreview); + }); + docWrap.querySelector('form')?.addEventListener('submit', (e) => { + e.preventDefault(); + void submitDoc(); + }); + updateDocPreview(); + } + + // --- Raw mode: one OZ context rule straight through add_context_rule ------ + + function renderRawForm(): void { + rawWrap.innerHTML = `