Skip to content

Commit fed571f

Browse files
Add local auth signer materialization
1 parent 54444ee commit fed571f

4 files changed

Lines changed: 82 additions & 6 deletions

File tree

.changeset/local-auth-signer.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@treecrdt/interface': minor
3+
'@treecrdt/wa-sqlite': minor
4+
---
5+
6+
Allow local write auth sessions to annotate materialization events with signer public keys.

packages/treecrdt-sqlite-node/tests/conformance.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
runTreecrdtEngineConformanceScenario,
88
treecrdtEngineConformanceScenarios,
99
} from '@treecrdt/engine-conformance';
10+
import type { MaterializationEvent } from '@treecrdt/interface/engine';
1011
import {
1112
createTreecrdtClient,
1213
defaultExtensionPath,
@@ -68,9 +69,11 @@ test('sqlite auth-aware local write rolls back on auth failure', async () => {
6869

6970
test('sqlite auth-aware local write emits materialization after auth succeeds', async () => {
7071
const client = await createNodeEngine({ docId: 'sqlite-auth-local-success' });
71-
const events: unknown[] = [];
72+
const events: MaterializationEvent[] = [];
7273
const unsubscribe = client.onMaterialized((event) => events.push(event));
74+
const signerPublicKey = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
7375
const authSession = {
76+
signer: { publicKey: signerPublicKey },
7477
authorizeLocalOps: vi.fn(async () => {
7578
expect(events).toHaveLength(0);
7679
}),
@@ -85,6 +88,7 @@ test('sqlite auth-aware local write emits materialization after auth succeeds',
8588
expect(op.kind.type).toBe('insert');
8689
expect(authSession.authorizeLocalOps).toHaveBeenCalledTimes(1);
8790
expect(events).toHaveLength(1);
91+
expect(events[0]!.changes[0]!.source?.signer?.publicKey).toEqual(signerPublicKey);
8892
expect(await client.tree.exists(node)).toBe(true);
8993
expect(await client.ops.all()).toHaveLength(1);
9094
} finally {

packages/treecrdt-ts/src/engine.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { Operation, ReplicaId } from './index.js';
22
import type { SqliteTreeChildRow, SqliteTreeRow, TreecrdtSqlitePlacement } from './sqlite.js';
33

4+
export type MaterializationSigner = {
5+
publicKey: Uint8Array;
6+
};
7+
48
export type MaterializationSource = {
59
/**
610
* Operation that caused the visible change.
@@ -15,10 +19,8 @@ export type MaterializationSource = {
1519
/** Lamport timestamp assigned to the operation. */
1620
lamport: number;
1721
};
18-
/** Future auth metadata for the operation signer, when available. */
19-
signer?: {
20-
publicKey: Uint8Array;
21-
};
22+
/** Auth signer metadata for the operation, when available. */
23+
signer?: MaterializationSigner;
2224
};
2325

2426
type ChangeSource = {
@@ -115,7 +117,14 @@ export type WriteOptions = {
115117
};
116118

117119
export type LocalWriteAuthSession = {
120+
/**
121+
* Authorizes local ops before they are exposed as committed.
122+
*
123+
* If the session has signer metadata available, expose it on `signer` so SQLite writers can
124+
* annotate materialization events.
125+
*/
118126
authorizeLocalOps: (ops: readonly Operation[]) => Promise<unknown>;
127+
signer?: MaterializationSigner;
119128
};
120129

121130
export type LocalWriteOptions = {

packages/treecrdt-ts/src/sqlite.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
LocalWriteOptions,
55
MaterializationEvent,
66
MaterializationOutcome,
7+
MaterializationSigner,
78
MaterializationSource,
89
} from './engine.js';
910
import {
@@ -85,6 +86,58 @@ function emitLocalOutcome(
8586
emit?.({ ...outcome, ...(writeId ? { writeIds: [writeId] } : {}) });
8687
}
8788

89+
function isRecord(value: unknown): value is Record<string, unknown> {
90+
return typeof value === 'object' && value !== null;
91+
}
92+
93+
function decodeMaterializationSigner(raw: unknown): MaterializationSigner | undefined {
94+
if (!isRecord(raw)) return undefined;
95+
const publicKey = raw.publicKey;
96+
if (publicKey instanceof Uint8Array) return { publicKey: Uint8Array.from(publicKey) };
97+
if (Array.isArray(publicKey)) return { publicKey: Uint8Array.from(publicKey) };
98+
return undefined;
99+
}
100+
101+
function localWriteAuthSigner(
102+
authSession: NonNullable<LocalWriteOptions['authSession']>,
103+
): MaterializationSigner | undefined {
104+
return decodeMaterializationSigner(authSession.signer);
105+
}
106+
107+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
108+
if (a.length !== b.length) return false;
109+
for (let i = 0; i < a.length; i += 1) {
110+
if (a[i] !== b[i]) return false;
111+
}
112+
return true;
113+
}
114+
115+
function annotateOutcomeSigner(
116+
outcome: MaterializationOutcome,
117+
op: Operation,
118+
signer?: MaterializationSigner,
119+
): MaterializationOutcome {
120+
if (!signer) return outcome;
121+
const opReplica = replicaIdToBytes(op.meta.id.replica);
122+
const opCounter = op.meta.id.counter;
123+
return {
124+
...outcome,
125+
changes: outcome.changes.map((change) => {
126+
const source = change.source;
127+
if (!source) return change;
128+
if (source.operation.id.counter !== opCounter) return change;
129+
if (!bytesEqual(source.operation.id.replica, opReplica)) return change;
130+
return {
131+
...change,
132+
source: {
133+
...source,
134+
signer,
135+
},
136+
};
137+
}),
138+
};
139+
}
140+
88141
const ROOT_NODE_BYTES = nodeIdToBytes16(ROOT_NODE_ID_HEX);
89142

90143
function buildAppendOp(
@@ -639,7 +692,11 @@ export function createTreecrdtSqliteWriter(
639692
await authSession.authorizeLocalOps([op]);
640693
await sqliteExec(runner, `RELEASE ${savepoint}`);
641694
released = true;
642-
emitLocalOutcome(outcome, opts.onMaterialized, writeOpts?.writeId);
695+
emitLocalOutcome(
696+
annotateOutcomeSigner(outcome, op, localWriteAuthSigner(authSession)),
697+
opts.onMaterialized,
698+
writeOpts?.writeId,
699+
);
643700
return op;
644701
} catch (err) {
645702
if (!released) {

0 commit comments

Comments
 (0)