@kubun/plugin-p2p 0.10.1 → 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/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +3 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +105 -91
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +5 -1
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +61 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +11 -0
- package/lib/groups/group-health-monitor.js +139 -1
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +20 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +0 -6
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +17 -48
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +17 -16
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +1 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +439 -2
- package/lib/sync/authorize.d.ts +1 -1
- package/lib/sync/authorize.js +73 -1
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +3 -2
- package/lib/sync/errors.js +19 -1
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +2 -2
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +1 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +3 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +11 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.js +21 -1
- package/package.json +47 -40
- package/lib/groups/broadcast-service.d.ts +0 -263
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/rejoin-codec.d.ts +0 -14
- package/lib/groups/rejoin-codec.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -34
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/circle-catchup-requester.d.ts +0 -58
- package/lib/hub/circle-catchup-requester.js +0 -1
- package/lib/hub/circle-catchup-responder.d.ts +0 -50
- package/lib/hub/circle-catchup-responder.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/epoch-stale-detector.d.ts +0 -18
- package/lib/hub/epoch-stale-detector.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -89
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -51
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/rejoin-manager.d.ts +0 -78
- package/lib/hub/rejoin-manager.js +0 -1
- package/lib/hub/rejoin-responder.d.ts +0 -32
- package/lib/hub/rejoin-responder.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -142
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
3
|
+
import { type CircleDefValue, type CircleMemberValue, type GroupSettingsValue, type IsAdminAtHLC } from './circle-reducers.js';
|
|
4
|
+
import type { GroupAnchor } from './group-anchor.js';
|
|
5
|
+
import { type VerifiedLedgerEntry } from './ledger-entry.js';
|
|
6
|
+
import { type FoldInput } from './ledger-fold.js';
|
|
7
|
+
/**
|
|
8
|
+
* Projection keys, one per entry type — the watermark slot a reproject /
|
|
9
|
+
* incremental advances. Mirrors `ADMIN_ROSTER_PROJECTION`: a single projection
|
|
10
|
+
* per ledger type, so the key is the type itself.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CIRCLE_DEF_PROJECTION = "circle.def";
|
|
13
|
+
export declare const CIRCLE_MEMBER_PROJECTION = "circle.member";
|
|
14
|
+
export declare const GROUP_SETTINGS_PROJECTION = "group.settings";
|
|
15
|
+
export declare const loadVerifiedCircleDefEntries: (store: P2PStoreAPI, groupID: string, logger?: Logger) => Promise<FoldInput<CircleDefValue>[]>;
|
|
16
|
+
export declare const loadVerifiedCircleMemberEntries: (store: P2PStoreAPI, groupID: string, logger?: Logger) => Promise<FoldInput<CircleMemberValue>[]>;
|
|
17
|
+
export declare const loadVerifiedGroupSettingsEntries: (store: P2PStoreAPI, groupID: string, logger?: Logger) => Promise<FoldInput<GroupSettingsValue>[]>;
|
|
18
|
+
/**
|
|
19
|
+
* One projected circle row as the fold decides it. `hlc` is the winning active
|
|
20
|
+
* entry's HLC (the row's `hlc` column); `removedAtHLC` is the winning tombstone
|
|
21
|
+
* HLC, or null when the circle is active. A removed circle retains its last
|
|
22
|
+
* active definition so the tombstone row carries real columns — mirroring the
|
|
23
|
+
* LWW store where `markCircleRemoved` only sets `removed_at_hlc` and leaves the
|
|
24
|
+
* name/description intact.
|
|
25
|
+
*/
|
|
26
|
+
export type CircleDefProjectionRow = {
|
|
27
|
+
name: string;
|
|
28
|
+
description: string;
|
|
29
|
+
catalogIDs: Array<string>;
|
|
30
|
+
hlc: string;
|
|
31
|
+
removedAtHLC: string | null;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Fold the circle-definition ledger into a per-circle projection covering ALL
|
|
35
|
+
* subjects, active and removed — the active reducer drops removed circles, but a
|
|
36
|
+
* projection must keep a tombstone row for them. Replays in total order under
|
|
37
|
+
* the same admin-authority gate as the reducer: an active entry sets the
|
|
38
|
+
* definition and clears any tombstone (re-add), a tombstone marks the row
|
|
39
|
+
* removed while keeping its last active value. A circle whose only entry is a
|
|
40
|
+
* tombstone (no prior active value) produces no row, matching the LWW store's
|
|
41
|
+
* ignore-if-absent removal.
|
|
42
|
+
*/
|
|
43
|
+
export declare function projectCircleDefs(entries: Array<FoldInput<CircleDefValue>>, isAdminAtHLC: IsAdminAtHLC): Map<string, CircleDefProjectionRow>;
|
|
44
|
+
/** One projected circle-member row, keyed by the composite ledger subject. */
|
|
45
|
+
export type CircleMemberProjectionRow = {
|
|
46
|
+
circleID: string;
|
|
47
|
+
memberDID: string;
|
|
48
|
+
role: string;
|
|
49
|
+
hlc: string;
|
|
50
|
+
removedAtHLC: string | null;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Fold the circle-member ledger into a per-subject projection covering ALL
|
|
54
|
+
* subjects, active and removed. Same structure as {@link projectCircleDefs}: an
|
|
55
|
+
* active entry sets the role and clears any tombstone, a tombstone marks the row
|
|
56
|
+
* removed while keeping its last active role. A malformed subject is skipped, as
|
|
57
|
+
* is a tombstone with no prior active value.
|
|
58
|
+
*/
|
|
59
|
+
export declare function projectCircleMembers(entries: Array<FoldInput<CircleMemberValue>>, isAdminAtHLC: IsAdminAtHLC): Map<string, CircleMemberProjectionRow>;
|
|
60
|
+
/**
|
|
61
|
+
* Authoritatively rebuild a circle/settings projection from the whole ledger and
|
|
62
|
+
* reset the watermark to the newest folded entry. Used on a first projection, an
|
|
63
|
+
* out-of-order arrival, or drift repair — anywhere the cached projection can no
|
|
64
|
+
* longer be trusted as a fold-through-watermark. Authority is the admin fold
|
|
65
|
+
* (`adminAuthorityFromEntries`), so every projection honors the same admin gate
|
|
66
|
+
* the broadcast path enforces. Each `reconcile*` write is unconditional, so the
|
|
67
|
+
* row converges to exactly the fold's decision regardless of prior contents.
|
|
68
|
+
*/
|
|
69
|
+
export type ReprojectCircleParams = {
|
|
70
|
+
store: P2PStoreAPI;
|
|
71
|
+
groupID: string;
|
|
72
|
+
anchor: GroupAnchor;
|
|
73
|
+
logger?: Logger;
|
|
74
|
+
};
|
|
75
|
+
export declare function reprojectCircleDefs(params: ReprojectCircleParams): Promise<void>;
|
|
76
|
+
export declare function reprojectCircleMembers(params: ReprojectCircleParams): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Reproject group settings onto the group row. Settings are PARTIAL per-field
|
|
79
|
+
* (no tombstone), so the committed reducer's active map IS the full projection:
|
|
80
|
+
* fold it under the admin gate, then write the merged fields via the
|
|
81
|
+
* unconditional `updateGroup`. A field no entry ever set stays at the group's
|
|
82
|
+
* original value (creation-time name/description).
|
|
83
|
+
*/
|
|
84
|
+
export declare function reprojectGroupSettings(params: ReprojectCircleParams): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Apply one strictly-newer-than-watermark entry onto the current projection
|
|
87
|
+
* without re-folding the whole ledger. The caller guarantees the entry is
|
|
88
|
+
* in-order, so the projection already equals the fold-so-far. Authority is the
|
|
89
|
+
* admin fold at the entry's own HLC: a non-admin issuer is dropped (never
|
|
90
|
+
* thrown). Unlike the admin roster's incremental applier — whose authority reads
|
|
91
|
+
* the projection itself — circle authority lives in the separate admin
|
|
92
|
+
* sub-ledger, so the admin entries are loaded here.
|
|
93
|
+
*
|
|
94
|
+
* The watermark is advanced by the caller (the ingest handler), not here —
|
|
95
|
+
* mirroring the admin roster's split of responsibility — so the projection
|
|
96
|
+
* mutation and the watermark advance stay in the handler's single transaction
|
|
97
|
+
* even when the entry is dropped (it is still durable and accounted for).
|
|
98
|
+
*/
|
|
99
|
+
export type ApplyCircleDefEntryIncrementalParams = {
|
|
100
|
+
store: P2PStoreAPI;
|
|
101
|
+
groupID: string;
|
|
102
|
+
anchor: GroupAnchor;
|
|
103
|
+
verified: VerifiedLedgerEntry<CircleDefValue>;
|
|
104
|
+
logger?: Logger;
|
|
105
|
+
};
|
|
106
|
+
export declare function applyCircleDefEntryIncremental(params: ApplyCircleDefEntryIncrementalParams): Promise<void>;
|
|
107
|
+
export type ApplyCircleMemberEntryIncrementalParams = {
|
|
108
|
+
store: P2PStoreAPI;
|
|
109
|
+
groupID: string;
|
|
110
|
+
anchor: GroupAnchor;
|
|
111
|
+
verified: VerifiedLedgerEntry<CircleMemberValue>;
|
|
112
|
+
logger?: Logger;
|
|
113
|
+
};
|
|
114
|
+
export declare function applyCircleMemberEntryIncremental(params: ApplyCircleMemberEntryIncrementalParams): Promise<void>;
|
|
115
|
+
export type ApplyGroupSettingsEntryIncrementalParams = {
|
|
116
|
+
store: P2PStoreAPI;
|
|
117
|
+
groupID: string;
|
|
118
|
+
anchor: GroupAnchor;
|
|
119
|
+
verified: VerifiedLedgerEntry<GroupSettingsValue>;
|
|
120
|
+
logger?: Logger;
|
|
121
|
+
};
|
|
122
|
+
export declare function applyGroupSettingsEntryIncremental(params: ApplyGroupSettingsEntryIncrementalParams): Promise<void>;
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { loadVerifiedAdminEntries } from './admin-roster.js';
|
|
2
|
+
import { adminAuthorityFromEntries, CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, createGroupSettingsReducer, decodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
|
|
3
|
+
import { verifyLedgerEntry } from './ledger-entry.js';
|
|
4
|
+
import { foldLedger } from './ledger-fold.js';
|
|
5
|
+
/**
|
|
6
|
+
* Projection keys, one per entry type — the watermark slot a reproject /
|
|
7
|
+
* incremental advances. Mirrors `ADMIN_ROSTER_PROJECTION`: a single projection
|
|
8
|
+
* per ledger type, so the key is the type itself.
|
|
9
|
+
*/ export const CIRCLE_DEF_PROJECTION = CIRCLE_DEF_ENTRY_TYPE;
|
|
10
|
+
export const CIRCLE_MEMBER_PROJECTION = CIRCLE_MEMBER_ENTRY_TYPE;
|
|
11
|
+
export const GROUP_SETTINGS_PROJECTION = GROUP_SETTINGS_ENTRY_TYPE;
|
|
12
|
+
function isRemovedDef(value) {
|
|
13
|
+
return 'removed' in value && value.removed === true;
|
|
14
|
+
}
|
|
15
|
+
function isRemovedMember(value) {
|
|
16
|
+
return 'removed' in value && value.removed === true;
|
|
17
|
+
}
|
|
18
|
+
/** Total order over the ledger: HLC first, content-addressed id breaks ties. */ function byTotalOrder(a, b) {
|
|
19
|
+
if (a.verified.entry.hlc !== b.verified.entry.hlc) {
|
|
20
|
+
return a.verified.entry.hlc < b.verified.entry.hlc ? -1 : 1;
|
|
21
|
+
}
|
|
22
|
+
if (a.entryID === b.entryID) return 0;
|
|
23
|
+
return a.entryID < b.entryID ? -1 : 1;
|
|
24
|
+
}
|
|
25
|
+
function maxHLC(entries) {
|
|
26
|
+
let max = null;
|
|
27
|
+
for (const { verified } of entries){
|
|
28
|
+
if (max == null || verified.entry.hlc > max) max = verified.entry.hlc;
|
|
29
|
+
}
|
|
30
|
+
return max;
|
|
31
|
+
}
|
|
32
|
+
// --- Per-type verified-entry loaders ---
|
|
33
|
+
/**
|
|
34
|
+
* Build a verified-entry loader for one ledger type, memoized per group on the
|
|
35
|
+
* stored id set. The stored token is re-verified rather than trusting the
|
|
36
|
+
* decoded `value` column — the ledger is the source of truth and must stay
|
|
37
|
+
* self-verifying, so a token that no longer verifies is dropped and the rest
|
|
38
|
+
* survive. The cache key is derived purely from content-addressed entry ids, so
|
|
39
|
+
* a forged or altered entry yields a different id set, a different key, and a
|
|
40
|
+
* full re-verification; one slot per group self-invalidates on every append.
|
|
41
|
+
* One cache instance per type keeps the slots isolated across ledger types.
|
|
42
|
+
*/ function createVerifiedEntryLoader(entryType) {
|
|
43
|
+
const cache = new Map();
|
|
44
|
+
return async (store, groupID, logger)=>{
|
|
45
|
+
const rows = await store.getLedgerEntriesByType(groupID, entryType);
|
|
46
|
+
const key = rows.map((row)=>row.entry_id).join('\n');
|
|
47
|
+
const cached = cache.get(groupID);
|
|
48
|
+
if (cached != null && cached.key === key) {
|
|
49
|
+
return cached.entries;
|
|
50
|
+
}
|
|
51
|
+
const entries = [];
|
|
52
|
+
for (const row of rows){
|
|
53
|
+
const verified = await verifyLedgerEntry(row.signed_token);
|
|
54
|
+
if (verified == null) {
|
|
55
|
+
logger?.warn('circle projection: dropped stored entry whose token no longer verifies', {
|
|
56
|
+
groupID,
|
|
57
|
+
entryType,
|
|
58
|
+
entryID: row.entry_id
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
entries.push({
|
|
63
|
+
verified,
|
|
64
|
+
entryID: row.entry_id
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
cache.set(groupID, {
|
|
68
|
+
key,
|
|
69
|
+
entries
|
|
70
|
+
});
|
|
71
|
+
return entries;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export const loadVerifiedCircleDefEntries = createVerifiedEntryLoader(CIRCLE_DEF_ENTRY_TYPE);
|
|
75
|
+
export const loadVerifiedCircleMemberEntries = createVerifiedEntryLoader(CIRCLE_MEMBER_ENTRY_TYPE);
|
|
76
|
+
export const loadVerifiedGroupSettingsEntries = createVerifiedEntryLoader(GROUP_SETTINGS_ENTRY_TYPE);
|
|
77
|
+
/**
|
|
78
|
+
* Fold the circle-definition ledger into a per-circle projection covering ALL
|
|
79
|
+
* subjects, active and removed — the active reducer drops removed circles, but a
|
|
80
|
+
* projection must keep a tombstone row for them. Replays in total order under
|
|
81
|
+
* the same admin-authority gate as the reducer: an active entry sets the
|
|
82
|
+
* definition and clears any tombstone (re-add), a tombstone marks the row
|
|
83
|
+
* removed while keeping its last active value. A circle whose only entry is a
|
|
84
|
+
* tombstone (no prior active value) produces no row, matching the LWW store's
|
|
85
|
+
* ignore-if-absent removal.
|
|
86
|
+
*/ export function projectCircleDefs(entries, isAdminAtHLC) {
|
|
87
|
+
const rows = new Map();
|
|
88
|
+
for (const { verified } of [
|
|
89
|
+
...entries
|
|
90
|
+
].sort(byTotalOrder)){
|
|
91
|
+
if (!isAdminAtHLC(verified.issuer, verified.entry.hlc)) continue;
|
|
92
|
+
const { subject, value, hlc } = verified.entry;
|
|
93
|
+
if (isRemovedDef(value)) {
|
|
94
|
+
const existing = rows.get(subject);
|
|
95
|
+
if (existing == null) continue;
|
|
96
|
+
rows.set(subject, {
|
|
97
|
+
...existing,
|
|
98
|
+
removedAtHLC: hlc
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
rows.set(subject, {
|
|
102
|
+
name: value.name,
|
|
103
|
+
description: value.description,
|
|
104
|
+
catalogIDs: value.catalogIDs,
|
|
105
|
+
hlc,
|
|
106
|
+
removedAtHLC: null
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Fold the circle-member ledger into a per-subject projection covering ALL
|
|
114
|
+
* subjects, active and removed. Same structure as {@link projectCircleDefs}: an
|
|
115
|
+
* active entry sets the role and clears any tombstone, a tombstone marks the row
|
|
116
|
+
* removed while keeping its last active role. A malformed subject is skipped, as
|
|
117
|
+
* is a tombstone with no prior active value.
|
|
118
|
+
*/ export function projectCircleMembers(entries, isAdminAtHLC) {
|
|
119
|
+
const rows = new Map();
|
|
120
|
+
for (const { verified } of [
|
|
121
|
+
...entries
|
|
122
|
+
].sort(byTotalOrder)){
|
|
123
|
+
if (!isAdminAtHLC(verified.issuer, verified.entry.hlc)) continue;
|
|
124
|
+
const { subject, value, hlc } = verified.entry;
|
|
125
|
+
if (isRemovedMember(value)) {
|
|
126
|
+
const existing = rows.get(subject);
|
|
127
|
+
if (existing == null) continue;
|
|
128
|
+
rows.set(subject, {
|
|
129
|
+
...existing,
|
|
130
|
+
removedAtHLC: hlc
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
const decoded = decodeCircleMemberSubject(subject);
|
|
134
|
+
if (decoded == null) continue;
|
|
135
|
+
rows.set(subject, {
|
|
136
|
+
circleID: decoded.circleID,
|
|
137
|
+
memberDID: decoded.memberDID,
|
|
138
|
+
role: value.role,
|
|
139
|
+
hlc,
|
|
140
|
+
removedAtHLC: null
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return rows;
|
|
145
|
+
}
|
|
146
|
+
async function adminAuthorityFor(params) {
|
|
147
|
+
const adminEntries = await loadVerifiedAdminEntries(params.store, params.groupID, params.logger);
|
|
148
|
+
return adminAuthorityFromEntries(adminEntries, params.anchor);
|
|
149
|
+
}
|
|
150
|
+
export async function reprojectCircleDefs(params) {
|
|
151
|
+
const { store, groupID } = params;
|
|
152
|
+
const [isAdmin, entries] = await Promise.all([
|
|
153
|
+
adminAuthorityFor(params),
|
|
154
|
+
loadVerifiedCircleDefEntries(store, groupID, params.logger)
|
|
155
|
+
]);
|
|
156
|
+
const rows = projectCircleDefs(entries, isAdmin);
|
|
157
|
+
for (const [circleID, row] of rows){
|
|
158
|
+
await store.reconcileCircleProjection({
|
|
159
|
+
id: circleID,
|
|
160
|
+
groupID,
|
|
161
|
+
name: row.name,
|
|
162
|
+
description: row.description,
|
|
163
|
+
catalogIDs: row.catalogIDs,
|
|
164
|
+
hlc: row.hlc,
|
|
165
|
+
removedAtHLC: row.removedAtHLC
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const max = maxHLC(entries);
|
|
169
|
+
if (max != null) {
|
|
170
|
+
await store.setProjectionWatermark(groupID, CIRCLE_DEF_PROJECTION, max);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export async function reprojectCircleMembers(params) {
|
|
174
|
+
const { store, groupID } = params;
|
|
175
|
+
const [isAdmin, entries] = await Promise.all([
|
|
176
|
+
adminAuthorityFor(params),
|
|
177
|
+
loadVerifiedCircleMemberEntries(store, groupID, params.logger)
|
|
178
|
+
]);
|
|
179
|
+
const rows = projectCircleMembers(entries, isAdmin);
|
|
180
|
+
for (const row of rows.values()){
|
|
181
|
+
await store.reconcileCircleMemberProjection({
|
|
182
|
+
circleID: row.circleID,
|
|
183
|
+
memberDID: row.memberDID,
|
|
184
|
+
role: row.role,
|
|
185
|
+
hlc: row.hlc,
|
|
186
|
+
removedAtHLC: row.removedAtHLC
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
const max = maxHLC(entries);
|
|
190
|
+
if (max != null) {
|
|
191
|
+
await store.setProjectionWatermark(groupID, CIRCLE_MEMBER_PROJECTION, max);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Reproject group settings onto the group row. Settings are PARTIAL per-field
|
|
196
|
+
* (no tombstone), so the committed reducer's active map IS the full projection:
|
|
197
|
+
* fold it under the admin gate, then write the merged fields via the
|
|
198
|
+
* unconditional `updateGroup`. A field no entry ever set stays at the group's
|
|
199
|
+
* original value (creation-time name/description).
|
|
200
|
+
*/ export async function reprojectGroupSettings(params) {
|
|
201
|
+
const { store, groupID, anchor } = params;
|
|
202
|
+
const [isAdmin, entries] = await Promise.all([
|
|
203
|
+
adminAuthorityFor(params),
|
|
204
|
+
loadVerifiedGroupSettingsEntries(store, groupID, params.logger)
|
|
205
|
+
]);
|
|
206
|
+
const { settings } = foldLedger(entries, anchor, createGroupSettingsReducer(isAdmin));
|
|
207
|
+
const merged = settings.get(groupID);
|
|
208
|
+
if (merged != null) {
|
|
209
|
+
await store.updateGroup(groupID, merged);
|
|
210
|
+
}
|
|
211
|
+
const max = maxHLC(entries);
|
|
212
|
+
if (max != null) {
|
|
213
|
+
await store.setProjectionWatermark(groupID, GROUP_SETTINGS_PROJECTION, max);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
export async function applyCircleDefEntryIncremental(params) {
|
|
217
|
+
const { store, groupID, anchor, verified, logger } = params;
|
|
218
|
+
const adminEntries = await loadVerifiedAdminEntries(store, groupID, logger);
|
|
219
|
+
const isAdmin = adminAuthorityFromEntries(adminEntries, anchor);
|
|
220
|
+
const { subject, value, hlc } = verified.entry;
|
|
221
|
+
if (!isAdmin(verified.issuer, hlc)) {
|
|
222
|
+
logger?.warn('circle projection: dropped incremental circle.def from a non-admin issuer', {
|
|
223
|
+
groupID,
|
|
224
|
+
issuer: verified.issuer
|
|
225
|
+
});
|
|
226
|
+
} else if (isRemovedDef(value)) {
|
|
227
|
+
// Tombstone an existing active row, leaving its name/description intact —
|
|
228
|
+
// the LWW removal write, correct for an in-order entry on the active row.
|
|
229
|
+
await store.markCircleRemoved(subject, hlc);
|
|
230
|
+
} else {
|
|
231
|
+
await store.reconcileCircleProjection({
|
|
232
|
+
id: subject,
|
|
233
|
+
groupID,
|
|
234
|
+
name: value.name,
|
|
235
|
+
description: value.description,
|
|
236
|
+
catalogIDs: value.catalogIDs,
|
|
237
|
+
hlc,
|
|
238
|
+
removedAtHLC: null
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export async function applyCircleMemberEntryIncremental(params) {
|
|
243
|
+
const { store, groupID, anchor, verified, logger } = params;
|
|
244
|
+
const adminEntries = await loadVerifiedAdminEntries(store, groupID, logger);
|
|
245
|
+
const isAdmin = adminAuthorityFromEntries(adminEntries, anchor);
|
|
246
|
+
const { subject, value, hlc } = verified.entry;
|
|
247
|
+
if (!isAdmin(verified.issuer, hlc)) {
|
|
248
|
+
logger?.warn('circle projection: dropped incremental circle.member from a non-admin issuer', {
|
|
249
|
+
groupID,
|
|
250
|
+
issuer: verified.issuer
|
|
251
|
+
});
|
|
252
|
+
} else if (isRemovedMember(value)) {
|
|
253
|
+
const decoded = decodeCircleMemberSubject(subject);
|
|
254
|
+
if (decoded != null) {
|
|
255
|
+
await store.removeCircleMember(decoded.circleID, decoded.memberDID, hlc);
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
const decoded = decodeCircleMemberSubject(subject);
|
|
259
|
+
if (decoded != null) {
|
|
260
|
+
await store.reconcileCircleMemberProjection({
|
|
261
|
+
circleID: decoded.circleID,
|
|
262
|
+
memberDID: decoded.memberDID,
|
|
263
|
+
role: value.role,
|
|
264
|
+
hlc,
|
|
265
|
+
removedAtHLC: null
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
export async function applyGroupSettingsEntryIncremental(params) {
|
|
271
|
+
const { store, groupID, anchor, verified, logger } = params;
|
|
272
|
+
const adminEntries = await loadVerifiedAdminEntries(store, groupID, logger);
|
|
273
|
+
const isAdmin = adminAuthorityFromEntries(adminEntries, anchor);
|
|
274
|
+
const { value, hlc } = verified.entry;
|
|
275
|
+
if (!isAdmin(verified.issuer, hlc)) {
|
|
276
|
+
logger?.warn('circle projection: dropped incremental group.settings from a non-admin issuer', {
|
|
277
|
+
groupID,
|
|
278
|
+
issuer: verified.issuer
|
|
279
|
+
});
|
|
280
|
+
} else {
|
|
281
|
+
// Per-field merge onto the group row: `updateGroup` sets only the fields the
|
|
282
|
+
// entry carries (an explicit `''` clears; an omitted field is left alone).
|
|
283
|
+
await store.updateGroup(groupID, {
|
|
284
|
+
name: value.name,
|
|
285
|
+
description: value.description
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { type AdminRoleValue } from './admin-roster.js';
|
|
2
|
+
import type { GroupAnchor } from './group-anchor.js';
|
|
3
|
+
import type { FoldInput, LedgerReducer } from './ledger-fold.js';
|
|
4
|
+
/**
|
|
5
|
+
* The fold's authority gate: was `issuer` a group admin at `hlc`? The reducer
|
|
6
|
+
* factories take this predicate so the projection fold stays synchronous and
|
|
7
|
+
* deterministic from the ledger alone — every peer that folds the same ledger
|
|
8
|
+
* resolves the same authority and converges to the same state. By design this
|
|
9
|
+
* gate is the LEDGER admin-fold ONLY: membership and MLS-removal are enforced
|
|
10
|
+
* outside the projection (the live broadcast gate and the MLS roster), never
|
|
11
|
+
* conjoined here, so the fold never needs an async store read.
|
|
12
|
+
*/
|
|
13
|
+
export type IsAdminAtHLC = (issuer: string, hlc: string) => boolean;
|
|
14
|
+
/** Ledger entry `type` for a circle-membership claim. */
|
|
15
|
+
export declare const CIRCLE_MEMBER_ENTRY_TYPE = "circle.member";
|
|
16
|
+
/**
|
|
17
|
+
* The claim carried by a circle-member entry. The active form names the
|
|
18
|
+
* member's role within the circle; the tombstone form removes them. Both are
|
|
19
|
+
* whole-value: the highest-ordered entry for a subject replaces the projected
|
|
20
|
+
* state outright, so the value alone decides active-with-role vs removed. `role`
|
|
21
|
+
* mirrors the store's free-form role column (no fixed enum), so it stays a
|
|
22
|
+
* string rather than inventing a circle-specific role type.
|
|
23
|
+
*/
|
|
24
|
+
export type CircleMemberValue = {
|
|
25
|
+
role: string;
|
|
26
|
+
} | {
|
|
27
|
+
removed: true;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Deterministic composite subject for a circle member. The signed entry's
|
|
31
|
+
* `subject` encodes both the circle and the member, so the ledger's `subject`
|
|
32
|
+
* column uniquely identifies the pair and per-member LWW ordering + dedup work
|
|
33
|
+
* without consulting the value.
|
|
34
|
+
*
|
|
35
|
+
* Unambiguous because the member is a normalized DID (`did:method:...`), which
|
|
36
|
+
* never contains the `/member:` separator: every reachable separator therefore
|
|
37
|
+
* sits between the circle id and the DID, and the decoder splits on the last
|
|
38
|
+
* one. The labeled `circle:` / `/member:` prefixes also keep the encoding
|
|
39
|
+
* distinct from any other reducer's subject scheme.
|
|
40
|
+
*/
|
|
41
|
+
export declare function encodeCircleMemberSubject(circleID: string, memberDID: string): string;
|
|
42
|
+
/** Tolerant inverse of {@link encodeCircleMemberSubject}: null on a malformed subject. */
|
|
43
|
+
export declare function decodeCircleMemberSubject(subject: string): {
|
|
44
|
+
circleID: string;
|
|
45
|
+
memberDID: string;
|
|
46
|
+
} | null;
|
|
47
|
+
/**
|
|
48
|
+
* Folded circle membership, keyed by composite subject. Only active members are
|
|
49
|
+
* present — a subject whose highest-ordered entry is a tombstone is absent from
|
|
50
|
+
* the map.
|
|
51
|
+
*/
|
|
52
|
+
export type CircleMemberState = {
|
|
53
|
+
members: Map<string, {
|
|
54
|
+
circleID: string;
|
|
55
|
+
memberDID: string;
|
|
56
|
+
role: string;
|
|
57
|
+
}>;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Per-member last-writer-wins circle roster. {@link foldLedger} sorts by the
|
|
61
|
+
* total order `(hlc, entryID)`, so each subject's highest-ordered entry is
|
|
62
|
+
* applied last and decides the outcome: an active value sets the member with its
|
|
63
|
+
* role, a tombstone removes them. Whole-value replacement means add / remove /
|
|
64
|
+
* re-add converge to the same state on every peer regardless of arrival order.
|
|
65
|
+
*
|
|
66
|
+
* Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
|
|
67
|
+
* HLC so a key authorized when it wrote stays authorized even after a later
|
|
68
|
+
* revoke. The gate is the ledger admin-fold ONLY — membership and MLS-removal
|
|
69
|
+
* are enforced outside this projection — so the fold remains synchronous and
|
|
70
|
+
* deterministic from the ledger alone.
|
|
71
|
+
*/
|
|
72
|
+
export declare function createCircleMemberReducer(isAdminAtHLC: IsAdminAtHLC): LedgerReducer<CircleMemberValue, CircleMemberState>;
|
|
73
|
+
/** Ledger entry `type` for a circle definition (create / update / delete). */
|
|
74
|
+
export declare const CIRCLE_DEF_ENTRY_TYPE = "circle.def";
|
|
75
|
+
/**
|
|
76
|
+
* The claim carried by a circle-definition entry. The active form is the whole
|
|
77
|
+
* circle definition; the tombstone form deletes it. Both are whole-value: the
|
|
78
|
+
* highest-ordered entry for a circle replaces the projected definition outright,
|
|
79
|
+
* so create and update share one shape and the value alone decides present vs
|
|
80
|
+
* deleted.
|
|
81
|
+
*/
|
|
82
|
+
export type CircleDefValue = {
|
|
83
|
+
name: string;
|
|
84
|
+
description: string;
|
|
85
|
+
catalogIDs: Array<string>;
|
|
86
|
+
} | {
|
|
87
|
+
removed: true;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Folded circle definitions, keyed by circleID. Only present circles appear — a
|
|
91
|
+
* circle whose highest-ordered entry is a tombstone is absent from the map.
|
|
92
|
+
*/
|
|
93
|
+
export type CircleDefState = {
|
|
94
|
+
circles: Map<string, {
|
|
95
|
+
name: string;
|
|
96
|
+
description: string;
|
|
97
|
+
catalogIDs: Array<string>;
|
|
98
|
+
}>;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Per-circle last-writer-wins definition. The signed entry's `subject` is the
|
|
102
|
+
* circleID directly, so no decode is needed. {@link foldLedger} sorts by the
|
|
103
|
+
* total order `(hlc, entryID)`, so each circle's highest-ordered entry is
|
|
104
|
+
* applied last and decides the outcome: an active value sets the definition, a
|
|
105
|
+
* tombstone deletes it. Whole-value replacement means create / update / delete
|
|
106
|
+
* converge to the same state on every peer regardless of arrival order.
|
|
107
|
+
*
|
|
108
|
+
* Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
|
|
109
|
+
* HLC. The gate is the ledger admin-fold ONLY — membership and MLS-removal are
|
|
110
|
+
* enforced outside this projection — so the fold remains synchronous and
|
|
111
|
+
* deterministic from the ledger alone.
|
|
112
|
+
*/
|
|
113
|
+
export declare function createCircleDefReducer(isAdminAtHLC: IsAdminAtHLC): LedgerReducer<CircleDefValue, CircleDefState>;
|
|
114
|
+
/** Ledger entry `type` for a group-settings claim. */
|
|
115
|
+
export declare const GROUP_SETTINGS_ENTRY_TYPE = "group.settings";
|
|
116
|
+
/**
|
|
117
|
+
* The claim carried by a group-settings entry. Unlike `circle.def` /
|
|
118
|
+
* `circle.member`, this value is PARTIAL: an entry sets only the fields it
|
|
119
|
+
* carries and stays silent about the rest. A field omitted from the
|
|
120
|
+
* value leaves the prior value untouched; setting a field to `''` is a real
|
|
121
|
+
* value that clears it. There is no tombstone — group settings are never
|
|
122
|
+
* deleted, only overwritten field by field.
|
|
123
|
+
*/
|
|
124
|
+
export type GroupSettingsValue = {
|
|
125
|
+
name?: string;
|
|
126
|
+
description?: string;
|
|
127
|
+
};
|
|
128
|
+
/** Folded group settings, keyed by groupID (the entry subject). */
|
|
129
|
+
export type GroupSettingsState = {
|
|
130
|
+
settings: Map<string, {
|
|
131
|
+
name?: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
}>;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Per-field last-writer-wins group settings. The signed entry's `subject` is the
|
|
137
|
+
* groupID directly, so no decode is needed. {@link foldLedger} sorts by the total
|
|
138
|
+
* order `(hlc, entryID)` and applies low→high, so for each field the
|
|
139
|
+
* highest-ordered entry that SETS that field wins naturally.
|
|
140
|
+
*
|
|
141
|
+
* This reducer merges per field rather than replacing the whole value the way
|
|
142
|
+
* `circle.def` / `circle.member` do: those project a single cohesive record
|
|
143
|
+
* where add / remove / re-add must converge to one state, so whole-value
|
|
144
|
+
* replacement is correct. Group settings instead accumulate independent fields
|
|
145
|
+
* (`name`, `description`) that different entries touch in isolation, so a
|
|
146
|
+
* name-only update must not clobber a previously-set description. Presence is
|
|
147
|
+
* decided by `value.field !== undefined` rather than `'field' in value`: an
|
|
148
|
+
* explicit `undefined` is treated as "not set" (no change), while `''` is a real
|
|
149
|
+
* value that clears the field.
|
|
150
|
+
*
|
|
151
|
+
* Authority is the injected `isAdminAtHLC`, evaluated against the entry's own
|
|
152
|
+
* HLC. The gate is the ledger admin-fold ONLY — membership and MLS-removal are
|
|
153
|
+
* enforced outside this projection — so the fold remains synchronous and
|
|
154
|
+
* deterministic from the ledger alone.
|
|
155
|
+
*/
|
|
156
|
+
export declare function createGroupSettingsReducer(isAdminAtHLC: IsAdminAtHLC): LedgerReducer<GroupSettingsValue, GroupSettingsState>;
|
|
157
|
+
/**
|
|
158
|
+
* Build an {@link IsAdminAtHLC} predicate from the group's verified admin-role
|
|
159
|
+
* ledger entries. It re-folds the admin sub-ledger at each queried entry's HLC,
|
|
160
|
+
* so the answer is rotation-aware (was-admin-at-HLC, not is-admin-now): a key
|
|
161
|
+
* granted admin at HLC h authorizes entries at or after h, and a later revoke
|
|
162
|
+
* stops authorizing entries from that point on. `foldAdminRoster`'s `atHLC`
|
|
163
|
+
* compare is inclusive (`entry.hlc <= atHLC`), so an entry at exactly the grant
|
|
164
|
+
* HLC sees the grant and is authorized, and an entry at exactly the revoke HLC
|
|
165
|
+
* sees the revoke and is not.
|
|
166
|
+
*
|
|
167
|
+
* Callers load the verified admin entries once (via `loadVerifiedAdminEntries`)
|
|
168
|
+
* and reuse the returned predicate across the circle / def / settings folds.
|
|
169
|
+
*
|
|
170
|
+
* Authority here is ledger-admin-only: it answers "did the ledger grant this
|
|
171
|
+
* issuer admin at this HLC?" and does NOT check the issuer's MLS membership at
|
|
172
|
+
* that HLC. So an admin who was removed from the MLS group but whose admin role
|
|
173
|
+
* was never revoked on the ledger still has authorizing entries folded in.
|
|
174
|
+
* Closing that requires removal to write a ledger revocation atomically with the
|
|
175
|
+
* MLS-roster delta (the same MLS-commit/roster atomicity work that keeps a
|
|
176
|
+
* removed member's writes from applying); it is deliberately out of scope here.
|
|
177
|
+
*/
|
|
178
|
+
export declare function adminAuthorityFromEntries(adminEntries: Array<FoldInput<AdminRoleValue>>, anchor: GroupAnchor): IsAdminAtHLC;
|