Skip to content

Commit ea21857

Browse files
Marcus PousetteMarcus Pousette
authored andcommitted
fix(auth): stage verified proof publication
1 parent 2b042a9 commit ea21857

3 files changed

Lines changed: 76 additions & 10 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@treecrdt/auth': patch
3+
---
4+
5+
Keep local operation authorization side-effect-free by deferring proof-store publication from
6+
`verifyOps` to `onVerifiedOps`. Remote sync still invokes `onVerifiedOps` before backend apply,
7+
preserving proof-first ingestion, while optimistic local backends can verify proposals without
8+
publishing proof material for a write that has not committed.

packages/treecrdt-auth/src/internal/cose-cwt-auth.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -660,7 +660,6 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn
660660
const dispositions: Array<
661661
{ status: 'allow' } | { status: 'pending_context'; message?: string }
662662
> = [];
663-
const toPersist: Array<{ opRef: OpRef; auth: OpAuth }> = [];
664663
for (let i = 0; i < ops.length; i += 1) {
665664
const op = ops[i]!;
666665
const a = auth[i]!;
@@ -731,9 +730,6 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn
731730
publicKey: replica,
732731
});
733732
if (!ok) throw new Error('invalid op signature');
734-
const opRef = deriveOpRefV0(ctx.docId, { replica, counter: op.meta.id.counter });
735-
opAuthByOpRefHex.set(bytesToHex(opRef), a);
736-
if (opts.opAuthStore) toPersist.push({ opRef, auth: a });
737733

738734
if (scopeRes === 'unknown') {
739735
dispositions.push({
@@ -746,13 +742,22 @@ export function createTreecrdtCoseCwtAuth(opts: TreecrdtCoseCwtAuthOptions): Syn
746742
}
747743

748744
if (dispositions.some((d) => d.status !== 'allow')) {
749-
if (opts.opAuthStore && toPersist.length > 0) {
750-
await ensureOpAuthStoreReady();
751-
await opts.opAuthStore.storeOpAuth(toPersist);
752-
}
753745
return { dispositions };
754746
}
755-
747+
},
748+
onVerifiedOps: async (ops, auth, ctx) => {
749+
if (ops.length !== auth.length) {
750+
throw new Error(`verified auth has ${auth.length} entries for ${ops.length} ops`);
751+
}
752+
const toPersist: Array<{ opRef: OpRef; auth: OpAuth }> = [];
753+
for (let i = 0; i < ops.length; i += 1) {
754+
const op = ops[i]!;
755+
const entry = auth[i]!;
756+
const replica = replicaIdToBytes(op.meta.id.replica);
757+
const opRef = deriveOpRefV0(ctx.docId, { replica, counter: op.meta.id.counter });
758+
opAuthByOpRefHex.set(bytesToHex(opRef), entry);
759+
if (opts.opAuthStore) toPersist.push({ opRef, auth: entry });
760+
}
756761
if (opts.opAuthStore && toPersist.length > 0) {
757762
await ensureOpAuthStoreReady();
758763
await opts.opAuthStore.storeOpAuth(toPersist);

packages/treecrdt-auth/tests/auth.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
verifyTreecrdtOpV2,
3333
verifyTreecrdtRevocationRecordV1,
3434
} from '../dist/treecrdt-auth.js';
35-
import type { Capability, Filter, OpRef, SyncBackend } from '@treecrdt/sync-protocol';
35+
import type { Capability, Filter, OpAuth, OpRef, SyncBackend } from '@treecrdt/sync-protocol';
3636

3737
ed25519Hashes.sha512 = sha512;
3838

@@ -458,6 +458,59 @@ test('auth: signOps selects proof_ref per op when multiple tokens exist', async
458458
);
459459
});
460460

461+
test('auth: verifyOps is side-effect-free and onVerifiedOps publishes proof material', async () => {
462+
const docId = 'doc-auth-local-proof-staging';
463+
const root = '0'.repeat(32);
464+
const issuerSk = ed25519Utils.randomSecretKey();
465+
const issuerPk = await getPublicKey(issuerSk);
466+
const writerSk = ed25519Utils.randomSecretKey();
467+
const writerPk = await getPublicKey(writerSk);
468+
const token = issueTreecrdtCapabilityTokenV1({
469+
issuerPrivateKey: issuerSk,
470+
subjectPublicKey: writerPk,
471+
docId,
472+
actions: ['write_structure'],
473+
});
474+
const persisted: Array<{ opRef: OpRef; auth: OpAuth }> = [];
475+
const auth = createTreecrdtCoseCwtAuth({
476+
issuerPublicKeys: [issuerPk],
477+
localPrivateKey: writerSk,
478+
localPublicKey: writerPk,
479+
localCapabilityTokens: [token],
480+
requireProofRef: true,
481+
opAuthStore: {
482+
storeOpAuth: async (entries) => {
483+
persisted.push(...entries);
484+
},
485+
getOpAuthByOpRefs: async (opRefs) => opRefs.map(() => null),
486+
},
487+
});
488+
const ctx = { docId, purpose: 'reconcile' as const, filterId: '__local__' };
489+
const firstProposal = makeOp(writerPk, 1, 1, {
490+
type: 'insert',
491+
parent: root,
492+
node: nodeIdFromInt(1),
493+
orderKey: orderKeyFromPosition(0),
494+
});
495+
const retriedProposal = makeOp(writerPk, 1, 2, {
496+
type: 'insert',
497+
parent: root,
498+
node: nodeIdFromInt(2),
499+
orderKey: orderKeyFromPosition(1),
500+
});
501+
502+
const firstAuth = (await auth.signOps?.([firstProposal], ctx))!;
503+
await auth.verifyOps?.([firstProposal], firstAuth, ctx);
504+
const retriedAuth = (await auth.signOps?.([retriedProposal], ctx))!;
505+
await auth.verifyOps?.([retriedProposal], retriedAuth, ctx);
506+
expect(persisted).toHaveLength(0);
507+
508+
await auth.onVerifiedOps?.([retriedProposal], retriedAuth, ctx);
509+
expect(persisted).toHaveLength(1);
510+
expect(persisted[0]!.auth.sig).toEqual(retriedAuth[0]!.sig);
511+
expect(persisted[0]!.auth.sig).not.toEqual(firstAuth[0]!.sig);
512+
});
513+
461514
test('auth: restart reuses only a retained proof for the exact local operation', async () => {
462515
const docId = 'doc-auth-retained-local-proof';
463516
const issuerSk = ed25519Utils.randomSecretKey();

0 commit comments

Comments
 (0)