@githolon/dsl 0.96.0 → 0.97.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/dart/CHANGELOG.md +17 -0
- package/dart/lib/src/dev_cloud.dart +24 -5
- package/dart/lib/src/test_harness.dart +150 -28
- package/dart/lib/src/webview_bridge.dart +31 -10
- package/dart/pubspec.yaml +1 -1
- package/dart/test/cloud_bound_topology_test.dart +9 -2
- package/dart/test/cluster_births_test.dart +15 -8
- package/dart/test/first_app_gauntlet_test.dart +6 -1
- package/dart/test/four_layer_nested_topology_test.dart +8 -4
- package/dart/test/parent_signer_recovery_test.dart +3 -2
- package/dart/test/session_bound_test.dart +11 -1
- package/dart/test/watch_surfaces_test.QUARANTINED.md +17 -0
- package/package.json +1 -1
- package/src/compile_package_main.ts +1 -1
- package/src/framework/genesis.ts +5 -5
- package/src/framework/home.ts +26 -13
- package/src/framework/workspaces.ts +35 -34
- package/dart/test/co2_acceptance_test.dart +0 -101
- package/dart/test/watch_surfaces_test.dart +0 -240
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## 0.61.0
|
|
2
|
+
|
|
3
|
+
- **ERA-0 cloud-bound harness** — `NomosTestHarness.cloudBound` now runs the full WARRANTED onboarding
|
|
4
|
+
ceremony against the test cloud: the platform is BORN warranted+attested at cloud boot
|
|
5
|
+
(`NomosTestCloud.start(platform:, deployJsonPath:)`), the owner's device enrols through the REAL
|
|
6
|
+
first-device lane (`/dev/mint-token` → `enrollFirstDevice`), and `birthHome` is device-signed +
|
|
7
|
+
attested with `ownerKeyHash` (the born home is SIGNER-READY — it births estates/catalogues offline
|
|
8
|
+
from intent 0). Six named phases, all deadlined.
|
|
9
|
+
- **`NomosTestCloud.start(platform:, deployJsonPath:)`** — the dev cloud stands the platform up at boot
|
|
10
|
+
(era-0: the host does not create workspaces; the harness's old POST-create refuses 409 by design).
|
|
11
|
+
`/health` gates on `platformReady: true` so clients never race the ceremony.
|
|
12
|
+
- **`acquireSession(…, authorSecret:)`** — sign a locally-born (warranted) child session's writes with
|
|
13
|
+
its owner's device key; `deviceSecret(workspace)`/`devicePublicKey(workspace)` expose the harness
|
|
14
|
+
device (the harness IS the device) for `.births()` payload `ownerKeyHash` derivation.
|
|
15
|
+
- Requires `githolon`/`@githolon/client` ≥ the era-0 release (the client auto-signs self-serve birth
|
|
16
|
+
certs on the kernel's typed `birth-unattestable` refusal — apps never hand-roll certs).
|
|
17
|
+
|
|
1
18
|
## 0.56.0
|
|
2
19
|
|
|
3
20
|
- **NomosTestCloud (co2 #4)** — a headless, cloud-BOUND real-sync test harness: start/stop a local
|
|
@@ -93,6 +93,16 @@ class NomosTestCloud {
|
|
|
93
93
|
bool ephemeral = true,
|
|
94
94
|
String? dataDir,
|
|
95
95
|
String node = 'node',
|
|
96
|
+
|
|
97
|
+
/// ERA-0: a workspace is BORN, never host-created — so a platform the harness will offer births to must
|
|
98
|
+
/// be stood up BY THE CLOUD ITSELF at boot (the dev server's in-process operator lane, `--platform`).
|
|
99
|
+
/// A [NomosTestHarness.cloudBound] against this cloud REQUIRES this to name its `platformWorkspace`
|
|
100
|
+
/// (the old lane — the harness POST-creating the platform — refuses 409 `unborn` by design).
|
|
101
|
+
String? platform,
|
|
102
|
+
|
|
103
|
+
/// Optional platform law to deploy at boot (`--deploy build/<name>.deploy.json`) — the dev server
|
|
104
|
+
/// installs it on [platform] before serving. The harness may also deploy law itself post-boot.
|
|
105
|
+
String? deployJsonPath,
|
|
96
106
|
Duration readyTimeout = const Duration(seconds: 30),
|
|
97
107
|
void Function(String line)? onDiagnostic,
|
|
98
108
|
}) async {
|
|
@@ -102,6 +112,8 @@ class NomosTestCloud {
|
|
|
102
112
|
'--port', '$chosenPort',
|
|
103
113
|
if (ephemeral) '--ephemeral',
|
|
104
114
|
if (dataDir != null) ...['--data', dataDir],
|
|
115
|
+
if (platform != null) ...['--platform', platform],
|
|
116
|
+
if (deployJsonPath != null) ...['--deploy', deployJsonPath],
|
|
105
117
|
];
|
|
106
118
|
void log(String m) => onDiagnostic?.call('[nomos-test-cloud] $m');
|
|
107
119
|
log('spawning: $node ${args.join(' ')}');
|
|
@@ -111,23 +123,30 @@ class NomosTestCloud {
|
|
|
111
123
|
|
|
112
124
|
final upstream = Uri.parse('http://127.0.0.1:$chosenPort');
|
|
113
125
|
final client = HttpClient();
|
|
114
|
-
// Wait for /health
|
|
126
|
+
// Wait for /health — and, when a platform was requested, for `platformReady: true` (the boot-time
|
|
127
|
+
// platform ceremony: genesis → law deploy → K_root ceremony → warrant). Racing it would hit the
|
|
128
|
+
// half-born platform's typed refusals.
|
|
115
129
|
final deadline = DateTime.now().add(readyTimeout);
|
|
116
130
|
var ready = false;
|
|
117
131
|
while (DateTime.now().isBefore(deadline)) {
|
|
118
132
|
try {
|
|
119
133
|
final req = await client.getUrl(upstream.replace(path: '/health'));
|
|
120
134
|
final resp = await req.close();
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
final body = await resp.transform(utf8.decoder).join();
|
|
136
|
+
if (resp.statusCode == 200) {
|
|
137
|
+
final health = jsonDecode(body) as Map<String, dynamic>;
|
|
138
|
+
final platformReady = health['platformReady'];
|
|
139
|
+
if (platform == null || platformReady == true) { ready = true; break; }
|
|
140
|
+
}
|
|
123
141
|
} catch (_) {/* not up yet */}
|
|
124
142
|
await Future<void>.delayed(const Duration(milliseconds: 200));
|
|
125
143
|
}
|
|
126
144
|
if (!ready) {
|
|
127
145
|
proc.kill(ProcessSignal.sigkill);
|
|
128
|
-
throw NomosTestCloudException(
|
|
146
|
+
throw NomosTestCloudException(
|
|
147
|
+
'the dev cloud did not answer /health${platform != null ? ' with platformReady:true' : ''} within ${readyTimeout.inSeconds}s (serverEntry: $serverEntry)');
|
|
129
148
|
}
|
|
130
|
-
log('dev cloud healthy at $upstream');
|
|
149
|
+
log('dev cloud healthy at $upstream${platform != null ? ' (platform ceremony complete)' : ''}');
|
|
131
150
|
|
|
132
151
|
// The partition proxy — forwards to the dev cloud when ONLINE, fails fast when OFFLINE.
|
|
133
152
|
final proxy = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
|
@@ -129,6 +129,10 @@ class NomosTestHarness {
|
|
|
129
129
|
/// workspace → the base64 device PUBLIC key it was opened with (only for [cluster]'s `deviceKeyed` set).
|
|
130
130
|
final Map<String, String> _devicePublics;
|
|
131
131
|
|
|
132
|
+
/// workspace → the base64 device SECRET it was opened with (TEST HARNESS ONLY — the harness models the
|
|
133
|
+
/// device, so the test app may sign with the owner's key, e.g. to acquire a locally-born child session).
|
|
134
|
+
final Map<String, String> _deviceSecrets;
|
|
135
|
+
|
|
132
136
|
/// The nomos framework controller's content hash from the offline install — the `frameworkHash` a
|
|
133
137
|
/// `.births()` genesis installs (not derivable client-side). Same for every workspace on the plane.
|
|
134
138
|
final String? frameworkHash;
|
|
@@ -172,6 +176,7 @@ class NomosTestHarness {
|
|
|
172
176
|
NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
|
|
173
177
|
this._onDiagnostic, List<String> sessionNames,
|
|
174
178
|
{Map<String, String> devicePublics = const {},
|
|
179
|
+
Map<String, String> deviceSecrets = const {},
|
|
175
180
|
this.frameworkHash,
|
|
176
181
|
Map<String, List<String>> lawHashes = const {},
|
|
177
182
|
Map<String, String> snapshots = const {},
|
|
@@ -181,6 +186,7 @@ class NomosTestHarness {
|
|
|
181
186
|
this.homeWorkspace})
|
|
182
187
|
: _sessionNames = List.of(sessionNames), // GROWABLE — restoreWorkspace may add a fresh session
|
|
183
188
|
_devicePublics = devicePublics,
|
|
189
|
+
_deviceSecrets = deviceSecrets,
|
|
184
190
|
_lawHashes = Map.of(lawHashes),
|
|
185
191
|
_snapshots = Map.of(snapshots),
|
|
186
192
|
_installLane = installLane,
|
|
@@ -226,6 +232,11 @@ class NomosTestHarness {
|
|
|
226
232
|
/// `sha256(base64decode(...))` is the attestor keyHash a reader enrols to trust this source's signed reads.
|
|
227
233
|
String? devicePublicKey(String workspace) => _devicePublics[workspace];
|
|
228
234
|
|
|
235
|
+
/// The base64 device SECRET a session was opened with (TEST HARNESS ONLY — the harness IS the device;
|
|
236
|
+
/// a real app keeps this in secure storage). Sign writes to locally-born children with it, e.g.
|
|
237
|
+
/// `bridge.acquireSession(childWs, authorSecret: harness.deviceSecret(home))`.
|
|
238
|
+
String? deviceSecret(String workspace) => _deviceSecrets[workspace];
|
|
239
|
+
|
|
229
240
|
/// The workspace names on this device's local plane (one for [ephemeral], N for [cluster]).
|
|
230
241
|
List<String> get sessions => List.unmodifiable(_sessionNames);
|
|
231
242
|
|
|
@@ -363,6 +374,10 @@ class NomosTestHarness {
|
|
|
363
374
|
workspace: workspace,
|
|
364
375
|
clientId: clientId,
|
|
365
376
|
allowUnborn: true,
|
|
377
|
+
// THE HARNESS DEVICE (era-0): the install lane mints one device key per plane — sessions
|
|
378
|
+
// sign with it, and the client completes the self-serve birth ceremony (enrol + cert)
|
|
379
|
+
// with the SAME key, so offline `.births()` offers Just Work.
|
|
380
|
+
authorSecret: info['deviceSecret']?.toString(),
|
|
366
381
|
));
|
|
367
382
|
phase('ready — session "$workspace" connected (law ${(info['lawHashes'] as List?)?.isNotEmpty == true ? (info['lawHashes'] as List).first.toString().substring(0, 12) : "installed"})');
|
|
368
383
|
// THE DECOY SESSION (multi-workspace by default in tests): sole-session inference is how
|
|
@@ -390,6 +405,12 @@ class NomosTestHarness {
|
|
|
390
405
|
}
|
|
391
406
|
final harness = NomosTestHarness._(
|
|
392
407
|
plane.session(workspace), workspace, transport, workdir, onDiagnostic, [workspace],
|
|
408
|
+
devicePublics: {
|
|
409
|
+
if (info['devicePublic'] != null) workspace: info['devicePublic'].toString(),
|
|
410
|
+
},
|
|
411
|
+
deviceSecrets: {
|
|
412
|
+
if (info['deviceSecret'] != null) workspace: info['deviceSecret'].toString(),
|
|
413
|
+
},
|
|
393
414
|
frameworkHash: info['frameworkHash']?.toString(),
|
|
394
415
|
lawHashes: {
|
|
395
416
|
workspace: ((info['lawHashes'] as List?) ?? const [])
|
|
@@ -476,6 +497,8 @@ class NomosTestHarness {
|
|
|
476
497
|
// 1. install each workspace's law offline → one snapshot per workspace (colocated, still separate law).
|
|
477
498
|
final snapshots = <String, String>{};
|
|
478
499
|
final lawHashesByWs = <String, List<String>>{};
|
|
500
|
+
final installDevicePublics = <String, String>{};
|
|
501
|
+
final installDeviceSecrets = <String, String>{};
|
|
479
502
|
String? frameworkHash;
|
|
480
503
|
var _i = 0;
|
|
481
504
|
for (final e in workspaces.entries) {
|
|
@@ -505,6 +528,10 @@ class NomosTestHarness {
|
|
|
505
528
|
.map((x) => x.toString())
|
|
506
529
|
.toList();
|
|
507
530
|
frameworkHash ??= info['frameworkHash']?.toString();
|
|
531
|
+
// THE HARNESS DEVICE (era-0): the install lane mints one device key per workspace — sessions sign
|
|
532
|
+
// with it and the client completes the self-serve birth ceremony (enrol + cert) with the SAME key.
|
|
533
|
+
if (info['devicePublic'] != null) installDevicePublics[e.key] = info['devicePublic'].toString();
|
|
534
|
+
if (info['deviceSecret'] != null) installDeviceSecrets[e.key] = info['deviceSecret'].toString();
|
|
508
535
|
}
|
|
509
536
|
|
|
510
537
|
// 2. ONE runner / ONE bridge for the whole plane — no env restore default; each connect brings its own.
|
|
@@ -558,6 +585,8 @@ class NomosTestHarness {
|
|
|
558
585
|
clientId: '$clientId-${e.key}',
|
|
559
586
|
restoreFrom: snapshotB64[e.key],
|
|
560
587
|
allowUnborn: true,
|
|
588
|
+
// signed with the install-lane harness device (era-0 self-serve birth ceremony key)
|
|
589
|
+
authorSecret: installDeviceSecrets[e.key],
|
|
561
590
|
));
|
|
562
591
|
}
|
|
563
592
|
phase('cluster: ${workspaces.length} session(s) connected');
|
|
@@ -567,6 +596,7 @@ class NomosTestHarness {
|
|
|
567
596
|
// The re-open is the runner's ATOMIC session replace (dispose-then-connect under the hood); each
|
|
568
597
|
// leg runs as a named, deadlined phase so a wedge is a typed HarnessPhaseTimeout, never a hang.
|
|
569
598
|
final devicePublics = <String, String>{};
|
|
599
|
+
final deviceSecretsKeyed = <String, String>{};
|
|
570
600
|
for (final ws in deviceKeyed) {
|
|
571
601
|
if (!workspaces.containsKey(ws)) {
|
|
572
602
|
throw NomosTestHarnessException(
|
|
@@ -581,6 +611,7 @@ class NomosTestHarness {
|
|
|
581
611
|
onDiagnostic: onDiagnostic,
|
|
582
612
|
run: () => bridge.session(ws).authorKeygen());
|
|
583
613
|
devicePublics[ws] = (kp['public'] ?? '').toString();
|
|
614
|
+
deviceSecretsKeyed[ws] = (kp['secret'] ?? '').toString();
|
|
584
615
|
await _phased(
|
|
585
616
|
phase: "3b: keyed reconnect of '$ws'",
|
|
586
617
|
workspace: ws,
|
|
@@ -608,7 +639,9 @@ class NomosTestHarness {
|
|
|
608
639
|
final names = workspaces.keys.toList();
|
|
609
640
|
final harness = NomosTestHarness._(
|
|
610
641
|
bridge, names.first, transport, workdir, onDiagnostic, names,
|
|
611
|
-
|
|
642
|
+
// install-lane harness devices under; explicit deviceKeyed re-opens override for their ws
|
|
643
|
+
devicePublics: {...installDevicePublics, ...devicePublics},
|
|
644
|
+
deviceSecrets: {...installDeviceSecrets, ...deviceSecretsKeyed},
|
|
612
645
|
frameworkHash: frameworkHash,
|
|
613
646
|
lawHashes: lawHashesByWs,
|
|
614
647
|
snapshots: snapshotB64,
|
|
@@ -704,18 +737,33 @@ class NomosTestHarness {
|
|
|
704
737
|
final workdir = await Directory.systemTemp.createTemp('nomos-harness-');
|
|
705
738
|
NodeRunnerTransport? transport;
|
|
706
739
|
try {
|
|
707
|
-
// 1/
|
|
708
|
-
//
|
|
740
|
+
// 1/6 — the remote control plane: BOOT the BORN platform + deploy the law under the test cloud's own
|
|
741
|
+
// trust universe. ERA-0: the host does not create workspaces — the platform must have been stood
|
|
742
|
+
// up BY THE CLOUD at boot (`NomosTestCloud.start(platform: '$platformWorkspace')`, the dev
|
|
743
|
+
// server's in-process operator lane). Booting a born workspace is idempotent; an unborn one
|
|
744
|
+
// refuses 409, surfaced here with the fix.
|
|
709
745
|
String? frameworkHash;
|
|
710
746
|
final lawHashes = <String>[];
|
|
711
747
|
await _phased(
|
|
712
|
-
phase: '1/
|
|
748
|
+
phase: '1/6: boot the born platform + deploy law on the test cloud',
|
|
713
749
|
workspace: platformWorkspace,
|
|
714
750
|
awaiting: 'POST /v2/workspaces + /domains',
|
|
715
751
|
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
716
752
|
onDiagnostic: onDiagnostic,
|
|
717
753
|
run: () async {
|
|
718
|
-
|
|
754
|
+
Map<String, Object?> created;
|
|
755
|
+
try {
|
|
756
|
+
created = await post('/v2/workspaces/$platformWorkspace', null);
|
|
757
|
+
} on NomosTestHarnessException catch (e) {
|
|
758
|
+
if (e.message.contains('unborn')) {
|
|
759
|
+
throw NomosTestHarnessException(
|
|
760
|
+
"cloudBound: platform '$platformWorkspace' is UNBORN on the test cloud — era-0 has no "
|
|
761
|
+
"host-side create. Start the cloud with it: "
|
|
762
|
+
"NomosTestCloud.start(serverEntry: …, platform: '$platformWorkspace') "
|
|
763
|
+
'(the dev server births it at boot via its operator lane).');
|
|
764
|
+
}
|
|
765
|
+
rethrow;
|
|
766
|
+
}
|
|
719
767
|
frameworkHash =
|
|
720
768
|
((created['controller'] as Map?)?['domainHash'])?.toString();
|
|
721
769
|
for (final p in deployJsonPaths) {
|
|
@@ -734,46 +782,111 @@ class NomosTestHarness {
|
|
|
734
782
|
throw NomosTestHarnessException(
|
|
735
783
|
'cloudBound: no deploy.json carried a summary.domainHash — cannot run the birth ceremony (lawHash unknown)');
|
|
736
784
|
}
|
|
737
|
-
// 2/
|
|
738
|
-
//
|
|
785
|
+
// 2/6 — the LOCAL engine plane, CLOUD-BOUND, spawned BEFORE the birth: the runner's local kernel is
|
|
786
|
+
// what mints the device key + signs the birth cert (era-0: a birth is ATTESTED or not at all).
|
|
787
|
+
transport = await _phased(
|
|
788
|
+
phase: '2/6: spawn the node runner (cloud-bound)',
|
|
789
|
+
awaiting: 'Process.start($node)',
|
|
790
|
+
timeout: phaseTimeout ?? _kSpawnDeadline,
|
|
791
|
+
onDiagnostic: onDiagnostic,
|
|
792
|
+
run: () => NodeRunnerTransport.start(
|
|
793
|
+
entry: entry, resolveFrom: resolveFrom, node: node, onDiagnostic: onDiagnostic));
|
|
794
|
+
final plane = NomosBridge(transport!);
|
|
795
|
+
await _phased(
|
|
796
|
+
phase: '3/6: runner ready',
|
|
797
|
+
awaiting: "event:'ready'",
|
|
798
|
+
timeout: phaseTimeout ?? readyTimeout,
|
|
799
|
+
onDiagnostic: onDiagnostic,
|
|
800
|
+
run: () => plane.ready);
|
|
801
|
+
// 4/6 — THE ERA-0 DEVICE CEREMONY (the WARRANTED onboarding lane, exactly production's shape): mint the
|
|
802
|
+
// owner's device key in the LOCAL kernel, get an IdP token from the dev cloud's test IdP
|
|
803
|
+
// (device-bound: nonce == the device keyHash), and enrol the FIRST DEVICE against the platform's
|
|
804
|
+
// on-chain AuthProvider (enrollFirstDevice — no admin in the loop). The platform is WARRANTED, so
|
|
805
|
+
// plain unsigned enrolment refuses by design; this is the one first-device door.
|
|
806
|
+
final device = await _phased(
|
|
807
|
+
phase: '4/6: device ceremony (keygen → dev-IdP token → enrollFirstDevice)',
|
|
808
|
+
workspace: platformWorkspace,
|
|
809
|
+
awaiting: 'authorKeygen + /dev/mint-token + enrollFirstDevice',
|
|
810
|
+
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
811
|
+
onDiagnostic: onDiagnostic,
|
|
812
|
+
run: () async {
|
|
813
|
+
// The runner's crypto ops (authorKeygen/signBirthCert) run on a CONNECTED holon — open an
|
|
814
|
+
// onboarding session against the PLATFORM first (reads are open; this is the enrol surface).
|
|
815
|
+
await plane.connect(
|
|
816
|
+
cloud: cloud.origin,
|
|
817
|
+
workspace: platformWorkspace,
|
|
818
|
+
clientId: '$clientId-onboard',
|
|
819
|
+
authToken: authToken,
|
|
820
|
+
);
|
|
821
|
+
final kp = await plane.authorKeygen();
|
|
822
|
+
final publicB64 = (kp['public'] ?? '').toString();
|
|
823
|
+
final secret = (kp['secret'] ?? '').toString();
|
|
824
|
+
if (publicB64.isEmpty || secret.isEmpty) {
|
|
825
|
+
throw NomosTestHarnessException('cloudBound: authorKeygen returned no device key');
|
|
826
|
+
}
|
|
827
|
+
final keyHash = sha256.convert(base64Decode(publicB64)).toString();
|
|
828
|
+
final minted = await post('/dev/mint-token', {'sub': bareUid, 'nonce': keyHash});
|
|
829
|
+
final idpToken = (minted['token'] ?? '').toString();
|
|
830
|
+
final issuer = (minted['issuer'] ?? '').toString();
|
|
831
|
+
if (idpToken.isEmpty || issuer.isEmpty) {
|
|
832
|
+
throw NomosTestHarnessException('cloudBound: /dev/mint-token returned no token — is the test cloud started with platform:/deployJsonPath: and a law composing setAuthProvider?');
|
|
833
|
+
}
|
|
834
|
+
await post('/v2/workspaces/$platformWorkspace/enrollFirstDevice', {
|
|
835
|
+
'keyHash': keyHash,
|
|
836
|
+
'verifiedVia': issuer,
|
|
837
|
+
'validFromEpoch': 0,
|
|
838
|
+
'grantedAt': DateTime.now().toUtc().toIso8601String(),
|
|
839
|
+
'idpToken': idpToken,
|
|
840
|
+
'actor': subject,
|
|
841
|
+
'authorSecret': secret,
|
|
842
|
+
}, headers: {'x-nomos-auth': idpToken});
|
|
843
|
+
return (publicB64: publicB64, secret: secret, keyHash: keyHash, idpToken: idpToken);
|
|
844
|
+
});
|
|
845
|
+
// 5/6 — THE HOME CEREMONY, remotely: the platform's own gateway offer births the home (the ONE
|
|
846
|
+
// control-plane contact; the app lane below never touches the platform again). The offer carries
|
|
847
|
+
// the device-signed homeCert + the owner's device keyHash (the home's genesis ENROLS it, so the
|
|
848
|
+
// born home is SIGNER-READY — it can birth its own estates/catalogues from intent 0).
|
|
739
849
|
String? home;
|
|
740
850
|
await _phased(
|
|
741
|
-
phase: '
|
|
851
|
+
phase: '5/6: remote home-birth ceremony (attested)',
|
|
742
852
|
workspace: platformWorkspace,
|
|
743
853
|
awaiting: 'POST /v2/workspaces/$platformWorkspace/birthHome',
|
|
744
854
|
timeout: phaseTimeout ?? _kInstallDeadline,
|
|
745
855
|
onDiagnostic: onDiagnostic,
|
|
746
856
|
run: () async {
|
|
857
|
+
final homeName = _homeNameOf(subject);
|
|
858
|
+
final homeCert = await plane.signBirthCert({
|
|
859
|
+
'child': homeName,
|
|
860
|
+
'founder': subject,
|
|
861
|
+
// The cert's `verified` must MATCH the gate verdict (CAS-as-law): the offer carries the
|
|
862
|
+
// dev-IdP token, so the gate resolves a VERIFIED founder.
|
|
863
|
+
'verified': true,
|
|
864
|
+
'parentKey': device.publicB64,
|
|
865
|
+
'parentId': platformWorkspace,
|
|
866
|
+
'epoch': 0,
|
|
867
|
+
}, device.secret);
|
|
747
868
|
final out = await post('/v2/workspaces/$platformWorkspace/birthHome', {
|
|
748
869
|
'owner': subject,
|
|
870
|
+
'ownerKeyHash': device.keyHash,
|
|
749
871
|
'frameworkHash': frameworkHash,
|
|
750
872
|
'lawHash': lawHashes.first,
|
|
751
873
|
'bornAt': DateTime.now().toUtc().toIso8601String(),
|
|
752
|
-
|
|
874
|
+
'homeCert': jsonEncode(homeCert),
|
|
875
|
+
'actor': subject,
|
|
876
|
+
'authorSecret': device.secret,
|
|
877
|
+
}, headers: {'x-nomos-auth': device.idpToken});
|
|
753
878
|
final born = (out['born'] as List?) ?? const [];
|
|
754
879
|
final bornNames = born.map((b) => (b as Map)['workspace']?.toString()).whereType<String>().where((w) => w.isNotEmpty).toList();
|
|
755
880
|
home = bornNames.isEmpty ? null : bornNames.first;
|
|
756
|
-
home
|
|
881
|
+
if (home == null) {
|
|
882
|
+
final outcomes = jsonEncode(out['birthOutcomes'] ?? const []);
|
|
883
|
+
throw NomosTestHarnessException(
|
|
884
|
+
'cloudBound: birthHome admitted but spawned no home (birthOutcomes=$outcomes)');
|
|
885
|
+
}
|
|
757
886
|
});
|
|
758
|
-
//
|
|
759
|
-
// home's custody from the test cloud like a real device).
|
|
760
|
-
transport = await _phased(
|
|
761
|
-
phase: '3/5: spawn the node runner (cloud-bound)',
|
|
762
|
-
awaiting: 'Process.start($node)',
|
|
763
|
-
timeout: phaseTimeout ?? _kSpawnDeadline,
|
|
764
|
-
onDiagnostic: onDiagnostic,
|
|
765
|
-
run: () => NodeRunnerTransport.start(
|
|
766
|
-
entry: entry, resolveFrom: resolveFrom, node: node, onDiagnostic: onDiagnostic));
|
|
767
|
-
final plane = NomosBridge(transport!);
|
|
768
|
-
await _phased(
|
|
769
|
-
phase: '4/5: runner ready',
|
|
770
|
-
awaiting: "event:'ready'",
|
|
771
|
-
timeout: phaseTimeout ?? readyTimeout,
|
|
772
|
-
onDiagnostic: onDiagnostic,
|
|
773
|
-
run: () => plane.ready);
|
|
774
|
-
// 5/5 — mount the born home as a normal cloud-bound session (clone from the test cloud).
|
|
887
|
+
// 6/6 — mount the born home as a normal cloud-bound session (clone from the test cloud).
|
|
775
888
|
await _phased(
|
|
776
|
-
phase: '
|
|
889
|
+
phase: '6/6: mount the born home',
|
|
777
890
|
workspace: home,
|
|
778
891
|
awaiting: 'connect(cloud:, workspace: $home)',
|
|
779
892
|
timeout: phaseTimeout ?? connectTimeout,
|
|
@@ -783,6 +896,10 @@ class NomosTestHarness {
|
|
|
783
896
|
workspace: home!,
|
|
784
897
|
clientId: clientId,
|
|
785
898
|
authToken: authToken,
|
|
899
|
+
// The home was born WARRANTED with THIS device enrolled in its genesis (ownerKeyHash) —
|
|
900
|
+
// connect with the same key so every write is signed and the home's own `.births()` offers
|
|
901
|
+
// (estates/catalogues) are device-attested (the client signs the birth cert automatically).
|
|
902
|
+
authorSecret: device.secret,
|
|
786
903
|
));
|
|
787
904
|
// NO DECOY on a cloudBound harness: the app is genuinely multi-workspace (the home plus every
|
|
788
905
|
// locally-born child), so the plane is never sole-session — the decoy the offline harness
|
|
@@ -795,6 +912,11 @@ class NomosTestHarness {
|
|
|
795
912
|
workdir,
|
|
796
913
|
onDiagnostic,
|
|
797
914
|
[home!],
|
|
915
|
+
// The owner's device (enrolled in the home's genesis via ownerKeyHash) — the app derives the
|
|
916
|
+
// keyHash for its own `.births()` payloads from [devicePublicKey] and signs locally-born-child
|
|
917
|
+
// sessions with [deviceSecret].
|
|
918
|
+
devicePublics: {home!: device.publicB64},
|
|
919
|
+
deviceSecrets: {home!: device.secret},
|
|
798
920
|
frameworkHash: frameworkHash,
|
|
799
921
|
lawHashes: {home!: lawHashes},
|
|
800
922
|
phaseTimeout: phaseTimeout,
|
|
@@ -48,7 +48,7 @@ const int kWebviewBridgeVersion = 1;
|
|
|
48
48
|
/// pre-stamp bundle, which reports nothing) is a stale baked asset and fails loudly at boot.
|
|
49
49
|
/// MUST move with the paired @githolon/client release — a release-hygiene test pairs it with the
|
|
50
50
|
/// runner stamp (the pubspec-pins pattern).
|
|
51
|
-
const String kExpectedRunnerBundleVersion = '0.
|
|
51
|
+
const String kExpectedRunnerBundleVersion = '0.96.0';
|
|
52
52
|
|
|
53
53
|
/// A STALE BAKED RUNNER BUNDLE: the app's built assets carry an older nomos-runner.js than this
|
|
54
54
|
/// nomos_client release ships — Flutter's build cache kept it across a pub upgrade. Thrown from
|
|
@@ -1028,6 +1028,18 @@ class NomosBridge {
|
|
|
1028
1028
|
if (m['ok'] == true) {
|
|
1029
1029
|
completer.complete(m['result']);
|
|
1030
1030
|
} else {
|
|
1031
|
+
// TYPED BIRTH FAILURE: the JS holon fails FAST on a birth whose child did not come up, and the
|
|
1032
|
+
// runner forwards the per-child outcome entries on errorInfo.failedBirths — rehydrate the typed
|
|
1033
|
+
// [BirthIncomplete] here (apps switch on the type + entries, never scrape the prose).
|
|
1034
|
+
final failedBirths = ((m['errorInfo'] as Map?)?['failedBirths'] as List?)
|
|
1035
|
+
?.whereType<Map>()
|
|
1036
|
+
.map((e) => e.cast<String, Object?>())
|
|
1037
|
+
.toList();
|
|
1038
|
+
if (failedBirths != null && failedBirths.isNotEmpty) {
|
|
1039
|
+
completer.completeError(BirthIncomplete(failedBirths,
|
|
1040
|
+
head: ((m['errorInfo'] as Map?)?['head'] ?? '').toString()));
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1031
1043
|
final info = NomosErrorInfo.fromBridgeMessage(m,
|
|
1032
1044
|
fallbackOperation: op,
|
|
1033
1045
|
fallbackTrace: trace,
|
|
@@ -1474,8 +1486,10 @@ class NomosBridge {
|
|
|
1474
1486
|
/// `final estate = await engine.follow(outcome.bornRefs.single); final app = await EstateClient.bind(estate);`
|
|
1475
1487
|
/// No loose `{workspaceId, aggregateId}` string pairs in app code. [cloud] binds the mount to a
|
|
1476
1488
|
/// cloud (omit for the local plane — a locally-born child mounts in place, no re-clone).
|
|
1489
|
+
/// [authorSecret] — the device signing key for the followed session's writes (era-0: an attested-born
|
|
1490
|
+
/// child is WARRANTED; write access needs its owner's enrolled device key).
|
|
1477
1491
|
Future<NomosSession> follow(NomosRef ref,
|
|
1478
|
-
{String? cloud, String? clientId, String? authToken, bool allowUnborn = false}) async {
|
|
1492
|
+
{String? cloud, String? clientId, String? authToken, bool allowUnborn = false, String? authorSecret}) async {
|
|
1479
1493
|
final ws = ref.workspace;
|
|
1480
1494
|
if (ws.isEmpty) {
|
|
1481
1495
|
throw ArgumentError.value(ref, 'ref',
|
|
@@ -1488,7 +1502,8 @@ class NomosBridge {
|
|
|
1488
1502
|
session: ws,
|
|
1489
1503
|
clientId: clientId,
|
|
1490
1504
|
authToken: authToken,
|
|
1491
|
-
allowUnborn: allowUnborn
|
|
1505
|
+
allowUnborn: allowUnborn,
|
|
1506
|
+
authorSecret: authorSecret);
|
|
1492
1507
|
}
|
|
1493
1508
|
return session(ws);
|
|
1494
1509
|
}
|
|
@@ -1503,8 +1518,13 @@ class NomosBridge {
|
|
|
1503
1518
|
/// [ifAbsentCredentials] invoked — exactly once — and the connect made with what it returns.
|
|
1504
1519
|
/// Omitting [ifAbsentCredentials] makes a genuinely-absent workspace a named [StateError] instead of a
|
|
1505
1520
|
/// silent cloudless mount.
|
|
1521
|
+
/// [authorSecret] — the device signing key for THIS session's writes (era-0: a WARRANTED workspace —
|
|
1522
|
+
/// which every attested-born child is — refuses unsigned writes, so a locally-born child the app will
|
|
1523
|
+
/// WRITE to must be acquired with its owner's device key). Applies to the resident (local) open;
|
|
1524
|
+
/// the credentials path takes its own `NomosSessionCredentials.authorSecret` (falling back to this).
|
|
1506
1525
|
Future<NomosSession> acquireSession(String workspace,
|
|
1507
|
-
{Future<NomosSessionCredentials> Function()? ifAbsentCredentials
|
|
1526
|
+
{Future<NomosSessionCredentials> Function()? ifAbsentCredentials,
|
|
1527
|
+
String? authorSecret}) {
|
|
1508
1528
|
// Fast path: a live session needs no runner round-trip (and never asks for credentials).
|
|
1509
1529
|
final plane = _eventPlane;
|
|
1510
1530
|
if (plane._sessionNames.contains(workspace)) {
|
|
@@ -1524,7 +1544,7 @@ class NomosBridge {
|
|
|
1524
1544
|
// a pre-connect plane has no registry yet — fall through to the credentials decision below.
|
|
1525
1545
|
}
|
|
1526
1546
|
if (resident) {
|
|
1527
|
-
await connect(workspace: workspace, session: workspace, allowUnborn: true);
|
|
1547
|
+
await connect(workspace: workspace, session: workspace, allowUnborn: true, authorSecret: authorSecret);
|
|
1528
1548
|
return session(workspace);
|
|
1529
1549
|
}
|
|
1530
1550
|
if (ifAbsentCredentials == null) {
|
|
@@ -1539,7 +1559,7 @@ class NomosBridge {
|
|
|
1539
1559
|
session: workspace,
|
|
1540
1560
|
clientId: c.clientId,
|
|
1541
1561
|
authToken: c.authToken,
|
|
1542
|
-
authorSecret: c.authorSecret,
|
|
1562
|
+
authorSecret: c.authorSecret ?? authorSecret,
|
|
1543
1563
|
allowUnborn: c.allowUnborn,
|
|
1544
1564
|
);
|
|
1545
1565
|
return session(workspace);
|
|
@@ -2495,15 +2515,16 @@ class NomosSession extends NomosBridge {
|
|
|
2495
2515
|
/// single-flight registry; a session never owns mounts).
|
|
2496
2516
|
@override
|
|
2497
2517
|
Future<NomosSession> acquireSession(String workspace,
|
|
2498
|
-
{Future<NomosSessionCredentials> Function()? ifAbsentCredentials
|
|
2499
|
-
|
|
2518
|
+
{Future<NomosSessionCredentials> Function()? ifAbsentCredentials,
|
|
2519
|
+
String? authorSecret}) =>
|
|
2520
|
+
parent.acquireSession(workspace, ifAbsentCredentials: ifAbsentCredentials, authorSecret: authorSecret);
|
|
2500
2521
|
|
|
2501
2522
|
/// A followed ref mounts on the SHARED plane — delegate to the parent (a session never owns mounts).
|
|
2502
2523
|
@override
|
|
2503
2524
|
Future<NomosSession> follow(NomosRef ref,
|
|
2504
|
-
{String? cloud, String? clientId, String? authToken, bool allowUnborn = false}) =>
|
|
2525
|
+
{String? cloud, String? clientId, String? authToken, bool allowUnborn = false, String? authorSecret}) =>
|
|
2505
2526
|
parent.follow(ref,
|
|
2506
|
-
cloud: cloud, clientId: clientId, authToken: authToken, allowUnborn: allowUnborn);
|
|
2527
|
+
cloud: cloud, clientId: clientId, authToken: authToken, allowUnborn: allowUnborn, authorSecret: authorSecret);
|
|
2507
2528
|
|
|
2508
2529
|
@override
|
|
2509
2530
|
Future<Object?> _call(String op, [Map<String, Object?> args = const {}]) =>
|
package/dart/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
|
|
12
12
|
code-generated per-domain aggregate + directive-payload types. (De)serializes
|
|
13
13
|
the EXACT serde JSON the Rust kernel + projection produce/consume.
|
|
14
|
-
version: 0.
|
|
14
|
+
version: 0.61.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import 'dart:convert';
|
|
18
18
|
import 'dart:io';
|
|
19
19
|
|
|
20
|
+
import 'package:crypto/crypto.dart' show sha256;
|
|
20
21
|
import 'package:nomos_client/nomos_headless.dart';
|
|
21
22
|
import 'package:test/test.dart';
|
|
22
23
|
|
|
@@ -47,7 +48,7 @@ void main() {
|
|
|
47
48
|
final diag = Platform.environment['NOMOS_HARNESS_DIAG'] == '1'
|
|
48
49
|
? (String l) => stderr.writeln(l)
|
|
49
50
|
: null;
|
|
50
|
-
final cloud = await NomosTestCloud.start(serverEntry: _devServer, onDiagnostic: diag);
|
|
51
|
+
final cloud = await NomosTestCloud.start(serverEntry: _devServer, platform: 'topology-platform', deployJsonPath: _deploy, onDiagnostic: diag);
|
|
51
52
|
NomosTestHarness? harness;
|
|
52
53
|
try {
|
|
53
54
|
// 1+2 — the remote ceremony + the local mount, in one constructor (the app's boot path).
|
|
@@ -70,6 +71,11 @@ void main() {
|
|
|
70
71
|
cloud.setOnline(false);
|
|
71
72
|
|
|
72
73
|
// 4 — the home births the estate LOCALLY (the app lane never needs the network).
|
|
74
|
+
// ownerKeyHash: the owner's device (enrolled in the home's genesis) — the estate enrols the SAME
|
|
75
|
+
// key at birth so it accepts the owner's signed writes from intent 0 (era-0 signer-ready births).
|
|
76
|
+
final ownerKeyHash = sha256
|
|
77
|
+
.convert(base64Decode(harness.devicePublicKey(home.workspace)!))
|
|
78
|
+
.toString();
|
|
73
79
|
final outcome = await harness.bridge.session(home.workspace).offerCreates(
|
|
74
80
|
domain: 'home',
|
|
75
81
|
directiveId: 'birthEstateWorkspace',
|
|
@@ -78,6 +84,7 @@ void main() {
|
|
|
78
84
|
'frameworkHash': harness.frameworkHash,
|
|
79
85
|
'lawHash': harness.lawHashes(home.workspace).first,
|
|
80
86
|
'bornAt': '2026-07-14T00:00:00Z',
|
|
87
|
+
'ownerKeyHash': ownerKeyHash,
|
|
81
88
|
},
|
|
82
89
|
actor: _subject,
|
|
83
90
|
);
|
|
@@ -86,7 +93,7 @@ void main() {
|
|
|
86
93
|
expect(estateWs, startsWith('estate-'));
|
|
87
94
|
|
|
88
95
|
// 5 — the estate opens as a sibling session and accepts a LOCAL WRITE while offline.
|
|
89
|
-
final estate = await harness.bridge.acquireSession(estateWs);
|
|
96
|
+
final estate = await harness.bridge.acquireSession(estateWs, authorSecret: harness.deviceSecret(home.workspace));
|
|
90
97
|
await estate.offerCreates(
|
|
91
98
|
domain: 'estate',
|
|
92
99
|
directiveId: 'writeEstateNote',
|
|
@@ -49,13 +49,15 @@ const _owner = 'sample-owner';
|
|
|
49
49
|
/// Set up a single-session plane over the estate-birth law + OFFER `birthEstateWorkspace` on it. Returns the
|
|
50
50
|
/// harness (for teardown) + the offer outcome (born / birthOutcomes surfaced). [frameworkHash] overrides the
|
|
51
51
|
/// real framework hash (pass a bogus one to force a genesis failure).
|
|
52
|
-
Future<(NomosTestHarness, NomosOfferOutcome)> _birth(
|
|
52
|
+
Future<(NomosTestHarness, NomosOfferOutcome)> _birth(
|
|
53
|
+
{String? frameworkHash, void Function(NomosTestHarness)? onHarness}) async {
|
|
53
54
|
final harness = await NomosTestHarness.ephemeral(
|
|
54
55
|
deployJsonPaths: [_birthDeploy],
|
|
55
56
|
nodeEntry: _repoEntry,
|
|
56
57
|
runtimeDir: _runtimeDir,
|
|
57
58
|
workspace: 'estate-birth',
|
|
58
59
|
);
|
|
60
|
+
onHarness?.call(harness);
|
|
59
61
|
// The genesis recipe installs the framework + child law BY HASH — the harness surfaces both (frameworkHash
|
|
60
62
|
// is not derivable client-side; lawHash is this package's own domainHash).
|
|
61
63
|
final fw = frameworkHash ?? harness.frameworkHash;
|
|
@@ -171,18 +173,23 @@ void main() {
|
|
|
171
173
|
}
|
|
172
174
|
// A well-formed but WRONG framework hash the parent does not fold → the child's step-0 install can't be
|
|
173
175
|
// satisfied → the genesis fold refuses. The offer still admits on the parent (the birth ORDER is valid);
|
|
174
|
-
// the child's fate
|
|
176
|
+
// the child's fate FAILS FAST as the TYPED [BirthIncomplete] (both the JS holon and the bridge throw it
|
|
177
|
+
// by design — a silent partial birth would strand the app polling head:null), carrying the per-child
|
|
178
|
+
// typed outcome entries (workspace, status, code, reason) — never a stringly error or a silent drop.
|
|
175
179
|
final badHash = 'deadbeef' * 8; // 64 hex chars, not a package the parent holds
|
|
176
|
-
|
|
180
|
+
NomosTestHarness? harness;
|
|
177
181
|
try {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
182
|
+
await _birth(frameworkHash: badHash, onHarness: (h) => harness = h);
|
|
183
|
+
fail('an unresolvable framework hash must fail the birth (BirthIncomplete expected)');
|
|
184
|
+
} on BirthIncomplete catch (e) {
|
|
185
|
+
expect(e.failedBirths, isNotEmpty,
|
|
186
|
+
reason: 'a failed birth still reports its typed per-child verdicts');
|
|
187
|
+
expect(e.failedBirths.first['status'], isNot('born'),
|
|
181
188
|
reason: 'an unresolvable framework hash cannot yield a born child');
|
|
182
|
-
expect((
|
|
189
|
+
expect((e.failedBirths.first['reason'] ?? '').toString(), isNotEmpty,
|
|
183
190
|
reason: 'the failure carries a human-legible reason (never a silent drop)');
|
|
184
191
|
} finally {
|
|
185
|
-
await harness
|
|
192
|
+
await harness?.dispose();
|
|
186
193
|
}
|
|
187
194
|
}, timeout: const Timeout(Duration(minutes: 3)));
|
|
188
195
|
}
|
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
@TestOn('vm')
|
|
15
15
|
library;
|
|
16
16
|
|
|
17
|
+
import 'dart:convert';
|
|
17
18
|
import 'dart:io';
|
|
18
19
|
|
|
20
|
+
import 'package:crypto/crypto.dart' show sha256;
|
|
21
|
+
|
|
19
22
|
import 'package:nomos_client/nomos_headless.dart';
|
|
20
23
|
import 'package:test/test.dart';
|
|
21
24
|
|
|
@@ -139,6 +142,8 @@ void main() {
|
|
|
139
142
|
frameworkHash: harness.frameworkHash!,
|
|
140
143
|
lawHash: harness.lawHashes(home.workspace).first,
|
|
141
144
|
bornAt: DateTime.now().toUtc().toIso8601String(),
|
|
145
|
+
// era-0: the estate enrols the owner's device at birth (signer-ready) — the harness device.
|
|
146
|
+
ownerKeyHash: sha256.convert(base64Decode(harness.devicePublicKey(home.workspace)!)).toString(),
|
|
142
147
|
),
|
|
143
148
|
);
|
|
144
149
|
expect(birth.workspace, home.workspace,
|
|
@@ -146,7 +151,7 @@ void main() {
|
|
|
146
151
|
expect(birth.born, hasLength(1));
|
|
147
152
|
expect(birth.birthOutcomes.first['status'], 'born');
|
|
148
153
|
|
|
149
|
-
final estateSession = await harness.bridge.follow(birth.bornRefs.single);
|
|
154
|
+
final estateSession = await harness.bridge.follow(birth.bornRefs.single, authorSecret: harness.deviceSecret(home.workspace));
|
|
150
155
|
expect(estateSession.workspace, birth.born.single);
|
|
151
156
|
|
|
152
157
|
final estateClient = await EstateClient.bind(estateSession, actor: _owner);
|