@kubun/plugin-p2p 0.10.1 → 0.12.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 +1646 -1
- package/lib/context/hub.js +151 -1
- package/lib/context/join.js +115 -1
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +252 -1
- package/lib/context/types.d.ts +58 -22
- package/lib/context/types.js +48 -1
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +55 -0
- package/lib/groups/admin-roster.js +69 -0
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +238 -106
- package/lib/groups/broadcast.js +702 -1
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +90 -0
- package/lib/groups/circle-projection.js +202 -0
- package/lib/groups/circle-reducers.d.ts +139 -0
- package/lib/groups/circle-reducers.js +128 -0
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +19 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +24 -0
- package/lib/groups/group-anchor.js +22 -0
- package/lib/groups/group-crypto.d.ts +42 -0
- package/lib/groups/group-crypto.js +159 -0
- package/lib/groups/group-handle-registry.d.ts +138 -9
- package/lib/groups/group-handle-registry.js +533 -1
- package/lib/groups/group-handlers.d.ts +30 -0
- package/lib/groups/group-handlers.js +218 -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 +66 -0
- package/lib/groups/group-mls.js +696 -0
- package/lib/groups/group-peer-manager.d.ts +301 -0
- package/lib/groups/group-peer-manager.js +1336 -0
- package/lib/groups/group-protocols.d.ts +754 -0
- package/lib/groups/group-protocols.js +448 -0
- package/lib/groups/invite-payload.d.ts +49 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +61 -0
- package/lib/groups/join-utils.js +366 -1
- package/lib/groups/ledger-affected-events.d.ts +20 -0
- package/lib/groups/ledger-affected-events.js +85 -0
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +217 -85
- package/lib/groups/manager.js +798 -1
- package/lib/groups/mls-codec.d.ts +28 -7
- package/lib/groups/mls-codec.js +33 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +33 -1
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +18 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +27 -0
- package/lib/groups/mls-receive-errors.js +39 -0
- package/lib/groups/mls-state.d.ts +6 -4
- package/lib/groups/mls-state.js +79 -1
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +7 -3
- package/lib/groups/store-received-grant.js +161 -1
- package/lib/groups/store-received-revocation.d.ts +19 -16
- package/lib/groups/store-received-revocation.js +83 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/http-client.d.ts +18 -10
- package/lib/hub/http-client.js +15 -1
- package/lib/hub/hub-like.d.ts +75 -0
- package/lib/hub/hub-like.js +1022 -0
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +155 -1
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +77 -51
- package/lib/hub/wiring.js +189 -1
- package/lib/index.d.ts +58 -22
- package/lib/index.js +563 -1
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +214 -1
- package/lib/protocol.js +519 -1
- package/lib/schema.d.ts +3 -1
- package/lib/schema.js +1150 -39
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +58 -4
- package/lib/sync/authorize.js +114 -1
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +182 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +84 -1
- package/lib/sync/errors.d.ts +12 -2
- package/lib/sync/errors.js +29 -1
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +271 -1
- package/lib/sync/handlers.js +200 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
- package/lib/sync/hub-tunnel-sync-listener.js +261 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
- package/lib/sync/hub-tunnel-sync-provider.js +113 -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 +11 -3
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +15 -11
- package/lib/sync/peer-registry.js +82 -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 +14 -4
- package/lib/sync/sync-client.js +254 -1
- package/lib/sync/sync-manager.d.ts +24 -6
- package/lib/sync/sync-manager.js +301 -1
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +704 -22
- package/lib/types.js +1 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +44 -1
- package/lib/utils.js +21 -1
- package/package.json +58 -47
- 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/did-observing-transport.d.ts +0 -64
- package/lib/hub/did-observing-transport.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/errors.d.ts +0 -30
- package/lib/hub/errors.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,50 @@
|
|
|
1
|
+
import { type OwnIdentity } from '@kokuin/token';
|
|
2
|
+
import type { CatalogRecord } from '@kubun/protocol';
|
|
3
|
+
type SetPayload = {
|
|
4
|
+
op: 'set';
|
|
5
|
+
catalog: CatalogRecord;
|
|
6
|
+
};
|
|
7
|
+
type UpdatePayload = {
|
|
8
|
+
op: 'update';
|
|
9
|
+
catalogID: string;
|
|
10
|
+
update: {
|
|
11
|
+
name?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
filterCriteria?: CatalogRecord['filterCriteria'];
|
|
14
|
+
};
|
|
15
|
+
hlc: string;
|
|
16
|
+
};
|
|
17
|
+
type RemovePayload = {
|
|
18
|
+
op: 'remove';
|
|
19
|
+
catalogID: string;
|
|
20
|
+
hlc: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The verified claim behind a catalog control broadcast. `issuer` is the
|
|
24
|
+
* authenticated author (the verified token `iss`). Every authoritative field
|
|
25
|
+
* comes from the signed payload, so a receiver never trusts the surrounding
|
|
26
|
+
* plaintext frame. Sender-bound: the receiver applies a set only when the
|
|
27
|
+
* catalog's `ownerDID` equals this issuer, and an update/remove only when the
|
|
28
|
+
* locally-stored catalog is owned by this issuer.
|
|
29
|
+
*/
|
|
30
|
+
export type VerifiedCatalog = {
|
|
31
|
+
issuer: string;
|
|
32
|
+
claim: SetPayload;
|
|
33
|
+
} | {
|
|
34
|
+
issuer: string;
|
|
35
|
+
claim: UpdatePayload;
|
|
36
|
+
} | {
|
|
37
|
+
issuer: string;
|
|
38
|
+
claim: RemovePayload;
|
|
39
|
+
};
|
|
40
|
+
export declare function signCatalogSet(identity: OwnIdentity, catalog: CatalogRecord): Promise<string>;
|
|
41
|
+
export declare function signCatalogUpdate(identity: OwnIdentity, claim: Omit<UpdatePayload, 'op'>): Promise<string>;
|
|
42
|
+
export declare function signCatalogRemove(identity: OwnIdentity, claim: Omit<RemovePayload, 'op'>): Promise<string>;
|
|
43
|
+
/**
|
|
44
|
+
* Verify a signed catalog token and extract its claim. Returns `null` (never
|
|
45
|
+
* throws) when the token is unparseable, unsigned (`alg: 'none'`), or
|
|
46
|
+
* structurally malformed — an attacker cannot forge an `iss` this way. The
|
|
47
|
+
* issuer is the normalized verified `iss`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function verifyCatalog(token: string): Promise<VerifiedCatalog | null>;
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
|
|
2
|
+
export async function signCatalogSet(identity, catalog) {
|
|
3
|
+
const signed = await identity.signToken({
|
|
4
|
+
op: 'set',
|
|
5
|
+
catalog
|
|
6
|
+
}, {
|
|
7
|
+
embedLongForm: true
|
|
8
|
+
});
|
|
9
|
+
return stringifyToken(signed);
|
|
10
|
+
}
|
|
11
|
+
export async function signCatalogUpdate(identity, claim) {
|
|
12
|
+
const signed = await identity.signToken({
|
|
13
|
+
op: 'update',
|
|
14
|
+
...claim
|
|
15
|
+
}, {
|
|
16
|
+
embedLongForm: true
|
|
17
|
+
});
|
|
18
|
+
return stringifyToken(signed);
|
|
19
|
+
}
|
|
20
|
+
export async function signCatalogRemove(identity, claim) {
|
|
21
|
+
const signed = await identity.signToken({
|
|
22
|
+
op: 'remove',
|
|
23
|
+
...claim
|
|
24
|
+
}, {
|
|
25
|
+
embedLongForm: true
|
|
26
|
+
});
|
|
27
|
+
return stringifyToken(signed);
|
|
28
|
+
}
|
|
29
|
+
function isCatalogRecord(value) {
|
|
30
|
+
if (value == null || typeof value !== 'object') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const record = value;
|
|
34
|
+
return typeof record.id === 'string' && typeof record.ownerDID === 'string' && typeof record.name === 'string' && typeof record.hlc === 'string';
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Verify a signed catalog token and extract its claim. Returns `null` (never
|
|
38
|
+
* throws) when the token is unparseable, unsigned (`alg: 'none'`), or
|
|
39
|
+
* structurally malformed — an attacker cannot forge an `iss` this way. The
|
|
40
|
+
* issuer is the normalized verified `iss`.
|
|
41
|
+
*/ export async function verifyCatalog(token) {
|
|
42
|
+
let verified;
|
|
43
|
+
try {
|
|
44
|
+
verified = await verifyToken(token);
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (!isVerifiedToken(verified)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const { iss, op } = verified.payload;
|
|
52
|
+
const issuer = normalizeDID(iss);
|
|
53
|
+
if (op === 'set') {
|
|
54
|
+
const { catalog } = verified.payload;
|
|
55
|
+
if (!isCatalogRecord(catalog)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
issuer,
|
|
60
|
+
claim: {
|
|
61
|
+
op: 'set',
|
|
62
|
+
catalog
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (op === 'update') {
|
|
67
|
+
const { catalogID, update, hlc } = verified.payload;
|
|
68
|
+
if (typeof catalogID !== 'string' || typeof hlc !== 'string' || update == null) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
issuer,
|
|
73
|
+
claim: {
|
|
74
|
+
op: 'update',
|
|
75
|
+
catalogID,
|
|
76
|
+
update: update,
|
|
77
|
+
hlc
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
if (op === 'remove') {
|
|
82
|
+
const { catalogID, hlc } = verified.payload;
|
|
83
|
+
if (typeof catalogID !== 'string' || typeof hlc !== 'string') {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
issuer,
|
|
88
|
+
claim: {
|
|
89
|
+
op: 'remove',
|
|
90
|
+
catalogID,
|
|
91
|
+
hlc
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Logger } from '@kubun/logger';
|
|
2
|
+
import type { P2PStoreAPI } from '@kubun/store-p2p';
|
|
3
|
+
import type { GroupAnchor, LedgerLogEntry } from '@kumiai/mls';
|
|
4
|
+
import { type CircleDefValue, type CircleMemberValue, type GroupSettingsValue, type IsAdminAtPosition } from './circle-reducers.js';
|
|
5
|
+
import { type PositionedFoldInput } from './ledger.js';
|
|
6
|
+
export declare const selectCircleMemberEntries: (ledger: ReadonlyArray<LedgerLogEntry>) => PositionedFoldInput<CircleMemberValue>[];
|
|
7
|
+
/**
|
|
8
|
+
* One projected circle row as the fold decides it. `hlc` is the winning active
|
|
9
|
+
* entry's HLC (the row's `hlc` column); `removedAtHLC` is the winning tombstone
|
|
10
|
+
* HLC, or null when the circle is active. A removed circle retains its last
|
|
11
|
+
* active definition so the tombstone row carries real columns — mirroring the
|
|
12
|
+
* LWW store where `markCircleRemoved` only sets `removed_at_hlc` and leaves the
|
|
13
|
+
* name/description intact.
|
|
14
|
+
*/
|
|
15
|
+
export type CircleDefProjectionRow = {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
catalogIDs: Array<string>;
|
|
19
|
+
hlc: string;
|
|
20
|
+
removedAtHLC: string | null;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Fold the circle-definition ledger into a per-circle projection covering ALL
|
|
24
|
+
* subjects, active and removed — the active reducer drops removed circles, but a
|
|
25
|
+
* projection must keep a tombstone row for them. Replays in the ledger's own
|
|
26
|
+
* enactment order under the same admin-authority gate as the reducer: an active entry sets the
|
|
27
|
+
* definition and clears any tombstone (re-add), a tombstone marks the row
|
|
28
|
+
* removed while keeping its last active value. A circle whose only entry is a
|
|
29
|
+
* tombstone (no prior active value) produces no row, matching the LWW store's
|
|
30
|
+
* ignore-if-absent removal.
|
|
31
|
+
*/
|
|
32
|
+
export declare function projectCircleDefs(entries: Array<PositionedFoldInput<CircleDefValue>>, isAdmin: IsAdminAtPosition): Map<string, CircleDefProjectionRow>;
|
|
33
|
+
/** One projected circle-member row, keyed by the composite ledger subject. */
|
|
34
|
+
export type CircleMemberProjectionRow = {
|
|
35
|
+
circleID: string;
|
|
36
|
+
memberDID: string;
|
|
37
|
+
role: string;
|
|
38
|
+
hlc: string;
|
|
39
|
+
removedAtHLC: string | null;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Fold the circle-member ledger into a per-subject projection covering ALL
|
|
43
|
+
* subjects, active and removed. Same structure as {@link projectCircleDefs}: an
|
|
44
|
+
* active entry sets the role and clears any tombstone, a tombstone marks the row
|
|
45
|
+
* removed while keeping its last active role. A malformed subject is skipped, as
|
|
46
|
+
* is a tombstone with no prior active value. Gates each entry on the same
|
|
47
|
+
* admin-authority check as the reducer's fold — an entry from a non-admin issuer
|
|
48
|
+
* never projects.
|
|
49
|
+
*/
|
|
50
|
+
export declare function projectCircleMembers(entries: Array<PositionedFoldInput<CircleMemberValue>>, isAdmin: IsAdminAtPosition): Map<string, CircleMemberProjectionRow>;
|
|
51
|
+
/**
|
|
52
|
+
* Rebuild a circle/settings projection from the whole ledger.
|
|
53
|
+
*
|
|
54
|
+
* The only projection path there is: an entry is judged against the ledger prefix
|
|
55
|
+
* through its own slot, so folding the whole log is the same answer as folding it
|
|
56
|
+
* incrementally, and there is no cached-projection-is-stale case to detect.
|
|
57
|
+
* Authority is the admin fold (`adminAuthorityFromEntries`), so every projection
|
|
58
|
+
* honors one gate. Each `reconcile*` write is unconditional, so the row converges
|
|
59
|
+
* to exactly the fold's decision regardless of prior contents.
|
|
60
|
+
*/
|
|
61
|
+
export type ReprojectCircleParams = {
|
|
62
|
+
store: P2PStoreAPI;
|
|
63
|
+
groupID: string;
|
|
64
|
+
anchor: GroupAnchor;
|
|
65
|
+
/**
|
|
66
|
+
* The group's control ledger, in the handle's enactment order. Passed in rather
|
|
67
|
+
* than read: these projections run where the handle is already open, and every
|
|
68
|
+
* entry they fold is one the handle verified on the way in.
|
|
69
|
+
*/
|
|
70
|
+
ledger: ReadonlyArray<LedgerLogEntry>;
|
|
71
|
+
logger?: Logger;
|
|
72
|
+
};
|
|
73
|
+
export declare function reprojectCircleDefs(params: ReprojectCircleParams): Promise<void>;
|
|
74
|
+
export declare function reprojectCircleMembers(params: ReprojectCircleParams): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Reproject group settings onto the group row. Settings are PARTIAL per-field
|
|
77
|
+
* (no tombstone), so the committed reducer's active map IS the full projection:
|
|
78
|
+
* fold it under the admin gate, then write the merged fields via the
|
|
79
|
+
* unconditional `updateGroup`. A field no entry ever set stays at the group's
|
|
80
|
+
* original value (creation-time name/description).
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* Fold the group's settings ledger under the admin gate and return the merged
|
|
84
|
+
* per-field value, or `undefined` when no authorized settings entry has been
|
|
85
|
+
* folded. This is the same fold {@link reprojectGroupSettings} materializes,
|
|
86
|
+
* exposed so a producer can read the CURRENT folded designation before authoring
|
|
87
|
+
* — what a device projects and what it checks before writing cannot then drift.
|
|
88
|
+
*/
|
|
89
|
+
export declare function foldGroupSettings(ledger: ReadonlyArray<LedgerLogEntry>, groupID: string, anchor: GroupAnchor): GroupSettingsValue | undefined;
|
|
90
|
+
export declare function reprojectGroupSettings(params: ReprojectCircleParams): Promise<void>;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { selectRoleEntries } from './admin-roster.js';
|
|
2
|
+
import { adminAuthorityFromEntries, authorizedEntries, CIRCLE_DEF_ENTRY_TYPE, CIRCLE_MEMBER_ENTRY_TYPE, createGroupSettingsReducer, decodeCircleMemberSubject, GROUP_SETTINGS_ENTRY_TYPE } from './circle-reducers.js';
|
|
3
|
+
import { entryOrd, foldLedgerEntries } from './ledger.js';
|
|
4
|
+
function isRemovedDef(value) {
|
|
5
|
+
return 'removed' in value && value.removed === true;
|
|
6
|
+
}
|
|
7
|
+
function isRemovedMember(value) {
|
|
8
|
+
return 'removed' in value && value.removed === true;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Build a selector for one ledger type, carrying each entry's slot in the WHOLE
|
|
12
|
+
* ledger so its authority can be judged against the prefix that precedes it.
|
|
13
|
+
*
|
|
14
|
+
* Entries come from the handle's own log, and every door into it verifies —
|
|
15
|
+
* `GroupHandle.applyLedgerEntries`, `bootstrapLedger` and an accepted commit
|
|
16
|
+
* each re-verify the tokens they admit — so these are pure filters over an
|
|
17
|
+
* in-memory array: no re-verification, and nothing to memoize. They replaced
|
|
18
|
+
* loaders that re-verified every stored `signed_token` on each call, which was
|
|
19
|
+
* needed only because the SQL row they read could not vouch for itself.
|
|
20
|
+
*
|
|
21
|
+
* Selecting by `type` is what licenses the cast: the log is typed over an unknown
|
|
22
|
+
* claim value, and the type is the claim about what that value is.
|
|
23
|
+
*/ function createEntrySelector(entryType) {
|
|
24
|
+
return (ledger)=>{
|
|
25
|
+
const entries = [];
|
|
26
|
+
for (const [position, held] of ledger.entries()){
|
|
27
|
+
if (held.verified.entry.type !== entryType) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
entries.push({
|
|
31
|
+
verified: held.verified,
|
|
32
|
+
entryID: held.entryID,
|
|
33
|
+
position
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return entries;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const selectCircleDefEntries = createEntrySelector(CIRCLE_DEF_ENTRY_TYPE);
|
|
40
|
+
export const selectCircleMemberEntries = createEntrySelector(CIRCLE_MEMBER_ENTRY_TYPE);
|
|
41
|
+
const selectGroupSettingsEntries = createEntrySelector(GROUP_SETTINGS_ENTRY_TYPE);
|
|
42
|
+
/**
|
|
43
|
+
* Fold the circle-definition ledger into a per-circle projection covering ALL
|
|
44
|
+
* subjects, active and removed — the active reducer drops removed circles, but a
|
|
45
|
+
* projection must keep a tombstone row for them. Replays in the ledger's own
|
|
46
|
+
* enactment order under the same admin-authority gate as the reducer: an active entry sets the
|
|
47
|
+
* definition and clears any tombstone (re-add), a tombstone marks the row
|
|
48
|
+
* removed while keeping its last active value. A circle whose only entry is a
|
|
49
|
+
* tombstone (no prior active value) produces no row, matching the LWW store's
|
|
50
|
+
* ignore-if-absent removal.
|
|
51
|
+
*/ export function projectCircleDefs(entries, isAdmin) {
|
|
52
|
+
const rows = new Map();
|
|
53
|
+
for (const { verified } of authorizedEntries(entries, isAdmin)){
|
|
54
|
+
const { subject, value } = verified.entry;
|
|
55
|
+
const hlc = entryOrd(verified.entry);
|
|
56
|
+
// Kubun stamps `ord` on every circle entry it signs; one without is foreign
|
|
57
|
+
// or malformed. Skipping is fail-closed — projecting it would have to invent
|
|
58
|
+
// an HLC, and on a tombstone a `null` `removed_at_hlc` reads as ACTIVE and
|
|
59
|
+
// silently revives the row.
|
|
60
|
+
if (hlc == null) continue;
|
|
61
|
+
if (isRemovedDef(value)) {
|
|
62
|
+
const existing = rows.get(subject);
|
|
63
|
+
if (existing == null) continue;
|
|
64
|
+
rows.set(subject, {
|
|
65
|
+
...existing,
|
|
66
|
+
removedAtHLC: hlc
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
rows.set(subject, {
|
|
70
|
+
name: value.name,
|
|
71
|
+
description: value.description,
|
|
72
|
+
catalogIDs: value.catalogIDs,
|
|
73
|
+
hlc,
|
|
74
|
+
removedAtHLC: null
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Fold the circle-member ledger into a per-subject projection covering ALL
|
|
82
|
+
* subjects, active and removed. Same structure as {@link projectCircleDefs}: an
|
|
83
|
+
* active entry sets the role and clears any tombstone, a tombstone marks the row
|
|
84
|
+
* removed while keeping its last active role. A malformed subject is skipped, as
|
|
85
|
+
* is a tombstone with no prior active value. Gates each entry on the same
|
|
86
|
+
* admin-authority check as the reducer's fold — an entry from a non-admin issuer
|
|
87
|
+
* never projects.
|
|
88
|
+
*/ export function projectCircleMembers(entries, isAdmin) {
|
|
89
|
+
const rows = new Map();
|
|
90
|
+
for (const { verified } of authorizedEntries(entries, isAdmin)){
|
|
91
|
+
const { subject, value } = verified.entry;
|
|
92
|
+
const hlc = entryOrd(verified.entry);
|
|
93
|
+
// See {@link projectCircleDefs}: no `ord` means foreign or malformed, and a
|
|
94
|
+
// null tombstone HLC would read as active.
|
|
95
|
+
if (hlc == null) continue;
|
|
96
|
+
if (isRemovedMember(value)) {
|
|
97
|
+
const existing = rows.get(subject);
|
|
98
|
+
if (existing == null) continue;
|
|
99
|
+
rows.set(subject, {
|
|
100
|
+
...existing,
|
|
101
|
+
removedAtHLC: hlc
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
const decoded = decodeCircleMemberSubject(subject);
|
|
105
|
+
if (decoded == null) continue;
|
|
106
|
+
rows.set(subject, {
|
|
107
|
+
circleID: decoded.circleID,
|
|
108
|
+
memberDID: decoded.memberDID,
|
|
109
|
+
role: value.role,
|
|
110
|
+
hlc,
|
|
111
|
+
removedAtHLC: null
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
function adminAuthorityFor(params) {
|
|
118
|
+
return adminAuthorityFromEntries(selectRoleEntries(params.ledger), params.anchor, params.groupID);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The group's CURRENT open-circle set, folded from the settings ledger under the
|
|
122
|
+
* supplied admin authority. `open` is a derived cache of this set on each circle
|
|
123
|
+
* row, so both the settings→circles and circles←settings reprojection paths
|
|
124
|
+
* derive it from the one authoritative fold and converge regardless of which
|
|
125
|
+
* entry (the `circle.def` or the `group.settings` opening it) arrives first.
|
|
126
|
+
*/ function foldOpenCircleIDs(ledger, groupID, anchor, isAdmin) {
|
|
127
|
+
const entries = selectGroupSettingsEntries(ledger);
|
|
128
|
+
const { settings } = foldLedgerEntries(authorizedEntries(entries, isAdmin), anchor, createGroupSettingsReducer());
|
|
129
|
+
return new Set(settings.get(groupID)?.openCircleIDs ?? []);
|
|
130
|
+
}
|
|
131
|
+
export async function reprojectCircleDefs(params) {
|
|
132
|
+
const { store, groupID, anchor, ledger } = params;
|
|
133
|
+
const isAdmin = adminAuthorityFor(params);
|
|
134
|
+
const entries = selectCircleDefEntries(ledger);
|
|
135
|
+
const openIDs = foldOpenCircleIDs(ledger, groupID, anchor, isAdmin);
|
|
136
|
+
const rows = projectCircleDefs(entries, isAdmin);
|
|
137
|
+
for (const [circleID, row] of rows){
|
|
138
|
+
await store.reconcileCircleProjection({
|
|
139
|
+
id: circleID,
|
|
140
|
+
groupID,
|
|
141
|
+
name: row.name,
|
|
142
|
+
description: row.description,
|
|
143
|
+
catalogIDs: row.catalogIDs,
|
|
144
|
+
hlc: row.hlc,
|
|
145
|
+
removedAtHLC: row.removedAtHLC,
|
|
146
|
+
open: openIDs.has(circleID)
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
export async function reprojectCircleMembers(params) {
|
|
151
|
+
const { store, ledger } = params;
|
|
152
|
+
const isAdmin = adminAuthorityFor(params);
|
|
153
|
+
const entries = selectCircleMemberEntries(ledger);
|
|
154
|
+
const rows = projectCircleMembers(entries, isAdmin);
|
|
155
|
+
for (const row of rows.values()){
|
|
156
|
+
await store.reconcileCircleMemberProjection({
|
|
157
|
+
circleID: row.circleID,
|
|
158
|
+
memberDID: row.memberDID,
|
|
159
|
+
role: row.role,
|
|
160
|
+
hlc: row.hlc,
|
|
161
|
+
removedAtHLC: row.removedAtHLC
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Reproject group settings onto the group row. Settings are PARTIAL per-field
|
|
167
|
+
* (no tombstone), so the committed reducer's active map IS the full projection:
|
|
168
|
+
* fold it under the admin gate, then write the merged fields via the
|
|
169
|
+
* unconditional `updateGroup`. A field no entry ever set stays at the group's
|
|
170
|
+
* original value (creation-time name/description).
|
|
171
|
+
*/ /**
|
|
172
|
+
* Fold the group's settings ledger under the admin gate and return the merged
|
|
173
|
+
* per-field value, or `undefined` when no authorized settings entry has been
|
|
174
|
+
* folded. This is the same fold {@link reprojectGroupSettings} materializes,
|
|
175
|
+
* exposed so a producer can read the CURRENT folded designation before authoring
|
|
176
|
+
* — what a device projects and what it checks before writing cannot then drift.
|
|
177
|
+
*/ export function foldGroupSettings(ledger, groupID, anchor) {
|
|
178
|
+
const isAdmin = adminAuthorityFromEntries(selectRoleEntries(ledger), anchor, groupID);
|
|
179
|
+
const { settings } = foldLedgerEntries(authorizedEntries(selectGroupSettingsEntries(ledger), isAdmin), anchor, createGroupSettingsReducer());
|
|
180
|
+
return settings.get(groupID);
|
|
181
|
+
}
|
|
182
|
+
export async function reprojectGroupSettings(params) {
|
|
183
|
+
const { store, groupID, anchor, ledger } = params;
|
|
184
|
+
const merged = foldGroupSettings(ledger, groupID, anchor);
|
|
185
|
+
if (merged != null) {
|
|
186
|
+
// name/description live on the group row directly.
|
|
187
|
+
await store.updateGroup(groupID, {
|
|
188
|
+
name: merged.name,
|
|
189
|
+
description: merged.description
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
// `openCircleIDs` is the authoritative open set; project it onto each circle's
|
|
193
|
+
// `open` flag (settings → circles). Runs unconditionally so a fold with no open
|
|
194
|
+
// circles closes every circle in the group.
|
|
195
|
+
await store.reconcileCircleOpenFlags(groupID, merged?.openCircleIDs ?? []);
|
|
196
|
+
// `commitHub` is the group's consensus commit-hub designation; materialize it
|
|
197
|
+
// onto the local `is_commit_hub` flag so the fold, not each device's first
|
|
198
|
+
// hub-bind, decides which bound hub the group commits through. A fold carrying
|
|
199
|
+
// no designation is a no-op here, leaving the local auto-designated flag in
|
|
200
|
+
// place until an admin authors one.
|
|
201
|
+
await store.reconcileCommitHub(groupID, merged?.commitHub);
|
|
202
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { GroupAnchor } from '@kumiai/mls';
|
|
2
|
+
import { type AdminRoleValue } from './admin-roster.js';
|
|
3
|
+
import type { LedgerReducer, PositionedFoldInput } from './ledger.js';
|
|
4
|
+
/**
|
|
5
|
+
* The fold's authority gate: was `issuer` a group admin when the entry at
|
|
6
|
+
* `position` was enacted?
|
|
7
|
+
*
|
|
8
|
+
* The LEDGER admin-fold ONLY — membership and MLS-removal are enforced outside the
|
|
9
|
+
* projection, never conjoined here, so the fold stays synchronous and every peer
|
|
10
|
+
* folding the same ledger converges. Applied by {@link authorizedEntries} BEFORE
|
|
11
|
+
* the fold, not inside a reducer's `verifyAuthority`: that hook sees only the
|
|
12
|
+
* entry, and the prefix an entry is judged against is a property of its slot.
|
|
13
|
+
*/
|
|
14
|
+
export type IsAdminAtPosition = (issuer: string, position: number) => boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Drop the entries whose issuer lacked admin authority at their own slot, keeping
|
|
17
|
+
* the rest in ledger order.
|
|
18
|
+
*
|
|
19
|
+
* INCLUSIVE compare against the ledger prefix through the entry's own position: a
|
|
20
|
+
* grant authorizes from its own slot onward, a demotion stops authorizing from its
|
|
21
|
+
* own slot onward.
|
|
22
|
+
*/
|
|
23
|
+
export declare function authorizedEntries<TValue>(entries: Array<PositionedFoldInput<TValue>>, isAdmin: IsAdminAtPosition): Array<PositionedFoldInput<TValue>>;
|
|
24
|
+
/**
|
|
25
|
+
* Ledger entry `type` for a circle-membership claim. Every control-ledger type
|
|
26
|
+
* kubun mints lives under `kubun.*` — that namespace is kubun's, and the split
|
|
27
|
+
* between "kubun projects this" and "the MLS library interprets this" is drawn
|
|
28
|
+
* on it. Namespaces the library reserves for itself are its own to name and
|
|
29
|
+
* rename, so kubun stays out of them and never restates them.
|
|
30
|
+
*/
|
|
31
|
+
export declare const CIRCLE_MEMBER_ENTRY_TYPE = "kubun.circle.member";
|
|
32
|
+
/**
|
|
33
|
+
* The claim carried by a circle-member entry. The active form names the
|
|
34
|
+
* member's role within the circle; the tombstone form removes them. Both are
|
|
35
|
+
* whole-value: the last entry for a subject in the ledger replaces the projected
|
|
36
|
+
* state outright, so the value alone decides active-with-role vs removed. `role`
|
|
37
|
+
* mirrors the store's free-form role column (no fixed enum), so it stays a
|
|
38
|
+
* string rather than inventing a circle-specific role type.
|
|
39
|
+
*/
|
|
40
|
+
export type CircleMemberValue = {
|
|
41
|
+
role: string;
|
|
42
|
+
} | {
|
|
43
|
+
removed: true;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Deterministic composite subject for a circle member. The signed entry's
|
|
47
|
+
* `subject` encodes both the circle and the member, so the ledger's `subject`
|
|
48
|
+
* column uniquely identifies the pair and per-member LWW ordering + dedup work
|
|
49
|
+
* without consulting the value.
|
|
50
|
+
*
|
|
51
|
+
* Unambiguous because the member is a normalized DID (`did:method:...`), which
|
|
52
|
+
* never contains the `/member:` separator: every reachable separator therefore
|
|
53
|
+
* sits between the circle id and the DID, and the decoder splits on the last
|
|
54
|
+
* one. The labeled `circle:` / `/member:` prefixes also keep the encoding
|
|
55
|
+
* distinct from any other reducer's subject scheme.
|
|
56
|
+
*/
|
|
57
|
+
export declare function encodeCircleMemberSubject(circleID: string, memberDID: string): string;
|
|
58
|
+
/** Tolerant inverse of {@link encodeCircleMemberSubject}: null on a malformed subject. */
|
|
59
|
+
export declare function decodeCircleMemberSubject(subject: string): {
|
|
60
|
+
circleID: string;
|
|
61
|
+
memberDID: string;
|
|
62
|
+
} | null;
|
|
63
|
+
/** Ledger entry `type` for a circle definition (create / update / delete). */
|
|
64
|
+
export declare const CIRCLE_DEF_ENTRY_TYPE = "kubun.circle.def";
|
|
65
|
+
/**
|
|
66
|
+
* The claim carried by a circle-definition entry. The active form is the whole
|
|
67
|
+
* circle definition; the tombstone form deletes it. Both are whole-value: the
|
|
68
|
+
* last entry for a circle in the ledger replaces the projected definition outright,
|
|
69
|
+
* so create and update share one shape and the value alone decides present vs
|
|
70
|
+
* deleted.
|
|
71
|
+
*/
|
|
72
|
+
export type CircleDefValue = {
|
|
73
|
+
name: string;
|
|
74
|
+
description: string;
|
|
75
|
+
catalogIDs: Array<string>;
|
|
76
|
+
} | {
|
|
77
|
+
removed: true;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Ledger entry `type` for a group-settings claim. Namespaced under `kubun.*`
|
|
81
|
+
* like every kubun control-ledger type — a bare `group.settings` sits in the
|
|
82
|
+
* namespace the MLS library reserves, so a commit carrying it is refused whole.
|
|
83
|
+
*/
|
|
84
|
+
export declare const GROUP_SETTINGS_ENTRY_TYPE = "kubun.group.settings";
|
|
85
|
+
/**
|
|
86
|
+
* The claim carried by a group-settings entry. FIELD-WISE LWW, no tombstone: an
|
|
87
|
+
* omitted field leaves the prior value untouched, while `''` (or an empty list) is
|
|
88
|
+
* a REAL value that clears it.
|
|
89
|
+
*
|
|
90
|
+
* `commitHub` is the group's consensus commit-hub designation — the shared hub
|
|
91
|
+
* identity, NOT a local hub-row id. Folding it as group state is what stops two
|
|
92
|
+
* devices forking the group by each committing through their own first-bound hub.
|
|
93
|
+
*/
|
|
94
|
+
export type GroupSettingsValue = {
|
|
95
|
+
name?: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
openCircleIDs?: Array<string>;
|
|
98
|
+
commitHub?: {
|
|
99
|
+
url: string;
|
|
100
|
+
serverDID?: string;
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
/** Folded group settings, keyed by groupID (the entry subject). */
|
|
104
|
+
export type GroupSettingsState = {
|
|
105
|
+
settings: Map<string, GroupSettingsValue>;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* Per-field last-writer-wins group settings. The signed entry's `subject` is the
|
|
109
|
+
* groupID directly, so no decode is needed. Replayed in the ledger's own
|
|
110
|
+
* enactment order, so for each field the LAST entry that SETS that field wins
|
|
111
|
+
* naturally.
|
|
112
|
+
*
|
|
113
|
+
* Merges PER FIELD rather than replacing the whole value the way `circle.def` /
|
|
114
|
+
* `circle.member` do, because these fields are touched in isolation: a name-only
|
|
115
|
+
* update must not clobber a previously-set description. Presence is
|
|
116
|
+
* `value.field !== undefined`, not `'field' in value`, so an explicit `undefined`
|
|
117
|
+
* means no change while `''` or an empty list CLEARS the field.
|
|
118
|
+
*
|
|
119
|
+
* Unauthorized entries never reach here — see {@link authorizedEntries}.
|
|
120
|
+
*/
|
|
121
|
+
export declare function createGroupSettingsReducer(): LedgerReducer<GroupSettingsValue, GroupSettingsState>;
|
|
122
|
+
/**
|
|
123
|
+
* Build an {@link IsAdminAtPosition} predicate from the group's admin-role ledger
|
|
124
|
+
* entries. It re-folds the admin sub-ledger through each queried slot, so the
|
|
125
|
+
* answer is rotation-aware (was-admin-then, not is-admin-now): a key granted
|
|
126
|
+
* admin at slot n authorizes entries from slot n onward, and a later demotion
|
|
127
|
+
* stops authorizing from its own slot onward. The compare is inclusive
|
|
128
|
+
* (`position <= throughPosition`), which matters only for a role entry judging
|
|
129
|
+
* itself — a circle entry never shares a slot with the grant that authorizes it.
|
|
130
|
+
*
|
|
131
|
+
* Callers select the role entries once (via `selectRoleEntries`) and reuse the
|
|
132
|
+
* returned predicate across the circle / def / settings folds.
|
|
133
|
+
*
|
|
134
|
+
* LEDGER-ADMIN ONLY — it does not check the issuer's MLS membership, so an admin
|
|
135
|
+
* removed from the MLS group whose admin role was never revoked on the ledger still
|
|
136
|
+
* has its entries folded in. Closing that needs removal to write a ledger
|
|
137
|
+
* revocation atomically with the roster delta; out of scope here.
|
|
138
|
+
*/
|
|
139
|
+
export declare function adminAuthorityFromEntries(adminEntries: Array<PositionedFoldInput<AdminRoleValue>>, anchor: GroupAnchor, groupID: string): IsAdminAtPosition;
|