@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.
Files changed (62) hide show
  1. package/lib/context/group.js +3 -0
  2. package/lib/context/join.js +3 -0
  3. package/lib/context/peer.js +7 -2
  4. package/lib/context/sync.js +63 -5
  5. package/lib/context/types.d.ts +14 -1
  6. package/lib/context/types.js +12 -0
  7. package/lib/groups/access-default-apply.d.ts +42 -0
  8. package/lib/groups/access-default-apply.js +75 -0
  9. package/lib/groups/broadcast-codec.d.ts +1 -1
  10. package/lib/groups/broadcast-message.d.ts +240 -0
  11. package/lib/groups/broadcast-message.js +1 -0
  12. package/lib/groups/broadcast.d.ts +19 -266
  13. package/lib/groups/broadcast.js +30 -204
  14. package/lib/groups/credential-apply.d.ts +82 -0
  15. package/lib/groups/credential-apply.js +410 -0
  16. package/lib/groups/credential-grant-token.d.ts +42 -0
  17. package/lib/groups/credential-grant-token.js +51 -0
  18. package/lib/groups/credential-grant.d.ts +98 -0
  19. package/lib/groups/credential-grant.js +165 -0
  20. package/lib/groups/group-handlers.js +25 -2
  21. package/lib/groups/group-mls.d.ts +5 -0
  22. package/lib/groups/group-mls.js +4 -1
  23. package/lib/groups/group-peer-manager.d.ts +7 -1
  24. package/lib/groups/group-peer-manager.js +44 -1
  25. package/lib/groups/group-protocols.d.ts +227 -0
  26. package/lib/groups/group-protocols.js +146 -0
  27. package/lib/groups/join-utils.d.ts +5 -0
  28. package/lib/groups/join-utils.js +5 -1
  29. package/lib/groups/ledger-adopt.d.ts +84 -0
  30. package/lib/groups/ledger-adopt.js +142 -0
  31. package/lib/groups/ledger-commit-fold.d.ts +3 -1
  32. package/lib/groups/ledger-commit-fold.js +3 -0
  33. package/lib/groups/ledger-ingest.d.ts +23 -1
  34. package/lib/groups/ledger-ingest.js +30 -1
  35. package/lib/groups/manager.d.ts +5 -0
  36. package/lib/groups/manager.js +6 -1
  37. package/lib/groups/peer-presence.d.ts +9 -2
  38. package/lib/groups/peer-presence.js +14 -2
  39. package/lib/groups/peer-selection.d.ts +9 -0
  40. package/lib/groups/peer-selection.js +10 -0
  41. package/lib/hub/wiring.d.ts +6 -1
  42. package/lib/hub/wiring.js +2 -1
  43. package/lib/index.d.ts +5 -1
  44. package/lib/index.js +58 -6
  45. package/lib/peer/blob-fetch.d.ts +45 -0
  46. package/lib/peer/blob-fetch.js +89 -0
  47. package/lib/peer/blob-handlers.d.ts +11 -0
  48. package/lib/peer/blob-handlers.js +123 -0
  49. package/lib/peer/controller-fetch.d.ts +6 -0
  50. package/lib/peer/controller-fetch.js +59 -0
  51. package/lib/peer/controller-handlers.d.ts +10 -0
  52. package/lib/peer/controller-handlers.js +28 -0
  53. package/lib/protocol.d.ts +279 -0
  54. package/lib/protocol.js +358 -0
  55. package/lib/sync/access-default-sender.d.ts +1 -1
  56. package/lib/sync/broadcast-queue.d.ts +1 -1
  57. package/lib/sync/forwarder.d.ts +1 -1
  58. package/lib/sync/handlers.js +98 -1
  59. package/lib/sync/sync-manager.d.ts +10 -1
  60. package/lib/sync/sync-manager.js +24 -2
  61. package/lib/types.d.ts +18 -0
  62. package/package.json +55 -43
@@ -815,6 +815,9 @@ export function createGroupContext(ctx, deps) {
815
815
  accessDefaults: invite.accessDefaults,
816
816
  catalogs: invite.catalogs,
817
817
  hlc: deps.hlc,
818
+ ...deps.maxDriftMS != null ? {
819
+ maxDriftMS: deps.maxDriftMS
820
+ } : {},
818
821
  getGroupAnchor: (gid)=>deps.registry.readHandle(gid, (handle)=>readGroupAnchor(handle), {
819
822
  stores: deps.stores
820
823
  }),
@@ -94,6 +94,9 @@ export function createJoinContext(ctx, deps) {
94
94
  accessDefaults: invite.accessDefaults,
95
95
  catalogs: invite.catalogs,
96
96
  hlc: deps.hlc,
97
+ ...deps.maxDriftMS != null ? {
98
+ maxDriftMS: deps.maxDriftMS
99
+ } : {},
97
100
  getGroupAnchor: (gid)=>deps.registry.readHandle(gid, (handle)=>readGroupAnchor(handle), {
98
101
  stores: deps.stores
99
102
  }),
@@ -1,13 +1,14 @@
1
1
  import { Client } from '@enkaku/client';
2
2
  import { ClientTransport } from '@enkaku/http-fetch';
3
3
  import { normalizeDID } from '@kokuin/token';
4
+ import { createDeviceAuthority } from '@kubun/credential';
4
5
  import { getGraphStore } from '@kubun/store-graph';
5
6
  import { getP2PStore } from '@kubun/store-p2p';
6
7
  import { readGroupAnchor } from '@kumiai/mls';
7
8
  import { blake3 } from '@noble/hashes/blake3.js';
8
9
  import { GraphQLError } from 'graphql';
10
+ import { applyAccessDefaultSetFrame } from '../groups/access-default-apply.js';
9
11
  import { signAccessDefaultSet } from '../groups/access-default-token.js';
10
- import { applyAccessDefaultSetFrame } from '../groups/broadcast.js';
11
12
  import { foldGroupSettings } from '../groups/circle-projection.js';
12
13
  import { awaitControlRequestSettled } from '../groups/control-request.js';
13
14
  import { resolveJoinRequestDID } from '../groups/join-request-identity.js';
@@ -238,8 +239,12 @@ function isPeerInfoResponse(value) {
238
239
  if (normalizeDID(credentialDID) !== normalizeDID(callerDID)) {
239
240
  throw new Error('group/invite: join request DID does not match the authenticated caller');
240
241
  }
242
+ // Out-of-band peer-handler context: it acts as the local device, so a
243
+ // self-scoped device authority is the correct credential authority (the group
244
+ // ops here never consult it).
241
245
  const groupCtx = createGroupContext({
242
- viewerDID: deps.identity.id
246
+ viewerDID: deps.identity.id,
247
+ credentialAuthority: createDeviceAuthority(deps.identity.id)
243
248
  }, deps);
244
249
  // A caller already in the MLS roster is a joiner that lost its local group
245
250
  // state — re-adding it directly would fail (MLS rejects an Add for a current
@@ -1,6 +1,8 @@
1
+ import { CREDENTIAL_STORE, getCredentialStore } from '@kubun/store-credential';
1
2
  import { getGraphStore } from '@kubun/store-graph';
2
3
  import { getP2PStore } from '@kubun/store-p2p';
3
- import { rankSyncPeers } from '../groups/peer-selection.js';
4
+ import { applyCredentialReconcile } from '../groups/credential-apply.js';
5
+ import { answersCredentialSync, rankSyncPeers } from '../groups/peer-selection.js';
4
6
  import { resolveCatalogSyncScopes } from '../sync/catalog-scope.js';
5
7
  const NO_OP = {
6
8
  messagesReceived: 0,
@@ -75,13 +77,56 @@ export function createSyncContext(_ctx, deps) {
75
77
  * Route only. Which documents a session serves is decided per document at the
76
78
  * far end, so preferring a tunnel changes who can be dialled and nothing about
77
79
  * what comes back.
78
- */ const resolveRoute = async (groupID, peerDID)=>{
80
+ */ const resolveAnnouncement = async (groupID, peerDID)=>{
79
81
  const p2pStore = await getP2PStore(deps.stores);
80
- const announcement = await p2pStore.getPeerAnnouncement({
82
+ return await p2pStore.getPeerAnnouncement({
81
83
  groupID,
82
84
  peerDID
83
85
  });
84
- return announcement == null ? undefined : `tunnel://${groupID}/${peerDID}`;
86
+ };
87
+ /**
88
+ * The credential subject ids this device holds, to ride a doc sync as the
89
+ * catch-up's `held` — or `undefined` to skip the lane entirely. Gated twice: the
90
+ * peer must advertise `credential-sync` (its opt-out is the absence of the
91
+ * capability), and this device must have a credential store to apply into.
92
+ */ const credentialHeld = async (announcement)=>{
93
+ if (announcement == null || !answersCredentialSync(announcement) || !deps.stores.hasStore(CREDENTIAL_STORE)) {
94
+ return undefined;
95
+ }
96
+ return await (await getCredentialStore(deps.stores)).listHeldSubjects();
97
+ };
98
+ /**
99
+ * Materialise the credential catch-up the doc session pulled back. Independent
100
+ * of the doc lane: a failure here never fails the catch-up, so it is logged and
101
+ * swallowed. The server's scoping is not trusted — `applyCredentialReconcile`
102
+ * re-verifies every row.
103
+ */ const applyReconcile = async (peerDID, reconcile)=>{
104
+ if (reconcile == null) {
105
+ return;
106
+ }
107
+ try {
108
+ const store = await getCredentialStore(deps.stores);
109
+ const result = await applyCredentialReconcile({
110
+ store,
111
+ selfDID: deps.identity.id,
112
+ bundles: reconcile.bundles,
113
+ tombstones: reconcile.tombstones,
114
+ hlc: deps.hlc,
115
+ ...deps.maxDriftMS == null ? {} : {
116
+ maxDriftMS: deps.maxDriftMS
117
+ },
118
+ logger: deps.logger
119
+ });
120
+ deps.logger.debug('credential reconcile piggybacked on catch-up', {
121
+ peerDID,
122
+ ...result
123
+ });
124
+ } catch (error) {
125
+ deps.logger.warn('credential reconcile apply failed; doc catch-up unaffected', {
126
+ peerDID,
127
+ error
128
+ });
129
+ }
85
130
  };
86
131
  // Bidirectional: each device pushes the docs it owns (its own owner-scope
87
132
  // authorizes) and pulls what it may. Reaching the peer needs no prior
@@ -95,7 +140,12 @@ export function createSyncContext(_ctx, deps) {
95
140
  if (scopes.length === 0) {
96
141
  return NO_OP;
97
142
  }
98
- const endpoint = await resolveRoute(groupID, peerDID);
143
+ const announcement = await resolveAnnouncement(groupID, peerDID);
144
+ const endpoint = announcement == null ? undefined : `tunnel://${groupID}/${peerDID}`;
145
+ // Decided before the sync so it can ride the same session: the credential
146
+ // lane must reuse the doc session, since a second dial to the same peer hangs
147
+ // on a responder still locked to the first.
148
+ const held = await credentialHeld(announcement);
99
149
  const result = await deps.syncManager.merkleSyncWithPeer({
100
150
  peerDID,
101
151
  scopes,
@@ -104,8 +154,16 @@ export function createSyncContext(_ctx, deps) {
104
154
  direction: 'both',
105
155
  ...endpoint == null ? {} : {
106
156
  endpoint
157
+ },
158
+ ...held == null ? {} : {
159
+ reconcileCredentialsHeld: held
107
160
  }
108
161
  });
162
+ // Materialise whatever the session pulled back. Independent of the doc lane:
163
+ // its failure is swallowed inside `applyReconcile`.
164
+ if (held != null) {
165
+ await applyReconcile(peerDID, result.credentialReconcile);
166
+ }
109
167
  return {
110
168
  messagesReceived: result.messagesReceived,
111
169
  messagesSent: result.messagesSent,
@@ -6,7 +6,7 @@ import type { Logger } from '@kubun/logger';
6
6
  import type { Circle } from '@kubun/store-p2p';
7
7
  import type { LaneResult, PendingCommit } from '@kumiai/rpc';
8
8
  import type { Runtime } from '@sozai/runtime';
9
- import type { GroupBroadcastMessage } from '../groups/broadcast.js';
9
+ import type { GroupBroadcastMessage } from '../groups/broadcast-message.js';
10
10
  import type { P2PEventEmitter } from '../groups/events.js';
11
11
  import type { GroupHandleRegistry } from '../groups/group-handle-registry.js';
12
12
  import type { GroupHealthMonitor } from '../groups/group-health-monitor.js';
@@ -34,6 +34,12 @@ export type ContextDeps = {
34
34
  deviceStores: StoreProvider;
35
35
  adapter: Adapter;
36
36
  hlc: HLC;
37
+ /**
38
+ * How far into the future a peer's stamp may sit before this device refuses to
39
+ * merge it into {@link hlc}. The engine's bound, carried so the control-ledger
40
+ * adoption a join performs uses the configured value rather than the default.
41
+ */
42
+ maxDriftMS?: number;
37
43
  emitter: P2PEventEmitter;
38
44
  runtime: Runtime;
39
45
  autoAcceptPeers?: Array<string>;
@@ -102,4 +108,11 @@ export declare function toCircleData(row: Circle): CircleData;
102
108
  * into an object. SerializedMLSGroupState expects a JSON string.
103
109
  */
104
110
  export declare function normalizeCredential(value: unknown): string;
111
+ /**
112
+ * The DID a signed peer-procedure payload was issued by, having checked the
113
+ * caller is not forging one: a subject must equal its issuer, or the caller is
114
+ * claiming an identity it did not sign as. Shared by every direct-request serve
115
+ * handler (blob, controller) that gates on the caller.
116
+ */
117
+ export declare function verifiedCaller(payload: unknown): string;
105
118
  export declare function checkPeerAccess(viewerDID: string, identityID: string, autoAcceptPeers?: Array<string>): void;
@@ -29,6 +29,18 @@ export function toISO(timestamp) {
29
29
  if (typeof value === 'string') return value;
30
30
  return JSON.stringify(value);
31
31
  }
32
+ /**
33
+ * The DID a signed peer-procedure payload was issued by, having checked the
34
+ * caller is not forging one: a subject must equal its issuer, or the caller is
35
+ * claiming an identity it did not sign as. Shared by every direct-request serve
36
+ * handler (blob, controller) that gates on the caller.
37
+ */ export function verifiedCaller(payload) {
38
+ const signed = payload;
39
+ if (signed.sub != null && signed.sub !== signed.iss) {
40
+ throw new Error('Peer access denied: signed payload subject does not match issuer');
41
+ }
42
+ return signed.iss;
43
+ }
32
44
  export function checkPeerAccess(viewerDID, identityID, autoAcceptPeers) {
33
45
  // The device operator is always allowed to run its own peer ops. The
34
46
  // allow-list gates OTHER DIDs (remote callers of the dance handlers); it must
@@ -0,0 +1,42 @@
1
+ import type { Logger } from '@kubun/logger';
2
+ import type { GraphStoreAPI } from '@kubun/store-graph';
3
+ import { type AccessDefaultRule } from './access-default-token.js';
4
+ /**
5
+ * Sender-bound, LWW apply of a signed `access-default:set` frame. Shared by the
6
+ * live broadcast path and the invite-seeding path so both authenticate through
7
+ * the same token verification. The authoritative fields come from the verified
8
+ * token; the plaintext `ownerDID` is used only for the sender-bound equality
9
+ * check. Returns whether a row was written.
10
+ */
11
+ export type AccessDefaultSetFrame = {
12
+ ownerDID: string;
13
+ modelID: string;
14
+ permissionType: 'read' | 'write';
15
+ rule: AccessDefaultRule;
16
+ hlc: string;
17
+ auth: string;
18
+ };
19
+ export type ApplyAccessDefaultSetFrameParams = {
20
+ graphStore: GraphStoreAPI;
21
+ frame: AccessDefaultSetFrame;
22
+ logger?: Logger;
23
+ /** Scopes the log lines to the group the frame arrived on, when it arrived on one. */
24
+ groupID?: string;
25
+ };
26
+ export declare function applyAccessDefaultSetFrame(params: ApplyAccessDefaultSetFrameParams): Promise<boolean>;
27
+ /**
28
+ * Apply one signed access-default set token, with no surrounding frame.
29
+ *
30
+ * What a catch-up reply carries: a token and nothing else, so there is no
31
+ * plaintext mirror to cross-check and none is needed — the frame's mirror
32
+ * fields exist for a receiver rebuilding what was signed, and the authoritative
33
+ * values were always the claim's. Every other rule is the live path's: the owner
34
+ * is the authenticated issuer, and the write happens only if strictly newer than
35
+ * what is stored.
36
+ */
37
+ export declare function applyAccessDefaultSetToken(params: {
38
+ graphStore: GraphStoreAPI;
39
+ token: string;
40
+ logger?: Logger;
41
+ groupID: string;
42
+ }): Promise<boolean>;
@@ -0,0 +1,75 @@
1
+ import { normalizeDID } from '@kokuin/token';
2
+ import { verifyAccessDefault } from './access-default-token.js';
3
+ export async function applyAccessDefaultSetFrame(params) {
4
+ const { graphStore, frame, logger, groupID } = params;
5
+ // Authenticate via the signed token: a forged or unsigned token verifies to
6
+ // null and is dropped. Every authoritative field comes from the verified
7
+ // payload, never the surrounding plaintext frame.
8
+ const verified = await verifyAccessDefault(frame.auth);
9
+ if (verified == null || !isSetClaim(verified)) {
10
+ logger?.warn('access-default:set dropped: token verification failed', {
11
+ groupID
12
+ });
13
+ return false;
14
+ }
15
+ // Sender-bound: a member states only its OWN sharing policy. The row owner is
16
+ // the authenticated issuer; a frame claiming a foreign owner is dropped. The
17
+ // issuer is already normalized, so the plaintext owner must be too — an
18
+ // equivalent DID in another form is the same principal.
19
+ if (normalizeDID(frame.ownerDID) !== verified.issuer) {
20
+ logger?.warn('access-default:set dropped: owner is not the authenticated author', {
21
+ groupID
22
+ });
23
+ return false;
24
+ }
25
+ return await applyVerifiedAccessDefaultSet(graphStore, verified);
26
+ }
27
+ /**
28
+ * Apply one signed access-default set token, with no surrounding frame.
29
+ *
30
+ * What a catch-up reply carries: a token and nothing else, so there is no
31
+ * plaintext mirror to cross-check and none is needed — the frame's mirror
32
+ * fields exist for a receiver rebuilding what was signed, and the authoritative
33
+ * values were always the claim's. Every other rule is the live path's: the owner
34
+ * is the authenticated issuer, and the write happens only if strictly newer than
35
+ * what is stored.
36
+ */ export async function applyAccessDefaultSetToken(params) {
37
+ const verified = await verifyAccessDefault(params.token);
38
+ if (verified == null || !isSetClaim(verified)) {
39
+ params.logger?.warn('access-default catch-up entry dropped: verification failed', {
40
+ groupID: params.groupID
41
+ });
42
+ return false;
43
+ }
44
+ return await applyVerifiedAccessDefaultSet(params.graphStore, verified);
45
+ }
46
+ /**
47
+ * A verified access-default that states a rule rather than withdrawing one.
48
+ * Written as a guard because the union discriminates on a NESTED field, which a
49
+ * `verified.claim.op !== 'set'` check does not narrow the parent by.
50
+ */ function isSetClaim(verified) {
51
+ return verified.claim.op === 'set';
52
+ }
53
+ async function applyVerifiedAccessDefaultSet(graphStore, verified) {
54
+ const claim = verified.claim;
55
+ // LWW: apply only when strictly newer than the stored rule's anchor. An
56
+ // unstamped stored rule (null hlc) is treated as oldest, so any stamped
57
+ // incoming rule wins.
58
+ const storedHLC = await graphStore.getUserModelAccessDefaultHLC(verified.issuer, claim.modelID, claim.permissionType);
59
+ if (storedHLC != null && claim.hlc <= storedHLC) {
60
+ return false;
61
+ }
62
+ await graphStore.setUserModelAccessDefault({
63
+ ownerDID: verified.issuer,
64
+ modelID: claim.modelID,
65
+ permissionType: claim.permissionType,
66
+ // Narrowed by `isRule` at verification, which refuses any level outside the
67
+ // three the read predicate is written for.
68
+ accessLevel: claim.rule.level,
69
+ allowedDIDs: claim.rule.allowedDIDs,
70
+ allowedCircles: claim.rule.allowedCircles,
71
+ allowedGroups: claim.rule.allowedGroups,
72
+ hlc: claim.hlc
73
+ });
74
+ return true;
75
+ }
@@ -1,3 +1,3 @@
1
- import type { GroupBroadcastMessage } from './broadcast.js';
1
+ import type { GroupBroadcastMessage } from './broadcast-message.js';
2
2
  export declare function serializeBroadcast(message: GroupBroadcastMessage): Uint8Array;
3
3
  export declare function deserializeBroadcast(bytes: Uint8Array): GroupBroadcastMessage;
@@ -0,0 +1,240 @@
1
+ import type { CatalogRecord } from '@kubun/protocol';
2
+ import type { CredentialFactorDescriptor } from '@kubun/store-credential';
3
+ import type { PeerAvailability, PeerCapability } from './group-protocols.js';
4
+ export type MutationApplyEntry = {
5
+ mutationJWT: string;
6
+ docID: string;
7
+ /** HLC string identifying this mutation's version. */
8
+ version: string;
9
+ modelID: string;
10
+ };
11
+ /**
12
+ * Optional signed token over a control operation. Its issuer (`iss`) is the
13
+ * authenticated author; receivers verify it and authorize that author as a
14
+ * group admin, dropping the operation when the token is absent or invalid. The
15
+ * signed payload is the whole message minus this field, so the signature covers
16
+ * every operation field, not just the author.
17
+ */
18
+ export type ControlAuth = string;
19
+ /**
20
+ * The one wrapping a grant addresses, as it rides the wire.
21
+ *
22
+ * Binary fields are base64url rather than `Uint8Array`: every lane payload is
23
+ * JSON-schema'd and JSON-encoded, so bytes not encoded here would not survive
24
+ * the trip. `factors` is already JSON-safe — a descriptor holds base64 public
25
+ * material, never raw bytes and never a secret.
26
+ */
27
+ export type CredentialKeyGrantWrapping = {
28
+ wrappingID: string;
29
+ /** ORDERED, and covered by the wrapping's AAD — a reordered copy will not open. */
30
+ factors: Array<CredentialFactorDescriptor>;
31
+ iv: string;
32
+ wrappedKey: string;
33
+ /**
34
+ * Un-normalized, as the stored column is: this is a key-resolution input, and
35
+ * a `did:peer:4` short form carries no document. Compare it only through
36
+ * `normalizeDID`.
37
+ */
38
+ recipientDID: string;
39
+ /**
40
+ * The granter's signed op for this row, verbatim. The receiver stores these
41
+ * bytes rather than signing its own: the merkle leaf hashes the op, so a
42
+ * receiver that minted one would compute a different leaf for the same
43
+ * logical row and the two devices would never converge.
44
+ */
45
+ op: string;
46
+ };
47
+ /** One entry ciphertext, copied verbatim into a grant. */
48
+ export type CredentialKeyGrantEntry = {
49
+ entryID: string;
50
+ iv: string;
51
+ ciphertext: string;
52
+ hlc: string;
53
+ /** The granter's signed op — see {@link CredentialKeyGrantWrapping.op}. */
54
+ op: string;
55
+ };
56
+ /**
57
+ * The row bytes of one credential key addressed to one recipient, minus the
58
+ * grant's single-frame `auth` envelope.
59
+ *
60
+ * The shape a `credential:key-grant` carries under `type`/`auth`, reused as the
61
+ * catch-up unit of the credential reconcile lane: the serving device already
62
+ * holds the recipient's row bytes, so a missed grant is pulled as this same
63
+ * bundle. One shape, one apply, for push and pull. Each row still
64
+ * self-authenticates through its own `op`, so no frame-level signature is needed
65
+ * — the reconcile response is not one signed frame.
66
+ */
67
+ export type CredentialKeyBundle = {
68
+ keyID: string;
69
+ keyVersion: number;
70
+ suite: number;
71
+ /** A mirror the receiver ignores in favour of each op's verified issuer. */
72
+ ownerDID: string;
73
+ keyOp: string;
74
+ keyBranches: Array<string>;
75
+ wrapping: CredentialKeyGrantWrapping;
76
+ entries: Array<CredentialKeyGrantEntry>;
77
+ };
78
+ export type GroupBroadcastMessage = {
79
+ type: 'catalog:create';
80
+ catalog: CatalogRecord;
81
+ auth?: ControlAuth;
82
+ } | {
83
+ type: 'catalog:update';
84
+ catalogID: string;
85
+ update: {
86
+ name?: string;
87
+ description?: string;
88
+ filterCriteria?: CatalogRecord['filterCriteria'];
89
+ hlc: string;
90
+ };
91
+ auth?: ControlAuth;
92
+ } | {
93
+ type: 'catalog:delete';
94
+ catalogID: string;
95
+ hlc: string;
96
+ auth?: ControlAuth;
97
+ } | {
98
+ /**
99
+ * A member states its own model access-default so co-members can evaluate
100
+ * the owner's documents. `auth` is a signed token whose issuer is the
101
+ * owner; the receiver derives every authoritative field from that verified
102
+ * token and applies the rule ONLY when `ownerDID` equals the issuer (a
103
+ * member states its own policy, never another's). LWW by `hlc`.
104
+ */
105
+ type: 'access-default:set';
106
+ modelID: string;
107
+ permissionType: 'read' | 'write';
108
+ rule: {
109
+ level: string;
110
+ allowedDIDs: Array<string> | null;
111
+ allowedCircles: Array<string> | null;
112
+ allowedGroups: Array<string> | null;
113
+ };
114
+ ownerDID: string;
115
+ hlc: string;
116
+ auth: ControlAuth;
117
+ } | {
118
+ /**
119
+ * A member removes its own model access-default(s). Same sender-bound and
120
+ * LWW rules as `access-default:set`: applied only when `ownerDID` equals
121
+ * the verified `auth` issuer and the removal `hlc` is newer than the
122
+ * stored rule's.
123
+ */
124
+ type: 'access-default:remove';
125
+ modelID: string;
126
+ permissionTypes: Array<'read' | 'write'>;
127
+ ownerDID: string;
128
+ hlc: string;
129
+ auth: ControlAuth;
130
+ } | {
131
+ /**
132
+ * Distributes a minted `document/write` capability token to group
133
+ * co-members. The recipient verifies the token's signature and stores a
134
+ * held row only when the token's audience (`aud`) is the receiving
135
+ * device, so the engine's auto-attach can reuse it on later mutations.
136
+ */
137
+ type: 'delegation:share';
138
+ /** Stringified capability JWT carrying the `document/write` grant. */
139
+ token: string;
140
+ /** Group whose broadcast channel carries this grant. */
141
+ groupID: string;
142
+ /** HLC stamped by the grantor; mirrors the grantor-side issued row. */
143
+ hlc: string;
144
+ } | {
145
+ /**
146
+ * Distributes a signed revocation record for a previously minted
147
+ * `document/write` capability. The recipient verifies the token's
148
+ * signature and stores the revocation; verification against the
149
+ * referenced cap's `iss` flips `verified_at` once the cap is known.
150
+ * Revocation is binary and reactive — once verified locally, any
151
+ * subsequent mutation under the revoked `jti` is denied regardless
152
+ * of `mutation.atTime`.
153
+ */
154
+ type: 'delegation:revoke';
155
+ /** Stringified signed revocation JWT carrying `{ jti, iss, rev, iat }`. */
156
+ token: string;
157
+ /** Group whose broadcast channel carries this revocation. */
158
+ groupID: string;
159
+ /** HLC stamped by the revoker; mirrors the revoker-side stored row. */
160
+ hlc: string;
161
+ } | {
162
+ /**
163
+ * A member announces a voluntary leave. MLS forbids self-removal, so this
164
+ * is an advisory notification (not an authoritative removal): receivers
165
+ * surface a `groupLeaveRequested` event for an admin to act on by issuing
166
+ * a real `removeMember`. Deliberately does NOT tombstone — a forged
167
+ * leave-request must not be able to evict an arbitrary member.
168
+ */
169
+ type: 'group:leaveRequest';
170
+ groupID: string;
171
+ memberDID: string;
172
+ hlc: string;
173
+ } | {
174
+ type: 'mutation:apply';
175
+ entries: Array<MutationApplyEntry>;
176
+ /**
177
+ * DID of the peer this broadcast arrived FROM — used for loopback drop.
178
+ * For a forwarded mutation that is the forwarding peer, not the author, so
179
+ * it is never an authorization input: each entry carries its own signed
180
+ * `mutationJWT`, verified independently.
181
+ *
182
+ * `undefined` when the frame opened at a leaf that could not be named. The
183
+ * entries still apply — see the loopback guard.
184
+ */
185
+ senderPeerDID: string | undefined;
186
+ } | {
187
+ /**
188
+ * Hands one co-member what it needs to open a credential key: the key's
189
+ * public record, the single wrapping addressed to that member, and every
190
+ * entry ciphertext at that version. No plaintext, and no key material
191
+ * outside the wrapping.
192
+ *
193
+ * Group-wide, because confidentiality is content-level — only the
194
+ * addressed DID's agreement key opens the wrapping. A co-member that is
195
+ * not the recipient receives the frame and applies nothing, so what it
196
+ * retains is the frame, never a row.
197
+ */
198
+ type: 'credential:key-grant';
199
+ keyID: string;
200
+ keyVersion: number;
201
+ suite: number;
202
+ /**
203
+ * Who administers the key, as the granter's own row records it. A MIRROR,
204
+ * not an input: the receiver writes `owner_did` from the verified `auth`
205
+ * issuer, so this field is what the frame claims and never what is stored.
206
+ */
207
+ ownerDID: string;
208
+ /**
209
+ * The granter's signed op for the key row. Top-level because the key row
210
+ * itself is inline on this frame and has nowhere else to hang.
211
+ */
212
+ keyOp: string;
213
+ /** The introducing key-op(s) ride inline so every branch label can be authenticated. */
214
+ keyBranches: Array<string>;
215
+ wrapping: CredentialKeyGrantWrapping;
216
+ entries: Array<CredentialKeyGrantEntry>;
217
+ /**
218
+ * Signs identifiers and digests, not the ciphertext — see
219
+ * {@link CredentialKeyGrantClaim}. The receiver recomputes both digests
220
+ * from the plaintext frame before writing anything.
221
+ */
222
+ auth: ControlAuth;
223
+ } | {
224
+ /**
225
+ * A device advertises itself to its co-members. Everything here except
226
+ * `announcerDID` came off the wire; `announcerDID` did NOT — it is the
227
+ * MLS-authenticated sender the handler read from `message.payload.iss`,
228
+ * because the announce body carries no DID field at all.
229
+ *
230
+ * `undefined` when the frame opened at a leaf that could not be named, and
231
+ * the apply then writes nothing: a capability claim attributed to nobody is
232
+ * worse than no row.
233
+ */
234
+ type: 'peer:announce';
235
+ label: string;
236
+ availability: PeerAvailability;
237
+ capabilities: Array<PeerCapability>;
238
+ hlc: string;
239
+ announcerDID: string | undefined;
240
+ };
@@ -0,0 +1 @@
1
+ export { };