Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
724 changes: 724 additions & 0 deletions packages/frontend/src/components/PolicyBuilder.ts

Large diffs are not rendered by default.

442 changes: 442 additions & 0 deletions packages/frontend/src/components/PolicyInspector.ts

Large diffs are not rendered by default.

254 changes: 254 additions & 0 deletions packages/frontend/src/lib/policy/docDiff.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading
Loading