@githolon/dsl 0.49.0 → 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/webview_bridge.dart +160 -6
- package/package.json +3 -2
- package/src/check_package_main.ts +83 -0
- package/src/codegen_dart_client.ts +103 -8
- package/src/codegen_proof.ts +1 -1
- package/src/codegen_ts.ts +289 -14
- package/src/compile_package_main.ts +19 -0
- package/src/law_check.ts +375 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
3
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
4
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
5
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
6
|
+
|
|
7
|
+
// `githolon check` launcher — the twin of `compile_package.mjs`: resolve `tsx`
|
|
8
|
+
// (the caller's, else the DSL's own dependency) and run `src/check_package_main.ts`
|
|
9
|
+
// under it (the main dynamically imports the caller's TS domain modules, which
|
|
10
|
+
// needs the tsx ESM loader). Resolution goes through Node's resolver (createRequire),
|
|
11
|
+
// NOT node_modules/.bin paths — hoisted/workspace installs put the bin shims elsewhere.
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
const DSL_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const MAIN = path.join(DSL_DIR, "src", "check_package_main.ts");
|
|
20
|
+
|
|
21
|
+
function resolvePkgDir(name, fromDir) {
|
|
22
|
+
try {
|
|
23
|
+
const req = createRequire(pathToFileURL(path.join(fromDir, "noop.js")));
|
|
24
|
+
return path.dirname(req.resolve(`${name}/package.json`));
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tsxDir = resolvePkgDir("tsx", process.cwd()) ?? resolvePkgDir("tsx", DSL_DIR);
|
|
31
|
+
if (!tsxDir) {
|
|
32
|
+
console.error("githolon check: tsx not found — reinstall @githolon/dsl (tsx is one of its dependencies)");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const tsxPkg = JSON.parse(readFileSync(path.join(tsxDir, "package.json"), "utf8"));
|
|
36
|
+
const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
|
|
37
|
+
const tsxCli = path.join(tsxDir, tsxBinRel ?? "dist/cli.mjs");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
execFileSync(process.execPath, [tsxCli, MAIN, ...process.argv.slice(2)], {
|
|
41
|
+
stdio: "inherit",
|
|
42
|
+
cwd: process.cwd(),
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
process.exit(e.status ?? 1);
|
|
46
|
+
}
|
|
@@ -292,6 +292,96 @@ class NomosBbox {
|
|
|
292
292
|
{'minLng': minLng, 'minLat': minLat, 'maxLng': maxLng, 'maxLat': maxLat};
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// ── E2E SEALING (the sealing client) — the GENERIC key-custody surface. Generated domain clients bake the
|
|
296
|
+
// per-domain map + seal/read helpers, but these types are domain-agnostic, so they live here (in the
|
|
297
|
+
// nomos_client library) exactly like [MissingOfferAuthority] — one definition, re-exported per client. ──
|
|
298
|
+
|
|
299
|
+
/// A held E2E scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation).
|
|
300
|
+
class NomosScopeKey {
|
|
301
|
+
final String scope;
|
|
302
|
+
final int epoch;
|
|
303
|
+
final String key;
|
|
304
|
+
const NomosScopeKey(
|
|
305
|
+
{required this.scope, required this.epoch, required this.key});
|
|
306
|
+
Map<String, Object?> toJson() => {'scope': scope, 'epoch': epoch, 'key': key};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// The app-supplied KEY CUSTODY surface (E2E sealing). Supply one to a generated `<Domain>Client` and its
|
|
310
|
+
/// encrypted directive fields SEAL automatically before dispatch + encrypted read fields DECRYPT
|
|
311
|
+
/// automatically. [WrappedKeyStore] is the default over the on-chain WrappedKeys; an app may implement this
|
|
312
|
+
/// to source keys from secure storage (the key custody policy stays the app's; the protocol plumbing does not).
|
|
313
|
+
abstract class NomosKeyStore {
|
|
314
|
+
/// The CURRENT (highest-epoch) scope key, for SEALING a write. null ⇒ EncryptionKeyUnavailable.
|
|
315
|
+
Future<NomosScopeKey?> scopeKey(String scope);
|
|
316
|
+
|
|
317
|
+
/// EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts.
|
|
318
|
+
Future<List<NomosScopeKey>> readKeys(List<String> scopes);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/// Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the write
|
|
322
|
+
/// NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway; we pre-fire).
|
|
323
|
+
class EncryptionKeyUnavailable implements Exception {
|
|
324
|
+
final String scope;
|
|
325
|
+
final String field;
|
|
326
|
+
String get code => 'nomos.crypto.key_unavailable';
|
|
327
|
+
String get recommendedAction => 'obtainScopeKey';
|
|
328
|
+
EncryptionKeyUnavailable(this.scope, this.field);
|
|
329
|
+
@override
|
|
330
|
+
String toString() =>
|
|
331
|
+
'EncryptionKeyUnavailable: cannot seal "$field": no scope key held for "$scope" (obtain the cap + wrap the scope epoch first)';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/// THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching per
|
|
335
|
+
/// scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side, the
|
|
336
|
+
/// operator stays blind. Key custody (where `deviceSecret` lives — enclave, password-derived, …) is the app's.
|
|
337
|
+
class WrappedKeyStore implements NomosKeyStore {
|
|
338
|
+
final NomosBridge bridge;
|
|
339
|
+
final String principal;
|
|
340
|
+
final String deviceSecret;
|
|
341
|
+
final Map<String, Future<List<NomosScopeKey>>> _cache = {};
|
|
342
|
+
WrappedKeyStore(
|
|
343
|
+
{required this.bridge,
|
|
344
|
+
required this.principal,
|
|
345
|
+
required this.deviceSecret});
|
|
346
|
+
|
|
347
|
+
Future<List<NomosScopeKey>> _loadScope(String scope) {
|
|
348
|
+
return _cache.putIfAbsent(scope, () async {
|
|
349
|
+
final rows = await bridge
|
|
350
|
+
.query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
|
|
351
|
+
final held = <NomosScopeKey>[];
|
|
352
|
+
for (final row in rows) {
|
|
353
|
+
final d = ((row['data'] ?? row) as Map);
|
|
354
|
+
if (d['scope']?.toString() != scope) continue;
|
|
355
|
+
final key = await bridge.cryptoUnwrapKey(
|
|
356
|
+
secret: deviceSecret,
|
|
357
|
+
hpkeEpk: d['hpkeEpk'].toString(),
|
|
358
|
+
ct: d['ct'].toString());
|
|
359
|
+
held.add(NomosScopeKey(
|
|
360
|
+
scope: scope,
|
|
361
|
+
epoch: (d['epoch'] as num?)?.toInt() ?? 0,
|
|
362
|
+
key: key));
|
|
363
|
+
}
|
|
364
|
+
held.sort((a, b) => b.epoch - a.epoch);
|
|
365
|
+
return held;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
@override
|
|
370
|
+
Future<NomosScopeKey?> scopeKey(String scope) async {
|
|
371
|
+
final ks = await _loadScope(scope);
|
|
372
|
+
return ks.isEmpty ? null : ks.first;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
@override
|
|
376
|
+
Future<List<NomosScopeKey>> readKeys(List<String> scopes) async {
|
|
377
|
+
final all = <NomosScopeKey>[];
|
|
378
|
+
for (final s in scopes) {
|
|
379
|
+
all.addAll(await _loadScope(s));
|
|
380
|
+
}
|
|
381
|
+
return all;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
295
385
|
/// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
|
|
296
386
|
class NomosOfferOutcome {
|
|
297
387
|
final String head;
|
|
@@ -771,14 +861,78 @@ class NomosBridge {
|
|
|
771
861
|
Map<String, Object?> args) async =>
|
|
772
862
|
_asMap(await _call('attestationSign', {'args': args}));
|
|
773
863
|
|
|
774
|
-
/// A declared query's rows from the local projection.
|
|
864
|
+
/// A declared query's rows from the local projection. `keys` (E2E sealing) injects the reader's held
|
|
865
|
+
/// scope keys so encrypted fields AEAD-open in-wasm; absent ⇒ they stay opaque envelopes (today's
|
|
866
|
+
/// behavior). `principal` stamps the private-read gate (defaults to the connection's bound principal).
|
|
867
|
+
/// Both are OMITTED from the wire when absent, so a public un-keyed read is byte-identical to before.
|
|
775
868
|
Future<List<Map<String, dynamic>>> query(String queryId,
|
|
776
|
-
[Map<String, Object?> params = const {}
|
|
777
|
-
|
|
869
|
+
[Map<String, Object?> params = const {},
|
|
870
|
+
List<NomosScopeKey>? keys,
|
|
871
|
+
String? principal]) async =>
|
|
872
|
+
_asRows(await _call('query', {
|
|
873
|
+
'queryId': queryId,
|
|
874
|
+
'params': params,
|
|
875
|
+
...?_readAuthArgs(keys, principal),
|
|
876
|
+
}));
|
|
877
|
+
|
|
878
|
+
/// A single aggregate by id (the by-id read). `keys`/`principal`: see [query].
|
|
879
|
+
Future<List<Map<String, dynamic>>> queryById(String id,
|
|
880
|
+
[List<NomosScopeKey>? keys, String? principal]) async =>
|
|
881
|
+
_asRows(await _call('queryById', {
|
|
882
|
+
'id': id,
|
|
883
|
+
...?_readAuthArgs(keys, principal),
|
|
884
|
+
}));
|
|
885
|
+
|
|
886
|
+
/// The read auth sidecar (private reads + E2E): `keys`/`principal` as top-level call args, or null when
|
|
887
|
+
/// neither is present (so the wire is byte-identical to before this existed).
|
|
888
|
+
Map<String, Object?>? _readAuthArgs(List<NomosScopeKey>? keys, String? principal) {
|
|
889
|
+
final out = <String, Object?>{};
|
|
890
|
+
if (principal != null && principal.isNotEmpty) out['principal'] = principal;
|
|
891
|
+
if (keys != null && keys.isNotEmpty) out['keys'] = keys.map((k) => k.toJson()).toList();
|
|
892
|
+
return out.isEmpty ? null : out;
|
|
893
|
+
}
|
|
778
894
|
|
|
779
|
-
///
|
|
780
|
-
|
|
781
|
-
|
|
895
|
+
/// E2E CRYPTO OPS (operator-blind sealing) — routed to the kernel via the WebView runner. XChaCha isn't in
|
|
896
|
+
/// WebCrypto/Dart, so these drive the wasm; the scope key + device secret are INJECTED per call and never
|
|
897
|
+
/// touch the chain. Mirror of the TS holon's crypto seam (@githolon/client).
|
|
898
|
+
/// Mint an X25519 device keypair for the handshake: `{secret, public}` (base64). Secret stays on device.
|
|
899
|
+
Future<Map<String, Object?>> cryptoKeygen() async =>
|
|
900
|
+
_asMap(await _call('cryptoKeygen'));
|
|
901
|
+
|
|
902
|
+
/// AUTHOR-SIDE SEAL: encrypt `plaintext` into its on-chain ciphertext envelope, bound to the scope + #58
|
|
903
|
+
/// stable sids (rename-safe). Returns the envelope string the encrypted field value is written as.
|
|
904
|
+
Future<String> cryptoSealField(
|
|
905
|
+
{required String scopeKey,
|
|
906
|
+
required String scope,
|
|
907
|
+
required int epoch,
|
|
908
|
+
required String aggSid,
|
|
909
|
+
required String fieldSid,
|
|
910
|
+
required String plaintext}) async =>
|
|
911
|
+
(_asMap(await _call('cryptoSealField', {
|
|
912
|
+
'scopeKey': scopeKey,
|
|
913
|
+
'scope': scope,
|
|
914
|
+
'epoch': epoch,
|
|
915
|
+
'aggSid': aggSid,
|
|
916
|
+
'fieldSid': fieldSid,
|
|
917
|
+
'plaintext': plaintext,
|
|
918
|
+
}))['envelope'] ?? '')
|
|
919
|
+
.toString();
|
|
920
|
+
|
|
921
|
+
/// Wrap a scope key TO a recipient's public key (committer side): `{hpkeEpk, ct}` base64 ciphertext.
|
|
922
|
+
Future<Map<String, Object?>> cryptoWrapKey(
|
|
923
|
+
{required String recipientPub, required String scopeKey}) async =>
|
|
924
|
+
_asMap(await _call('cryptoWrapKey',
|
|
925
|
+
{'recipientPub': recipientPub, 'scopeKey': scopeKey}));
|
|
926
|
+
|
|
927
|
+
/// Unwrap an on-chain WrappedKey with this device's secret → the base64 scope key (reader side).
|
|
928
|
+
Future<String> cryptoUnwrapKey(
|
|
929
|
+
{required String secret,
|
|
930
|
+
required String hpkeEpk,
|
|
931
|
+
required String ct}) async =>
|
|
932
|
+
(_asMap(await _call('cryptoUnwrapKey',
|
|
933
|
+
{'secret': secret, 'hpkeEpk': hpkeEpk, 'ct': ct}))['scopeKey'] ??
|
|
934
|
+
'')
|
|
935
|
+
.toString();
|
|
782
936
|
|
|
783
937
|
/// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
|
|
784
938
|
/// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"dart",
|
|
47
47
|
"!dart/.dart_tool",
|
|
48
48
|
"!dart/build",
|
|
49
|
-
"compile_package.mjs"
|
|
49
|
+
"compile_package.mjs",
|
|
50
|
+
"check_package.mjs"
|
|
50
51
|
],
|
|
51
52
|
"publishConfig": {
|
|
52
53
|
"access": "public"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded
|
|
4
|
+
// QuickJS engine}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `githolon check` — the author-time gate as a standalone command
|
|
8
|
+
* (architecture/author_time_gate.md, slice 1). Loads `nomos.package.mjs`, composes each
|
|
9
|
+
* domain the SAME way `compile` does (`composeDomainModule` — the one owner of directive
|
|
10
|
+
* discovery), runs {@link checkDomainModules}, prints the unified report, and exits
|
|
11
|
+
* non-zero on any REFUSE-level finding. `compile` runs the same twins implicitly.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
import { composeDomainModule, type Mod } from "./build_package.js";
|
|
18
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
19
|
+
import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
|
|
20
|
+
|
|
21
|
+
interface DomainConfig {
|
|
22
|
+
key: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
modules: string[];
|
|
25
|
+
}
|
|
26
|
+
interface PackageConfig {
|
|
27
|
+
name: string;
|
|
28
|
+
domains: DomainConfig[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fail(msg: string): never {
|
|
32
|
+
console.error(`githolon check: ${msg}`);
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compose the package's domain modules from `nomos.package.mjs` — the check-time subset of what
|
|
38
|
+
* `compile_package_main` does: import the config, import each domain's TS modules, and hand them to
|
|
39
|
+
* `composeDomainModule` (the shared composition primitive that discovers directives/aggregates and
|
|
40
|
+
* applies the secondary-ensure auto-declare). Returns the composed modules ready for the twins.
|
|
41
|
+
*/
|
|
42
|
+
export async function loadComposedModules(configArg: string): Promise<DomainModule[]> {
|
|
43
|
+
const configPath = path.resolve(process.cwd(), configArg);
|
|
44
|
+
if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
|
|
45
|
+
const cfgDir = path.dirname(configPath);
|
|
46
|
+
const cfgModule = (await import(pathToFileURL(configPath).href)) as { default?: PackageConfig };
|
|
47
|
+
const cfg = cfgModule.default;
|
|
48
|
+
if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
|
|
49
|
+
fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
|
|
50
|
+
}
|
|
51
|
+
const composed: DomainModule[] = [];
|
|
52
|
+
for (const d of cfg.domains) {
|
|
53
|
+
if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
|
|
54
|
+
fail(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
|
|
55
|
+
}
|
|
56
|
+
const mods: Mod[] = [];
|
|
57
|
+
for (const rel of d.modules) {
|
|
58
|
+
const abs = path.resolve(cfgDir, rel);
|
|
59
|
+
if (!existsSync(abs)) fail(`domain '${d.key}': module not found: ${abs}`);
|
|
60
|
+
mods.push((await import(pathToFileURL(abs).href)) as Mod);
|
|
61
|
+
}
|
|
62
|
+
composed.push(composeDomainModule({ name: d.name ?? d.key, domain: d.key, modules: mods }));
|
|
63
|
+
}
|
|
64
|
+
return composed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function main(): Promise<void> {
|
|
68
|
+
const argv = process.argv.slice(2);
|
|
69
|
+
const configArg = argv.find((a) => !a.startsWith("--")) ?? "nomos.package.mjs";
|
|
70
|
+
const modules = await loadComposedModules(configArg);
|
|
71
|
+
const findings = checkDomainModules(modules);
|
|
72
|
+
|
|
73
|
+
console.log(`githolon check: ${findings.length} finding(s)`);
|
|
74
|
+
for (const line of formatFindings(findings)) {
|
|
75
|
+
const level = line.includes("REFUSE") ? " ✗ " : line.includes("WARN") ? " ⚠ " : " • ";
|
|
76
|
+
console.log(level + line);
|
|
77
|
+
}
|
|
78
|
+
if (findings.length === 0) console.log(" all clear — no static twin flagged anything");
|
|
79
|
+
|
|
80
|
+
process.exit(hasRefusal(findings) ? 1 : 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
void main();
|
|
@@ -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
|
@@ -1384,7 +1384,7 @@ export interface ChildBirthParentSetup {
|
|
|
1384
1384
|
/** A stub `nomos` global (mint + sha256) so a `.births()` plan that mints / hashes a child name runs to
|
|
1385
1385
|
* completion at codegen. The hash is a deterministic STUB (the codegen never needs the real digest — the
|
|
1386
1386
|
* recipe's workspace is read at RUNTIME from the offer's `born`); mint mirrors `harvestPrincipalFlows`. */
|
|
1387
|
-
function withBirthProbeGlobals<T>(run: () => T): T {
|
|
1387
|
+
export function withBirthProbeGlobals<T>(run: () => T): T {
|
|
1388
1388
|
const g = globalThis as unknown as { nomos?: { mint?: (t: string) => string; sha256?: (s: string) => string; read?: unknown } };
|
|
1389
1389
|
const had = "nomos" in g;
|
|
1390
1390
|
const prior = g.nomos;
|