@kubun/store-p2p 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/lib/api.d.ts +24 -6
  2. package/lib/api.js +17 -2
  3. package/lib/definition.js +1 -1
  4. package/lib/hub-url.d.ts +1 -0
  5. package/lib/hub-url.js +3 -0
  6. package/lib/index.d.ts +8 -2
  7. package/lib/index.js +6 -0
  8. package/lib/migrations.js +153 -9
  9. package/lib/ops/app-lane.d.ts +32 -2
  10. package/lib/ops/app-lane.js +55 -2
  11. package/lib/ops/app-outbox.d.ts +44 -0
  12. package/lib/ops/app-outbox.js +236 -0
  13. package/lib/ops/catalog-offers.d.ts +18 -0
  14. package/lib/ops/catalog-offers.js +59 -0
  15. package/lib/ops/catalog-routes.d.ts +25 -0
  16. package/lib/ops/catalog-routes.js +60 -0
  17. package/lib/ops/catalog-sync.d.ts +122 -0
  18. package/lib/ops/catalog-sync.js +251 -0
  19. package/lib/ops/circles.d.ts +3 -4
  20. package/lib/ops/circles.js +31 -14
  21. package/lib/ops/control.d.ts +6 -0
  22. package/lib/ops/control.js +12 -3
  23. package/lib/ops/delegations.d.ts +31 -0
  24. package/lib/ops/delegations.js +71 -0
  25. package/lib/ops/document-sweep.d.ts +11 -0
  26. package/lib/ops/document-sweep.js +16 -0
  27. package/lib/ops/groups.d.ts +70 -22
  28. package/lib/ops/groups.js +130 -35
  29. package/lib/ops/hubs.js +8 -4
  30. package/lib/ops/log-handler-completion.d.ts +20 -0
  31. package/lib/ops/log-handler-completion.js +41 -0
  32. package/lib/ops/mls.d.ts +39 -6
  33. package/lib/ops/mls.js +115 -21
  34. package/lib/ops/own-agents.d.ts +68 -0
  35. package/lib/ops/own-agents.js +359 -0
  36. package/lib/ops/sync.d.ts +16 -0
  37. package/lib/ops/sync.js +25 -0
  38. package/lib/owner-classification.d.ts +14 -0
  39. package/lib/owner-classification.js +18 -0
  40. package/lib/tables.d.ts +193 -5
  41. package/package.json +16 -11
package/lib/ops/mls.js CHANGED
@@ -1,12 +1,49 @@
1
1
  export function createMLSOps(db, adapter) {
2
2
  return {
3
- async saveMLSState (state) {
4
- await db.insertInto('kubun_p2p_group_mls_state').values({
3
+ async saveMLSState (state, expected) {
4
+ if (expected === null) {
5
+ const inserted = await db.insertInto('kubun_p2p_group_mls_state').values({
6
+ group_id: state.group_id,
7
+ device_id: state.device_id,
8
+ mls_state: adapter.encodeBinary(state.mls_state),
9
+ credential: state.credential,
10
+ epoch: state.epoch,
11
+ state_version: state.state_version ?? 0,
12
+ is_deleted: 0,
13
+ sender_generation_hwm: state.sender_generation_hwm ?? 0,
14
+ ledger_entries: state.ledger_entries
15
+ }).onConflict((oc)=>oc.columns([
16
+ 'group_id',
17
+ 'device_id'
18
+ ]).doNothing()).returning('state_version').executeTakeFirst();
19
+ return inserted != null;
20
+ }
21
+ if (expected != null) {
22
+ if (state.epoch < expected.epoch) return false;
23
+ if ((state.state_version ?? 0) <= expected.stateVersion) {
24
+ throw new Error(`MLS state version must advance for group ${state.group_id}`);
25
+ }
26
+ const saved = await db.updateTable('kubun_p2p_group_mls_state').set((eb)=>({
27
+ mls_state: adapter.encodeBinary(state.mls_state),
28
+ credential: state.credential,
29
+ epoch: state.epoch,
30
+ state_version: state.state_version ?? 0,
31
+ is_deleted: 0,
32
+ sender_generation_hwm: state.epoch === expected.epoch ? eb.ref('sender_generation_hwm') : 0,
33
+ ledger_entries: state.ledger_entries ?? '[]',
34
+ updated_at: adapter.encodeTimestamp(new Date())
35
+ })).where('group_id', '=', state.group_id).where('device_id', '=', state.device_id).where('epoch', '=', expected.epoch).where('state_version', '=', expected.stateVersion).returning('state_version').executeTakeFirst();
36
+ return saved != null;
37
+ }
38
+ const saved = await db.insertInto('kubun_p2p_group_mls_state').values({
5
39
  group_id: state.group_id,
6
40
  device_id: state.device_id,
7
41
  mls_state: adapter.encodeBinary(state.mls_state),
8
42
  credential: state.credential,
9
43
  epoch: state.epoch,
44
+ state_version: state.state_version ?? 0,
45
+ is_deleted: 0,
46
+ sender_generation_hwm: state.sender_generation_hwm ?? 0,
10
47
  ledger_entries: state.ledger_entries
11
48
  }).onConflict((oc)=>oc.columns([
12
49
  'group_id',
@@ -15,36 +52,93 @@ export function createMLSOps(db, adapter) {
15
52
  mls_state: eb.ref('excluded.mls_state'),
16
53
  credential: eb.ref('excluded.credential'),
17
54
  epoch: eb.ref('excluded.epoch'),
55
+ state_version: eb.ref('excluded.state_version'),
56
+ is_deleted: 0,
57
+ sender_generation_hwm: eb.case().when('kubun_p2p_group_mls_state.epoch', '=', eb.ref('excluded.epoch')).then(eb.ref('kubun_p2p_group_mls_state.sender_generation_hwm')).else(0).end(),
18
58
  ledger_entries: eb.ref('excluded.ledger_entries'),
19
59
  updated_at: adapter.encodeTimestamp(new Date())
20
- }))// Monotonic-epoch guard: only overwrite when the incoming epoch is
21
- // at least the stored one. The MLS epoch never moves backwards, so a
22
- // write carrying a lower epoch is a stale handle re-persisting an
23
- // already-superseded ratchet — apply it and we roll the ratchet back.
24
- // When this predicate is false the existing row is left untouched
25
- // with no error (a silent no-op); a fresh insert has no conflict and
26
- // is unaffected. The bound is `<=`, not `<`: decrypting an
27
- // application message advances the secret-tree generation WITHIN an
28
- // epoch while `epoch` stays constant, so a legitimate same-epoch
29
- // re-persist carries a changed `mls_state` blob at the same epoch and
30
- // must still win — `<` would reject it and wedge decryption.
31
- .where((eb)=>eb('kubun_p2p_group_mls_state.epoch', '<=', eb.ref('excluded.epoch')))).execute();
60
+ }))// Epochs order commits; the state version orders in-place secret-tree
61
+ // mutations within an epoch. A late persist of an older snapshot
62
+ // must never overwrite a newer generation at the same epoch.
63
+ .where((eb)=>eb.or([
64
+ eb('kubun_p2p_group_mls_state.epoch', '<', eb.ref('excluded.epoch')),
65
+ eb.and([
66
+ eb('kubun_p2p_group_mls_state.epoch', '=', eb.ref('excluded.epoch')),
67
+ eb('kubun_p2p_group_mls_state.state_version', '<', eb.ref('excluded.state_version'))
68
+ ])
69
+ ]))).returning('state_version').executeTakeFirst();
70
+ return saved != null;
71
+ },
72
+ async reserveSenderGenerations ({ groupID, deviceID, epoch, current, size, expectedHWM, senderState }) {
73
+ if (!Number.isSafeInteger(current) || current < 0 || !Number.isSafeInteger(size) || size < 1) {
74
+ throw new Error('Invalid sender generation reservation');
75
+ }
76
+ for(;;){
77
+ const row = await db.selectFrom('kubun_p2p_group_mls_state').select([
78
+ 'epoch',
79
+ 'sender_generation_hwm'
80
+ ]).where('group_id', '=', groupID).where('device_id', '=', deviceID).where('is_deleted', '=', 0).executeTakeFirst();
81
+ if (row == null || row.epoch !== epoch) {
82
+ throw new Error(`Sender generation reservation has stale epoch for group ${groupID}`);
83
+ }
84
+ if (row.sender_generation_hwm !== expectedHWM) return null;
85
+ const next = Math.max(row.sender_generation_hwm, current + size);
86
+ if (!Number.isSafeInteger(next)) throw new Error('Sender generation reservation overflow');
87
+ let reserved = next;
88
+ if (next !== row.sender_generation_hwm) {
89
+ const updated = await db.updateTable('kubun_p2p_group_mls_state').set({
90
+ sender_generation_hwm: next
91
+ }).where('group_id', '=', groupID).where('device_id', '=', deviceID).where('epoch', '=', epoch).where('is_deleted', '=', 0).where('sender_generation_hwm', '=', row.sender_generation_hwm).returning('sender_generation_hwm').executeTakeFirst();
92
+ if (updated == null) continue;
93
+ reserved = updated.sender_generation_hwm;
94
+ }
95
+ if (senderState != null) {
96
+ // A later reservation by another holder wins: its state is newer.
97
+ await db.updateTable('kubun_p2p_group_mls_state').set({
98
+ mls_state: adapter.encodeBinary(senderState.mlsState)
99
+ }).where('group_id', '=', groupID).where('device_id', '=', deviceID).where('epoch', '=', epoch).where('is_deleted', '=', 0).where('state_version', '=', senderState.stateVersion).where('sender_generation_hwm', '=', reserved).execute();
100
+ }
101
+ return reserved;
102
+ }
32
103
  },
33
- async getMLSState (groupID, deviceID) {
34
- return await db.selectFrom('kubun_p2p_group_mls_state').selectAll().where('group_id', '=', groupID).where('device_id', '=', deviceID).executeTakeFirst();
104
+ async getMLSState (groupID, deviceID, options) {
105
+ let query = db.selectFrom('kubun_p2p_group_mls_state').selectAll().where('group_id', '=', groupID).where('device_id', '=', deviceID);
106
+ if (options?.includeDeleted !== true) query = query.where('is_deleted', '=', 0);
107
+ return await query.executeTakeFirst();
35
108
  },
36
- async deleteMLSState (groupID, deviceID) {
37
- await db.deleteFrom('kubun_p2p_group_mls_state').where('group_id', '=', groupID).where('device_id', '=', deviceID).execute();
109
+ async deleteMLSState (groupID, deviceID, expected) {
110
+ let query = db.updateTable('kubun_p2p_group_mls_state').set((eb)=>({
111
+ mls_state: adapter.encodeBinary(new Uint8Array()),
112
+ credential: '',
113
+ ledger_entries: '[]',
114
+ sender_generation_hwm: 0,
115
+ is_deleted: 1,
116
+ state_version: eb('state_version', '+', 1),
117
+ updated_at: adapter.encodeTimestamp(new Date())
118
+ })).where('group_id', '=', groupID).where('device_id', '=', deviceID).where('is_deleted', '=', 0);
119
+ if (expected != null) {
120
+ query = query.where('epoch', '=', expected.epoch).where('state_version', '=', expected.stateVersion);
121
+ }
122
+ const deleted = await query.returning('state_version').executeTakeFirst();
123
+ return deleted != null;
38
124
  },
39
125
  async listMLSStates (groupID) {
40
- return await db.selectFrom('kubun_p2p_group_mls_state').selectAll().where('group_id', '=', groupID).execute();
126
+ return await db.selectFrom('kubun_p2p_group_mls_state').selectAll().where('group_id', '=', groupID).where('is_deleted', '=', 0).execute();
41
127
  },
42
128
  async listMLSStateGroupIDs (deviceID) {
43
- const rows = await db.selectFrom('kubun_p2p_group_mls_state').select('group_id').where('device_id', '=', deviceID).execute();
129
+ const rows = await db.selectFrom('kubun_p2p_group_mls_state').select('group_id').where('device_id', '=', deviceID).where('is_deleted', '=', 0).execute();
44
130
  return rows.map((row)=>row.group_id);
45
131
  },
46
132
  async deleteGroupMLSStates (groupID) {
47
- await db.deleteFrom('kubun_p2p_group_mls_state').where('group_id', '=', groupID).execute();
133
+ await db.updateTable('kubun_p2p_group_mls_state').set((eb)=>({
134
+ mls_state: adapter.encodeBinary(new Uint8Array()),
135
+ credential: '',
136
+ ledger_entries: '[]',
137
+ sender_generation_hwm: 0,
138
+ is_deleted: 1,
139
+ state_version: eb('state_version', '+', 1),
140
+ updated_at: adapter.encodeTimestamp(new Date())
141
+ })).where('group_id', '=', groupID).where('is_deleted', '=', 0).execute();
48
142
  }
49
143
  };
50
144
  }
@@ -0,0 +1,68 @@
1
+ import { type MethodRegistry } from '@kokuin/token';
2
+ import { type Adapter } from '@kubun/db-adapter';
3
+ import { type RecordDecision, type RecordDecisionMap, type RecordKey, type SignedRecord } from '@kubun/records';
4
+ import { type Kysely } from 'kysely';
5
+ import type { GroupKind, OwnAgentsGrantKind, P2PStoreTables } from '../tables.js';
6
+ export type OwnAgentsRecordKey = RecordKey;
7
+ export type OwnAgentsRecord = SignedRecord;
8
+ export type OwnAgentsGrant = {
9
+ controllerDID: string;
10
+ kind: OwnAgentsGrantKind;
11
+ token: string;
12
+ issuedAt: number;
13
+ expiresAt: number;
14
+ };
15
+ export type OwnAgentsJoinCapability = {
16
+ groupID: string;
17
+ recipientDID: string;
18
+ capabilityID: string;
19
+ token: string;
20
+ expiresAt: number;
21
+ signerBinding?: {
22
+ did: string;
23
+ leafKey: Uint8Array;
24
+ capability: string;
25
+ prefix: Array<string>;
26
+ };
27
+ };
28
+ export type BindingEvidence = {
29
+ controllerDID: string;
30
+ deviceDID: string;
31
+ };
32
+ export declare class OwnAgentsTransactionError extends Error {
33
+ constructor();
34
+ }
35
+ export declare class OwnAgentsGroupNotFoundError extends Error {
36
+ constructor(groupID: string);
37
+ }
38
+ export declare class OwnAgentsKindDowngradeError extends Error {
39
+ constructor(groupID: string);
40
+ }
41
+ export declare class OwnAgentsRecordError extends Error {
42
+ constructor(message: string);
43
+ }
44
+ export type OwnAgentsOps = {
45
+ retainAccessDefaultRevocations(controllerDID: string, deviceDIDs: ReadonlyArray<string>): Promise<void>;
46
+ isAccessDefaultSignerRevoked(controllerDID: string, deviceDID: string): Promise<boolean>;
47
+ /** Idempotent and append-only: rows survive revocation, removal and MLS state loss. */
48
+ recordBindingEvidence(bindings: ReadonlyArray<BindingEvidence>): Promise<void>;
49
+ /** Every controller this device has seen `deviceDID` authentically bound to. */
50
+ listBindingControllers(deviceDID: string): Promise<Array<string>>;
51
+ setGroupKind(groupID: string, kind: GroupKind): Promise<void>;
52
+ getGroupKind(groupID: string): Promise<GroupKind | undefined>;
53
+ getOwnAgentsRecordDecision(key: RecordKey, methods?: MethodRegistry): Promise<RecordDecision | null>;
54
+ /** The stored decision of each key, from one read per chunk of ids of a type. */
55
+ getOwnAgentsRecordDecisions(keys: ReadonlyArray<RecordKey>, methods?: MethodRegistry): Promise<RecordDecisionMap>;
56
+ listOwnAgentsRecordDecisions(types: ReadonlyArray<string>, methods?: MethodRegistry): Promise<Array<RecordDecision>>;
57
+ putOwnAgentsRecordCopy(record: SignedRecord, methods?: MethodRegistry): Promise<boolean>;
58
+ pruneOwnAgentsRecordCopy(key: RecordKey, signerDID: string, methods?: MethodRegistry): Promise<void>;
59
+ lockOwnAgentsRecordKeys(keys: ReadonlyArray<string>): Promise<void>;
60
+ putOwnGrant(controllerDID: string, kind: OwnAgentsGrantKind, token: string, issuedAt: number, expiresAt: number): Promise<void>;
61
+ getOwnGrants(controllerDID?: string): Promise<Array<OwnAgentsGrant>>;
62
+ putJoinCapability(capability: OwnAgentsJoinCapability): Promise<void>;
63
+ getJoinCapability(groupID: string): Promise<OwnAgentsJoinCapability | undefined>;
64
+ deleteJoinCapability(groupID: string): Promise<void>;
65
+ };
66
+ export declare const ownAgentsRecordLockKey: (key: RecordKey) => string;
67
+ export declare function ownAgentsAdvisoryLockIDs(ids: ReadonlyArray<bigint>): Array<bigint>;
68
+ export declare function createOwnAgentsOps(db: Kysely<P2PStoreTables>, adapter: Adapter): OwnAgentsOps;
@@ -0,0 +1,359 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { AbstractPostgresAdapter, chunked } from '@kubun/db-adapter';
3
+ import { recordClaimDigest, recordDecisionFromVerifiedCopies, recordKeyText, recordPayloadMatches, recordSignerKey, verifySignedRecord } from '@kubun/records';
4
+ import { sql } from 'kysely';
5
+ export class OwnAgentsTransactionError extends Error {
6
+ constructor(){
7
+ super('Own agents operation requires a transaction');
8
+ this.name = 'OwnAgentsTransactionError';
9
+ }
10
+ }
11
+ export class OwnAgentsGroupNotFoundError extends Error {
12
+ constructor(groupID){
13
+ super(`Group not found: ${groupID}`);
14
+ this.name = 'OwnAgentsGroupNotFoundError';
15
+ }
16
+ }
17
+ export class OwnAgentsKindDowngradeError extends Error {
18
+ constructor(groupID){
19
+ super(`Own agents kind cannot downgrade: ${groupID}`);
20
+ this.name = 'OwnAgentsKindDowngradeError';
21
+ }
22
+ }
23
+ export class OwnAgentsRecordError extends Error {
24
+ constructor(message){
25
+ super(message);
26
+ this.name = 'OwnAgentsRecordError';
27
+ }
28
+ }
29
+ const compareStrings = (left, right)=>left < right ? -1 : left > right ? 1 : 0;
30
+ export const ownAgentsRecordLockKey = (key)=>JSON.stringify([
31
+ key.type,
32
+ key.owner,
33
+ key.id
34
+ ]);
35
+ function requireType(type) {
36
+ if (type !== 'ownAgents.group' && type !== 'ownAgents.capability') throw new OwnAgentsRecordError(`Unsupported own agents record type: ${type}`);
37
+ }
38
+ async function verifiedRows(rows, methods) {
39
+ const valid = [];
40
+ for (const row of rows){
41
+ const copy = {
42
+ type: row.type,
43
+ owner: row.owner,
44
+ id: row.id,
45
+ hlc: row.hlc,
46
+ opJWT: row.op_jwt,
47
+ opHash: row.op_hash,
48
+ payload: row.payload === null ? null : Uint8Array.from(row.payload)
49
+ };
50
+ const op = await verifySignedRecord(copy, methods);
51
+ if (op !== null && recordPayloadMatches(copy, op.claim)) valid.push({
52
+ copy,
53
+ op
54
+ });
55
+ }
56
+ return valid;
57
+ }
58
+ export function ownAgentsAdvisoryLockIDs(ids) {
59
+ return [
60
+ ...new Set(ids)
61
+ ].sort((left, right)=>left < right ? -1 : left > right ? 1 : 0);
62
+ }
63
+ export function createOwnAgentsOps(db, adapter) {
64
+ const lockKeys = async (tx, keys)=>{
65
+ if (!(adapter instanceof AbstractPostgresAdapter)) return;
66
+ const ids = new Set();
67
+ for (const key of new Set(keys)){
68
+ const result = await sql`select hashtextextended(${key}, 0)::text as id`.execute(tx);
69
+ for (const row of result.rows)ids.add(BigInt(row.id));
70
+ }
71
+ for (const id of ownAgentsAdvisoryLockIDs([
72
+ ...ids
73
+ ])){
74
+ await sql`select pg_advisory_xact_lock(${id.toString()}::bigint)`.execute(tx);
75
+ }
76
+ };
77
+ const rowsFor = (tx, key)=>tx.selectFrom('kubun_p2p_own_agents_records').selectAll().where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).execute();
78
+ const deleteCopy = (tx, key, hash)=>tx.deleteFrom('kubun_p2p_own_agents_records').where('type', '=', key.type).where('owner', '=', key.owner).where('id', '=', key.id).where('op_hash', '=', hash).execute();
79
+ return {
80
+ async retainAccessDefaultRevocations (controllerDID, deviceDIDs) {
81
+ if (deviceDIDs.length === 0) return;
82
+ await db.insertInto('kubun_p2p_access_default_revocations').values(deviceDIDs.map((did)=>({
83
+ controller_did: controllerDID,
84
+ device_did: normalizeDID(did)
85
+ }))).onConflict((oc)=>oc.columns([
86
+ 'controller_did',
87
+ 'device_did'
88
+ ]).doNothing()).execute();
89
+ },
90
+ async isAccessDefaultSignerRevoked (controllerDID, deviceDID) {
91
+ return await db.selectFrom('kubun_p2p_access_default_revocations').select('device_did').where('controller_did', '=', controllerDID).where('device_did', '=', normalizeDID(deviceDID)).executeTakeFirst() != null;
92
+ },
93
+ async recordBindingEvidence (bindings) {
94
+ const rows = new Map();
95
+ for (const binding of bindings){
96
+ const row = {
97
+ controller_did: normalizeDID(binding.controllerDID),
98
+ device_did: normalizeDID(binding.deviceDID)
99
+ };
100
+ rows.set(JSON.stringify([
101
+ row.controller_did,
102
+ row.device_did
103
+ ]), row);
104
+ }
105
+ if (rows.size === 0) return;
106
+ await db.insertInto('kubun_p2p_binding_evidence').values([
107
+ ...rows.values()
108
+ ]).onConflict((oc)=>oc.columns([
109
+ 'controller_did',
110
+ 'device_did'
111
+ ]).doNothing()).execute();
112
+ },
113
+ async listBindingControllers (deviceDID) {
114
+ const rows = await db.selectFrom('kubun_p2p_binding_evidence').select('controller_did').where('device_did', '=', normalizeDID(deviceDID)).orderBy('controller_did').execute();
115
+ return rows.map((row)=>row.controller_did);
116
+ },
117
+ async setGroupKind (groupID, kind) {
118
+ if (!db.isTransaction) throw new OwnAgentsTransactionError();
119
+ let update = db.updateTable('kubun_p2p_groups').set({
120
+ kind
121
+ }).where('id', '=', groupID);
122
+ if (kind === 'standard') update = update.where('kind', '=', 'standard');
123
+ const result = await update.executeTakeFirst();
124
+ if (result.numUpdatedRows === 0n) {
125
+ const row = await db.selectFrom('kubun_p2p_groups').select('kind').where('id', '=', groupID).executeTakeFirst();
126
+ if (row === undefined) throw new OwnAgentsGroupNotFoundError(groupID);
127
+ throw new OwnAgentsKindDowngradeError(groupID);
128
+ }
129
+ },
130
+ async getGroupKind (groupID) {
131
+ return (await db.selectFrom('kubun_p2p_groups').select('kind').where('id', '=', groupID).executeTakeFirst())?.kind;
132
+ },
133
+ async getOwnAgentsRecordDecision (key, methods) {
134
+ requireType(key.type);
135
+ return recordDecisionFromVerifiedCopies(key, await verifiedRows(await rowsFor(db, key), methods), 'hlc');
136
+ },
137
+ async getOwnAgentsRecordDecisions (keys, methods) {
138
+ const wanted = new Map();
139
+ const idsByType = new Map();
140
+ for (const key of keys){
141
+ requireType(key.type);
142
+ wanted.set(recordKeyText(key), key);
143
+ const ids = idsByType.get(key.type) ?? new Set();
144
+ ids.add(key.id);
145
+ idsByType.set(key.type, ids);
146
+ }
147
+ const groups = new Map();
148
+ for (const [type, idSet] of idsByType){
149
+ const ids = [
150
+ ...idSet
151
+ ];
152
+ for (const chunk of chunked(ids)){
153
+ const rows = await db.selectFrom('kubun_p2p_own_agents_records').selectAll().where('type', '=', type).where('id', 'in', chunk).execute();
154
+ for (const row of rows){
155
+ const text = recordKeyText(row);
156
+ if (!wanted.has(text)) continue;
157
+ const group = groups.get(text) ?? [];
158
+ group.push(row);
159
+ groups.set(text, group);
160
+ }
161
+ }
162
+ }
163
+ const decisions = new Map();
164
+ for (const [text, rows] of groups){
165
+ const key = wanted.get(text);
166
+ const decision = recordDecisionFromVerifiedCopies(key, await verifiedRows(rows, methods), 'hlc');
167
+ if (decision !== null) decisions.set(text, decision);
168
+ }
169
+ return decisions;
170
+ },
171
+ async listOwnAgentsRecordDecisions (types, methods) {
172
+ for (const type of types)requireType(type);
173
+ if (types.length === 0) return [];
174
+ const rows = await db.selectFrom('kubun_p2p_own_agents_records').selectAll().where('type', 'in', [
175
+ ...types
176
+ ]).orderBy('type').orderBy('owner').orderBy('id').execute();
177
+ const groups = new Map();
178
+ for (const row of rows){
179
+ const key = ownAgentsRecordLockKey(row);
180
+ const group = groups.get(key) ?? [];
181
+ group.push(row);
182
+ groups.set(key, group);
183
+ }
184
+ const decisions = [];
185
+ for (const rows of groups.values()){
186
+ const first = rows[0];
187
+ if (first === undefined) continue;
188
+ const key = {
189
+ type: first.type,
190
+ owner: first.owner,
191
+ id: first.id
192
+ };
193
+ const decision = recordDecisionFromVerifiedCopies(key, await verifiedRows(rows, methods), 'hlc');
194
+ if (decision !== null) decisions.push(decision);
195
+ }
196
+ return decisions;
197
+ },
198
+ async putOwnAgentsRecordCopy (record, methods) {
199
+ requireType(record.type);
200
+ const op = await verifySignedRecord(record, methods);
201
+ if (op === null || !recordPayloadMatches(record, op.claim)) throw new OwnAgentsRecordError('Invalid signed own agents copy');
202
+ const signerKey = recordSignerKey(op.issuer, record.opJWT);
203
+ if (signerKey === null) throw new OwnAgentsRecordError('Invalid own agents signer key');
204
+ const key = {
205
+ type: record.type,
206
+ owner: record.owner,
207
+ id: record.id
208
+ };
209
+ const digest = recordClaimDigest(op.claim);
210
+ const write = async (tx)=>{
211
+ await lockKeys(tx, [
212
+ ownAgentsRecordLockKey(key)
213
+ ]);
214
+ const rows = await rowsFor(tx, key);
215
+ const valid = await verifiedRows(rows, methods);
216
+ const winner = recordDecisionFromVerifiedCopies(key, valid, 'hlc');
217
+ const retained = winner?.decisionHLC === record.hlc && winner.contentDigest === digest ? valid : [];
218
+ const keepBySigner = new Map([
219
+ [
220
+ signerKey,
221
+ record.opHash
222
+ ]
223
+ ]);
224
+ for (const entry of retained){
225
+ const key = recordSignerKey(entry.op.issuer, entry.copy.opJWT);
226
+ if (key === null) continue;
227
+ const previous = keepBySigner.get(key);
228
+ if (previous === undefined || compareStrings(entry.copy.opHash, previous) < 0) keepBySigner.set(key, entry.copy.opHash);
229
+ }
230
+ for (const row of rows){
231
+ const entry = retained.find((entry)=>entry.copy.opHash === row.op_hash);
232
+ const signer = entry === undefined ? null : recordSignerKey(entry.op.issuer, entry.copy.opJWT);
233
+ if (signer === null || keepBySigner.get(signer) !== row.op_hash) await deleteCopy(tx, key, row.op_hash);
234
+ }
235
+ if (keepBySigner.get(signerKey) !== record.opHash) return false;
236
+ await tx.insertInto('kubun_p2p_own_agents_records').values({
237
+ type: record.type,
238
+ owner: record.owner,
239
+ id: record.id,
240
+ hlc: record.hlc,
241
+ op_hash: record.opHash,
242
+ op_jwt: record.opJWT,
243
+ payload: record.payload === null ? null : adapter.encodeBinary(record.payload),
244
+ payload_digest: op.claim.payloadDigest,
245
+ deleted: op.claim.deleted ? 1 : 0,
246
+ meta: adapter.encodeJSON(op.claim.meta)
247
+ }).onConflict((oc)=>oc.columns([
248
+ 'type',
249
+ 'owner',
250
+ 'id',
251
+ 'op_hash'
252
+ ]).doNothing()).execute();
253
+ return true;
254
+ };
255
+ if (db.isTransaction) return await write(db);
256
+ return await db.transaction().execute(write);
257
+ },
258
+ async pruneOwnAgentsRecordCopy (key, signerDID, methods) {
259
+ requireType(key.type);
260
+ const signer = normalizeDID(signerDID);
261
+ const write = async (tx)=>{
262
+ await lockKeys(tx, [
263
+ ownAgentsRecordLockKey(key)
264
+ ]);
265
+ const valid = await verifiedRows(await rowsFor(tx, key), methods);
266
+ if (!valid.some((entry)=>entry.op.issuer !== signer)) return;
267
+ for (const entry of valid){
268
+ if (entry.op.issuer === signer) await deleteCopy(tx, key, entry.copy.opHash);
269
+ }
270
+ };
271
+ if (db.isTransaction) await write(db);
272
+ else await db.transaction().execute(write);
273
+ },
274
+ async lockOwnAgentsRecordKeys (keys) {
275
+ if (!db.isTransaction) throw new OwnAgentsTransactionError();
276
+ await lockKeys(db, keys);
277
+ },
278
+ async putOwnGrant (controllerDID, kind, token, issuedAt, expiresAt) {
279
+ const row = {
280
+ controller_did: controllerDID,
281
+ kind,
282
+ token,
283
+ issued_at: issuedAt,
284
+ expires_at: expiresAt
285
+ };
286
+ await db.insertInto('kubun_p2p_own_agents_grants').values(row).onConflict((oc)=>oc.columns([
287
+ 'controller_did',
288
+ 'kind'
289
+ ]).doUpdateSet(row).whereRef('excluded.issued_at', '>', 'kubun_p2p_own_agents_grants.issued_at')).execute();
290
+ },
291
+ async getOwnGrants (controllerDID) {
292
+ let query = db.selectFrom('kubun_p2p_own_agents_grants').selectAll();
293
+ if (controllerDID != null) query = query.where('controller_did', '=', controllerDID);
294
+ const rows = await query.orderBy('kind').execute();
295
+ return rows.map((row)=>({
296
+ controllerDID: row.controller_did,
297
+ kind: row.kind,
298
+ token: row.token,
299
+ issuedAt: Number(row.issued_at),
300
+ expiresAt: Number(row.expires_at)
301
+ }));
302
+ },
303
+ async putJoinCapability (capability) {
304
+ const row = {
305
+ group_id: capability.groupID,
306
+ recipient_did: capability.recipientDID,
307
+ capability_id: capability.capabilityID,
308
+ token: capability.token,
309
+ expires_at: capability.expiresAt,
310
+ signer_did: capability.signerBinding?.did ?? null,
311
+ signer_leaf_key: capability.signerBinding == null ? null : adapter.encodeBinary(capability.signerBinding.leafKey),
312
+ signer_capability: capability.signerBinding?.capability ?? null,
313
+ signer_prefix: capability.signerBinding == null ? null : adapter.encodeJSON({
314
+ events: capability.signerBinding.prefix
315
+ })
316
+ };
317
+ const preserveBinding = (column)=>{
318
+ return sql`case
319
+ when kubun_p2p_own_agents_join_capabilities.token = excluded.token
320
+ then ${sql.ref(`kubun_p2p_own_agents_join_capabilities.${column}`)}
321
+ else null end`;
322
+ };
323
+ await db.insertInto('kubun_p2p_own_agents_join_capabilities').values(row).onConflict((oc)=>{
324
+ return oc.column('group_id').doUpdateSet({
325
+ ...row,
326
+ // Re-accepting the same consent must not erase authenticated admission evidence.
327
+ ...capability.signerBinding != null ? {} : {
328
+ signer_did: preserveBinding('signer_did'),
329
+ signer_leaf_key: preserveBinding('signer_leaf_key'),
330
+ signer_capability: preserveBinding('signer_capability'),
331
+ signer_prefix: preserveBinding('signer_prefix')
332
+ }
333
+ });
334
+ }).execute();
335
+ },
336
+ async deleteJoinCapability (groupID) {
337
+ await db.deleteFrom('kubun_p2p_own_agents_join_capabilities').where('group_id', '=', groupID).execute();
338
+ },
339
+ async getJoinCapability (groupID) {
340
+ const row = await db.selectFrom('kubun_p2p_own_agents_join_capabilities').selectAll().where('group_id', '=', groupID).executeTakeFirst();
341
+ return row == null ? undefined : {
342
+ groupID: row.group_id,
343
+ recipientDID: row.recipient_did,
344
+ capabilityID: row.capability_id,
345
+ token: row.token,
346
+ expiresAt: Number(row.expires_at),
347
+ ...row.signer_did == null || row.signer_leaf_key == null || row.signer_capability == null || row.signer_prefix == null ? {} : {
348
+ signerBinding: {
349
+ did: row.signer_did,
350
+ leafKey: new Uint8Array(row.signer_leaf_key),
351
+ capability: row.signer_capability,
352
+ // The JSON results plugin also parses serialized events inside arrays.
353
+ prefix: row.signer_prefix.events.map((event)=>typeof event === 'string' ? event : JSON.stringify(event))
354
+ }
355
+ }
356
+ };
357
+ }
358
+ };
359
+ }
package/lib/ops/sync.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import type { Adapter } from '@kubun/db-adapter';
2
2
  import type { Kysely } from 'kysely';
3
3
  import type { InsertSyncPeer, P2PStoreTables, SyncPeer } from '../tables.js';
4
+ export declare class BroadcastLogPruneParamsError extends Error {
5
+ get code(): 'P2P_BROADCAST_LOG_PRUNE_PARAMS';
6
+ constructor(message: string);
7
+ }
8
+ export type PruneBroadcastLogParams = {
9
+ /** Epoch milliseconds: rows broadcast before it are deleted. */
10
+ olderThan: number;
11
+ /** Most rows one call deletes. */
12
+ limit: number;
13
+ };
4
14
  /** Sync peers and the broadcast dedup log. */
5
15
  export type SyncOps = {
6
16
  addSyncPeer(peer: InsertSyncPeer): Promise<void>;
@@ -18,5 +28,11 @@ export type SyncOps = {
18
28
  version: string;
19
29
  mlsGroupID: string;
20
30
  }): Promise<boolean>;
31
+ /**
32
+ * Delete up to `limit` rows broadcast before `olderThan` and return how many went. One
33
+ * statement, no transaction of its own: safe inside a caller's. A pruned row only dedups
34
+ * sends, so the next send of its version goes out again as a duplicate.
35
+ */
36
+ pruneBroadcastLog(params: PruneBroadcastLogParams): Promise<number>;
21
37
  };
22
38
  export declare function createSyncOps(db: Kysely<P2PStoreTables>, adapter: Adapter): SyncOps;
package/lib/ops/sync.js CHANGED
@@ -1,4 +1,13 @@
1
1
  import { normalizeDID } from '@kokuin/token';
2
+ export class BroadcastLogPruneParamsError extends Error {
3
+ get code() {
4
+ return 'P2P_BROADCAST_LOG_PRUNE_PARAMS';
5
+ }
6
+ constructor(message){
7
+ super(message);
8
+ this.name = 'BroadcastLogPruneParamsError';
9
+ }
10
+ }
2
11
  export function createSyncOps(db, adapter) {
3
12
  return {
4
13
  async addSyncPeer (peer) {
@@ -47,6 +56,22 @@ export function createSyncOps(db, adapter) {
47
56
  async hasBroadcast (entry) {
48
57
  const row = await db.selectFrom('kubun_p2p_broadcast_log').select('doc_id').where('doc_id', '=', entry.docID).where('version', '=', entry.version).where('mls_group_id', '=', entry.mlsGroupID).limit(1).executeTakeFirst();
49
58
  return row != null;
59
+ },
60
+ async pruneBroadcastLog (params) {
61
+ if (!Number.isFinite(params.olderThan)) {
62
+ throw new BroadcastLogPruneParamsError('pruneBroadcastLog: olderThan must be finite');
63
+ }
64
+ // SQLite reads a negative LIMIT as no limit.
65
+ if (!Number.isInteger(params.limit) || params.limit <= 0) {
66
+ throw new BroadcastLogPruneParamsError('pruneBroadcastLog: limit must be a positive integer');
67
+ }
68
+ const before = adapter.encodeTimestamp(new Date(params.olderThan));
69
+ const result = await db.deleteFrom('kubun_p2p_broadcast_log').where((eb)=>eb(eb.refTuple('doc_id', 'version', 'mls_group_id'), 'in', eb.selectFrom('kubun_p2p_broadcast_log').select([
70
+ 'doc_id',
71
+ 'version',
72
+ 'mls_group_id'
73
+ ]).where('broadcast_at', '<', before).limit(params.limit).$asTuple('doc_id', 'version', 'mls_group_id'))).executeTakeFirst();
74
+ return Number(result?.numDeletedRows ?? 0n);
50
75
  }
51
76
  };
52
77
  }