@githolon/dsl 0.48.1 → 0.50.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/lib/src/test_harness.dart +46 -14
- package/dart/lib/src/webview_bridge.dart +160 -6
- package/dart/test/test_harness_test.dart +42 -0
- package/package.json +3 -2
- package/src/build_package.ts +44 -1
- package/src/check_package_main.ts +83 -0
- package/src/codegen_dart.ts +8 -0
- package/src/codegen_dart_client.ts +103 -8
- package/src/codegen_proof.ts +28 -1
- package/src/codegen_ts.ts +289 -14
- package/src/compile_package_main.ts +75 -3
- package/src/fields.ts +12 -0
- package/src/law_check.ts +375 -0
- package/src/secondary_ensure.ts +86 -0
- package/src/value_object.ts +76 -6
|
@@ -36,7 +36,7 @@ import type { DomainModule } from "./codegen_dart.js";
|
|
|
36
36
|
import { cap, readModelClassName } from "./codegen_dart.js";
|
|
37
37
|
import { finishCount } from "./count.js";
|
|
38
38
|
import { finishSum } from "./sum.js";
|
|
39
|
-
import { autoStampFields } from "./codegen_ts.js";
|
|
39
|
+
import { autoStampFields, domainEncryptionMap, type EncDomainMap, type InterfaceAggShape } from "./codegen_ts.js";
|
|
40
40
|
|
|
41
41
|
const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
|
|
42
42
|
const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
|
|
@@ -127,6 +127,24 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
127
127
|
const aggById = new Map<string, AggregateHandle>();
|
|
128
128
|
for (const a of mod.aggregates) aggById.set(a.id, a);
|
|
129
129
|
|
|
130
|
+
// THE E2E-SEALING MAP (the sealing client) — non-null iff a field is `.encrypted()`. When null NOTHING
|
|
131
|
+
// below emits (the generated Dart client is byte-identical to a pre-sealing compile). Same map + sids as
|
|
132
|
+
// the TS client (one source of truth: `domainEncryptionMap`); requires the baked interface for the sids.
|
|
133
|
+
const encMap: EncDomainMap | null =
|
|
134
|
+
opts.interface !== undefined
|
|
135
|
+
? domainEncryptionMap(mod, opts.interface as { aggregates?: Record<string, InterfaceAggShape> })
|
|
136
|
+
: null;
|
|
137
|
+
const encPresent = encMap !== null;
|
|
138
|
+
/** The distinct scopes a read of `typeId` must inject keys for (empty ⇒ nothing encrypted on it). */
|
|
139
|
+
const readScopesFor = (typeId: string): string[] => {
|
|
140
|
+
const r = encMap?.reads?.[typeId];
|
|
141
|
+
return r === undefined ? [] : [...new Set(Object.values(r).map((x) => x.scope))].sort();
|
|
142
|
+
};
|
|
143
|
+
const keysArg = (typeId: string): string => {
|
|
144
|
+
const scopes = readScopesFor(typeId);
|
|
145
|
+
return scopes.length ? `, await _readKeys([${scopes.map((s) => dartStr(s)).join(", ")}])` : "";
|
|
146
|
+
};
|
|
147
|
+
|
|
130
148
|
const methods: string[] = [];
|
|
131
149
|
|
|
132
150
|
// ── directives → typed offer ──
|
|
@@ -148,6 +166,34 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
148
166
|
const gateDoc = gate !== undefined
|
|
149
167
|
? ` /// GATED: needs an \`actor\` (the \`${gate.relation}\` relation) — throws MissingOfferAuthority otherwise.`
|
|
150
168
|
: "";
|
|
169
|
+
// E2E SEALING (the sealing client): a directive with `.encrypted()` payload fields SEALS them via the
|
|
170
|
+
// bound [keyStore] before dispatch (a missing key throws [EncryptionKeyUnavailable] — never a plaintext
|
|
171
|
+
// write). One async block covers creates + mutate + gated + stamps; `seal: false` is the escape hatch.
|
|
172
|
+
if (encMap?.directives?.[d.id] !== undefined) {
|
|
173
|
+
const isCreates = (d as { marker?: string }).marker === "creates";
|
|
174
|
+
const retType = isCreates ? "NomosOfferOutcome" : "String";
|
|
175
|
+
const offerFn = isCreates ? "offerCreates" : "offerDirective";
|
|
176
|
+
methods.push(
|
|
177
|
+
` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
|
|
178
|
+
` /// Encrypted field(s) are SEALED via the bound [keyStore] before dispatch (pass \`seal: false\` to skip).`,
|
|
179
|
+
...(stamps.length > 0 ? [` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`] : []),
|
|
180
|
+
...(gateDoc ? [gateDoc] : []),
|
|
181
|
+
` Future<${retType}> ${method}(${payloadClass} payload, {bool seal = true}) async {`,
|
|
182
|
+
...(gateGuard ? [gateGuard] : []),
|
|
183
|
+
` final data = payload.toPayloadJson();`,
|
|
184
|
+
...(stamps.length > 0
|
|
185
|
+
? [
|
|
186
|
+
` final now = DateTime.now().toUtc().toIso8601String();`,
|
|
187
|
+
...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
|
|
188
|
+
]
|
|
189
|
+
: []),
|
|
190
|
+
` await _sealPayload(data, ${dartStr(d.id)}, seal);`,
|
|
191
|
+
` return bridge.${offerFn}(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
|
|
192
|
+
` }`,
|
|
193
|
+
``,
|
|
194
|
+
);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
151
197
|
if ((d as { marker?: string }).marker === "creates") {
|
|
152
198
|
// LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
|
|
153
199
|
// carries none — callers never supply ids). The bridge's offerCreates hands the minted ids back with the
|
|
@@ -213,9 +259,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
213
259
|
const argMap = `{${q.key.map((k) => `${dartStr(k)}: ${dartKeyParam(k)}`).join(", ")}}`;
|
|
214
260
|
const m = safeMethod(lcFirst(camel(q.id)));
|
|
215
261
|
methods.push(
|
|
216
|
-
` /// Declared query \`${q.id}\` (indexed probe — routed by the read manifest)
|
|
262
|
+
` /// Declared query \`${q.id}\` (indexed probe — routed by the read manifest).${readScopesFor(q.returns).length ? " Encrypted field(s) decrypt when the bound [keyStore] holds the scope key; else they stay opaque envelopes." : ""}`,
|
|
217
263
|
` Future<List<${model}>> ${m}({${params}}) async =>`,
|
|
218
|
-
` (await bridge.query(${dartStr(q.id)}, ${argMap})).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
264
|
+
` (await bridge.query(${dartStr(q.id)}, ${argMap}${keysArg(q.returns)})).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
219
265
|
``,
|
|
220
266
|
` /// Reactive \`${q.id}\` — re-emits on every local change (cancel the subscription to stop).`,
|
|
221
267
|
` Stream<List<${model}>> watch${cap(camel(q.id))}({${params}}) =>`,
|
|
@@ -267,9 +313,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
267
313
|
const lc = lcFirst(camel(agg.id));
|
|
268
314
|
const Cap = cap(camel(agg.id));
|
|
269
315
|
methods.push(
|
|
270
|
-
` /// Read one ${agg.id} by aggregate id
|
|
316
|
+
` /// Read one ${agg.id} by aggregate id.${readScopesFor(agg.id).length ? " Encrypted field(s) decrypt when the bound [keyStore] holds the scope key." : ""}`,
|
|
271
317
|
` Future<List<${model}>> ${safeMethod(`${lc}ById`)}(String id) async =>`,
|
|
272
|
-
` (await bridge.queryById(id)).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
318
|
+
` (await bridge.queryById(id${keysArg(agg.id)})).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
273
319
|
``,
|
|
274
320
|
` /// Watch one ${agg.id} by aggregate id (local reactive read).`,
|
|
275
321
|
` Stream<List<${model}>> watch${Cap}ById(String id) =>`,
|
|
@@ -277,7 +323,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
277
323
|
``,
|
|
278
324
|
` /// Every ${agg.id} (the type-index probe — O(result), not a scan).`,
|
|
279
325
|
` Future<List<${model}>> ${safeMethod(`list${Cap}s`)}() async =>`,
|
|
280
|
-
` (await bridge.query('aggregatesByType', {'__type': ${dartStr(agg.id)}})).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
326
|
+
` (await bridge.query('aggregatesByType', {'__type': ${dartStr(agg.id)}}${keysArg(agg.id)})).map((r) => ${rowToModel(model, "r")}).toList();`,
|
|
281
327
|
``,
|
|
282
328
|
);
|
|
283
329
|
}
|
|
@@ -305,7 +351,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
305
351
|
`// client file gives an app the whole typed surface.`,
|
|
306
352
|
`export '${mod.name}.dart';`,
|
|
307
353
|
...(opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
|
|
308
|
-
`export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion;`,
|
|
354
|
+
`export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore" : ""};`,
|
|
309
355
|
``,
|
|
310
356
|
`/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
|
|
311
357
|
`const String ${hashConst} = ${dartStr(opts.domainHash)};`,
|
|
@@ -344,8 +390,18 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
344
390
|
` /// The verified principal uid every offer is authored as (sealed as \`security.actor\` by the one encoder).`,
|
|
345
391
|
` /// Null ⇒ open (un-gated) authoring; a gated directive then throws [MissingOfferAuthority].`,
|
|
346
392
|
` final String? actor;`,
|
|
347
|
-
|
|
393
|
+
...(encPresent
|
|
394
|
+
? [
|
|
395
|
+
``,
|
|
396
|
+
` /// THE KEY CUSTODY surface (E2E sealing). When set, encrypted directive fields SEAL automatically`,
|
|
397
|
+
` /// before dispatch and encrypted read fields DECRYPT automatically; null ⇒ ciphertext passes through`,
|
|
398
|
+
` /// unchanged (today's behavior). See [WrappedKeyStore] for the default over the on-chain WrappedKeys.`,
|
|
399
|
+
` final NomosKeyStore? keyStore;`,
|
|
400
|
+
]
|
|
401
|
+
: []),
|
|
402
|
+
` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor${encPresent ? ", this.keyStore" : ""}});`,
|
|
348
403
|
``,
|
|
404
|
+
...(encPresent ? dartEncClassMembers(encMap!) : []),
|
|
349
405
|
...(opts.interface !== undefined
|
|
350
406
|
? [
|
|
351
407
|
` /// This client's structural interface requirement (#72) as JSON — hand it to the`,
|
|
@@ -370,3 +426,42 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
|
|
|
370
426
|
``,
|
|
371
427
|
].join("\n");
|
|
372
428
|
}
|
|
429
|
+
|
|
430
|
+
/** The in-class E2E-sealing members: the baked map + the seal/read private helpers. */
|
|
431
|
+
function dartEncClassMembers(encMap: EncDomainMap): string[] {
|
|
432
|
+
return [
|
|
433
|
+
` /// The baked E2E-sealing map (compile-time, from the SAME sids the law was compiled with): which`,
|
|
434
|
+
` /// directive payload fields seal → { scope, aggSid, fieldSid }, and which read-model fields decrypt.`,
|
|
435
|
+
` static final Map<String, dynamic> encryptionMap = (jsonDecode(${dartRawJson(encMap)}) as Map).cast<String, dynamic>();`,
|
|
436
|
+
``,
|
|
437
|
+
` /// Seal the mapped encrypted fields of \`data\` in place before dispatch. No keyStore / \`seal == false\``,
|
|
438
|
+
` /// ⇒ passthrough (today's behavior); a held key seals; a missing key throws [EncryptionKeyUnavailable].`,
|
|
439
|
+
` Future<void> _sealPayload(Map<String, dynamic> data, String directiveId, bool seal) async {`,
|
|
440
|
+
` if (!seal || keyStore == null) return;`,
|
|
441
|
+
` final fields = (encryptionMap['directives'] as Map)[directiveId] as Map?;`,
|
|
442
|
+
` if (fields == null) return;`,
|
|
443
|
+
` for (final entry in fields.entries) {`,
|
|
444
|
+
` final field = entry.key as String;`,
|
|
445
|
+
` final m = entry.value as Map;`,
|
|
446
|
+
` if (data[field] == null) continue;`,
|
|
447
|
+
` final scope = m['scope'] as String;`,
|
|
448
|
+
` final sk = await keyStore!.scopeKey(scope);`,
|
|
449
|
+
` if (sk == null) throw EncryptionKeyUnavailable(scope, field);`,
|
|
450
|
+
` data[field] = await bridge.cryptoSealField(`,
|
|
451
|
+
` scopeKey: sk.key, scope: scope, epoch: sk.epoch,`,
|
|
452
|
+
` aggSid: m['aggSid'] as String, fieldSid: m['fieldSid'] as String,`,
|
|
453
|
+
` plaintext: data[field].toString(),`,
|
|
454
|
+
` );`,
|
|
455
|
+
` }`,
|
|
456
|
+
` }`,
|
|
457
|
+
``,
|
|
458
|
+
` /// The read keys to inject for \`scopes\`, or null when there is nothing to inject (no keyStore / no`,
|
|
459
|
+
` /// held keys) — the read then behaves exactly as today (encrypted fields stay opaque envelopes).`,
|
|
460
|
+
` Future<List<NomosScopeKey>?> _readKeys(List<String> scopes) async {`,
|
|
461
|
+
` if (keyStore == null || scopes.isEmpty) return null;`,
|
|
462
|
+
` final keys = await keyStore!.readKeys(scopes);`,
|
|
463
|
+
` return keys.isEmpty ? null : keys;`,
|
|
464
|
+
` }`,
|
|
465
|
+
``,
|
|
466
|
+
];
|
|
467
|
+
}
|
package/src/codegen_proof.ts
CHANGED
|
@@ -659,6 +659,33 @@ function probePlanOps(
|
|
|
659
659
|
return { ops, synth, probePayload };
|
|
660
660
|
}
|
|
661
661
|
|
|
662
|
+
/**
|
|
663
|
+
* Compile-side plan-probe of a directive's SECONDARY deterministic ENSURES — the aggregate TYPES its
|
|
664
|
+
* plan introduces via `ensure(Agg, "det:id")` (an `ensures`-marked op at a dev-chosen deterministic id),
|
|
665
|
+
* beyond whatever its PRIMARY referential marker declares. This is the discovery half of the "secondary
|
|
666
|
+
* create auto-declare" DX (`secondary_ensure.ts`): the kernel's id-mint gate admits an Ensure-introduced
|
|
667
|
+
* typed id ONLY when the certified manifest declares `Ensure → Type`, so `composeDomainModule` sanctions
|
|
668
|
+
* each discovered type with a no-op ensures directive.
|
|
669
|
+
*
|
|
670
|
+
* BEST-EFFORT (the reliability caveat): runs the plan ONCE under a synthesized payload + the deterministic
|
|
671
|
+
* probe ports. A plan that can't be probed (unsynthesizable payload / throws) or that ensures CONDITIONALLY
|
|
672
|
+
* on a payload shape the synthesis didn't hit yields fewer (or no) types — in which case the runtime
|
|
673
|
+
* id-mint gate still refuses the un-sanctioned ensure exactly as before (NO regression, NO weakening: the
|
|
674
|
+
* courtesy simply didn't fire). Returns the sorted set of ensured types (INCLUDING the directive's own
|
|
675
|
+
* primary target if it marks `ensures`; the caller subtracts the domain-wide declared set).
|
|
676
|
+
*/
|
|
677
|
+
export function probeEnsuredTypes(dir: Directive<unknown>, where: string): string[] {
|
|
678
|
+
const probed = probePlanOps(dir, where);
|
|
679
|
+
if (probed === undefined) return [];
|
|
680
|
+
const out = new Set<string>();
|
|
681
|
+
for (const o of probed.ops) {
|
|
682
|
+
if ((o as { marker?: string }).marker !== "ensures") continue;
|
|
683
|
+
const ty = o.aggregateType;
|
|
684
|
+
if (typeof ty === "string" && ty.length > 0) out.add(ty);
|
|
685
|
+
}
|
|
686
|
+
return [...out].sort();
|
|
687
|
+
}
|
|
688
|
+
|
|
662
689
|
/** The `status` of a `subjects` setEntry leaf (`writeTuple`/`removeTuple` lowers a JSON `{status, …}` leaf).
|
|
663
690
|
* `"active"` = a POSITIVE grant; `"revoked"` = a REVOKE. `undefined` ⇒ not a parseable subject leaf. */
|
|
664
691
|
function subjectLeafStatus(op: FieldOp): string | undefined {
|
|
@@ -1357,7 +1384,7 @@ export interface ChildBirthParentSetup {
|
|
|
1357
1384
|
/** A stub `nomos` global (mint + sha256) so a `.births()` plan that mints / hashes a child name runs to
|
|
1358
1385
|
* completion at codegen. The hash is a deterministic STUB (the codegen never needs the real digest — the
|
|
1359
1386
|
* recipe's workspace is read at RUNTIME from the offer's `born`); mint mirrors `harvestPrincipalFlows`. */
|
|
1360
|
-
function withBirthProbeGlobals<T>(run: () => T): T {
|
|
1387
|
+
export function withBirthProbeGlobals<T>(run: () => T): T {
|
|
1361
1388
|
const g = globalThis as unknown as { nomos?: { mint?: (t: string) => string; sha256?: (s: string) => string; read?: unknown } };
|
|
1362
1389
|
const had = "nomos" in g;
|
|
1363
1390
|
const prior = g.nomos;
|
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,23 @@ 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
|
+
]
|
|
586
|
+
: [
|
|
587
|
+
` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
|
|
588
|
+
` queryById(aggregateId: string): Promise<unknown>;`,
|
|
589
|
+
]),
|
|
455
590
|
` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
|
|
456
591
|
` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
|
|
457
592
|
` count(countId: string, groupKey?: string): Promise<number>;`,
|
|
@@ -489,6 +624,106 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
489
624
|
``,
|
|
490
625
|
);
|
|
491
626
|
|
|
627
|
+
if (encPresent) {
|
|
628
|
+
out.push(
|
|
629
|
+
`// ── E2E SEALING (the sealing client) — baked map + KeyStore + seal/read helpers ──`,
|
|
630
|
+
`// The map below is baked at COMPILE time from the SAME descriptors + stable-id lockfile the law was`,
|
|
631
|
+
`// compiled with (never fetched). A sealing write is byte-indistinguishable from a hand-plumbed one.`,
|
|
632
|
+
``,
|
|
633
|
+
`/** Per directive: which payload fields seal → their { scope, aggSid, fieldSid }. Per aggregate: which`,
|
|
634
|
+
` * read-model fields are encrypted (+ their scope). Keyed by domain. */`,
|
|
635
|
+
`export const ${encConst} = ${JSON.stringify(encMap)} as const;`,
|
|
636
|
+
``,
|
|
637
|
+
`/** A held scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation). */`,
|
|
638
|
+
`export interface NomosScopeKey { scope: string; epoch: number; key: string }`,
|
|
639
|
+
``,
|
|
640
|
+
`/** The app-supplied KEY CUSTODY surface. Supply one to \`<domain>Client(holon, { keyStore })\` and the`,
|
|
641
|
+
` * generated methods seal writes + decrypt reads automatically. \`wrappedKeyStore\` is the default over`,
|
|
642
|
+
` * the on-chain WrappedKeys; an app may implement this to source keys from a secure enclave. */`,
|
|
643
|
+
`export interface NomosKeyStore {`,
|
|
644
|
+
` /** The CURRENT (highest-epoch) scope key, for SEALING a write. undefined ⇒ EncryptionKeyUnavailable. */`,
|
|
645
|
+
` scopeKey(scope: string): Promise<NomosScopeKey | undefined>;`,
|
|
646
|
+
` /** EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts. */`,
|
|
647
|
+
` readKeys(scopes: readonly string[]): Promise<NomosScopeKey[]>;`,
|
|
648
|
+
`}`,
|
|
649
|
+
``,
|
|
650
|
+
`/** Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the`,
|
|
651
|
+
` * write NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway). */`,
|
|
652
|
+
`export class EncryptionKeyUnavailable extends Error {`,
|
|
653
|
+
` readonly code = "nomos.crypto.key_unavailable";`,
|
|
654
|
+
` readonly recommendedAction = "obtainScopeKey";`,
|
|
655
|
+
` constructor(readonly scope: string, readonly field: string) {`,
|
|
656
|
+
" super(`cannot seal encrypted field \"${field}\": no scope key held for \"${scope}\" — obtain it (be granted the cap + wrapped the scope epoch) before writing`);",
|
|
657
|
+
` this.name = "EncryptionKeyUnavailable";`,
|
|
658
|
+
` }`,
|
|
659
|
+
`}`,
|
|
660
|
+
``,
|
|
661
|
+
`/** Seal the mapped encrypted fields of a payload before dispatch. No keyStore / \`seal:false\` ⇒ passthrough`,
|
|
662
|
+
` * (today's behavior). A held key seals; a missing key throws EncryptionKeyUnavailable (never plaintext). */`,
|
|
663
|
+
`const _sealFields = async (`,
|
|
664
|
+
` holon: NomosHolon,`,
|
|
665
|
+
` keyStore: NomosKeyStore | undefined,`,
|
|
666
|
+
` fieldMap: Record<string, { scope: string; aggSid: string; fieldSid: string }>,`,
|
|
667
|
+
` payload: Record<string, unknown>,`,
|
|
668
|
+
` seal: boolean,`,
|
|
669
|
+
`): Promise<Record<string, unknown>> => {`,
|
|
670
|
+
` if (seal === false || keyStore == null) return payload;`,
|
|
671
|
+
` let out: Record<string, unknown> | undefined;`,
|
|
672
|
+
` for (const field of Object.keys(fieldMap)) {`,
|
|
673
|
+
` const v = payload[field];`,
|
|
674
|
+
` if (v === undefined || v === null) continue;`,
|
|
675
|
+
` const m = fieldMap[field]!;`,
|
|
676
|
+
` const sk = await keyStore.scopeKey(m.scope);`,
|
|
677
|
+
` if (!sk) throw new EncryptionKeyUnavailable(m.scope, field);`,
|
|
678
|
+
` if (!holon.cryptoSealField) throw new Error("this holon predates E2E sealing — upgrade @githolon/client");`,
|
|
679
|
+
` const envelope = await holon.cryptoSealField({ scopeKey: sk.key, scope: m.scope, epoch: sk.epoch, aggSid: m.aggSid, fieldSid: m.fieldSid, plaintext: String(v) });`,
|
|
680
|
+
` (out ??= { ...payload })[field] = envelope;`,
|
|
681
|
+
` }`,
|
|
682
|
+
` return out ?? payload;`,
|
|
683
|
+
`};`,
|
|
684
|
+
``,
|
|
685
|
+
`/** The read keys to inject for the given scopes, or undefined when there is nothing to inject`,
|
|
686
|
+
` * (no keyStore, or no held keys) — the read then behaves exactly as today (opaque envelopes). */`,
|
|
687
|
+
`const _readKeys = async (keyStore: NomosKeyStore | undefined, scopes: readonly string[]): Promise<NomosScopeKey[] | undefined> => {`,
|
|
688
|
+
` if (keyStore == null || scopes.length === 0) return undefined;`,
|
|
689
|
+
` const keys = await keyStore.readKeys(scopes);`,
|
|
690
|
+
` return keys.length ? keys : undefined;`,
|
|
691
|
+
`};`,
|
|
692
|
+
``,
|
|
693
|
+
`/** THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching`,
|
|
694
|
+
` * per scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side,`,
|
|
695
|
+
` * the operator stays blind. Key custody (where \`deviceSecret\` lives) remains the app's concern. */`,
|
|
696
|
+
`export function wrappedKeyStore(opts: { holon: NomosHolon; principal: string; deviceSecret: string }): NomosKeyStore {`,
|
|
697
|
+
` const { holon, principal, deviceSecret } = opts;`,
|
|
698
|
+
` const cache = new Map<string, Promise<NomosScopeKey[]>>();`,
|
|
699
|
+
` const loadScope = (scope: string): Promise<NomosScopeKey[]> => {`,
|
|
700
|
+
` let p = cache.get(scope);`,
|
|
701
|
+
` if (p !== undefined) return p;`,
|
|
702
|
+
` p = (async () => {`,
|
|
703
|
+
` const rows = (await holon.query("wrappedKeysByRecipient", { recipientPrincipal: principal })) as { data?: Record<string, unknown> }[];`,
|
|
704
|
+
` const held: NomosScopeKey[] = [];`,
|
|
705
|
+
` for (const row of rows ?? []) {`,
|
|
706
|
+
` const d = (row.data ?? row) as Record<string, unknown>;`,
|
|
707
|
+
` if (String(d.scope) !== scope) continue;`,
|
|
708
|
+
` if (!holon.cryptoUnwrapKey) throw new Error("this holon predates E2E unwrap — upgrade @githolon/client");`,
|
|
709
|
+
` const key = await holon.cryptoUnwrapKey({ secret: deviceSecret, hpkeEpk: String(d.hpkeEpk), ct: String(d.ct) });`,
|
|
710
|
+
` held.push({ scope, epoch: Number(d.epoch) || 0, key });`,
|
|
711
|
+
` }`,
|
|
712
|
+
` held.sort((a, b) => b.epoch - a.epoch);`,
|
|
713
|
+
` return held;`,
|
|
714
|
+
` })();`,
|
|
715
|
+
` cache.set(scope, p);`,
|
|
716
|
+
` return p;`,
|
|
717
|
+
` };`,
|
|
718
|
+
` return {`,
|
|
719
|
+
` async scopeKey(scope) { return (await loadScope(scope))[0]; },`,
|
|
720
|
+
` async readKeys(scopes) { const all: NomosScopeKey[] = []; for (const s of scopes) all.push(...(await loadScope(s))); return all; },`,
|
|
721
|
+
` };`,
|
|
722
|
+
`}`,
|
|
723
|
+
``,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
492
727
|
const anyAutoStamp = modules.some((mod) =>
|
|
493
728
|
(mod.directives as Directive<unknown>[]).some(
|
|
494
729
|
(d) =>
|
|
@@ -769,6 +1004,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
769
1004
|
} catch { /* the compile gate already failed a bad taxonomy loudly */ }
|
|
770
1005
|
}
|
|
771
1006
|
const methods: string[] = [];
|
|
1007
|
+
// E2E READ INJECTION: the distinct encryption scopes a read of `typeId` must inject keys for (empty
|
|
1008
|
+
// ⇒ nothing encrypted on that type ⇒ the accessor is emitted plain, unchanged). The `_readKeys` helper
|
|
1009
|
+
// returns undefined when no keyStore is bound, so an injected accessor still behaves as today offline.
|
|
1010
|
+
const readScopesFor = (typeId: string): string[] => {
|
|
1011
|
+
const r = encMap?.[domain]?.reads?.[typeId];
|
|
1012
|
+
return r === undefined ? [] : [...new Set(Object.values(r).map((x) => x.scope))].sort();
|
|
1013
|
+
};
|
|
772
1014
|
for (const d of dirs) {
|
|
773
1015
|
let payloadName = `${cap(d.id)}Payload`;
|
|
774
1016
|
if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
|
|
@@ -784,6 +1026,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
784
1026
|
const gateGuard = gate !== undefined
|
|
785
1027
|
? ` if (!actor || !actor.trim()) throw new MissingOfferAuthority(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${JSON.stringify(gate.relation)});\n`
|
|
786
1028
|
: "";
|
|
1029
|
+
// E2E SEALING (the sealing client): a directive with `.encrypted()` payload fields seals them
|
|
1030
|
+
// (via the KeyStore) BEFORE dispatch. `wantSeal` forces an async block body + a `callOpts.seal`
|
|
1031
|
+
// escape hatch, and `pv` (the sealed copy) replaces `payload` downstream. Non-encrypted directives
|
|
1032
|
+
// are byte-identical (wantSeal === false ⇒ no prelude, no extra param, plain `payload`).
|
|
1033
|
+
const wantSeal = encMap?.[domain]?.directives?.[d.id] !== undefined;
|
|
1034
|
+
const paramSig = wantSeal ? `payload: ${payloadName}, callOpts?: { seal?: boolean }` : `payload: ${payloadName}`;
|
|
1035
|
+
const sealPrelude = wantSeal
|
|
1036
|
+
? ` 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};`
|
|
1037
|
+
: "";
|
|
1038
|
+
const pv = wantSeal ? "_sealed" : "payload";
|
|
787
1039
|
out.push(
|
|
788
1040
|
`/** \`${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
1041
|
`export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed)};`,
|
|
@@ -797,13 +1049,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
797
1049
|
? `, { tagHex: routeTagOfId(payload.${mint.tagFromIdField}) }`
|
|
798
1050
|
: "";
|
|
799
1051
|
const body = stamps.length
|
|
800
|
-
? `stampNow({
|
|
801
|
-
: `{
|
|
1052
|
+
? `stampNow({ ...${pv}, ${mint.field} }, ${JSON.stringify(stamps)})`
|
|
1053
|
+
: `{ ...${pv}, ${mint.field} }`;
|
|
802
1054
|
methods.push(
|
|
803
1055
|
` /** Author \`${domain}/${d.id}\`: the front-door MINTS \`${mint.field}\` (route-tagged) when omitted`,
|
|
804
1056
|
` * — ids stay internal plumbing — and returns it with the new local head.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
|
|
805
|
-
` ${d.id}: async (
|
|
1057
|
+
` ${d.id}: async (${paramSig}): Promise<{ head: string; ${mint.field}: string }> => {`,
|
|
806
1058
|
...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
|
|
1059
|
+
...(sealPrelude ? [sealPrelude] : []),
|
|
807
1060
|
` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
|
|
808
1061
|
` const head = await holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, { domainHash, actor });`,
|
|
809
1062
|
` return { head, ${mint.field} };`,
|
|
@@ -811,7 +1064,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
811
1064
|
);
|
|
812
1065
|
continue;
|
|
813
1066
|
}
|
|
814
|
-
const offerPayload = stamps.length ? `stampNow(
|
|
1067
|
+
const offerPayload = stamps.length ? `stampNow(${pv}, ${JSON.stringify(stamps)})` : pv;
|
|
815
1068
|
if (d.marker === "creates") {
|
|
816
1069
|
// LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
|
|
817
1070
|
// carries none). The holon hands the minted ids back on the offer (`returnCreated`), so the create
|
|
@@ -820,8 +1073,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
820
1073
|
methods.push(
|
|
821
1074
|
` /** Author \`${domain}/${d.id}\`: the LAW mints the ${d.aggregateId ?? "aggregate"} id inside the sealed intent`,
|
|
822
1075
|
` * 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 (
|
|
1076
|
+
` ${d.id}: async (${paramSig}): Promise<{ head: string; id: string }> => {`,
|
|
824
1077
|
...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
|
|
1078
|
+
...(sealPrelude ? [sealPrelude] : []),
|
|
825
1079
|
` 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
1080
|
` return typeof r === "object" && r !== null ? { head: r.head, id: r.created?.[0]?.id ?? "" } : { head: String(r), id: "" };`,
|
|
827
1081
|
` },`,
|
|
@@ -829,7 +1083,19 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
829
1083
|
continue;
|
|
830
1084
|
}
|
|
831
1085
|
const offerCall = `holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor })`;
|
|
832
|
-
if (
|
|
1086
|
+
if (wantSeal) {
|
|
1087
|
+
// SEALING directive (optionally also gated): an ASYNC block — seal the encrypted fields via the
|
|
1088
|
+
// bound { keyStore } (+ throw MissingOfferAuthority first if gated) before dispatch.
|
|
1089
|
+
methods.push(
|
|
1090
|
+
` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
|
|
1091
|
+
` * Encrypted field(s) are SEALED via the bound { keyStore } before dispatch (pass { seal: false } to skip).${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
|
|
1092
|
+
` ${d.id}: async (${paramSig}): Promise<string> => {`,
|
|
1093
|
+
...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
|
|
1094
|
+
sealPrelude,
|
|
1095
|
+
` return ${offerCall};`,
|
|
1096
|
+
` },`,
|
|
1097
|
+
);
|
|
1098
|
+
} else if (gateGuard) {
|
|
833
1099
|
// GATED directive: a block body that throws MissingOfferAuthority when no actor is bound — an
|
|
834
1100
|
// actorless gated offer can't be constructed (the war: the gate is structural, not just kernel-side).
|
|
835
1101
|
methods.push(
|
|
@@ -864,9 +1130,12 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
864
1130
|
return `${JSON.stringify(k)}: ${t}`;
|
|
865
1131
|
})
|
|
866
1132
|
.join("; ");
|
|
1133
|
+
const qScopes = readScopesFor(q.returns);
|
|
867
1134
|
methods.push(
|
|
868
|
-
` /** Declared query \`${q.id}\` (indexed probe — routed by the read manifest). */`,
|
|
869
|
-
|
|
1135
|
+
` /** 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." : ""} */`,
|
|
1136
|
+
qScopes.length
|
|
1137
|
+
? ` ${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>[]>,`
|
|
1138
|
+
: ` ${lcFirst(camel(q.id))}: (params: { ${params} }) => holon.query(${JSON.stringify(q.id)}, params) as Promise<Row<${q.returns}Data>[]>,`,
|
|
870
1139
|
);
|
|
871
1140
|
// THE LOGICAL PAGED ROW SURFACE (sharding §5.3, ruling 2 — slice 4): emitted
|
|
872
1141
|
// ONLY for packed-taxonomy domains (taxonomy-free clients stay byte-identical).
|
|
@@ -901,11 +1170,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
901
1170
|
);
|
|
902
1171
|
}
|
|
903
1172
|
for (const agg of mod.aggregates as AggregateHandle[]) {
|
|
1173
|
+
const aScopes = readScopesFor(agg.id);
|
|
904
1174
|
methods.push(
|
|
905
|
-
` /** Read one ${agg.id} by aggregate id. */`,
|
|
906
|
-
|
|
1175
|
+
` /** Read one ${agg.id} by aggregate id.${aScopes.length ? " Encrypted field(s) decrypt when the bound { keyStore } holds the scope key." : ""} */`,
|
|
1176
|
+
aScopes.length
|
|
1177
|
+
? ` ${lcFirst(camel(agg.id))}ById: async (id: string) => holon.queryById(id, await _readKeys(keyStore, ${JSON.stringify(aScopes)})) as Promise<Row<${agg.id}Data>[]>,`
|
|
1178
|
+
: ` ${lcFirst(camel(agg.id))}ById: (id: string) => holon.queryById(id) as Promise<Row<${agg.id}Data>[]>,`,
|
|
907
1179
|
` /** Every ${agg.id} (the type-index probe — O(result), not a scan). */`,
|
|
908
|
-
|
|
1180
|
+
aScopes.length
|
|
1181
|
+
? ` 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>[]>,`
|
|
1182
|
+
: ` list${agg.id}s: () => holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>,`,
|
|
909
1183
|
` /** Watch one ${agg.id} by aggregate id (local reactive read; returns stop()). */`,
|
|
910
1184
|
` watch${agg.id}ById: (id: string, cb: (rows: Row<${agg.id}Data>[]) => void, opts?: { intervalMs?: number }) =>`,
|
|
911
1185
|
` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
|
|
@@ -917,9 +1191,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
917
1191
|
` * Pass \`{ actor }\` (the verified principal uid) to author AS that user — every write seals it as`,
|
|
918
1192
|
` * the captured principal, so the ReBAC \`.requires(...)\` gate + the integrity-dual endorse model`,
|
|
919
1193
|
` * judge the real author. Omit it for open (un-gated) domains. */`,
|
|
920
|
-
`export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string } = {}) {`,
|
|
1194
|
+
`export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string${encPresent ? "; keyStore?: NomosKeyStore" : ""} } = {}) {`,
|
|
921
1195
|
` const domainHash = opts.domainHash ?? ${hashConst};`,
|
|
922
1196
|
` const actor = opts.actor;`,
|
|
1197
|
+
...(encPresent ? [` const keyStore = opts.keyStore;`] : []),
|
|
923
1198
|
...(opts.compat !== undefined
|
|
924
1199
|
? [` holon.checkCompat?.(${compatConstName(opts.packageName)});`]
|
|
925
1200
|
: []),
|