@kubun/plugin-p2p 0.12.0 → 0.13.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/context/group.js +3 -0
- package/lib/context/join.js +3 -0
- package/lib/context/peer.js +7 -2
- package/lib/context/sync.js +63 -5
- package/lib/context/types.d.ts +14 -1
- package/lib/context/types.js +12 -0
- package/lib/groups/access-default-apply.d.ts +42 -0
- package/lib/groups/access-default-apply.js +75 -0
- package/lib/groups/broadcast-codec.d.ts +1 -1
- package/lib/groups/broadcast-message.d.ts +240 -0
- package/lib/groups/broadcast-message.js +1 -0
- package/lib/groups/broadcast.d.ts +19 -266
- package/lib/groups/broadcast.js +30 -204
- package/lib/groups/credential-apply.d.ts +82 -0
- package/lib/groups/credential-apply.js +410 -0
- package/lib/groups/credential-grant-token.d.ts +42 -0
- package/lib/groups/credential-grant-token.js +51 -0
- package/lib/groups/credential-grant.d.ts +98 -0
- package/lib/groups/credential-grant.js +165 -0
- package/lib/groups/group-handlers.js +25 -2
- package/lib/groups/group-mls.d.ts +5 -0
- package/lib/groups/group-mls.js +4 -1
- package/lib/groups/group-peer-manager.d.ts +7 -1
- package/lib/groups/group-peer-manager.js +44 -1
- package/lib/groups/group-protocols.d.ts +227 -0
- package/lib/groups/group-protocols.js +146 -0
- package/lib/groups/join-utils.d.ts +5 -0
- package/lib/groups/join-utils.js +5 -1
- package/lib/groups/ledger-adopt.d.ts +84 -0
- package/lib/groups/ledger-adopt.js +142 -0
- package/lib/groups/ledger-commit-fold.d.ts +3 -1
- package/lib/groups/ledger-commit-fold.js +3 -0
- package/lib/groups/ledger-ingest.d.ts +23 -1
- package/lib/groups/ledger-ingest.js +30 -1
- package/lib/groups/manager.d.ts +5 -0
- package/lib/groups/manager.js +6 -1
- package/lib/groups/peer-presence.d.ts +9 -2
- package/lib/groups/peer-presence.js +14 -2
- package/lib/groups/peer-selection.d.ts +9 -0
- package/lib/groups/peer-selection.js +10 -0
- package/lib/hub/wiring.d.ts +6 -1
- package/lib/hub/wiring.js +2 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +58 -6
- package/lib/peer/blob-fetch.d.ts +45 -0
- package/lib/peer/blob-fetch.js +89 -0
- package/lib/peer/blob-handlers.d.ts +11 -0
- package/lib/peer/blob-handlers.js +123 -0
- package/lib/peer/controller-fetch.d.ts +6 -0
- package/lib/peer/controller-fetch.js +59 -0
- package/lib/peer/controller-handlers.d.ts +10 -0
- package/lib/peer/controller-handlers.js +28 -0
- package/lib/protocol.d.ts +279 -0
- package/lib/protocol.js +358 -0
- package/lib/sync/access-default-sender.d.ts +1 -1
- package/lib/sync/broadcast-queue.d.ts +1 -1
- package/lib/sync/forwarder.d.ts +1 -1
- package/lib/sync/handlers.js +98 -1
- package/lib/sync/sync-manager.d.ts +10 -1
- package/lib/sync/sync-manager.js +24 -2
- package/lib/types.d.ts +18 -0
- package/package.json +55 -43
|
@@ -1,159 +1,18 @@
|
|
|
1
1
|
import type { OwnIdentity } from '@kokuin/token';
|
|
2
2
|
import type { DefaultAccessLevel, GraphInternals } from '@kubun/engine';
|
|
3
|
-
import { HLC } from '@kubun/hlc';
|
|
3
|
+
import type { HLC } from '@kubun/hlc';
|
|
4
4
|
import type { Logger } from '@kubun/logger';
|
|
5
|
-
import type {
|
|
5
|
+
import type { CredentialStoreAPI } from '@kubun/store-credential';
|
|
6
6
|
import type { DelegationStoreAPI } from '@kubun/store-delegation';
|
|
7
7
|
import type { GraphStoreAPI } from '@kubun/store-graph';
|
|
8
8
|
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
9
|
-
import type { GroupAnchor, LedgerLogEntry
|
|
9
|
+
import type { GroupAnchor, LedgerLogEntry } from '@kumiai/mls';
|
|
10
10
|
import { type ForwardingConfig } from '../sync/forwarder.js';
|
|
11
11
|
import type { StoreUnreadableMode } from '../types.js';
|
|
12
|
-
import {
|
|
12
|
+
import type { GroupBroadcastMessage } from './broadcast-message.js';
|
|
13
13
|
import type { P2PEventEmitter } from './events.js';
|
|
14
|
-
import type {
|
|
15
|
-
import {
|
|
16
|
-
export type MutationApplyEntry = {
|
|
17
|
-
mutationJWT: string;
|
|
18
|
-
docID: string;
|
|
19
|
-
/** HLC string identifying this mutation's version. */
|
|
20
|
-
version: string;
|
|
21
|
-
modelID: string;
|
|
22
|
-
};
|
|
23
|
-
/**
|
|
24
|
-
* Optional signed token over a control operation. Its issuer (`iss`) is the
|
|
25
|
-
* authenticated author; receivers verify it and authorize that author as a
|
|
26
|
-
* group admin, dropping the operation when the token is absent or invalid. The
|
|
27
|
-
* signed payload is the whole message minus this field, so the signature covers
|
|
28
|
-
* every operation field, not just the author.
|
|
29
|
-
*/
|
|
30
|
-
type ControlAuth = string;
|
|
31
|
-
export type GroupBroadcastMessage = {
|
|
32
|
-
type: 'catalog:create';
|
|
33
|
-
catalog: CatalogRecord;
|
|
34
|
-
auth?: ControlAuth;
|
|
35
|
-
} | {
|
|
36
|
-
type: 'catalog:update';
|
|
37
|
-
catalogID: string;
|
|
38
|
-
update: {
|
|
39
|
-
name?: string;
|
|
40
|
-
description?: string;
|
|
41
|
-
filterCriteria?: CatalogRecord['filterCriteria'];
|
|
42
|
-
hlc: string;
|
|
43
|
-
};
|
|
44
|
-
auth?: ControlAuth;
|
|
45
|
-
} | {
|
|
46
|
-
type: 'catalog:delete';
|
|
47
|
-
catalogID: string;
|
|
48
|
-
hlc: string;
|
|
49
|
-
auth?: ControlAuth;
|
|
50
|
-
} | {
|
|
51
|
-
/**
|
|
52
|
-
* A member states its own model access-default so co-members can evaluate
|
|
53
|
-
* the owner's documents. `auth` is a signed token whose issuer is the
|
|
54
|
-
* owner; the receiver derives every authoritative field from that verified
|
|
55
|
-
* token and applies the rule ONLY when `ownerDID` equals the issuer (a
|
|
56
|
-
* member states its own policy, never another's). LWW by `hlc`.
|
|
57
|
-
*/
|
|
58
|
-
type: 'access-default:set';
|
|
59
|
-
modelID: string;
|
|
60
|
-
permissionType: 'read' | 'write';
|
|
61
|
-
rule: {
|
|
62
|
-
level: string;
|
|
63
|
-
allowedDIDs: Array<string> | null;
|
|
64
|
-
allowedCircles: Array<string> | null;
|
|
65
|
-
allowedGroups: Array<string> | null;
|
|
66
|
-
};
|
|
67
|
-
ownerDID: string;
|
|
68
|
-
hlc: string;
|
|
69
|
-
auth: ControlAuth;
|
|
70
|
-
} | {
|
|
71
|
-
/**
|
|
72
|
-
* A member removes its own model access-default(s). Same sender-bound and
|
|
73
|
-
* LWW rules as `access-default:set`: applied only when `ownerDID` equals
|
|
74
|
-
* the verified `auth` issuer and the removal `hlc` is newer than the
|
|
75
|
-
* stored rule's.
|
|
76
|
-
*/
|
|
77
|
-
type: 'access-default:remove';
|
|
78
|
-
modelID: string;
|
|
79
|
-
permissionTypes: Array<'read' | 'write'>;
|
|
80
|
-
ownerDID: string;
|
|
81
|
-
hlc: string;
|
|
82
|
-
auth: ControlAuth;
|
|
83
|
-
} | {
|
|
84
|
-
/**
|
|
85
|
-
* Distributes a minted `document/write` capability token to group
|
|
86
|
-
* co-members. The recipient verifies the token's signature and stores a
|
|
87
|
-
* held row only when the token's audience (`aud`) is the receiving
|
|
88
|
-
* device, so the engine's auto-attach can reuse it on later mutations.
|
|
89
|
-
*/
|
|
90
|
-
type: 'delegation:share';
|
|
91
|
-
/** Stringified capability JWT carrying the `document/write` grant. */
|
|
92
|
-
token: string;
|
|
93
|
-
/** Group whose broadcast channel carries this grant. */
|
|
94
|
-
groupID: string;
|
|
95
|
-
/** HLC stamped by the grantor; mirrors the grantor-side issued row. */
|
|
96
|
-
hlc: string;
|
|
97
|
-
} | {
|
|
98
|
-
/**
|
|
99
|
-
* Distributes a signed revocation record for a previously minted
|
|
100
|
-
* `document/write` capability. The recipient verifies the token's
|
|
101
|
-
* signature and stores the revocation; verification against the
|
|
102
|
-
* referenced cap's `iss` flips `verified_at` once the cap is known.
|
|
103
|
-
* Revocation is binary and reactive — once verified locally, any
|
|
104
|
-
* subsequent mutation under the revoked `jti` is denied regardless
|
|
105
|
-
* of `mutation.atTime`.
|
|
106
|
-
*/
|
|
107
|
-
type: 'delegation:revoke';
|
|
108
|
-
/** Stringified signed revocation JWT carrying `{ jti, iss, rev, iat }`. */
|
|
109
|
-
token: string;
|
|
110
|
-
/** Group whose broadcast channel carries this revocation. */
|
|
111
|
-
groupID: string;
|
|
112
|
-
/** HLC stamped by the revoker; mirrors the revoker-side stored row. */
|
|
113
|
-
hlc: string;
|
|
114
|
-
} | {
|
|
115
|
-
/**
|
|
116
|
-
* A member announces a voluntary leave. MLS forbids self-removal, so this
|
|
117
|
-
* is an advisory notification (not an authoritative removal): receivers
|
|
118
|
-
* surface a `groupLeaveRequested` event for an admin to act on by issuing
|
|
119
|
-
* a real `removeMember`. Deliberately does NOT tombstone — a forged
|
|
120
|
-
* leave-request must not be able to evict an arbitrary member.
|
|
121
|
-
*/
|
|
122
|
-
type: 'group:leaveRequest';
|
|
123
|
-
groupID: string;
|
|
124
|
-
memberDID: string;
|
|
125
|
-
hlc: string;
|
|
126
|
-
} | {
|
|
127
|
-
type: 'mutation:apply';
|
|
128
|
-
entries: Array<MutationApplyEntry>;
|
|
129
|
-
/**
|
|
130
|
-
* DID of the peer this broadcast arrived FROM — used for loopback drop.
|
|
131
|
-
* For a forwarded mutation that is the forwarding peer, not the author, so
|
|
132
|
-
* it is never an authorization input: each entry carries its own signed
|
|
133
|
-
* `mutationJWT`, verified independently.
|
|
134
|
-
*
|
|
135
|
-
* `undefined` when the frame opened at a leaf that could not be named. The
|
|
136
|
-
* entries still apply — see the loopback guard.
|
|
137
|
-
*/
|
|
138
|
-
senderPeerDID: string | undefined;
|
|
139
|
-
} | {
|
|
140
|
-
/**
|
|
141
|
-
* A device advertises itself to its co-members. Everything here except
|
|
142
|
-
* `announcerDID` came off the wire; `announcerDID` did NOT — it is the
|
|
143
|
-
* MLS-authenticated sender the handler read from `message.payload.iss`,
|
|
144
|
-
* because the announce body carries no DID field at all.
|
|
145
|
-
*
|
|
146
|
-
* `undefined` when the frame opened at a leaf that could not be named, and
|
|
147
|
-
* the apply then writes nothing: a capability claim attributed to nobody is
|
|
148
|
-
* worse than no row.
|
|
149
|
-
*/
|
|
150
|
-
type: 'peer:announce';
|
|
151
|
-
label: string;
|
|
152
|
-
availability: PeerAvailability;
|
|
153
|
-
capabilities: Array<PeerCapability>;
|
|
154
|
-
hlc: string;
|
|
155
|
-
announcerDID: string | undefined;
|
|
156
|
-
};
|
|
14
|
+
import type { ApplyLedgerEntriesParams } from './ledger-adopt.js';
|
|
15
|
+
import type { AffectedRow } from './ledger-ingest.js';
|
|
157
16
|
export type { AffectedRow, LedgerAffected } from './ledger-ingest.js';
|
|
158
17
|
export type ProcessBroadcastResult = {
|
|
159
18
|
applied: boolean;
|
|
@@ -200,6 +59,19 @@ export type ProcessBroadcastParams = {
|
|
|
200
59
|
*/
|
|
201
60
|
delegationStore: DelegationStoreAPI;
|
|
202
61
|
graphStore: GraphStoreAPI;
|
|
62
|
+
/**
|
|
63
|
+
* Credential store for `credential:key-grant`. Optional because the store is
|
|
64
|
+
* registered by `@kubun/plugin-credential`, not by this package: a device
|
|
65
|
+
* without that plugin has nowhere to put a grant and drops the frame rather
|
|
66
|
+
* than migrating a store nothing else on the device uses.
|
|
67
|
+
*/
|
|
68
|
+
credentialStore?: CredentialStoreAPI;
|
|
69
|
+
/**
|
|
70
|
+
* The engine's future-drift bound, applied to a `credential:key-grant`'s
|
|
71
|
+
* stamps. Optional: absence falls back to {@link DEFAULT_MAX_DRIFT_MS}, the
|
|
72
|
+
* same number the engine defaults to, so it never means an absent bound.
|
|
73
|
+
*/
|
|
74
|
+
maxDriftMS?: number;
|
|
203
75
|
/**
|
|
204
76
|
* Engine graph internals — required to apply `mutation:apply` entries via
|
|
205
77
|
* {@link GraphInternals.applyVerifiedMutation} with `origin: 'peer'`, which
|
|
@@ -302,122 +174,3 @@ export type ProcessBroadcastParams = {
|
|
|
302
174
|
* payload fidelity.
|
|
303
175
|
*/
|
|
304
176
|
export declare function processBroadcast(params: ProcessBroadcastParams, message: GroupBroadcastMessage, groupID: string): Promise<ProcessBroadcastResult>;
|
|
305
|
-
/**
|
|
306
|
-
* Sender-bound, LWW apply of a signed `access-default:set` frame. Shared by the
|
|
307
|
-
* live broadcast path and the invite-seeding path so both authenticate through
|
|
308
|
-
* the same token verification. The authoritative fields come from the verified
|
|
309
|
-
* token; the plaintext `ownerDID` is used only for the sender-bound equality
|
|
310
|
-
* check. Returns whether a row was written.
|
|
311
|
-
*/
|
|
312
|
-
export type AccessDefaultSetFrame = {
|
|
313
|
-
ownerDID: string;
|
|
314
|
-
modelID: string;
|
|
315
|
-
permissionType: 'read' | 'write';
|
|
316
|
-
rule: AccessDefaultRule;
|
|
317
|
-
hlc: string;
|
|
318
|
-
auth: string;
|
|
319
|
-
};
|
|
320
|
-
export type ApplyAccessDefaultSetFrameParams = {
|
|
321
|
-
graphStore: GraphStoreAPI;
|
|
322
|
-
frame: AccessDefaultSetFrame;
|
|
323
|
-
logger?: Logger;
|
|
324
|
-
/** Scopes the log lines to the group the frame arrived on, when it arrived on one. */
|
|
325
|
-
groupID?: string;
|
|
326
|
-
};
|
|
327
|
-
export declare function applyAccessDefaultSetFrame(params: ApplyAccessDefaultSetFrameParams): Promise<boolean>;
|
|
328
|
-
/**
|
|
329
|
-
* Apply one signed access-default set token, with no surrounding frame.
|
|
330
|
-
*
|
|
331
|
-
* What a catch-up reply carries: a token and nothing else, so there is no
|
|
332
|
-
* plaintext mirror to cross-check and none is needed — the frame's mirror
|
|
333
|
-
* fields exist for a receiver rebuilding what was signed, and the authoritative
|
|
334
|
-
* values were always the claim's. Every other rule is the live path's: the owner
|
|
335
|
-
* is the authenticated issuer, and the write happens only if strictly newer than
|
|
336
|
-
* what is stored.
|
|
337
|
-
*/
|
|
338
|
-
export declare function applyAccessDefaultSetToken(params: {
|
|
339
|
-
graphStore: GraphStoreAPI;
|
|
340
|
-
token: string;
|
|
341
|
-
logger?: Logger;
|
|
342
|
-
groupID: string;
|
|
343
|
-
}): Promise<boolean>;
|
|
344
|
-
/** Summary of a whole-ledger adoption. */
|
|
345
|
-
type ApplyLedgerCatchupReplyResult = {
|
|
346
|
-
/** Size of the ledger that was adopted. */
|
|
347
|
-
applied: number;
|
|
348
|
-
/**
|
|
349
|
-
* Always `0` on this path, and structurally so: the install verifies the whole
|
|
350
|
-
* ledger against the authenticated head before folding a single entry, so a
|
|
351
|
-
* bad token rejects the batch rather than dropping an entry out of it. Carried
|
|
352
|
-
* because the catch-up summary reports it beside counts from paths that CAN
|
|
353
|
-
* drop per entry.
|
|
354
|
-
*/
|
|
355
|
-
rejected: number;
|
|
356
|
-
};
|
|
357
|
-
/** Inputs the ledger-fold helpers need — a strict subset of the receive params. */
|
|
358
|
-
export type ApplyLedgerEntriesParams = LedgerIngestParams & {
|
|
359
|
-
/**
|
|
360
|
-
* Run the projection rebuild atomically, given the store to write it through.
|
|
361
|
-
*
|
|
362
|
-
* A rebuild materializes several tables from one fold — circle rows, every
|
|
363
|
-
* circle's `open` flag, the group row, the commit-hub designation — and a crash
|
|
364
|
-
* between them leaves them disagreeing. A device with no flagged commit hub
|
|
365
|
-
* cannot commit at all, which reads as a hang rather than an error.
|
|
366
|
-
*
|
|
367
|
-
* A seam rather than a `StoreProvider` because the surrounding operations must
|
|
368
|
-
* NOT be in the same transaction: an install or a gather waits on the network,
|
|
369
|
-
* and a transaction spanning one holds kubun's single SQLite connection across
|
|
370
|
-
* it. So the caller decides what the transaction covers. Omitted, the rebuild
|
|
371
|
-
* runs on {@link LedgerIngestParams.p2pStore} unwrapped — correct when the
|
|
372
|
-
* caller already holds a transaction, and self-healing on the bootstrap paths,
|
|
373
|
-
* where the next reproject rebuilds from the same ledger anyway.
|
|
374
|
-
*
|
|
375
|
-
* Declared here rather than on {@link LedgerIngestParams} because only the
|
|
376
|
-
* batch folds below read it: `ingestVerifiedLedgerEntry` reprojects through
|
|
377
|
-
* `p2pStore` directly, so a caller passing this to the single-entry path would
|
|
378
|
-
* be promised an atomicity it does not get.
|
|
379
|
-
*/
|
|
380
|
-
withProjectionTransaction?: (run: (store: P2PStoreAPI) => Promise<void>) => Promise<void>;
|
|
381
|
-
};
|
|
382
|
-
/**
|
|
383
|
-
* Adopt the control state a handle already holds: merge the HLCs its entries
|
|
384
|
-
* carry into this device's clock, then rebuild every projection from them.
|
|
385
|
-
*
|
|
386
|
-
* For a peer whose handle was filled by something other than a landed commit —
|
|
387
|
-
* a joiner, whose Welcome folded the group's whole ledger and checked it against
|
|
388
|
-
* the authenticated head before this device had any say. Those entries never
|
|
389
|
-
* passed through the receive path, so nothing else has observed their clocks or
|
|
390
|
-
* projected them.
|
|
391
|
-
*
|
|
392
|
-
* The merge keeps this device's later writes sorting after what the group did
|
|
393
|
-
* before it arrived. It decides no authority — that reads the enacted position —
|
|
394
|
-
* but the LWW comparisons that are not ledger folds (removal timestamps,
|
|
395
|
-
* access-default rules) still depend on a clock that has seen the group's.
|
|
396
|
-
*/
|
|
397
|
-
export declare function adoptHandleLedgerState(params: ApplyLedgerEntriesParams, groupID: string): Promise<void>;
|
|
398
|
-
/** Inputs {@link bootstrapGroupLedger} needs on top of the ingest's. */
|
|
399
|
-
export type BootstrapGroupLedgerParams = ApplyLedgerEntriesParams & {
|
|
400
|
-
/**
|
|
401
|
-
* Install a gathered WHOLE ledger into the group's handle, verified against
|
|
402
|
-
* the authenticated head before a single entry is folded, and answer with the
|
|
403
|
-
* roster the install left the handle holding. Throws when the list does not
|
|
404
|
-
* reproduce that head.
|
|
405
|
-
*
|
|
406
|
-
* The roster is returned rather than read back afterwards so it is the one the
|
|
407
|
-
* install itself produced, taken while the handle is still held: a second read
|
|
408
|
-
* could observe a handle another operation has since advanced.
|
|
409
|
-
*/
|
|
410
|
-
installGroupLedger: (groupID: string, tokens: Array<string>) => Promise<RosterState>;
|
|
411
|
-
};
|
|
412
|
-
/**
|
|
413
|
-
* Adopt a whole ledger gathered from one untrusted responder.
|
|
414
|
-
*
|
|
415
|
-
* Signatures do NOT cover omission or reordering — a responder can hand back
|
|
416
|
-
* genuine tokens with a demotion missing — so the list is installed against the
|
|
417
|
-
* authenticated head FIRST and rejected whole on mismatch, before anything is
|
|
418
|
-
* projected. Roles are reconciled against the roster the install folded, not the
|
|
419
|
-
* ledger, because nothing later revisits a stale `admin` row.
|
|
420
|
-
*
|
|
421
|
-
* Throws whatever the install throws — a rejected ledger changes nothing.
|
|
422
|
-
*/
|
|
423
|
-
export declare function bootstrapGroupLedger(params: BootstrapGroupLedgerParams, tokens: Array<string>, groupID: string): Promise<ApplyLedgerCatchupReplyResult>;
|
package/lib/groups/broadcast.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { normalizeDID } from '@kokuin/token';
|
|
2
|
-
import {
|
|
2
|
+
import { DEFAULT_MAX_DRIFT_MS } from '@kubun/mutation';
|
|
3
3
|
import { toISO } from '../context/types.js';
|
|
4
4
|
import { evaluateAndForward, rethrowIfProgrammerError } from '../sync/forwarder.js';
|
|
5
5
|
import { createReceiveAccessGate } from '../sync/receive-access-gate.js';
|
|
6
|
+
import { applyAccessDefaultSetFrame } from './access-default-apply.js';
|
|
6
7
|
import { verifyAccessDefault } from './access-default-token.js';
|
|
7
8
|
import { verifyCatalog } from './catalog-token.js';
|
|
8
|
-
import {
|
|
9
|
-
import { entryOrd } from './ledger.js';
|
|
10
|
-
import { resolveGroupAnchor } from './ledger-ingest.js';
|
|
11
|
-
import { mirrorRosterRoles } from './roster-projection.js';
|
|
9
|
+
import { applyCredentialKeyGrantFrame } from './credential-apply.js';
|
|
12
10
|
import { storeReceivedGrant } from './store-received-grant.js';
|
|
13
11
|
import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
14
12
|
/**
|
|
@@ -221,6 +219,33 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
221
219
|
applied: true
|
|
222
220
|
};
|
|
223
221
|
}
|
|
222
|
+
case 'credential:key-grant':
|
|
223
|
+
{
|
|
224
|
+
if (params.credentialStore == null) {
|
|
225
|
+
params.logger?.warn('credential:key-grant dropped: no credential store on this device', {
|
|
226
|
+
groupID
|
|
227
|
+
});
|
|
228
|
+
return {
|
|
229
|
+
applied: false
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const applied = await applyCredentialKeyGrantFrame({
|
|
233
|
+
store: params.credentialStore,
|
|
234
|
+
frame: message,
|
|
235
|
+
selfDID: params.selfDID,
|
|
236
|
+
maxDriftMS: params.maxDriftMS ?? DEFAULT_MAX_DRIFT_MS,
|
|
237
|
+
...params.hlc != null ? {
|
|
238
|
+
hlc: params.hlc
|
|
239
|
+
} : {},
|
|
240
|
+
...params.logger != null ? {
|
|
241
|
+
logger: params.logger
|
|
242
|
+
} : {},
|
|
243
|
+
groupID
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
applied
|
|
247
|
+
};
|
|
248
|
+
}
|
|
224
249
|
case 'delegation:share':
|
|
225
250
|
{
|
|
226
251
|
// Co-members relay the same broadcast but only the addressed device
|
|
@@ -501,202 +526,3 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
501
526
|
};
|
|
502
527
|
}
|
|
503
528
|
}
|
|
504
|
-
export async function applyAccessDefaultSetFrame(params) {
|
|
505
|
-
const { graphStore, frame, logger, groupID } = params;
|
|
506
|
-
// Authenticate via the signed token: a forged or unsigned token verifies to
|
|
507
|
-
// null and is dropped. Every authoritative field comes from the verified
|
|
508
|
-
// payload, never the surrounding plaintext frame.
|
|
509
|
-
const verified = await verifyAccessDefault(frame.auth);
|
|
510
|
-
if (verified == null || !isSetClaim(verified)) {
|
|
511
|
-
logger?.warn('access-default:set dropped: token verification failed', {
|
|
512
|
-
groupID
|
|
513
|
-
});
|
|
514
|
-
return false;
|
|
515
|
-
}
|
|
516
|
-
// Sender-bound: a member states only its OWN sharing policy. The row owner is
|
|
517
|
-
// the authenticated issuer; a frame claiming a foreign owner is dropped. The
|
|
518
|
-
// issuer is already normalized, so the plaintext owner must be too — an
|
|
519
|
-
// equivalent DID in another form is the same principal.
|
|
520
|
-
if (normalizeDID(frame.ownerDID) !== verified.issuer) {
|
|
521
|
-
logger?.warn('access-default:set dropped: owner is not the authenticated author', {
|
|
522
|
-
groupID
|
|
523
|
-
});
|
|
524
|
-
return false;
|
|
525
|
-
}
|
|
526
|
-
return await applyVerifiedAccessDefaultSet(graphStore, verified);
|
|
527
|
-
}
|
|
528
|
-
/**
|
|
529
|
-
* Apply one signed access-default set token, with no surrounding frame.
|
|
530
|
-
*
|
|
531
|
-
* What a catch-up reply carries: a token and nothing else, so there is no
|
|
532
|
-
* plaintext mirror to cross-check and none is needed — the frame's mirror
|
|
533
|
-
* fields exist for a receiver rebuilding what was signed, and the authoritative
|
|
534
|
-
* values were always the claim's. Every other rule is the live path's: the owner
|
|
535
|
-
* is the authenticated issuer, and the write happens only if strictly newer than
|
|
536
|
-
* what is stored.
|
|
537
|
-
*/ export async function applyAccessDefaultSetToken(params) {
|
|
538
|
-
const verified = await verifyAccessDefault(params.token);
|
|
539
|
-
if (verified == null || !isSetClaim(verified)) {
|
|
540
|
-
params.logger?.warn('access-default catch-up entry dropped: verification failed', {
|
|
541
|
-
groupID: params.groupID
|
|
542
|
-
});
|
|
543
|
-
return false;
|
|
544
|
-
}
|
|
545
|
-
return await applyVerifiedAccessDefaultSet(params.graphStore, verified);
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* A verified access-default that states a rule rather than withdrawing one.
|
|
549
|
-
* Written as a guard because the union discriminates on a NESTED field, which a
|
|
550
|
-
* `verified.claim.op !== 'set'` check does not narrow the parent by.
|
|
551
|
-
*/ function isSetClaim(verified) {
|
|
552
|
-
return verified.claim.op === 'set';
|
|
553
|
-
}
|
|
554
|
-
async function applyVerifiedAccessDefaultSet(graphStore, verified) {
|
|
555
|
-
const claim = verified.claim;
|
|
556
|
-
// LWW: apply only when strictly newer than the stored rule's anchor. An
|
|
557
|
-
// unstamped stored rule (null hlc) is treated as oldest, so any stamped
|
|
558
|
-
// incoming rule wins.
|
|
559
|
-
const storedHLC = await graphStore.getUserModelAccessDefaultHLC(verified.issuer, claim.modelID, claim.permissionType);
|
|
560
|
-
if (storedHLC != null && claim.hlc <= storedHLC) {
|
|
561
|
-
return false;
|
|
562
|
-
}
|
|
563
|
-
await graphStore.setUserModelAccessDefault({
|
|
564
|
-
ownerDID: verified.issuer,
|
|
565
|
-
modelID: claim.modelID,
|
|
566
|
-
permissionType: claim.permissionType,
|
|
567
|
-
// Narrowed by `isRule` at verification, which refuses any level outside the
|
|
568
|
-
// three the read predicate is written for.
|
|
569
|
-
accessLevel: claim.rule.level,
|
|
570
|
-
allowedDIDs: claim.rule.allowedDIDs,
|
|
571
|
-
allowedCircles: claim.rule.allowedCircles,
|
|
572
|
-
allowedGroups: claim.rule.allowedGroups,
|
|
573
|
-
hlc: claim.hlc
|
|
574
|
-
});
|
|
575
|
-
return true;
|
|
576
|
-
}
|
|
577
|
-
/**
|
|
578
|
-
* Adopt the control state a handle already holds: merge the HLCs its entries
|
|
579
|
-
* carry into this device's clock, then rebuild every projection from them.
|
|
580
|
-
*
|
|
581
|
-
* For a peer whose handle was filled by something other than a landed commit —
|
|
582
|
-
* a joiner, whose Welcome folded the group's whole ledger and checked it against
|
|
583
|
-
* the authenticated head before this device had any say. Those entries never
|
|
584
|
-
* passed through the receive path, so nothing else has observed their clocks or
|
|
585
|
-
* projected them.
|
|
586
|
-
*
|
|
587
|
-
* The merge keeps this device's later writes sorting after what the group did
|
|
588
|
-
* before it arrived. It decides no authority — that reads the enacted position —
|
|
589
|
-
* but the LWW comparisons that are not ledger folds (removal timestamps,
|
|
590
|
-
* access-default rules) still depend on a clock that has seen the group's.
|
|
591
|
-
*/ export async function adoptHandleLedgerState(params, groupID) {
|
|
592
|
-
const { hlc, logger } = params;
|
|
593
|
-
if (hlc != null && params.getGroupLedger != null) {
|
|
594
|
-
let ledger;
|
|
595
|
-
try {
|
|
596
|
-
ledger = await params.getGroupLedger(groupID);
|
|
597
|
-
} catch (error) {
|
|
598
|
-
// The reproject below reads the ledger through the same resolver and
|
|
599
|
-
// defers on its own when it cannot; losing the merge is not worth failing
|
|
600
|
-
// a join over.
|
|
601
|
-
logger?.warn('ledger clock merge skipped: ledger unreadable', {
|
|
602
|
-
groupID,
|
|
603
|
-
error
|
|
604
|
-
});
|
|
605
|
-
ledger = [];
|
|
606
|
-
}
|
|
607
|
-
for (const { verified } of ledger){
|
|
608
|
-
// An entry `@kumiai/mls` minted itself carries no `ord` — it orders by the
|
|
609
|
-
// epoch chain and never stamps kubun's clock. Nothing to merge.
|
|
610
|
-
const ord = entryOrd(verified.entry);
|
|
611
|
-
if (ord == null) {
|
|
612
|
-
continue;
|
|
613
|
-
}
|
|
614
|
-
try {
|
|
615
|
-
hlc.receive(HLC.parse(ord));
|
|
616
|
-
} catch (error) {
|
|
617
|
-
logger?.warn('ledger entry HLC merge failed', {
|
|
618
|
-
groupID,
|
|
619
|
-
hlc: ord,
|
|
620
|
-
error
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
await reprojectControlProjections(params, groupID);
|
|
626
|
-
}
|
|
627
|
-
/**
|
|
628
|
-
* Rebuild every control projection from the group's whole ledger.
|
|
629
|
-
*
|
|
630
|
-
* A batch may carry any mix of entry types, so all three rebuild unconditionally
|
|
631
|
-
* rather than the caller working out which were touched; reprojecting a type the
|
|
632
|
-
* batch didn't move is a no-op that re-confirms the current rows.
|
|
633
|
-
*/ async function reprojectControlProjections(params, groupID) {
|
|
634
|
-
const { p2pStore, logger } = params;
|
|
635
|
-
const resolution = await resolveGroupAnchor(params, groupID);
|
|
636
|
-
if (resolution.status !== 'anchored') {
|
|
637
|
-
logger?.debug('ledger batch applied; projection deferred', {
|
|
638
|
-
groupID,
|
|
639
|
-
reason: resolution.status === 'unknown' ? 'anchor unavailable' : 'no genesis anchor'
|
|
640
|
-
});
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
const { anchor, ledger } = resolution;
|
|
644
|
-
// Sequential, and in this order: the circle rebuilds upsert each row's `open`
|
|
645
|
-
// from the circle fold, then the settings rebuild overwrites `open` from the
|
|
646
|
-
// authoritative open set. Running them concurrently, or settings first, lets
|
|
647
|
-
// the circle upsert win and delists nothing.
|
|
648
|
-
const rebuild = async (store)=>{
|
|
649
|
-
await reprojectCircleDefs({
|
|
650
|
-
store,
|
|
651
|
-
groupID,
|
|
652
|
-
anchor,
|
|
653
|
-
ledger,
|
|
654
|
-
logger
|
|
655
|
-
});
|
|
656
|
-
await reprojectCircleMembers({
|
|
657
|
-
store,
|
|
658
|
-
groupID,
|
|
659
|
-
anchor,
|
|
660
|
-
ledger,
|
|
661
|
-
logger
|
|
662
|
-
});
|
|
663
|
-
await reprojectGroupSettings({
|
|
664
|
-
store,
|
|
665
|
-
groupID,
|
|
666
|
-
anchor,
|
|
667
|
-
ledger,
|
|
668
|
-
logger
|
|
669
|
-
});
|
|
670
|
-
};
|
|
671
|
-
if (params.withProjectionTransaction != null) {
|
|
672
|
-
await params.withProjectionTransaction(rebuild);
|
|
673
|
-
} else {
|
|
674
|
-
await rebuild(p2pStore);
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
/**
|
|
678
|
-
* Adopt a whole ledger gathered from one untrusted responder.
|
|
679
|
-
*
|
|
680
|
-
* Signatures do NOT cover omission or reordering — a responder can hand back
|
|
681
|
-
* genuine tokens with a demotion missing — so the list is installed against the
|
|
682
|
-
* authenticated head FIRST and rejected whole on mismatch, before anything is
|
|
683
|
-
* projected. Roles are reconciled against the roster the install folded, not the
|
|
684
|
-
* ledger, because nothing later revisits a stale `admin` row.
|
|
685
|
-
*
|
|
686
|
-
* Throws whatever the install throws — a rejected ledger changes nothing.
|
|
687
|
-
*/ export async function bootstrapGroupLedger(params, tokens, groupID) {
|
|
688
|
-
const roster = await params.installGroupLedger(groupID, tokens);
|
|
689
|
-
await reprojectControlProjections(params, groupID);
|
|
690
|
-
const members = await params.p2pStore.listGroupMembers(groupID);
|
|
691
|
-
await mirrorRosterRoles({
|
|
692
|
-
store: params.p2pStore,
|
|
693
|
-
groupID,
|
|
694
|
-
roster,
|
|
695
|
-
memberDIDs: members.map((member)=>member.member_did),
|
|
696
|
-
members
|
|
697
|
-
});
|
|
698
|
-
return {
|
|
699
|
-
applied: tokens.length,
|
|
700
|
-
rejected: 0
|
|
701
|
-
};
|
|
702
|
-
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { HLC } from '@kubun/hlc';
|
|
2
|
+
import type { Logger } from '@kubun/logger';
|
|
3
|
+
import type { CredentialStoreAPI } from '@kubun/store-credential';
|
|
4
|
+
import type { CredentialKeyBundle, GroupBroadcastMessage } from './broadcast-message.js';
|
|
5
|
+
export type ApplyCredentialKeyGrantFrameParams = {
|
|
6
|
+
store: CredentialStoreAPI;
|
|
7
|
+
frame: Extract<GroupBroadcastMessage, {
|
|
8
|
+
type: 'credential:key-grant';
|
|
9
|
+
}>;
|
|
10
|
+
/** This device, for the "is this addressed to me?" question. */
|
|
11
|
+
selfDID: string;
|
|
12
|
+
/**
|
|
13
|
+
* Device-wide monotonic clock, advanced past every applied entry's stamp so a
|
|
14
|
+
* later local credential write out-orders what was just received. Optional:
|
|
15
|
+
* tests that only assert stored rows omit it.
|
|
16
|
+
*/
|
|
17
|
+
hlc?: HLC;
|
|
18
|
+
/**
|
|
19
|
+
* How far into the future the granter's entry stamps may sit. The engine's
|
|
20
|
+
* bound, threaded here rather than configured separately: this lane writes the
|
|
21
|
+
* granter's stamp verbatim, so without it a peer could put an unreachable
|
|
22
|
+
* future into rows the device then floors its clock to.
|
|
23
|
+
*/
|
|
24
|
+
maxDriftMS: number;
|
|
25
|
+
logger?: Logger;
|
|
26
|
+
groupID?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Apply a signed `credential:key-grant`: the key's public record, the one
|
|
30
|
+
* wrapping addressed to this device, and every entry ciphertext at that version.
|
|
31
|
+
*
|
|
32
|
+
* Sender-binding here is DIRECTIONAL, unlike `access-default:set`'s. That rule
|
|
33
|
+
* ("the stored owner equals the verified issuer") presumes a row, and a grant is
|
|
34
|
+
* the first thing this device ever hears about the key:
|
|
35
|
+
*
|
|
36
|
+
* - key absent → MINT, with `owner_did` from the verified issuer. Accepting a
|
|
37
|
+
* key means accepting its signer as the principal who may rotate and revoke it.
|
|
38
|
+
* - key present → REQUIRE the stored `owner_did` to equal the issuer. That is
|
|
39
|
+
* what makes a re-sent frame a retry rather than a takeover: the original
|
|
40
|
+
* granter updates, anyone else is refused.
|
|
41
|
+
*
|
|
42
|
+
* Returns whether anything was written.
|
|
43
|
+
*/
|
|
44
|
+
export declare function applyCredentialKeyGrantFrame(params: ApplyCredentialKeyGrantFrameParams): Promise<boolean>;
|
|
45
|
+
/** A revoked subject's tombstone as the reconcile lane ships it. */
|
|
46
|
+
export type CredentialReconcileTombstone = {
|
|
47
|
+
keyID: string;
|
|
48
|
+
authorDID: string;
|
|
49
|
+
hlc: string;
|
|
50
|
+
opJWT: string;
|
|
51
|
+
opHash: string;
|
|
52
|
+
subjectID: string;
|
|
53
|
+
};
|
|
54
|
+
export type ApplyCredentialReconcileParams = {
|
|
55
|
+
store: CredentialStoreAPI;
|
|
56
|
+
/** This device, for the per-wrapping addressed-to-me check. */
|
|
57
|
+
selfDID: string;
|
|
58
|
+
/** Catch-up bundles for keys this device is a recipient of and was missing. */
|
|
59
|
+
bundles: Array<CredentialKeyBundle>;
|
|
60
|
+
/** Tombstones for advertised held IDs the server reports revoked. */
|
|
61
|
+
tombstones: Array<CredentialReconcileTombstone>;
|
|
62
|
+
hlc?: HLC;
|
|
63
|
+
/** Future-drift bound on the granter's stamps. Defaults to {@link DEFAULT_MAX_DRIFT_MS}. */
|
|
64
|
+
maxDriftMS?: number;
|
|
65
|
+
logger?: Logger;
|
|
66
|
+
};
|
|
67
|
+
export type ApplyCredentialReconcileResult = {
|
|
68
|
+
/** Bundles that wrote rows. */
|
|
69
|
+
applied: number;
|
|
70
|
+
/** Tombstones that deleted a row. */
|
|
71
|
+
tombstoned: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Apply a credential reconcile response: materialise the catch-up bundles a
|
|
75
|
+
* recipient missed, then GC the rows a tombstone revokes.
|
|
76
|
+
*
|
|
77
|
+
* The server's scoping is NOT trusted. Every row re-verifies its op signature
|
|
78
|
+
* and every wrapping re-checks it is addressed to this device before a write,
|
|
79
|
+
* exactly as an unsolicited grant would — a reconcile is a batch of the same
|
|
80
|
+
* self-authenticating rows, pulled instead of pushed.
|
|
81
|
+
*/
|
|
82
|
+
export declare function applyCredentialReconcile(params: ApplyCredentialReconcileParams): Promise<ApplyCredentialReconcileResult>;
|