@githolon/dsl 0.49.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/check_package.mjs +46 -0
- package/dart/example/nomos_secure_key_store.dart +82 -0
- package/dart/lib/src/webview_bridge.dart +442 -6
- package/package.json +3 -2
- package/src/check_package_main.ts +194 -0
- package/src/codegen_dart_client.ts +124 -8
- package/src/codegen_proof.ts +1 -1
- package/src/codegen_ts.ts +470 -14
- package/src/compile_package_main.ts +19 -0
- package/src/evolve_preview.ts +466 -0
- package/src/law_check.ts +777 -0
package/src/codegen_ts.ts
CHANGED
|
@@ -72,6 +72,118 @@ export const interfaceConstName = (packageName: string): string => `${upperSnake
|
|
|
72
72
|
/** `tool-library` → `TOOL_LIBRARY_COMPAT` — the baked client/law compat manifest (§5). */
|
|
73
73
|
export const compatConstName = (packageName: string): string => `${upperSnake(packageName)}_COMPAT`;
|
|
74
74
|
|
|
75
|
+
/** `tool-library` → `TOOL_LIBRARY_ENCRYPTION` — the baked E2E-sealing map (the sealing client). */
|
|
76
|
+
export const encryptionConstName = (packageName: string): string => `${upperSnake(packageName)}_ENCRYPTION`;
|
|
77
|
+
|
|
78
|
+
// ── E2E SEALING MAP (the sealing client) ──────────────────────────────────────────
|
|
79
|
+
// The compiler already knows everything the sealing plumbing needs: which fields are
|
|
80
|
+
// `.encrypted()` (`agg.encryptedFields`), their read/encryption SCOPE (`agg.visibility.requires`,
|
|
81
|
+
// the `requires(cap)` that IS the scope), and their #58 STABLE SIDS (via the baked structural
|
|
82
|
+
// interface — sid-keyed, carrying each field's label). This map is baked into the generated
|
|
83
|
+
// client like the domainHash — never fetched at runtime — so a sealing write is byte-identical
|
|
84
|
+
// to a hand-plumbed one. ADDITIVE: a domain with no encrypted field contributes NOTHING (the
|
|
85
|
+
// map is null and no const/helper/keyStore surface is emitted — byte-identical generated client).
|
|
86
|
+
|
|
87
|
+
/** One encrypted field's seal coordinates: the scope cap + the stable (agg,field) sids. */
|
|
88
|
+
export interface EncFieldMap {
|
|
89
|
+
readonly scope: string;
|
|
90
|
+
readonly aggSid: string;
|
|
91
|
+
readonly fieldSid: string;
|
|
92
|
+
}
|
|
93
|
+
/** Per-domain: which directive payload fields seal, and which read-model fields decrypt. */
|
|
94
|
+
export interface EncDomainMap {
|
|
95
|
+
/** directiveId → (payload field → seal coordinates). */
|
|
96
|
+
readonly directives: Record<string, Record<string, EncFieldMap>>;
|
|
97
|
+
/** aggregate type id → (field → { scope }) — the fields a read decrypts under `scope`. */
|
|
98
|
+
readonly reads: Record<string, Record<string, { scope: string }>>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The sid-keyed read shape of ONE domain's interface (mirrors `shape_compat.ts` DomainInterface). */
|
|
102
|
+
export interface InterfaceAggShape {
|
|
103
|
+
readonly sid: string;
|
|
104
|
+
readonly label: string;
|
|
105
|
+
readonly fields: Record<string, { kind: string; label: string }>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resolve (aggLabel, fieldLabel) → their STABLE SIDS from the baked domain interface. The seal binds
|
|
109
|
+
* the ciphertext to these sids so a rename of the type/field never breaks decryption (#58). */
|
|
110
|
+
function resolveSids(
|
|
111
|
+
domainIface: { aggregates?: Record<string, InterfaceAggShape> } | undefined,
|
|
112
|
+
aggLabel: string,
|
|
113
|
+
fieldLabel: string,
|
|
114
|
+
): { aggSid: string; fieldSid: string } | undefined {
|
|
115
|
+
const aggs = domainIface?.aggregates;
|
|
116
|
+
if (aggs === undefined) return undefined;
|
|
117
|
+
for (const [aggSid, a] of Object.entries(aggs)) {
|
|
118
|
+
if (a.label !== aggLabel) continue;
|
|
119
|
+
for (const [fieldSid, f] of Object.entries(a.fields ?? {})) {
|
|
120
|
+
if (f.label === fieldLabel) return { aggSid: a.sid ?? aggSid, fieldSid };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Build ONE domain's E2E-sealing map, or `null` when no field is `.encrypted()`. Resolves sids from the
|
|
127
|
+
* domain's baked interface — REQUIRES it when any field is encrypted (an encrypted aggregate is always
|
|
128
|
+
* private, so the interface is present). Shared by the TS + Dart client generators (one source of truth). */
|
|
129
|
+
export function domainEncryptionMap(
|
|
130
|
+
mod: DomainModule,
|
|
131
|
+
domainIface: { aggregates?: Record<string, InterfaceAggShape> } | undefined,
|
|
132
|
+
): EncDomainMap | null {
|
|
133
|
+
const domain = mod.domain ?? mod.name;
|
|
134
|
+
const aggs = mod.aggregates as AggregateHandle[];
|
|
135
|
+
const directives: Record<string, Record<string, EncFieldMap>> = {};
|
|
136
|
+
const reads: Record<string, Record<string, { scope: string }>> = {};
|
|
137
|
+
for (const agg of aggs) {
|
|
138
|
+
const scope = agg.visibility?.requires;
|
|
139
|
+
const encFields = agg.encryptedFields;
|
|
140
|
+
if (encFields === undefined || encFields.length === 0 || scope === undefined) continue;
|
|
141
|
+
const r: Record<string, { scope: string }> = {};
|
|
142
|
+
for (const f of encFields) r[f] = { scope };
|
|
143
|
+
reads[agg.id] = r;
|
|
144
|
+
}
|
|
145
|
+
for (const d of mod.directives as Directive<unknown>[]) {
|
|
146
|
+
const aggId = (d as { aggregateId?: string }).aggregateId;
|
|
147
|
+
const agg = aggs.find((a) => a.id === aggId);
|
|
148
|
+
const scope = agg?.visibility?.requires;
|
|
149
|
+
const encFields = agg?.encryptedFields;
|
|
150
|
+
if (agg === undefined || encFields === undefined || encFields.length === 0 || scope === undefined) continue;
|
|
151
|
+
const schema = (d as { payloadSchema?: unknown }).payloadSchema;
|
|
152
|
+
if (typeof schema !== "object" || schema === null || zodKind(schema as z.ZodTypeAny) !== "object") continue;
|
|
153
|
+
const shapeKeys = new Set(Object.keys(zodObjectShape(schema as z.ZodTypeAny)));
|
|
154
|
+
const fmap: Record<string, EncFieldMap> = {};
|
|
155
|
+
for (const f of encFields) {
|
|
156
|
+
if (!shapeKeys.has(f)) continue; // the directive doesn't carry this field on its payload
|
|
157
|
+
const sids = resolveSids(domainIface, agg.id, f);
|
|
158
|
+
if (sids === undefined) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`codegen: cannot resolve stable ids for encrypted field ${agg.id}.${f} in domain '${domain}' — ` +
|
|
161
|
+
`the package structural interface (with stable ids) is required to bake the E2E-sealing map`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
fmap[f] = { scope, aggSid: sids.aggSid, fieldSid: sids.fieldSid };
|
|
165
|
+
}
|
|
166
|
+
if (Object.keys(fmap).length > 0) directives[d.id] = fmap;
|
|
167
|
+
}
|
|
168
|
+
return Object.keys(directives).length > 0 || Object.keys(reads).length > 0 ? { directives, reads } : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Build the package's E2E-sealing map keyed by domain, or `null` when no field is `.encrypted()`
|
|
172
|
+
* anywhere (the additive-zero case). */
|
|
173
|
+
function buildEncryptionMap(
|
|
174
|
+
modules: readonly DomainModule[],
|
|
175
|
+
ifaceByDomain: Record<string, { aggregates?: Record<string, InterfaceAggShape> }> | undefined,
|
|
176
|
+
): Record<string, EncDomainMap> | null {
|
|
177
|
+
const out: Record<string, EncDomainMap> = {};
|
|
178
|
+
let any = false;
|
|
179
|
+
for (const mod of modules) {
|
|
180
|
+
const domain = mod.domain ?? mod.name;
|
|
181
|
+
const m = domainEncryptionMap(mod, ifaceByDomain?.[domain]);
|
|
182
|
+
if (m !== null) { out[domain] = m; any = true; }
|
|
183
|
+
}
|
|
184
|
+
return any ? out : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
75
187
|
// ── zod → TS (the same `_def ?? def` convention as codegen_dart's walker) ─────────
|
|
76
188
|
// The walkers are exported: codegen_proof.ts synthesizes SAMPLE payloads from the
|
|
77
189
|
// same zod shapes this file derives TYPES from — one introspection, two emitters.
|
|
@@ -405,6 +517,14 @@ function anyPackedTaxonomy(modules: readonly DomainModule[]): boolean {
|
|
|
405
517
|
|
|
406
518
|
export function generateTsClient(modules: readonly DomainModule[], opts: TsClientOptions): string {
|
|
407
519
|
const hashConst = tsHashConstName(opts.packageName);
|
|
520
|
+
// THE E2E-SEALING MAP (the sealing client): non-null iff any field is `.encrypted()`. When null,
|
|
521
|
+
// NOTHING below emits — the generated client is byte-identical to a pre-sealing compile.
|
|
522
|
+
const encMap = buildEncryptionMap(
|
|
523
|
+
modules,
|
|
524
|
+
opts.interface as Record<string, { aggregates?: Record<string, InterfaceAggShape> }> | undefined,
|
|
525
|
+
);
|
|
526
|
+
const encPresent = encMap !== null;
|
|
527
|
+
const encConst = encryptionConstName(opts.packageName);
|
|
408
528
|
const out: string[] = [];
|
|
409
529
|
out.push(
|
|
410
530
|
`// AUTO-GENERATED by nomos-compile — typed TS client for package "${opts.packageName}".`,
|
|
@@ -450,8 +570,27 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
450
570
|
` offerDirective(domain: string, directiveId: string, payload: unknown, opts?: { domainHash?: string; actor?: string; returnCreated?: boolean }): Promise<string>;`,
|
|
451
571
|
` /** Opaque-bytes offer — retained for carriers that already hold an encoded envelope (back-compat). */`,
|
|
452
572
|
` offer(intentBytes: string | Uint8Array): Promise<string>;`,
|
|
453
|
-
|
|
454
|
-
|
|
573
|
+
...(encPresent
|
|
574
|
+
? [
|
|
575
|
+
` /** \`keys\` (E2E sealing): the reader's held scope keys, injected so encrypted fields decrypt;`,
|
|
576
|
+
` * absent ⇒ encrypted fields stay opaque envelopes (today's behavior). */`,
|
|
577
|
+
` query(queryId: string, params?: Record<string, unknown>, keys?: readonly NomosScopeKey[]): Promise<unknown>;`,
|
|
578
|
+
` queryById(aggregateId: string, keys?: readonly NomosScopeKey[]): Promise<unknown>;`,
|
|
579
|
+
` /** THE AUTHOR-SIDE SEAL: encrypt a plaintext field value into its on-chain ciphertext envelope`,
|
|
580
|
+
` * (bound to the scope + stable sids). OPTIONAL: structural binding tolerates a pre-sealing holon`,
|
|
581
|
+
` * (the write path then throws with an upgrade hint before any plaintext dispatch). */`,
|
|
582
|
+
` cryptoSealField?(args: { scopeKey: string; scope: string; epoch: number; aggSid: string; fieldSid: string; plaintext: string }): Promise<string>;`,
|
|
583
|
+
` /** Unwrap an on-chain WrappedKey with this device's secret → the base64 scope key (default KeyStore). */`,
|
|
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 }>;`,
|
|
589
|
+
]
|
|
590
|
+
: [
|
|
591
|
+
` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
|
|
592
|
+
` queryById(aggregateId: string): Promise<unknown>;`,
|
|
593
|
+
]),
|
|
455
594
|
` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
|
|
456
595
|
` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
|
|
457
596
|
` count(countId: string, groupKey?: string): Promise<number>;`,
|
|
@@ -489,6 +628,265 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
489
628
|
``,
|
|
490
629
|
);
|
|
491
630
|
|
|
631
|
+
if (encPresent) {
|
|
632
|
+
out.push(
|
|
633
|
+
`// ── E2E SEALING (the sealing client) — baked map + KeyStore + seal/read helpers ──`,
|
|
634
|
+
`// The map below is baked at COMPILE time from the SAME descriptors + stable-id lockfile the law was`,
|
|
635
|
+
`// compiled with (never fetched). A sealing write is byte-indistinguishable from a hand-plumbed one.`,
|
|
636
|
+
``,
|
|
637
|
+
`/** Per directive: which payload fields seal → their { scope, aggSid, fieldSid }. Per aggregate: which`,
|
|
638
|
+
` * read-model fields are encrypted (+ their scope). Keyed by domain. */`,
|
|
639
|
+
`export const ${encConst} = ${JSON.stringify(encMap)} as const;`,
|
|
640
|
+
``,
|
|
641
|
+
`/** A held scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation). */`,
|
|
642
|
+
`export interface NomosScopeKey { scope: string; epoch: number; key: string }`,
|
|
643
|
+
``,
|
|
644
|
+
`/** The app-supplied KEY CUSTODY surface. Supply one to \`<domain>Client(holon, { keyStore })\` and the`,
|
|
645
|
+
` * generated methods seal writes + decrypt reads automatically. \`wrappedKeyStore\` is the default over`,
|
|
646
|
+
` * the on-chain WrappedKeys; an app may implement this to source keys from a secure enclave. */`,
|
|
647
|
+
`export interface NomosKeyStore {`,
|
|
648
|
+
` /** The CURRENT (highest-epoch) scope key, for SEALING a write. undefined ⇒ EncryptionKeyUnavailable. */`,
|
|
649
|
+
` scopeKey(scope: string): Promise<NomosScopeKey | undefined>;`,
|
|
650
|
+
` /** EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts. */`,
|
|
651
|
+
` readKeys(scopes: readonly string[]): Promise<NomosScopeKey[]>;`,
|
|
652
|
+
`}`,
|
|
653
|
+
``,
|
|
654
|
+
`/** Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the`,
|
|
655
|
+
` * write NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway). */`,
|
|
656
|
+
`export class EncryptionKeyUnavailable extends Error {`,
|
|
657
|
+
` readonly code = "nomos.crypto.key_unavailable";`,
|
|
658
|
+
` readonly recommendedAction = "obtainScopeKey";`,
|
|
659
|
+
` constructor(readonly scope: string, readonly field: string) {`,
|
|
660
|
+
" super(`cannot seal encrypted field \"${field}\": no scope key held for \"${scope}\" — obtain it (be granted the cap + wrapped the scope epoch) before writing`);",
|
|
661
|
+
` this.name = "EncryptionKeyUnavailable";`,
|
|
662
|
+
` }`,
|
|
663
|
+
`}`,
|
|
664
|
+
``,
|
|
665
|
+
`/** Seal the mapped encrypted fields of a payload before dispatch. No keyStore / \`seal:false\` ⇒ passthrough`,
|
|
666
|
+
` * (today's behavior). A held key seals; a missing key throws EncryptionKeyUnavailable (never plaintext). */`,
|
|
667
|
+
`const _sealFields = async (`,
|
|
668
|
+
` holon: NomosHolon,`,
|
|
669
|
+
` keyStore: NomosKeyStore | undefined,`,
|
|
670
|
+
` fieldMap: Record<string, { scope: string; aggSid: string; fieldSid: string }>,`,
|
|
671
|
+
` payload: Record<string, unknown>,`,
|
|
672
|
+
` seal: boolean,`,
|
|
673
|
+
`): Promise<Record<string, unknown>> => {`,
|
|
674
|
+
` if (seal === false || keyStore == null) return payload;`,
|
|
675
|
+
` let out: Record<string, unknown> | undefined;`,
|
|
676
|
+
` for (const field of Object.keys(fieldMap)) {`,
|
|
677
|
+
` const v = payload[field];`,
|
|
678
|
+
` if (v === undefined || v === null) continue;`,
|
|
679
|
+
` const m = fieldMap[field]!;`,
|
|
680
|
+
` const sk = await keyStore.scopeKey(m.scope);`,
|
|
681
|
+
` if (!sk) throw new EncryptionKeyUnavailable(m.scope, field);`,
|
|
682
|
+
` if (!holon.cryptoSealField) throw new Error("this holon predates E2E sealing — upgrade @githolon/client");`,
|
|
683
|
+
` const envelope = await holon.cryptoSealField({ scopeKey: sk.key, scope: m.scope, epoch: sk.epoch, aggSid: m.aggSid, fieldSid: m.fieldSid, plaintext: String(v) });`,
|
|
684
|
+
` (out ??= { ...payload })[field] = envelope;`,
|
|
685
|
+
` }`,
|
|
686
|
+
` return out ?? payload;`,
|
|
687
|
+
`};`,
|
|
688
|
+
``,
|
|
689
|
+
`/** The read keys to inject for the given scopes, or undefined when there is nothing to inject`,
|
|
690
|
+
` * (no keyStore, or no held keys) — the read then behaves exactly as today (opaque envelopes). */`,
|
|
691
|
+
`const _readKeys = async (keyStore: NomosKeyStore | undefined, scopes: readonly string[]): Promise<NomosScopeKey[] | undefined> => {`,
|
|
692
|
+
` if (keyStore == null || scopes.length === 0) return undefined;`,
|
|
693
|
+
` const keys = await keyStore.readKeys(scopes);`,
|
|
694
|
+
` return keys.length ? keys : undefined;`,
|
|
695
|
+
`};`,
|
|
696
|
+
``,
|
|
697
|
+
`/** THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching`,
|
|
698
|
+
` * per scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side,`,
|
|
699
|
+
` * the operator stays blind. Key custody (where \`deviceSecret\` lives) remains the app's concern. */`,
|
|
700
|
+
`export function wrappedKeyStore(opts: { holon: NomosHolon; principal: string; deviceSecret: string }): NomosKeyStore {`,
|
|
701
|
+
` const { holon, principal, deviceSecret } = opts;`,
|
|
702
|
+
` const cache = new Map<string, Promise<NomosScopeKey[]>>();`,
|
|
703
|
+
` const loadScope = (scope: string): Promise<NomosScopeKey[]> => {`,
|
|
704
|
+
` let p = cache.get(scope);`,
|
|
705
|
+
` if (p !== undefined) return p;`,
|
|
706
|
+
` p = (async () => {`,
|
|
707
|
+
` const rows = (await holon.query("wrappedKeysByRecipient", { recipientPrincipal: principal })) as { data?: Record<string, unknown> }[];`,
|
|
708
|
+
` const held: NomosScopeKey[] = [];`,
|
|
709
|
+
` for (const row of rows ?? []) {`,
|
|
710
|
+
` const d = (row.data ?? row) as Record<string, unknown>;`,
|
|
711
|
+
` if (String(d.scope) !== scope) continue;`,
|
|
712
|
+
` if (!holon.cryptoUnwrapKey) throw new Error("this holon predates E2E unwrap — upgrade @githolon/client");`,
|
|
713
|
+
` const key = await holon.cryptoUnwrapKey({ secret: deviceSecret, hpkeEpk: String(d.hpkeEpk), ct: String(d.ct) });`,
|
|
714
|
+
` held.push({ scope, epoch: Number(d.epoch) || 0, key });`,
|
|
715
|
+
` }`,
|
|
716
|
+
` held.sort((a, b) => b.epoch - a.epoch);`,
|
|
717
|
+
` return held;`,
|
|
718
|
+
` })();`,
|
|
719
|
+
` cache.set(scope, p);`,
|
|
720
|
+
` return p;`,
|
|
721
|
+
` };`,
|
|
722
|
+
` return {`,
|
|
723
|
+
` async scopeKey(scope) { return (await loadScope(scope))[0]; },`,
|
|
724
|
+
` async readKeys(scopes) { const all: NomosScopeKey[] = []; for (const s of scopes) all.push(...(await loadScope(s))); return all; },`,
|
|
725
|
+
` };`,
|
|
726
|
+
`}`,
|
|
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
|
+
``,
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
492
890
|
const anyAutoStamp = modules.some((mod) =>
|
|
493
891
|
(mod.directives as Directive<unknown>[]).some(
|
|
494
892
|
(d) =>
|
|
@@ -769,6 +1167,23 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
769
1167
|
} catch { /* the compile gate already failed a bad taxonomy loudly */ }
|
|
770
1168
|
}
|
|
771
1169
|
const methods: string[] = [];
|
|
1170
|
+
// E2E READ INJECTION: the distinct encryption scopes a read of `typeId` must inject keys for (empty
|
|
1171
|
+
// ⇒ nothing encrypted on that type ⇒ the accessor is emitted plain, unchanged). The `_readKeys` helper
|
|
1172
|
+
// returns undefined when no keyStore is bound, so an injected accessor still behaves as today offline.
|
|
1173
|
+
const readScopesFor = (typeId: string): string[] => {
|
|
1174
|
+
const r = encMap?.[domain]?.reads?.[typeId];
|
|
1175
|
+
return r === undefined ? [] : [...new Set(Object.values(r).map((x) => x.scope))].sort();
|
|
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
|
+
})();
|
|
772
1187
|
for (const d of dirs) {
|
|
773
1188
|
let payloadName = `${cap(d.id)}Payload`;
|
|
774
1189
|
if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
|
|
@@ -784,6 +1199,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
784
1199
|
const gateGuard = gate !== undefined
|
|
785
1200
|
? ` if (!actor || !actor.trim()) throw new MissingOfferAuthority(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${JSON.stringify(gate.relation)});\n`
|
|
786
1201
|
: "";
|
|
1202
|
+
// E2E SEALING (the sealing client): a directive with `.encrypted()` payload fields seals them
|
|
1203
|
+
// (via the KeyStore) BEFORE dispatch. `wantSeal` forces an async block body + a `callOpts.seal`
|
|
1204
|
+
// escape hatch, and `pv` (the sealed copy) replaces `payload` downstream. Non-encrypted directives
|
|
1205
|
+
// are byte-identical (wantSeal === false ⇒ no prelude, no extra param, plain `payload`).
|
|
1206
|
+
const wantSeal = encMap?.[domain]?.directives?.[d.id] !== undefined;
|
|
1207
|
+
const paramSig = wantSeal ? `payload: ${payloadName}, callOpts?: { seal?: boolean }` : `payload: ${payloadName}`;
|
|
1208
|
+
const sealPrelude = wantSeal
|
|
1209
|
+
? ` const _sealed = await _sealFields(holon, keyStore, ${encConst}[${JSON.stringify(domain)}].directives[${JSON.stringify(d.id)}] as Record<string, { scope: string; aggSid: string; fieldSid: string }>, payload as Record<string, unknown>, callOpts?.seal !== false) as ${payloadName};`
|
|
1210
|
+
: "";
|
|
1211
|
+
const pv = wantSeal ? "_sealed" : "payload";
|
|
787
1212
|
out.push(
|
|
788
1213
|
`/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at offer.` : ""}${mint ? ` Omitted \`${mint.field}\` is KERNEL-MINTED at offer (returned on the result).` : ""}${placed.length ? " Omitted `shard` is picked LEAST-LOADED at offer (the routed holon)." : ""} */`,
|
|
789
1214
|
`export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed)};`,
|
|
@@ -797,13 +1222,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
797
1222
|
? `, { tagHex: routeTagOfId(payload.${mint.tagFromIdField}) }`
|
|
798
1223
|
: "";
|
|
799
1224
|
const body = stamps.length
|
|
800
|
-
? `stampNow({
|
|
801
|
-
: `{
|
|
1225
|
+
? `stampNow({ ...${pv}, ${mint.field} }, ${JSON.stringify(stamps)})`
|
|
1226
|
+
: `{ ...${pv}, ${mint.field} }`;
|
|
802
1227
|
methods.push(
|
|
803
1228
|
` /** Author \`${domain}/${d.id}\`: the front-door MINTS \`${mint.field}\` (route-tagged) when omitted`,
|
|
804
1229
|
` * — ids stay internal plumbing — and returns it with the new local head.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
|
|
805
|
-
` ${d.id}: async (
|
|
1230
|
+
` ${d.id}: async (${paramSig}): Promise<{ head: string; ${mint.field}: string }> => {`,
|
|
806
1231
|
...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
|
|
1232
|
+
...(sealPrelude ? [sealPrelude] : []),
|
|
807
1233
|
` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
|
|
808
1234
|
` const head = await holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, { domainHash, actor });`,
|
|
809
1235
|
` return { head, ${mint.field} };`,
|
|
@@ -811,7 +1237,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
811
1237
|
);
|
|
812
1238
|
continue;
|
|
813
1239
|
}
|
|
814
|
-
const offerPayload = stamps.length ? `stampNow(
|
|
1240
|
+
const offerPayload = stamps.length ? `stampNow(${pv}, ${JSON.stringify(stamps)})` : pv;
|
|
815
1241
|
if (d.marker === "creates") {
|
|
816
1242
|
// LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
|
|
817
1243
|
// carries none). The holon hands the minted ids back on the offer (`returnCreated`), so the create
|
|
@@ -820,8 +1246,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
820
1246
|
methods.push(
|
|
821
1247
|
` /** Author \`${domain}/${d.id}\`: the LAW mints the ${d.aggregateId ?? "aggregate"} id inside the sealed intent`,
|
|
822
1248
|
` * and it returns here with the new local head — no id in the payload, no query-after.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
|
|
823
|
-
` ${d.id}: async (
|
|
1249
|
+
` ${d.id}: async (${paramSig}): Promise<{ head: string; id: string }> => {`,
|
|
824
1250
|
...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
|
|
1251
|
+
...(sealPrelude ? [sealPrelude] : []),
|
|
825
1252
|
` const r = await (holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>);`,
|
|
826
1253
|
` return typeof r === "object" && r !== null ? { head: r.head, id: r.created?.[0]?.id ?? "" } : { head: String(r), id: "" };`,
|
|
827
1254
|
` },`,
|
|
@@ -829,7 +1256,19 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
829
1256
|
continue;
|
|
830
1257
|
}
|
|
831
1258
|
const offerCall = `holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor })`;
|
|
832
|
-
if (
|
|
1259
|
+
if (wantSeal) {
|
|
1260
|
+
// SEALING directive (optionally also gated): an ASYNC block — seal the encrypted fields via the
|
|
1261
|
+
// bound { keyStore } (+ throw MissingOfferAuthority first if gated) before dispatch.
|
|
1262
|
+
methods.push(
|
|
1263
|
+
` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
|
|
1264
|
+
` * Encrypted field(s) are SEALED via the bound { keyStore } before dispatch (pass { seal: false } to skip).${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
|
|
1265
|
+
` ${d.id}: async (${paramSig}): Promise<string> => {`,
|
|
1266
|
+
...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
|
|
1267
|
+
sealPrelude,
|
|
1268
|
+
` return ${offerCall};`,
|
|
1269
|
+
` },`,
|
|
1270
|
+
);
|
|
1271
|
+
} else if (gateGuard) {
|
|
833
1272
|
// GATED directive: a block body that throws MissingOfferAuthority when no actor is bound — an
|
|
834
1273
|
// actorless gated offer can't be constructed (the war: the gate is structural, not just kernel-side).
|
|
835
1274
|
methods.push(
|
|
@@ -864,9 +1303,12 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
864
1303
|
return `${JSON.stringify(k)}: ${t}`;
|
|
865
1304
|
})
|
|
866
1305
|
.join("; ");
|
|
1306
|
+
const qScopes = readScopesFor(q.returns);
|
|
867
1307
|
methods.push(
|
|
868
|
-
` /** Declared query \`${q.id}\` (indexed probe — routed by the read manifest). */`,
|
|
869
|
-
|
|
1308
|
+
` /** Declared query \`${q.id}\` (indexed probe — routed by the read manifest).${qScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key; else they stay opaque envelopes." : ""} */`,
|
|
1309
|
+
qScopes.length
|
|
1310
|
+
? ` ${lcFirst(camel(q.id))}: async (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params, await _readKeys(keyStore, ${JSON.stringify(qScopes)})) as Promise<Row<${q.returns}Data>[]>,`
|
|
1311
|
+
: ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>,`,
|
|
870
1312
|
);
|
|
871
1313
|
// THE LOGICAL PAGED ROW SURFACE (sharding §5.3, ruling 2 — slice 4): emitted
|
|
872
1314
|
// ONLY for packed-taxonomy domains (taxonomy-free clients stay byte-identical).
|
|
@@ -901,11 +1343,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
901
1343
|
);
|
|
902
1344
|
}
|
|
903
1345
|
for (const agg of mod.aggregates as AggregateHandle[]) {
|
|
1346
|
+
const aScopes = readScopesFor(agg.id);
|
|
904
1347
|
methods.push(
|
|
905
|
-
` /** Read one ${agg.id} by aggregate id. */`,
|
|
906
|
-
|
|
1348
|
+
` /** Read one ${agg.id} by aggregate id.${aScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key." : ""} */`,
|
|
1349
|
+
aScopes.length
|
|
1350
|
+
? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>,`
|
|
1351
|
+
: ` ${lcFirst(camel(agg.id))}ById: (id: string) => holon.queryById(id) as Promise<Row<${agg.id}Data>[]>,`,
|
|
907
1352
|
` /** Every ${agg.id} (the type-index probe — O(result), not a scan). */`,
|
|
908
|
-
|
|
1353
|
+
aScopes.length
|
|
1354
|
+
? ` list${agg.id}s: async () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>,`
|
|
1355
|
+
: ` list${agg.id}s: () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>,`,
|
|
909
1356
|
` /** Watch one ${agg.id} by aggregate id (local reactive read; returns stop()). */`,
|
|
910
1357
|
` watch${agg.id}ById: (id: string, cb: (rows: Row<${agg.id}Data>[]) => void, opts?: { intervalMs?: number }) =>`,
|
|
911
1358
|
` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
|
|
@@ -917,9 +1364,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
917
1364
|
` * Pass \`{ actor }\` (the verified principal uid) to author AS that user — every write seals it as`,
|
|
918
1365
|
` * the captured principal, so the ReBAC \`.requires(...)\` gate + the integrity-dual endorse model`,
|
|
919
1366
|
` * judge the real author. Omit it for open (un-gated) domains. */`,
|
|
920
|
-
`export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string } = {}) {`,
|
|
1367
|
+
`export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string${encPresent ? "; keyStore?: NomosKeyStore" : ""} } = {}) {`,
|
|
921
1368
|
` const domainHash = opts.domainHash ?? ${hashConst};`,
|
|
922
1369
|
` const actor = opts.actor;`,
|
|
1370
|
+
...(encPresent ? [` const keyStore = opts.keyStore;`] : []),
|
|
923
1371
|
...(opts.compat !== undefined
|
|
924
1372
|
? [` holon.checkCompat?.(${compatConstName(opts.packageName)});`]
|
|
925
1373
|
: []),
|
|
@@ -929,6 +1377,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
929
1377
|
...(opts.interface !== undefined
|
|
930
1378
|
? [` interface: ${interfaceConstName(opts.packageName)}[${JSON.stringify(domain)}],`]
|
|
931
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
|
+
: []),
|
|
932
1388
|
...methods,
|
|
933
1389
|
` };`,
|
|
934
1390
|
`}`,
|
|
@@ -121,6 +121,7 @@ import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
|
|
|
121
121
|
import { generateOpenApiFragment, mergeOpenApiFragments } from "./codegen_openapi.js";
|
|
122
122
|
import { domainInterface, type DomainInterface } from "./shape_compat.js";
|
|
123
123
|
import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
|
|
124
|
+
import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
|
|
124
125
|
import type { NomosCompat } from "./compat_manifest.js";
|
|
125
126
|
|
|
126
127
|
const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -1816,6 +1817,24 @@ async function main(): Promise<void> {
|
|
|
1816
1817
|
for (const w of birthWarnings) {
|
|
1817
1818
|
console.log(` ⚠ births ${w}`);
|
|
1818
1819
|
}
|
|
1820
|
+
// ── THE AUTHOR-TIME GATE (architecture/author_time_gate.md) — the static twins of the kernel
|
|
1821
|
+
// gate's refusal vocabulary, unified into ONE report. `githolon check` runs the same pass;
|
|
1822
|
+
// compile runs it implicitly and REFUSES on any refuse-level finding (a statically-provable
|
|
1823
|
+
// runtime refusal shipped as law). Warns/notes are printed but never block the compile.
|
|
1824
|
+
const checkFindings = checkDomainModules(domainModules);
|
|
1825
|
+
if (checkFindings.length > 0) {
|
|
1826
|
+
console.log(` check ${checkFindings.length} finding(s) (githolon check):`);
|
|
1827
|
+
for (const line of formatFindings(checkFindings)) {
|
|
1828
|
+
const mark = line.includes("REFUSE") ? "✗" : line.includes("WARN") ? "⚠" : "•";
|
|
1829
|
+
console.log(` ${mark} ${line}`);
|
|
1830
|
+
}
|
|
1831
|
+
if (hasRefusal(checkFindings)) {
|
|
1832
|
+
// Emit the artifacts (already written) but REFUSE the compile: a refuse-level finding is a
|
|
1833
|
+
// statically-provable runtime gate refusal — shipping it as law would fail at first dispatch.
|
|
1834
|
+
console.error(` ✗ check REFUSED — fix the refuse-level finding(s) above (or run \`githolon check\`)`);
|
|
1835
|
+
process.exitCode = 1;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1819
1838
|
if (dartOut !== undefined) {
|
|
1820
1839
|
const supportText =
|
|
1821
1840
|
dartSupportImport === "vendored" ? "vendored nomos_client support" : "package nomos_client imports";
|