@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.
@@ -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
+ }
@@ -0,0 +1,82 @@
1
+ // COPY THIS INTO YOUR FLUTTER APP. It is an EXAMPLE, not part of the `nomos_client` library: it depends on
2
+ // `flutter_secure_storage`, which the pure-Dart `nomos_client` package deliberately does NOT depend on (so
3
+ // the protocol logic stays Flutter-free and unit-testable). This is the ~40 lines of app-specific glue for
4
+ // where your device secret lives — the E2E key CUSTODY policy is yours; the protocol plumbing is not.
5
+ //
6
+ // THE SEALING CLIENT (e2e_sealing_client.md): a generated `<Domain>Client(bridge, keyStore: ...)` seals
7
+ // encrypted directive fields before dispatch + decrypts encrypted read fields automatically. The `keyStore`
8
+ // is the ONE thing you supply. `WrappedKeyStore` (in `nomos_client`) is the default: it unwraps this
9
+ // principal's on-chain WrappedKeys with a DEVICE SECRET. This example shows where that device secret lives —
10
+ // the platform keychain / Keystore via `flutter_secure_storage` — so it survives app restarts, stays off the
11
+ // chain, and never leaves the device.
12
+ //
13
+ // WIRING (once per signed-in principal):
14
+ // final store = await SecureDeviceKeyStore.open(bridge: bridge, principal: myUid);
15
+ // final app = NotesClient(bridge, actor: myUid, keyStore: store); // reads decrypt, writes seal
16
+ // final rows = await app.notesByOwner(owner: myUid); // `body` comes back plaintext
17
+ //
18
+ // The OWNER ceremony (grantAndWrap / revokeAndRotate / rotate) uses the SAME device pubkey this store
19
+ // publishes — see `<Domain>Client(bridge, actor: owner).encryption`.
20
+
21
+ import 'dart:convert';
22
+
23
+ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
24
+ import 'package:nomos_client/nomos_client.dart';
25
+
26
+ /// A [NomosKeyStore] whose device secret is held in the platform keychain (iOS Keychain / Android Keystore)
27
+ /// via `flutter_secure_storage`. It delegates the scope-key unwrap to the framework's [WrappedKeyStore]; the
28
+ /// only thing it adds is DURABLE, PLATFORM-PROTECTED custody of the X25519 device secret.
29
+ class SecureDeviceKeyStore implements NomosKeyStore {
30
+ final WrappedKeyStore _inner;
31
+
32
+ /// This device's X25519 PUBLIC key — publish it (publishEncKey) / hand it to the owner as `publicKey` on
33
+ /// `grantAndWrap` so the scope key can be wrapped TO this device.
34
+ final String devicePublicKey;
35
+
36
+ SecureDeviceKeyStore._(this._inner, this.devicePublicKey);
37
+
38
+ /// Open (or first-time provision) the store for `principal`. On first run it MINTS an X25519 device keypair
39
+ /// in the kernel, persists the SECRET in secure storage, and returns the store; on later runs it reuses it.
40
+ static Future<SecureDeviceKeyStore> open({
41
+ required NomosBridge bridge,
42
+ required String principal,
43
+ FlutterSecureStorage storage = const FlutterSecureStorage(),
44
+ }) async {
45
+ final secretKey = 'nomos.enc.deviceSecret.$principal';
46
+ final publicKey = 'nomos.enc.devicePublic.$principal';
47
+ var secret = await storage.read(key: secretKey);
48
+ var public = await storage.read(key: publicKey);
49
+ if (secret == null || public == null) {
50
+ // FIRST RUN: mint the device keypair (secret stays client-side, never on chain) and persist it.
51
+ final kp = await bridge.cryptoKeygen();
52
+ secret = (kp['secret'] ?? '').toString();
53
+ public = (kp['public'] ?? '').toString();
54
+ await storage.write(key: secretKey, value: secret);
55
+ await storage.write(key: publicKey, value: public);
56
+ }
57
+ final inner = WrappedKeyStore(
58
+ bridge: bridge, principal: principal, deviceSecret: secret);
59
+ return SecureDeviceKeyStore._(inner, public);
60
+ }
61
+
62
+ @override
63
+ Future<NomosScopeKey?> scopeKey(String scope) => _inner.scopeKey(scope);
64
+
65
+ @override
66
+ Future<List<NomosScopeKey>> readKeys(List<String> scopes) =>
67
+ _inner.readKeys(scopes);
68
+
69
+ /// Convenience: the JSON a `publishEncKey` payload carries (bind the device pubkey to this principal on
70
+ /// chain so an owner's `grantAndWrap` can resolve it without you passing `publicKey` each time).
71
+ Map<String, Object?> publishPayload({required String publishedAt}) => {
72
+ 'principal': _inner.principal,
73
+ 'encPublicKey': devicePublicKey,
74
+ 'publishedAt': publishedAt,
75
+ };
76
+
77
+ @override
78
+ String toString() => 'SecureDeviceKeyStore(${jsonEncode({
79
+ 'principal': _inner.principal,
80
+ 'devicePublicKey': devicePublicKey,
81
+ })})';
82
+ }
@@ -15,6 +15,7 @@
15
15
  library;
16
16
 
17
17
  import 'dart:async';
18
+ import 'dart:convert';
18
19
  import 'dart:math';
19
20
 
20
21
  import 'errors.dart' show nomosErrorFromInfo;
@@ -292,6 +293,377 @@ class NomosBbox {
292
293
  {'minLng': minLng, 'minLat': minLat, 'maxLng': maxLng, 'maxLat': maxLat};
293
294
  }
294
295
 
296
+ // ── E2E SEALING (the sealing client) — the GENERIC key-custody surface. Generated domain clients bake the
297
+ // per-domain map + seal/read helpers, but these types are domain-agnostic, so they live here (in the
298
+ // nomos_client library) exactly like [MissingOfferAuthority] — one definition, re-exported per client. ──
299
+
300
+ /// A held E2E scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation).
301
+ class NomosScopeKey {
302
+ final String scope;
303
+ final int epoch;
304
+ final String key;
305
+ const NomosScopeKey(
306
+ {required this.scope, required this.epoch, required this.key});
307
+ Map<String, Object?> toJson() => {'scope': scope, 'epoch': epoch, 'key': key};
308
+ }
309
+
310
+ /// The app-supplied KEY CUSTODY surface (E2E sealing). Supply one to a generated `<Domain>Client` and its
311
+ /// encrypted directive fields SEAL automatically before dispatch + encrypted read fields DECRYPT
312
+ /// automatically. [WrappedKeyStore] is the default over the on-chain WrappedKeys; an app may implement this
313
+ /// to source keys from secure storage (the key custody policy stays the app's; the protocol plumbing does not).
314
+ abstract class NomosKeyStore {
315
+ /// The CURRENT (highest-epoch) scope key, for SEALING a write. null ⇒ EncryptionKeyUnavailable.
316
+ Future<NomosScopeKey?> scopeKey(String scope);
317
+
318
+ /// EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts.
319
+ Future<List<NomosScopeKey>> readKeys(List<String> scopes);
320
+ }
321
+
322
+ /// Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the write
323
+ /// NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway; we pre-fire).
324
+ class EncryptionKeyUnavailable implements Exception {
325
+ final String scope;
326
+ final String field;
327
+ String get code => 'nomos.crypto.key_unavailable';
328
+ String get recommendedAction => 'obtainScopeKey';
329
+ EncryptionKeyUnavailable(this.scope, this.field);
330
+ @override
331
+ String toString() =>
332
+ 'EncryptionKeyUnavailable: cannot seal "$field": no scope key held for "$scope" (obtain the cap + wrap the scope epoch first)';
333
+ }
334
+
335
+ /// THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching per
336
+ /// scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side, the
337
+ /// operator stays blind. Key custody (where `deviceSecret` lives — enclave, password-derived, …) is the app's.
338
+ class WrappedKeyStore implements NomosKeyStore {
339
+ final NomosBridge bridge;
340
+ final String principal;
341
+ final String deviceSecret;
342
+ final Map<String, Future<List<NomosScopeKey>>> _cache = {};
343
+ WrappedKeyStore(
344
+ {required this.bridge,
345
+ required this.principal,
346
+ required this.deviceSecret});
347
+
348
+ Future<List<NomosScopeKey>> _loadScope(String scope) {
349
+ return _cache.putIfAbsent(scope, () async {
350
+ final rows = await bridge
351
+ .query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
352
+ final held = <NomosScopeKey>[];
353
+ for (final row in rows) {
354
+ final d = ((row['data'] ?? row) as Map);
355
+ if (d['scope']?.toString() != scope) continue;
356
+ final key = await bridge.cryptoUnwrapKey(
357
+ secret: deviceSecret,
358
+ hpkeEpk: d['hpkeEpk'].toString(),
359
+ ct: d['ct'].toString());
360
+ held.add(NomosScopeKey(
361
+ scope: scope,
362
+ epoch: (d['epoch'] as num?)?.toInt() ?? 0,
363
+ key: key));
364
+ }
365
+ held.sort((a, b) => b.epoch - a.epoch);
366
+ return held;
367
+ });
368
+ }
369
+
370
+ @override
371
+ Future<NomosScopeKey?> scopeKey(String scope) async {
372
+ final ks = await _loadScope(scope);
373
+ return ks.isEmpty ? null : ks.first;
374
+ }
375
+
376
+ @override
377
+ Future<List<NomosScopeKey>> readKeys(List<String> scopes) async {
378
+ final all = <NomosScopeKey>[];
379
+ for (final s in scopes) {
380
+ all.addAll(await _loadScope(s));
381
+ }
382
+ return all;
383
+ }
384
+ }
385
+
386
+ // ── E2E OWNER CEREMONY (the sealing client, slice 2) — the owner-side twin of [WrappedKeyStore]. Domain-
387
+ // agnostic, so it lives here (re-exported per client) exactly like the KeyStore. It composes the framework
388
+ // key-epoch fan-out (`advanceKeyEpoch`, dispatched by its REAL id) with the domain's own grant/revoke
389
+ // directive, resolving the roster + each member's wrap-target pubkey FROM THE CHAIN — so the operator-blind
390
+ // handshake (grant → advance epoch → wrap to every member) is ONE call. Mirror of the TS `_ownerEncryption`. ──
391
+
392
+ /// Thrown by the owner ceremony when a roster member has NO wrap-target key (no publishEncKey on chain, no
393
+ /// prior wrap, no supplied override) — the ceremony NEVER wraps to a default (fail-closed; a keyless member
394
+ /// is "not yet a member", per e2e_encryption.md's FOOTGUNS).
395
+ class EncryptionMemberKeyMissing implements Exception {
396
+ final String principal;
397
+ final String scope;
398
+ String get code => 'nomos.crypto.member_key_missing';
399
+ String get recommendedAction => 'publishEncKey';
400
+ EncryptionMemberKeyMissing(this.principal, this.scope);
401
+ @override
402
+ String toString() =>
403
+ 'EncryptionMemberKeyMissing: cannot wrap the "$scope" scope key to member "$principal" — no encryption public key on chain (publishEncKey) and none supplied (fail-closed)';
404
+ }
405
+
406
+ /// Thrown when the resolved roster != the caller's declared `expectRoster` BEFORE dispatch — pre-firing the
407
+ /// kernel fan-out gate (wrap set == roster) so a rotation against a stale roster view never spends an epoch.
408
+ class EncryptionRosterMismatch implements Exception {
409
+ final String scope;
410
+ final List<String> missing;
411
+ final List<String> extra;
412
+ String get code => 'nomos.crypto.roster_mismatch';
413
+ String get recommendedAction => 'reconcileRoster';
414
+ EncryptionRosterMismatch(this.scope, this.missing, this.extra);
415
+ @override
416
+ String toString() =>
417
+ 'EncryptionRosterMismatch: roster for "$scope" != expected (the fan-out gate would refuse): missing $missing, unexpected $extra';
418
+ }
419
+
420
+ /// The outcome of an epoch-advancing ceremony verb: the new local [head], the [epoch] opened, and the
421
+ /// [roster] the key was wrapped to.
422
+ class NomosEpochResult {
423
+ final String head;
424
+ final int epoch;
425
+ final List<String> roster;
426
+ const NomosEpochResult(
427
+ {required this.head, required this.epoch, required this.roster});
428
+ }
429
+
430
+ /// THE OWNER CEREMONY surface — `<Domain>Client(bridge, actor: owner).encryption`. All three verbs open a
431
+ /// FRESH epoch (a fresh 32-byte secret) and fan the wraps to the resolved roster; the membership change (if
432
+ /// any) is the domain grant/revoke dispatched first, in the same call.
433
+ class NomosEncryption {
434
+ final NomosBridge bridge;
435
+ final String domain;
436
+ final String? domainHash;
437
+ final String? actor;
438
+
439
+ /// The encryption scopes this domain defines (each a `requires(cap)`).
440
+ final List<String> scopes;
441
+
442
+ NomosEncryption(
443
+ {required this.bridge,
444
+ required this.domain,
445
+ this.domainHash,
446
+ this.actor,
447
+ required this.scopes});
448
+
449
+ String _defScope([String? scope]) {
450
+ if (scope != null && scope.isNotEmpty) return scope;
451
+ if (scopes.length == 1) return scopes.first;
452
+ throw StateError(
453
+ 'this domain defines ${scopes.length} encryption scopes $scopes — pass a scope to pick one');
454
+ }
455
+
456
+ // A BARE scope IS `workspace:self#<relation>` (the era rule); a per-record scope is `<type>:<id>#<relation>`.
457
+ ({String object, String relation}) _parseScope(String scope) {
458
+ final h = scope.indexOf('#');
459
+ return h < 0
460
+ ? (object: 'workspace:self', relation: scope)
461
+ : (object: scope.substring(0, h), relation: scope.substring(h + 1));
462
+ }
463
+
464
+ /// The CURRENT roster of a scope (active direct-grant members, principal-form). Defaults to the sole scope.
465
+ Future<List<String>> roster([String? scope]) => _readRoster(_defScope(scope));
466
+
467
+ Future<List<String>> _readRoster(String scope) async {
468
+ final s = _parseScope(scope);
469
+ final rows =
470
+ await bridge.queryById('reltuple-${s.object}-${s.relation}');
471
+ final subjects = rows.isEmpty
472
+ ? const <String, dynamic>{}
473
+ : ((rows.first['data'] as Map?)?['subjects'] as Map?) ??
474
+ const <String, dynamic>{};
475
+ final out = <String>[];
476
+ subjects.forEach((subj, leafRaw) {
477
+ Map? leaf;
478
+ try {
479
+ leaf = leafRaw is String
480
+ ? jsonDecode(leafRaw) as Map
481
+ : (leafRaw as Map);
482
+ } catch (_) {
483
+ leaf = null;
484
+ }
485
+ final status = leaf?['status'];
486
+ if (status != null && status != 'active') return;
487
+ final p = subj.toString();
488
+ out.add(p.startsWith('user:') ? p.substring(5) : p);
489
+ });
490
+ out.sort();
491
+ return out;
492
+ }
493
+
494
+ Future<int> _currentEpoch(String scope) async {
495
+ final rows = await bridge.queryById('keyscope-$scope');
496
+ if (rows.isEmpty) return -1;
497
+ final e = (rows.first['data'] as Map?)?['currentEpoch'];
498
+ return e is num ? e.toInt() : -1;
499
+ }
500
+
501
+ Future<String> _freshScopeKey() async {
502
+ final kp = await bridge.cryptoKeygen();
503
+ final secret = base64Decode((kp['secret'] ?? '').toString());
504
+ return base64Encode(secret.sublist(0, 32)); // the 32-byte epoch secret; NEVER lands on chain
505
+ }
506
+
507
+ Future<String?> _memberPubKey(String principal,
508
+ {String? newcomer, String? newcomerPub, Map<String, String>? memberKeys}) async {
509
+ if (memberKeys != null && memberKeys[principal] != null) {
510
+ return memberKeys[principal];
511
+ }
512
+ if (newcomer == principal && newcomerPub != null) return newcomerPub;
513
+ // Published enc key (encKeyByPrincipal) — present only when the domain composed the registry; tolerate absence.
514
+ try {
515
+ final reg =
516
+ await bridge.query('encKeyByPrincipal', {'principal': principal});
517
+ if (reg.isNotEmpty) {
518
+ final pub = (reg.first['data'] as Map?)?['encPublicKey'];
519
+ if (pub != null && pub.toString().isNotEmpty) return pub.toString();
520
+ }
521
+ } catch (_) {/* not routed — fall through to the prior-wrap lane */}
522
+ // The member's most-recent WrappedKey pins the pubkey it was last sealed to (rotation needs no re-publish).
523
+ try {
524
+ final wraps = await bridge
525
+ .query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
526
+ int bestEpoch = -1;
527
+ String? bestPub;
528
+ for (final w in wraps) {
529
+ final d = (w['data'] as Map?) ?? const {};
530
+ final pub = d['recipientPubKey'];
531
+ final ep = (d['epoch'] as num?)?.toInt() ?? 0;
532
+ if (pub != null && ep >= bestEpoch) {
533
+ bestEpoch = ep;
534
+ bestPub = pub.toString();
535
+ }
536
+ }
537
+ if (bestPub != null) return bestPub;
538
+ } catch (_) {/* not routed */}
539
+ return null;
540
+ }
541
+
542
+ Future<NomosEpochResult> _advance(
543
+ String scope, {
544
+ String? addPrincipal,
545
+ String? addPublicKey,
546
+ List<String> remove = const [],
547
+ String? scopeKey,
548
+ Map<String, String>? memberKeys,
549
+ List<String>? expectRoster,
550
+ String? openedAt,
551
+ }) async {
552
+ final roster = (await _readRoster(scope)).toSet();
553
+ if (addPrincipal != null) roster.add(addPrincipal);
554
+ roster.removeAll(remove);
555
+ final members = roster.toList()..sort();
556
+ // PRE-FIRE the fan-out gate: if the caller declared the roster it means to wrap, refuse on drift.
557
+ if (expectRoster != null) {
558
+ final want = expectRoster.toSet();
559
+ final missing = (want.difference(roster).toList())..sort();
560
+ final extra = members.where((m) => !want.contains(m)).toList();
561
+ if (missing.isNotEmpty || extra.isNotEmpty) {
562
+ throw EncryptionRosterMismatch(scope, missing, extra);
563
+ }
564
+ }
565
+ final key = scopeKey ?? await _freshScopeKey();
566
+ final wraps = <Map<String, Object?>>[];
567
+ for (final principal in members) {
568
+ final pub = await _memberPubKey(principal,
569
+ newcomer: addPrincipal, newcomerPub: addPublicKey, memberKeys: memberKeys);
570
+ if (pub == null) throw EncryptionMemberKeyMissing(principal, scope);
571
+ final w = await bridge.cryptoWrapKey(recipientPub: pub, scopeKey: key);
572
+ wraps.add({
573
+ 'recipientPrincipal': principal,
574
+ 'recipientPubKey': pub,
575
+ 'hpkeEpk': (w['hpkeEpk'] ?? '').toString(),
576
+ 'ct': (w['ct'] ?? '').toString(),
577
+ });
578
+ }
579
+ final epoch = (await _currentEpoch(scope)) + 1; // forward-only: strictly increments
580
+ final head = await bridge.offerDirective(
581
+ domain: domain,
582
+ directiveId: 'advanceKeyEpoch',
583
+ payload: {
584
+ 'scope': scope,
585
+ 'epoch': epoch,
586
+ 'openedAt': openedAt ?? DateTime.now().toUtc().toIso8601String(),
587
+ 'wraps': wraps,
588
+ },
589
+ actor: actor,
590
+ domainHash: domainHash,
591
+ );
592
+ return NomosEpochResult(head: head, epoch: epoch, roster: members);
593
+ }
594
+
595
+ /// Grant a newcomer + open a fresh epoch wrapping the key to EVERY member (incl. the newcomer). Dispatches
596
+ /// `grantDirective(grantPayload)` first, then `advanceKeyEpoch`. `publicKey` is the newcomer's X25519 key
597
+ /// (else resolved from an on-chain publishEncKey). Existing members' keys come from their prior wraps.
598
+ Future<NomosEpochResult> grantAndWrap({
599
+ required String principal,
600
+ String? publicKey,
601
+ String? scope,
602
+ required String grantDirective,
603
+ required Map<String, Object?> grantPayload,
604
+ String? scopeKey,
605
+ Map<String, String>? memberKeys,
606
+ List<String>? expectRoster,
607
+ String? openedAt,
608
+ }) async {
609
+ final s = _defScope(scope);
610
+ await bridge.offerDirective(
611
+ domain: domain,
612
+ directiveId: grantDirective,
613
+ payload: grantPayload,
614
+ actor: actor,
615
+ domainHash: domainHash);
616
+ return _advance(s,
617
+ addPrincipal: principal,
618
+ addPublicKey: publicKey,
619
+ scopeKey: scopeKey,
620
+ memberKeys: memberKeys,
621
+ expectRoster: expectRoster,
622
+ openedAt: openedAt);
623
+ }
624
+
625
+ /// Revoke a member + open a fresh epoch re-wrapping the key to the SURVIVORS only. FORWARD-ONLY: the removed
626
+ /// member gets no NEW reads, but old ciphertext it already holds a key for stays readable (not re-encrypted).
627
+ Future<NomosEpochResult> revokeAndRotate({
628
+ required String principal,
629
+ String? scope,
630
+ required String revokeDirective,
631
+ required Map<String, Object?> revokePayload,
632
+ String? scopeKey,
633
+ Map<String, String>? memberKeys,
634
+ List<String>? expectRoster,
635
+ String? openedAt,
636
+ }) async {
637
+ final s = _defScope(scope);
638
+ await bridge.offerDirective(
639
+ domain: domain,
640
+ directiveId: revokeDirective,
641
+ payload: revokePayload,
642
+ actor: actor,
643
+ domainHash: domainHash);
644
+ return _advance(s,
645
+ remove: [principal],
646
+ scopeKey: scopeKey,
647
+ memberKeys: memberKeys,
648
+ expectRoster: expectRoster,
649
+ openedAt: openedAt);
650
+ }
651
+
652
+ /// Routine rotation — open a fresh epoch re-wrapping the key to the CURRENT roster (no membership change).
653
+ Future<NomosEpochResult> rotate({
654
+ String? scope,
655
+ String? scopeKey,
656
+ Map<String, String>? memberKeys,
657
+ List<String>? expectRoster,
658
+ String? openedAt,
659
+ }) =>
660
+ _advance(_defScope(scope),
661
+ scopeKey: scopeKey,
662
+ memberKeys: memberKeys,
663
+ expectRoster: expectRoster,
664
+ openedAt: openedAt);
665
+ }
666
+
295
667
  /// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
296
668
  class NomosOfferOutcome {
297
669
  final String head;
@@ -771,14 +1143,78 @@ class NomosBridge {
771
1143
  Map<String, Object?> args) async =>
772
1144
  _asMap(await _call('attestationSign', {'args': args}));
773
1145
 
774
- /// A declared query's rows from the local projection.
1146
+ /// A declared query's rows from the local projection. `keys` (E2E sealing) injects the reader's held
1147
+ /// scope keys so encrypted fields AEAD-open in-wasm; absent ⇒ they stay opaque envelopes (today's
1148
+ /// behavior). `principal` stamps the private-read gate (defaults to the connection's bound principal).
1149
+ /// Both are OMITTED from the wire when absent, so a public un-keyed read is byte-identical to before.
775
1150
  Future<List<Map<String, dynamic>>> query(String queryId,
776
- [Map<String, Object?> params = const {}]) async =>
777
- _asRows(await _call('query', {'queryId': queryId, 'params': params}));
1151
+ [Map<String, Object?> params = const {},
1152
+ List<NomosScopeKey>? keys,
1153
+ String? principal]) async =>
1154
+ _asRows(await _call('query', {
1155
+ 'queryId': queryId,
1156
+ 'params': params,
1157
+ ...?_readAuthArgs(keys, principal),
1158
+ }));
1159
+
1160
+ /// A single aggregate by id (the by-id read). `keys`/`principal`: see [query].
1161
+ Future<List<Map<String, dynamic>>> queryById(String id,
1162
+ [List<NomosScopeKey>? keys, String? principal]) async =>
1163
+ _asRows(await _call('queryById', {
1164
+ 'id': id,
1165
+ ...?_readAuthArgs(keys, principal),
1166
+ }));
1167
+
1168
+ /// The read auth sidecar (private reads + E2E): `keys`/`principal` as top-level call args, or null when
1169
+ /// neither is present (so the wire is byte-identical to before this existed).
1170
+ Map<String, Object?>? _readAuthArgs(List<NomosScopeKey>? keys, String? principal) {
1171
+ final out = <String, Object?>{};
1172
+ if (principal != null && principal.isNotEmpty) out['principal'] = principal;
1173
+ if (keys != null && keys.isNotEmpty) out['keys'] = keys.map((k) => k.toJson()).toList();
1174
+ return out.isEmpty ? null : out;
1175
+ }
778
1176
 
779
- /// A single aggregate by id (the by-id read).
780
- Future<List<Map<String, dynamic>>> queryById(String id) async =>
781
- _asRows(await _call('queryById', {'id': id}));
1177
+ /// E2E CRYPTO OPS (operator-blind sealing) — routed to the kernel via the WebView runner. XChaCha isn't in
1178
+ /// WebCrypto/Dart, so these drive the wasm; the scope key + device secret are INJECTED per call and never
1179
+ /// touch the chain. Mirror of the TS holon's crypto seam (@githolon/client).
1180
+ /// Mint an X25519 device keypair for the handshake: `{secret, public}` (base64). Secret stays on device.
1181
+ Future<Map<String, Object?>> cryptoKeygen() async =>
1182
+ _asMap(await _call('cryptoKeygen'));
1183
+
1184
+ /// AUTHOR-SIDE SEAL: encrypt `plaintext` into its on-chain ciphertext envelope, bound to the scope + #58
1185
+ /// stable sids (rename-safe). Returns the envelope string the encrypted field value is written as.
1186
+ Future<String> cryptoSealField(
1187
+ {required String scopeKey,
1188
+ required String scope,
1189
+ required int epoch,
1190
+ required String aggSid,
1191
+ required String fieldSid,
1192
+ required String plaintext}) async =>
1193
+ (_asMap(await _call('cryptoSealField', {
1194
+ 'scopeKey': scopeKey,
1195
+ 'scope': scope,
1196
+ 'epoch': epoch,
1197
+ 'aggSid': aggSid,
1198
+ 'fieldSid': fieldSid,
1199
+ 'plaintext': plaintext,
1200
+ }))['envelope'] ?? '')
1201
+ .toString();
1202
+
1203
+ /// Wrap a scope key TO a recipient's public key (committer side): `{hpkeEpk, ct}` base64 ciphertext.
1204
+ Future<Map<String, Object?>> cryptoWrapKey(
1205
+ {required String recipientPub, required String scopeKey}) async =>
1206
+ _asMap(await _call('cryptoWrapKey',
1207
+ {'recipientPub': recipientPub, 'scopeKey': scopeKey}));
1208
+
1209
+ /// Unwrap an on-chain WrappedKey with this device's secret → the base64 scope key (reader side).
1210
+ Future<String> cryptoUnwrapKey(
1211
+ {required String secret,
1212
+ required String hpkeEpk,
1213
+ required String ct}) async =>
1214
+ (_asMap(await _call('cryptoUnwrapKey',
1215
+ {'secret': secret, 'hpkeEpk': hpkeEpk, 'ct': ct}))['scopeKey'] ??
1216
+ '')
1217
+ .toString();
782
1218
 
783
1219
  /// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
784
1220
  /// 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.49.0",
3
+ "version": "0.51.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"