@kubun/store-p2p 0.10.0 → 0.11.0

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.
package/lib/api.d.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { Adapter } from '@kubun/db-adapter';
2
- import { type Logger } from '@kubun/logger';
3
2
  import type { Kysely } from 'kysely';
4
- import type { Circle, CircleMember, DelegationToken, Group, GroupMember, GroupMLSState, HeldRevocation, Hub, InsertCircle, InsertCircleMember, InsertDelegationToken, InsertGroup, InsertGroupHub, InsertGroupMember, InsertGroupMLSState, InsertHub, InsertRevokedCapability, InsertSyncPeer, P2PStoreTables, PaginatedResult, PaginationParams, RevokedCapability, SyncPeer } from './tables.js';
3
+ import type { Circle, CircleMember, ControlLedgerEntry, Group, GroupMember, GroupMLSState, Hub, InsertCircle, InsertCircleMember, InsertControlLedgerEntry, InsertGroup, InsertGroupDelegation, InsertGroupHub, InsertGroupMember, InsertGroupMLSState, InsertHub, InsertSyncPeer, P2PStoreTables, PaginatedResult, PaginationParams, SyncPeer } from './tables.js';
5
4
  export type P2PStoreAPI = {
6
5
  createGroup(group: InsertGroup): Promise<void>;
7
6
  getGroup(id: string): Promise<Group | undefined>;
@@ -10,6 +9,13 @@ export type P2PStoreAPI = {
10
9
  listGroups(): Promise<Array<Group>>;
11
10
  listGroupsOffsetPaged(params: PaginationParams): Promise<PaginatedResult<Group>>;
12
11
  addGroupMember(member: InsertGroupMember): Promise<void>;
12
+ /**
13
+ * Add (or revive) several members in a single multi-row insert. Same
14
+ * re-add-clears-tombstone semantics as {@link addGroupMember}, applied to every
15
+ * row. A single statement keeps the whole batch atomic and avoids issuing one
16
+ * round-trip per member on a single-connection adapter. A no-op for an empty list.
17
+ */
18
+ addGroupMembers(members: Array<InsertGroupMember>): Promise<void>;
13
19
  /**
14
20
  * Soft-delete a member by tombstoning the row with `hlc`. Delegates to
15
21
  * `markMemberRemoved`: earliest-wins LWW, ignore-if-absent. The row stays in
@@ -36,6 +42,34 @@ export type P2PStoreAPI = {
36
42
  } | undefined>;
37
43
  getGroupMember(groupID: string, memberDID: string): Promise<GroupMember | undefined>;
38
44
  isGroupMember(groupID: string, memberDID: string): Promise<boolean>;
45
+ /**
46
+ * Whether `did` was a member of `groupID` at the given HLC, ignoring role.
47
+ * Mirrors {@link isGroupAdmin}'s removal-boundary semantics without the
48
+ * `role = 'admin'` filter: with no `atHLC`, tombstoned rows are excluded
49
+ * (`removed_at_hlc IS NULL`); with `atHLC`, a member removed at HLC `R` still
50
+ * counts iff `atHLC < R`. Conjoined with ledger admin authority so an
51
+ * MLS-removed member loses admin regardless of the ledger.
52
+ */
53
+ isGroupMemberAtHLC(groupID: string, did: string, atHLC?: string): Promise<boolean>;
54
+ /**
55
+ * Whether `did` holds an active `admin` role in `groupID`. With no `atHLC`,
56
+ * tombstoned rows are ignored (`removed_at_hlc IS NULL`) — the apply-time
57
+ * admin check. With `atHLC`, an admin removed at HLC `R` still counts iff the
58
+ * supplied HLC strictly precedes `R` (`atHLC < R`): a write claiming a moment
59
+ * before the removal is admitted, one at or after it is denied. This mirrors
60
+ * the membership boundary in `checkMembership` (which denies at
61
+ * `removed_at_hlc <= mutationHLC`), so the two checks agree on the
62
+ * remove-boundary instant.
63
+ */
64
+ isGroupAdmin(groupID: string, did: string, atHLC?: string): Promise<boolean>;
65
+ /**
66
+ * Overlay the role column for an existing member. Update-only: a grant for a
67
+ * DID with no membership row is a no-op, since membership is MLS-native and a
68
+ * non-member can hold no role. Never touches `hlc` or `removed_at_hlc`, so it
69
+ * neither churns membership LWW nor resurrects a tombstone — it is purely the
70
+ * projection target for the admin-roster fold.
71
+ */
72
+ setGroupMemberRole(groupID: string, memberDID: string, role: string): Promise<void>;
39
73
  isMemberOfAnyGroup(viewerDID: string, groupIDs: Array<string>): Promise<boolean>;
40
74
  listGroupMembers(groupID: string): Promise<Array<GroupMember>>;
41
75
  getGroupsForMember(memberDID: string): Promise<Array<Group>>;
@@ -50,7 +84,15 @@ export type P2PStoreAPI = {
50
84
  */
51
85
  getGroupsForMembers(memberDIDs: Array<string>): Promise<Map<string, Array<Group>>>;
52
86
  createCircle(circle: InsertCircle): Promise<void>;
53
- getCircle(id: string): Promise<Circle | undefined>;
87
+ /**
88
+ * Active-only by default (`removed_at_hlc IS NULL`). Pass
89
+ * `{ includeRemoved: true }` to also see tombstoned circles — used by the
90
+ * apply path so a `circle:create`/`circle:update` can HLC-compare against a
91
+ * tombstoned row and resurrect it, and by the catch-up responder.
92
+ */
93
+ getCircle(id: string, options?: {
94
+ includeRemoved?: boolean;
95
+ }): Promise<Circle | undefined>;
54
96
  /**
55
97
  * Batch fetch circles by ID. Returns a Map keyed by ID; missing IDs are
56
98
  * absent from the map. Single SQL query with `WHERE id IN (...)`. Empty
@@ -61,131 +103,93 @@ export type P2PStoreAPI = {
61
103
  getCircles(ids: Array<string>): Promise<Map<string, Circle>>;
62
104
  updateCircle(id: string, update: Partial<Pick<InsertCircle, 'name' | 'description' | 'catalog_ids' | 'hlc'>>): Promise<void>;
63
105
  deleteCircle(id: string): Promise<void>;
64
- listCirclesByGroup(groupID: string): Promise<Array<Circle>>;
106
+ /**
107
+ * Tombstone an existing circle row at `hlc` using earliest-wins LWW: the
108
+ * removal sticks only when no removal is recorded yet, or when `hlc` precedes
109
+ * the stored removal HLC. A removal for a `circleID` with no local row is a
110
+ * no-op — absence means there is no circle to tombstone, and no row is
111
+ * created.
112
+ */
113
+ markCircleRemoved(circleID: string, hlc: string): Promise<void>;
114
+ /**
115
+ * Active-only by default (`removed_at_hlc IS NULL`). Pass
116
+ * `{ includeRemoved: true }` to include tombstoned circles for the catch-up
117
+ * responder.
118
+ */
119
+ listCirclesByGroup(groupID: string, options?: {
120
+ includeRemoved?: boolean;
121
+ }): Promise<Array<Circle>>;
65
122
  addCircleMember(member: InsertCircleMember): Promise<void>;
66
- removeCircleMember(circleID: string, memberDID: string): Promise<void>;
123
+ /**
124
+ * Soft-delete a circle member by tombstoning the row with `hlc`: earliest-wins
125
+ * LWW, ignore-if-absent. The row stays in the table so the removal HLC stays
126
+ * comparable.
127
+ */
128
+ removeCircleMember(circleID: string, memberDID: string, hlc: string): Promise<void>;
67
129
  getCircleMember(circleID: string, memberDID: string): Promise<CircleMember | undefined>;
68
- listCircleMembers(circleID: string): Promise<Array<CircleMember>>;
130
+ /**
131
+ * Active-only by default (`removed_at_hlc IS NULL`). Pass
132
+ * `{ includeRemoved: true }` to include tombstoned members for the catch-up
133
+ * responder.
134
+ */
135
+ listCircleMembers(circleID: string, options?: {
136
+ includeRemoved?: boolean;
137
+ }): Promise<Array<CircleMember>>;
69
138
  getCirclesForMember(memberDID: string): Promise<Array<Circle>>;
70
139
  isMemberOfAnyCircle(memberDID: string, circleIDs: Array<string>): Promise<boolean>;
71
140
  /**
72
- * Store a delegation token. A re-minted grant covering the same
73
- * `(grantor, audience, resource)` replaces the existing row when its `hlc`
74
- * is causally newer; an older or equal `hlc` is ignored. Held rows
75
- * (`audience` is self) and issued rows (`grantor` is self) are distinct keys
76
- * and never collide.
77
- *
78
- * Returns `true` when the stored row is new or materially different from
79
- * the prior row (insert, or LWW upsert that changed any of `jti`, `token`,
80
- * `act`, `exp`, `group_id`). Returns `false` when the call was a no-op:
81
- * either the incoming `hlc` lost LWW against an existing row, or the LWW
82
- * upsert won but every content field matched the prior row (identical
83
- * re-broadcast). Used by emission gates to suppress duplicate
84
- * `delegationTokenAdded` events on idempotent re-stores.
85
- */
86
- addDelegationToken(token: InsertDelegationToken): Promise<boolean>;
87
- getDelegationTokens(params: {
88
- grantor: string;
89
- audience: string;
90
- }): Promise<Array<DelegationToken>>;
91
- /**
92
- * Return the tokens held by `audience` that are still valid at `atTime`
93
- * (`exp > atTime`). `atTime` is a Unix epoch in seconds, matching the
94
- * `exp` column. Expired tokens and tokens addressed to other audiences are
95
- * excluded.
96
- */
97
- getHeldTokens(params: {
98
- audience: string;
99
- atTime: number;
100
- }): Promise<Array<DelegationToken>>;
101
- listIssuedTokens(grantor: string): Promise<Array<DelegationToken>>;
102
- /**
103
- * Look up a delegation token by its `jti`. `jti` is not part of the schema's
104
- * composite PK `(grantor, audience, resource)`, so the column is non-unique
105
- * at the SQL level; in practice every `grantWriteCapability` call mints a
106
- * fresh random `jti`, so collisions across distinct `(grantor, audience,
107
- * resource)` tuples do not occur. Returns the first matching row, or `null`
108
- * when no row exists for the given `jti`.
109
- */
110
- getDelegationTokenByJti(jti: string): Promise<DelegationToken | null>;
111
- /**
112
- * Store a revocation record keyed by the revoked capability's `jti`. LWW on
113
- * `hlc` — a re-broadcast with an older or equal `hlc` is ignored. Returns
114
- * `true` when the row is new, or when LWW won and any content field
115
- * (`revoker_did`, `revoked_iat`, `revocation_token`, `verified_at`,
116
- * `cap_exp`, `group_id`) materially changed. Returns `false` on a no-op
117
- * (lost LWW, or won LWW with byte-identical content). Used by emission
118
- * gates to suppress duplicate downstream events on idempotent re-stores.
119
- *
120
- * `verified_at` is `null` while the revoker-vs-cap-issuer cross-check is
121
- * pending; verification flips it via `markRevocationVerified`.
122
- */
123
- addRevocation(input: InsertRevokedCapability): Promise<boolean>;
124
- getRevocation(jti: string): Promise<RevokedCapability | null>;
125
- /**
126
- * Hot-path check used at mutation-apply time. Returns `true` only when a
127
- * row exists for `jti` AND it has been verified against the cap's issuer
128
- * (`verified_at IS NOT NULL`). Pending (unverified) revocations do not
129
- * gate writes; the gate fails open for light clients that do not
130
- * replicate the revocations table.
131
- */
132
- isRevoked(jti: string): Promise<boolean>;
133
- /**
134
- * Return verified revocations that target capabilities held by `audience`.
135
- * INNER JOIN against `kubun_p2p_delegation_tokens` on `jti` — verified
136
- * revocations whose cap row is missing locally are excluded, and
137
- * `audience`/`grantor` are sourced from the cap row. Pending revocations
138
- * (`verified_at IS NULL`) are not surfaced. No `atTime` filter: verified
139
- * revocations remain visible to the audience until lifecycle GC removes
140
- * them, even after the underlying cap has expired.
141
- */
142
- getHeldRevocations(params: {
143
- audience: string;
144
- }): Promise<Array<HeldRevocation>>;
145
- /**
146
- * Flip a pending revocation to verified. Idempotent: returns `true` only
147
- * when a previously-pending row was updated; returns `false` when the row
148
- * is missing or already verified.
149
- */
150
- markRevocationVerified(jti: string, params: {
151
- cap_exp: number;
152
- verified_at?: number;
153
- }): Promise<boolean>;
154
- getPendingRevocationByJti(jti: string): Promise<RevokedCapability | null>;
141
+ * Fold-authoritative circle projection write: unconditionally set the row for
142
+ * `id` to the supplied derived state. Distinct from the LWW writers —
143
+ * `createCircle` (insert-only), `updateCircle` (update-only, clears the
144
+ * tombstone) and `markCircleRemoved` (earliest-wins LWW). A reproject derives
145
+ * `hlc` from the winning active entry and `removedAtHLC` from the winning
146
+ * tombstone (or null when active), then overwrites every projected column so
147
+ * the row equals exactly what the ledger fold decides regardless of prior
148
+ * contents. A removed circle keeps a tombstone row (`removed_at_hlc` set), not
149
+ * a hard delete, so `includeRemoved` reads and the read-ACL joins keep working.
150
+ */
151
+ reconcileCircleProjection(params: {
152
+ id: string;
153
+ groupID: string;
154
+ name: string;
155
+ description: string;
156
+ catalogIDs: Array<string>;
157
+ hlc: string;
158
+ removedAtHLC: string | null;
159
+ }): Promise<void>;
155
160
  /**
156
- * Delete a pending revocation row (`verified_at IS NULL`) for the given
157
- * `jti`. Verified rows are preserved — a verified revocation is binding and
158
- * must not be removed by this method (use `purgeExpiredRevocations` for
159
- * lifecycle cleanup). Returns `true` when a pending row was deleted, `false`
160
- * otherwise (no row, or row already verified).
161
- *
162
- * Used by the cap-arrival cross-check: when a held cap arrives whose issuer
163
- * does not match a pre-existing pending revocation's `revoker_did`, the
164
- * pending row is treated as a forged broadcast and dropped.
161
+ * Fold-authoritative circle-member projection write, the member-row analogue
162
+ * of {@link reconcileCircleProjection}: unconditionally upsert the
163
+ * `(circleID, memberDID)` row to the derived `(role, hlc, removedAtHLC)` state.
164
+ * A removed member keeps a tombstone row so the read-ACL joins still gate on
165
+ * the marker.
165
166
  */
166
- deletePendingRevocation(jti: string): Promise<boolean>;
167
+ reconcileCircleMemberProjection(params: {
168
+ circleID: string;
169
+ memberDID: string;
170
+ role: string;
171
+ hlc: string;
172
+ removedAtHLC: string | null;
173
+ }): Promise<void>;
167
174
  /**
168
- * Delete verified revocation rows whose underlying capability expired more
169
- * than `graceSeconds` ago (`cap_exp + graceSeconds < now`). Rows with a
170
- * NULL `cap_exp` and pending (unverified) rows are preserved — they have
171
- * no expiry to compare against. Returns the number of rows deleted.
175
+ * Record the group a delegation `jti` was granted in. Idempotent on `jti`
176
+ * (the primary key): a re-record for a `jti` already present is a no-op. The
177
+ * delegation token itself lives in the core delegation store; this edge only
178
+ * carries the group association the p2p layer needs.
172
179
  */
173
- purgeExpiredRevocations(params: {
174
- graceSeconds: number;
175
- }): Promise<number>;
180
+ addGroupDelegation(edge: InsertGroupDelegation): Promise<void>;
176
181
  /**
177
- * Delete pending revocation rows (`verified_at IS NULL`) whose `created_at`
178
- * is older than `maxAgeSeconds`. Verified rows are preserved regardless of
179
- * their `created_at` age. Returns the number of rows deleted.
180
- *
181
- * A negative `maxAgeSeconds` pushes the threshold into the future and will
182
- * delete every pending row regardless of age — intended only for test
183
- * scaffolding (avoids needing raw-SQL `UPDATE` to backdate `created_at`).
184
- * Do not pass negative values from production code paths.
185
- */
186
- purgeDeadPendingRevocations(params: {
187
- maxAgeSeconds: number;
188
- }): Promise<number>;
182
+ * Resolve the group a delegation `jti` was granted in, or `null` when no edge
183
+ * exists. Used to target revoke broadcasts and to enrich revocation listings
184
+ * with their originating group.
185
+ */
186
+ getGroupIDByJTI(jti: string): Promise<string | null>;
187
+ /**
188
+ * Batch variant of {@link getGroupIDByJTI}: resolve the group each `jti` was
189
+ * granted in, returning a `Map` keyed by `jti`. A `jti` with no edge is absent
190
+ * from the map. Used to enrich revocation listings without an N+1 lookup.
191
+ */
192
+ getGroupIDsByJTIs(jtis: Array<string>): Promise<Map<string, string>>;
189
193
  saveMLSState(state: InsertGroupMLSState): Promise<void>;
190
194
  getMLSState(groupID: string, deviceID: string): Promise<GroupMLSState | undefined>;
191
195
  deleteMLSState(groupID: string, deviceID: string): Promise<void>;
@@ -206,6 +210,31 @@ export type P2PStoreAPI = {
206
210
  version: string;
207
211
  mlsGroupID: string;
208
212
  }): Promise<boolean>;
213
+ /**
214
+ * Append a verified ledger entry. Idempotent on `entry_id` (the token
215
+ * digest): a re-delivered entry is a no-op. `issuer_did` is normalized on
216
+ * write. Returns `true` when a new row was inserted, `false` on a duplicate.
217
+ */
218
+ appendLedgerEntry(entry: InsertControlLedgerEntry): Promise<boolean>;
219
+ /** All ledger entries for a group, ordered by `(hlc, entry_id)`. */
220
+ getLedgerEntries(groupID: string): Promise<Array<ControlLedgerEntry>>;
221
+ /** Ledger entries for a group filtered by `type`, ordered by `(hlc, entry_id)`. */
222
+ getLedgerEntriesByType(groupID: string, type: string): Promise<Array<ControlLedgerEntry>>;
223
+ /**
224
+ * The HLC through which `projection` has been folded for `groupID`, or `null`
225
+ * when no projection has run yet. A caller treats an entry whose HLC is `>`
226
+ * this value as in-order (apply incrementally); `<=` or `null` means the
227
+ * cached projection is no longer a correct fold-through-this-entry and must be
228
+ * rebuilt from the whole ledger.
229
+ */
230
+ getProjectionWatermark(groupID: string, projection: string): Promise<string | null>;
231
+ /**
232
+ * Set the `(groupID, projection)` watermark to `hlc`. Overwrite-on-conflict:
233
+ * the row is unconditionally replaced. Callers must only advance the watermark
234
+ * — the incremental path passes a value strictly newer than the current one,
235
+ * and the reproject path passes the true max over the folded ledger.
236
+ */
237
+ setProjectionWatermark(groupID: string, projection: string, hlc: string): Promise<void>;
209
238
  insertHub(hub: InsertHub): Promise<void>;
210
239
  getHub(id: string): Promise<Hub | undefined>;
211
240
  getHubByURL(url: string): Promise<Hub | undefined>;
@@ -222,4 +251,4 @@ export type P2PStoreAPI = {
222
251
  listGroupsByHubID(hubID: string): Promise<Array<Group>>;
223
252
  deleteGroupHubsByHubID(hubID: string): Promise<void>;
224
253
  };
225
- export declare function createP2PStore(db: Kysely<P2PStoreTables>, adapter: Adapter, logger?: Logger): P2PStoreAPI;
254
+ export declare function createP2PStore(db: Kysely<P2PStoreTables>, adapter: Adapter): P2PStoreAPI;