@githolon/dsl 0.50.0 → 0.51.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/src/codegen_ts.ts CHANGED
@@ -582,6 +582,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
582
582
  ` cryptoSealField?(args: { scopeKey: string; scope: string; epoch: number; aggSid: string; fieldSid: string; plaintext: string }): Promise<string>;`,
583
583
  ` /** Unwrap an on-chain WrappedKey with this device's secret → the base64 scope key (default KeyStore). */`,
584
584
  ` cryptoUnwrapKey?(args: { secret: string; hpkeEpk: string; ct: string }): Promise<string>;`,
585
+ ` /** OWNER CEREMONY (slice 2): HPKE-wrap a scope epoch secret TO a member's published X25519 pubkey. */`,
586
+ ` cryptoWrapKey?(args: { recipientPub: string; scopeKey: string }): Promise<{ hpkeEpk: string; ct: string }>;`,
587
+ ` /** OWNER CEREMONY (slice 2): mint a fresh X25519 keypair — the source of a fresh epoch secret (32 bytes of its secret). */`,
588
+ ` cryptoKeygen?(): Promise<{ public: string; secret: string }>;`,
585
589
  ]
586
590
  : [
587
591
  ` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
@@ -721,6 +725,165 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
721
725
  ` };`,
722
726
  `}`,
723
727
  ``,
728
+ `// ── E2E OWNER CEREMONY (the sealing client, slice 2) — grantAndWrap / revokeAndRotate / rotate ──`,
729
+ `// The owner-side twin of the KeyStore. It composes the framework key-epoch fan-out (\`advanceKeyEpoch\`,`,
730
+ `// dispatched by its REAL id — never an \`export const alias\`) with the domain's own grant/revoke`,
731
+ `// directive, resolving the roster + each member's wrap-target pubkey FROM THE CHAIN, so the`,
732
+ `// operator-blind handshake (grant → advance epoch → wrap to every member) is ONE call.`,
733
+ ``,
734
+ `/** Base64 ⇄ bytes that work in both the browser (atob/btoa) and Node (Buffer) — the generated`,
735
+ ` * client is zero-import, so it derives a fresh 32-byte epoch secret from a keygen without a lib. */`,
736
+ `const _b64ToBytes = (b64: string): Uint8Array => {`,
737
+ ` if (typeof atob === "function") { const s = atob(b64); const u = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) u[i] = s.charCodeAt(i); return u; }`,
738
+ ` return Uint8Array.from((globalThis as unknown as { Buffer: { from(s: string, e: string): Uint8Array } }).Buffer.from(b64, "base64"));`,
739
+ `};`,
740
+ `const _bytesToB64 = (u: Uint8Array): string => {`,
741
+ ` if (typeof btoa === "function") { let s = ""; for (const b of u) s += String.fromCharCode(b); return btoa(s); }`,
742
+ ` return (globalThis as unknown as { Buffer: { from(u: Uint8Array): { toString(e: string): string } } }).Buffer.from(u).toString("base64");`,
743
+ `};`,
744
+ ``,
745
+ `/** Thrown by the owner ceremony when a roster member has NO wrap-target key (no publishEncKey on chain,`,
746
+ ` * no prior wrap, no supplied override) — the ceremony NEVER wraps to a default (fail-closed; a member`,
747
+ ` * with no key is "not yet a member", per e2e_encryption.md's FOOTGUNS). */`,
748
+ `export class EncryptionMemberKeyMissing extends Error {`,
749
+ ` readonly code = "nomos.crypto.member_key_missing";`,
750
+ ` readonly recommendedAction = "publishEncKey";`,
751
+ ` constructor(readonly principal: string, readonly scope: string) {`,
752
+ " super(`cannot wrap the \"${scope}\" scope key to member \"${principal}\": no encryption public key on chain (publishEncKey) and none supplied — a keyless member is not yet wrappable (fail-closed)`);",
753
+ ` this.name = "EncryptionMemberKeyMissing";`,
754
+ ` }`,
755
+ `}`,
756
+ ``,
757
+ `/** Thrown when the resolved roster != the caller's declared \`expectRoster\` BEFORE dispatch — pre-firing`,
758
+ ` * the kernel fan-out gate (wrap set == roster) so a rotation against a stale roster view never spends`,
759
+ ` * an epoch on the chain. */`,
760
+ `export class EncryptionRosterMismatch extends Error {`,
761
+ ` readonly code = "nomos.crypto.roster_mismatch";`,
762
+ ` readonly recommendedAction = "reconcileRoster";`,
763
+ ` constructor(readonly scope: string, readonly missing: readonly string[], readonly extra: readonly string[]) {`,
764
+ " super(`roster for \"${scope}\" != expected (the fan-out gate would refuse): missing [${missing.join(\", \")}], unexpected [${extra.join(\", \")}]`);",
765
+ ` this.name = "EncryptionRosterMismatch";`,
766
+ ` }`,
767
+ `}`,
768
+ ``,
769
+ `/** The owner-ceremony surface — attached as \`<domain>Client(holon, { actor }).encryption\`. All three`,
770
+ ` * verbs open a FRESH epoch (a fresh 32-byte secret) and fan the wraps to the resolved roster; the`,
771
+ ` * membership change (if any) is the domain grant/revoke dispatched first, in the same call. */`,
772
+ `export interface NomosEncryption {`,
773
+ ` /** The encryption scopes this domain defines (each a \`requires(cap)\`). */`,
774
+ ` readonly scopes: readonly string[];`,
775
+ ` /** The CURRENT roster of a scope (active direct-grant members, principal-form). Defaults to the sole scope. */`,
776
+ ` roster(scope?: string): Promise<string[]>;`,
777
+ ` /** Grant a newcomer + open a fresh epoch wrapping the key to EVERY member (incl. the newcomer). Dispatches`,
778
+ ` * \`grantDirective(grantPayload)\` first, then \`advanceKeyEpoch\`. \`publicKey\` is the newcomer's X25519 key`,
779
+ ` * (else resolved from an on-chain publishEncKey). Existing members' keys come from their prior wraps. */`,
780
+ ` grantAndWrap(args: { principal: string; publicKey?: string; scope?: string; grantDirective: string; grantPayload: Record<string, unknown>; scopeKey?: string; memberKeys?: Record<string, string>; expectRoster?: readonly string[]; openedAt?: string }): Promise<{ head: string; epoch: number; roster: string[] }>;`,
781
+ ` /** Revoke a member + open a fresh epoch re-wrapping the key to the SURVIVORS only. FORWARD-ONLY: the removed`,
782
+ ` * member gets no NEW reads, but old ciphertext it already holds a key for stays readable (not re-encrypted). */`,
783
+ ` revokeAndRotate(args: { principal: string; scope?: string; revokeDirective: string; revokePayload: Record<string, unknown>; scopeKey?: string; memberKeys?: Record<string, string>; expectRoster?: readonly string[]; openedAt?: string }): Promise<{ head: string; epoch: number; roster: string[] }>;`,
784
+ ` /** Routine rotation — open a fresh epoch re-wrapping the key to the CURRENT roster (no membership change). */`,
785
+ ` rotate(args?: { scope?: string; scopeKey?: string; memberKeys?: Record<string, string>; expectRoster?: readonly string[]; openedAt?: string }): Promise<{ head: string; epoch: number; roster: string[] }>;`,
786
+ `}`,
787
+ ``,
788
+ `/** Build the owner ceremony for one domain (thin: all mechanism lives here; the factory just wires it). */`,
789
+ `function _ownerEncryption(holon: NomosHolon, cfg: { domain: string; domainHash?: string; actor?: string; scopes: readonly string[] }): NomosEncryption {`,
790
+ ` const { domain, domainHash, actor, scopes } = cfg;`,
791
+ ` const defScope = (scope?: string): string => {`,
792
+ ` if (scope) return scope;`,
793
+ ` if (scopes.length === 1) return scopes[0]!;`,
794
+ " throw new Error(`this domain defines ${scopes.length} encryption scopes [${scopes.join(\", \")}] — pass { scope } to pick one`);",
795
+ ` };`,
796
+ ` // A BARE scope IS \`workspace:self#<relation>\` (the era rule); a per-record scope is \`<type>:<id>#<relation>\`.`,
797
+ ` const parseScope = (scope: string): { object: string; relation: string } => {`,
798
+ ` const h = scope.indexOf("#");`,
799
+ ` return h < 0 ? { object: "workspace:self", relation: scope } : { object: scope.slice(0, h), relation: scope.slice(h + 1) };`,
800
+ ` };`,
801
+ ` const readRoster = async (scope: string): Promise<string[]> => {`,
802
+ ` const { object, relation } = parseScope(scope);`,
803
+ " const rows = (await holon.queryById(`reltuple-${object}-${relation}`)) as { data?: { subjects?: Record<string, unknown> } }[] | undefined;",
804
+ ` const subjects = rows?.[0]?.data?.subjects ?? {};`,
805
+ ` const out: string[] = [];`,
806
+ ` for (const [subj, leafRaw] of Object.entries(subjects)) {`,
807
+ ` let leaf: { status?: string };`,
808
+ ` try { leaf = typeof leafRaw === "string" ? JSON.parse(leafRaw) : (leafRaw as { status?: string }); } catch { leaf = {}; }`,
809
+ ` if (leaf?.status !== undefined && leaf.status !== "active") continue;`,
810
+ ` out.push(subj.startsWith("user:") ? subj.slice(5) : subj);`,
811
+ ` }`,
812
+ ` return out.sort();`,
813
+ ` };`,
814
+ ` const currentEpoch = async (scope: string): Promise<number> => {`,
815
+ " const rows = (await holon.queryById(`keyscope-${scope}`)) as { data?: { currentEpoch?: number } }[] | undefined;",
816
+ ` const e = rows?.[0]?.data?.currentEpoch;`,
817
+ ` return typeof e === "number" ? e : -1;`,
818
+ ` };`,
819
+ ` const freshScopeKey = async (): Promise<string> => {`,
820
+ ` if (!holon.cryptoKeygen) throw new Error("this holon predates E2E keygen — upgrade @githolon/client");`,
821
+ ` const kp = await holon.cryptoKeygen();`,
822
+ ` return _bytesToB64(_b64ToBytes(kp.secret).subarray(0, 32)); // the 32-byte epoch secret; NEVER lands on chain`,
823
+ ` };`,
824
+ ` const memberPubKey = async (principal: string, hints: { publicKey?: string; principal?: string; memberKeys?: Record<string, string> }): Promise<string | undefined> => {`,
825
+ ` if (hints.memberKeys?.[principal]) return hints.memberKeys[principal];`,
826
+ ` if (hints.principal === principal && hints.publicKey) return hints.publicKey;`,
827
+ ` // Published enc key (encKeyByPrincipal) — present only when the domain composed the registry; tolerate absence.`,
828
+ ` try {`,
829
+ ` const reg = (await holon.query("encKeyByPrincipal", { principal })) as { data?: { encPublicKey?: string } }[] | undefined;`,
830
+ ` const pub = Array.isArray(reg) ? reg[0]?.data?.encPublicKey : undefined;`,
831
+ ` if (pub) return pub;`,
832
+ ` } catch { /* not routed — fall through to the prior-wrap lane */ }`,
833
+ ` // The member's most-recent WrappedKey pins the pubkey it was last sealed to (rotation needs no re-publish).`,
834
+ ` try {`,
835
+ ` const wraps = (await holon.query("wrappedKeysByRecipient", { recipientPrincipal: principal })) as { data?: { epoch?: number; recipientPubKey?: string } }[] | undefined;`,
836
+ ` let best: { epoch: number; pub: string } | undefined;`,
837
+ ` for (const w of Array.isArray(wraps) ? wraps : []) { const d = w.data ?? {}; if (d.recipientPubKey && (best === undefined || (d.epoch ?? 0) >= best.epoch)) best = { epoch: d.epoch ?? 0, pub: d.recipientPubKey }; }`,
838
+ ` if (best) return best.pub;`,
839
+ ` } catch { /* not routed */ }`,
840
+ ` return undefined;`,
841
+ ` };`,
842
+ ` const advance = async (scope: string, opts: { add?: { principal: string; publicKey?: string }; remove?: readonly string[]; scopeKey?: string; memberKeys?: Record<string, string>; expectRoster?: readonly string[]; openedAt?: string }): Promise<{ head: string; epoch: number; roster: string[] }> => {`,
843
+ ` const roster = new Set(await readRoster(scope));`,
844
+ ` if (opts.add) roster.add(opts.add.principal);`,
845
+ ` for (const p of opts.remove ?? []) roster.delete(p);`,
846
+ ` const members = [...roster].sort();`,
847
+ ` // PRE-FIRE the fan-out gate: if the caller declared the roster it means to wrap, refuse on drift.`,
848
+ ` if (opts.expectRoster !== undefined) {`,
849
+ ` const want = new Set(opts.expectRoster);`,
850
+ ` const missing = [...want].filter((m) => !roster.has(m)).sort();`,
851
+ ` const extra = members.filter((m) => !want.has(m));`,
852
+ ` if (missing.length || extra.length) throw new EncryptionRosterMismatch(scope, missing, extra);`,
853
+ ` }`,
854
+ ` const key = opts.scopeKey ?? await freshScopeKey();`,
855
+ ` if (!holon.cryptoWrapKey) throw new Error("this holon predates E2E wrap — upgrade @githolon/client");`,
856
+ ` const wraps: { recipientPrincipal: string; recipientPubKey: string; hpkeEpk: string; ct: string }[] = [];`,
857
+ ` for (const principal of members) {`,
858
+ ` const pub = await memberPubKey(principal, { publicKey: opts.add?.publicKey, principal: opts.add?.principal, memberKeys: opts.memberKeys });`,
859
+ ` if (!pub) throw new EncryptionMemberKeyMissing(principal, scope);`,
860
+ ` const w = await holon.cryptoWrapKey({ recipientPub: pub, scopeKey: key });`,
861
+ ` wraps.push({ recipientPrincipal: principal, recipientPubKey: pub, hpkeEpk: w.hpkeEpk, ct: w.ct });`,
862
+ ` }`,
863
+ ` const epoch = (await currentEpoch(scope)) + 1; // forward-only: strictly increments`,
864
+ " const head = await holon.offerDirective(domain, \"advanceKeyEpoch\", { scope, epoch, openedAt: opts.openedAt ?? new Date().toISOString(), wraps }, { domainHash, actor });",
865
+ ` return { head, epoch, roster: members };`,
866
+ ` };`,
867
+ ` return {`,
868
+ ` scopes,`,
869
+ ` roster: (scope) => readRoster(defScope(scope)),`,
870
+ ` async grantAndWrap(args) {`,
871
+ ` const scope = defScope(args.scope);`,
872
+ ` await holon.offerDirective(domain, args.grantDirective, args.grantPayload, { domainHash, actor });`,
873
+ ` return advance(scope, { add: { principal: args.principal, publicKey: args.publicKey }, scopeKey: args.scopeKey, memberKeys: args.memberKeys, expectRoster: args.expectRoster, openedAt: args.openedAt });`,
874
+ ` },`,
875
+ ` async revokeAndRotate(args) {`,
876
+ ` const scope = defScope(args.scope);`,
877
+ ` await holon.offerDirective(domain, args.revokeDirective, args.revokePayload, { domainHash, actor });`,
878
+ ` return advance(scope, { remove: [args.principal], scopeKey: args.scopeKey, memberKeys: args.memberKeys, expectRoster: args.expectRoster, openedAt: args.openedAt });`,
879
+ ` },`,
880
+ ` async rotate(args) {`,
881
+ ` const scope = defScope(args?.scope);`,
882
+ ` return advance(scope, { scopeKey: args?.scopeKey, memberKeys: args?.memberKeys, expectRoster: args?.expectRoster, openedAt: args?.openedAt });`,
883
+ ` },`,
884
+ ` };`,
885
+ `}`,
886
+ ``,
724
887
  );
725
888
  }
726
889
 
@@ -1011,6 +1174,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1011
1174
  const r = encMap?.[domain]?.reads?.[typeId];
1012
1175
  return r === undefined ? [] : [...new Set(Object.values(r).map((x) => x.scope))].sort();
1013
1176
  };
1177
+ // The DISTINCT encryption scopes this domain defines (across encrypted reads + sealing directives) —
1178
+ // the set the owner ceremony rotates. Empty ⇒ no `encryption` surface is emitted for this domain.
1179
+ const domScopes: string[] = (() => {
1180
+ const dm = encMap?.[domain];
1181
+ if (dm === undefined) return [];
1182
+ const s = new Set<string>();
1183
+ for (const r of Object.values(dm.reads)) for (const f of Object.values(r)) s.add(f.scope);
1184
+ for (const dir of Object.values(dm.directives)) for (const f of Object.values(dir)) s.add(f.scope);
1185
+ return [...s].sort();
1186
+ })();
1014
1187
  for (const d of dirs) {
1015
1188
  let payloadName = `${cap(d.id)}Payload`;
1016
1189
  if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
@@ -1204,6 +1377,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1204
1377
  ...(opts.interface !== undefined
1205
1378
  ? [` interface: ${interfaceConstName(opts.packageName)}[${JSON.stringify(domain)}],`]
1206
1379
  : []),
1380
+ // THE OWNER CEREMONY (slice 2): only for a domain that actually defines an encryption scope.
1381
+ ...(domScopes.length > 0
1382
+ ? [
1383
+ ` /** OWNER CEREMONY (slice 2): grantAndWrap / revokeAndRotate / rotate — the operator-blind`,
1384
+ ` * key-epoch handshake as one call. Bind \`{ actor }\` (the owner) to author the fan-out. */`,
1385
+ ` encryption: _ownerEncryption(holon, { domain: ${JSON.stringify(domain)}, domainHash, actor, scopes: ${JSON.stringify(domScopes)} }) as NomosEncryption,`,
1386
+ ]
1387
+ : []),
1207
1388
  ...methods,
1208
1389
  ` };`,
1209
1390
  `}`,