@githolon/dsl 0.74.0 → 0.75.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 +28 -0
- package/dart/lib/nomos_headless.dart +8 -0
- package/dart/lib/src/webview_bridge.dart +149 -84
- package/dart/pubspec.yaml +1 -1
- package/dart/test/acquire_session_credentials_test.dart +132 -0
- package/dart/test/cluster_births_test.dart +0 -1
- package/dart/test/dlq_session_surface_test.dart +203 -0
- package/dart/test/event_plane_seam_test.dart +188 -0
- package/dart/test/first_app_gauntlet_test.dart +301 -0
- package/dart/test/sealing_client_test.dart +179 -0
- package/dart/test/test_harness_test.dart +0 -1
- package/dart/test/watch_surfaces_test.dart +240 -0
- package/package.json +1 -1
- package/src/aggregate.ts +75 -1
- package/src/codegen_dart.ts +101 -0
- package/src/codegen_dart_client.ts +129 -18
- package/src/codegen_ts.ts +44 -0
- package/src/compile_package_main.ts +26 -1
- package/src/drivers.ts +9 -1
- package/src/fields.ts +23 -0
- package/src/manifest.ts +11 -0
- package/src/usd.ts +24 -0
- package/src/usd_layers.ts +32 -0
- package/src/wire.ts +5 -1
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// THE FIRST-FIVE-MINUTES GAUNTLET — one sequential journey through EVERYTHING the test harness +
|
|
2
|
+
// session-bound client surface promises a NEW app developer, using ONLY the public API (the way a
|
|
3
|
+
// tenant would). Every lifecycle bug co2 found in their first minutes (session watch delegation,
|
|
4
|
+
// local autoSync deadlock, the keyed-reconnect lane, …) has a leg here, so the NEXT one is found
|
|
5
|
+
// by CI, not by a tenant.
|
|
6
|
+
//
|
|
7
|
+
// Structure: ONE test, numbered legs, fail-fast — a failure names its leg, so CI output reads as
|
|
8
|
+
// a checklist. Reuses the existing fixtures (estate-birth, surfaced, catalogue-src/-reader); no
|
|
9
|
+
// new law.
|
|
10
|
+
//
|
|
11
|
+
// Prereqs (fixtures compiled + a warm ~/.holon/runtime): locally the test SKIPS with the recipe
|
|
12
|
+
// when they are missing; in CI the job sets NOMOS_GAUNTLET_REQUIRED=1 so a missing prereq is a
|
|
13
|
+
// LOUD FAILURE, never a silently-green skip.
|
|
14
|
+
@TestOn('vm')
|
|
15
|
+
library;
|
|
16
|
+
|
|
17
|
+
import 'dart:io';
|
|
18
|
+
|
|
19
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
20
|
+
import 'package:test/test.dart';
|
|
21
|
+
|
|
22
|
+
import '../../../bench/scale/fixtures/estate-birth/build/dart/src/generated/home.client.dart';
|
|
23
|
+
import '../../../bench/scale/fixtures/estate-birth/build/dart/src/generated/estate.client.dart'
|
|
24
|
+
show EstateClient, SeedEstateWorkspacePayload;
|
|
25
|
+
|
|
26
|
+
String get _root => '${Directory.current.path}/../..';
|
|
27
|
+
String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
|
|
28
|
+
String get _homeDeploy =>
|
|
29
|
+
'$_root/bench/scale/fixtures/estate-birth/build/estate-birth.deploy.json';
|
|
30
|
+
String get _docsDeploy => '$_root/bench/scale/fixtures/surfaced/build/surfaced.deploy.json';
|
|
31
|
+
String get _catDeploy => '$_root/bench/scale/fixtures/catalogue-src/build/cat.deploy.json';
|
|
32
|
+
String get _readerDeploy =>
|
|
33
|
+
'$_root/bench/scale/fixtures/catalogue-reader/build/estate.deploy.json';
|
|
34
|
+
String get _runtimeDir =>
|
|
35
|
+
Platform.environment['NOMOS_DART_RUNTIME'] ??
|
|
36
|
+
'${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
37
|
+
|
|
38
|
+
const _owner = 'user:gauntlet-owner';
|
|
39
|
+
const _keyed = 'catalogue';
|
|
40
|
+
|
|
41
|
+
String? _missingPrereq() {
|
|
42
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
43
|
+
final fixtures = {
|
|
44
|
+
_homeDeploy: 'bench/scale/fixtures/estate-birth',
|
|
45
|
+
_docsDeploy: 'bench/scale/fixtures/surfaced',
|
|
46
|
+
_catDeploy: 'bench/scale/fixtures/catalogue-src',
|
|
47
|
+
_readerDeploy: 'bench/scale/fixtures/catalogue-reader',
|
|
48
|
+
};
|
|
49
|
+
for (final e in fixtures.entries) {
|
|
50
|
+
if (!File(e.key).existsSync()) {
|
|
51
|
+
return 'missing fixture ${e.key} — compile it: (cd ${e.value} && node ../../../../dsl/compile_package.mjs)';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!File('$_runtimeDir/holon.wasm').existsSync() ||
|
|
55
|
+
!File('$_runtimeDir/packages.json').existsSync()) {
|
|
56
|
+
return 'warm ~/.holon/runtime first (run any `githolon` command once, or curl '
|
|
57
|
+
'/v1/runtime/holon.wasm + /v1/runtime/packages from the cloud)';
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// Run one numbered leg fail-fast: log enter/exit so CI output reads as a checklist, and wrap any
|
|
63
|
+
/// failure so the failing LEG is named at the top of the report.
|
|
64
|
+
Future<T> _leg<T>(String name, Future<T> Function() body) async {
|
|
65
|
+
final sw = Stopwatch()..start();
|
|
66
|
+
// ignore: avoid_print
|
|
67
|
+
print('▶ GAUNTLET $name …');
|
|
68
|
+
try {
|
|
69
|
+
final r = await body();
|
|
70
|
+
// ignore: avoid_print
|
|
71
|
+
print('✔ GAUNTLET $name (${sw.elapsedMilliseconds}ms)');
|
|
72
|
+
return r;
|
|
73
|
+
} catch (e, st) {
|
|
74
|
+
throw TestFailure('GAUNTLET $name FAILED after ${sw.elapsedMilliseconds}ms:\n$e\n$st');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
void main() {
|
|
79
|
+
test('THE FIRST-FIVE-MINUTES GAUNTLET: harness → blessed shape → local posture → '
|
|
80
|
+
'acquire → reactive reads → attested read → diagnostics → disposal', () async {
|
|
81
|
+
final required = Platform.environment['NOMOS_GAUNTLET_REQUIRED'] == '1';
|
|
82
|
+
final missing = _missingPrereq();
|
|
83
|
+
if (missing != null) {
|
|
84
|
+
if (required) {
|
|
85
|
+
fail('NOMOS_GAUNTLET_REQUIRED=1 but a prereq is missing — the CI job must provide it: '
|
|
86
|
+
'$missing');
|
|
87
|
+
}
|
|
88
|
+
markTestSkipped(missing);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
final diagnostics = <String>[];
|
|
93
|
+
|
|
94
|
+
// ── LEG 1: harness up — a 4-workspace cluster, one deviceKeyed, phase diagnostics on. ──
|
|
95
|
+
final harness = await _leg('leg 1: NomosTestHarness.cluster (4 ws, one deviceKeyed)', () async {
|
|
96
|
+
final h = await NomosTestHarness.cluster(
|
|
97
|
+
workspaces: {
|
|
98
|
+
'home': [_homeDeploy],
|
|
99
|
+
'docs-ws': [_docsDeploy],
|
|
100
|
+
_keyed: [_catDeploy],
|
|
101
|
+
'reader': [_readerDeploy],
|
|
102
|
+
},
|
|
103
|
+
deviceKeyed: {_keyed},
|
|
104
|
+
nodeEntry: _repoEntry,
|
|
105
|
+
runtimeDir: _runtimeDir,
|
|
106
|
+
onDiagnostic: diagnostics.add,
|
|
107
|
+
phaseTimeout: const Duration(seconds: 120),
|
|
108
|
+
);
|
|
109
|
+
// Phase diagnostics (the "no silent hangs" promise): install, ready, connect, and the
|
|
110
|
+
// keyed 3b legs all log enter AND exit lines.
|
|
111
|
+
expect(diagnostics.any((l) => l.contains('offline law install')), isTrue,
|
|
112
|
+
reason: 'phase 1 install diagnostics must appear');
|
|
113
|
+
expect(diagnostics.any((l) => l.contains('runner ready') && l.contains('done')), isTrue,
|
|
114
|
+
reason: 'the runner-ready phase must log its exit');
|
|
115
|
+
expect(diagnostics.any((l) => l.contains('3b: device keygen')), isTrue,
|
|
116
|
+
reason: 'the deviceKeyed keygen phase must log');
|
|
117
|
+
expect(
|
|
118
|
+
diagnostics.any(
|
|
119
|
+
(l) => l.contains("3b: keyed reconnect of '$_keyed'") && l.contains('done')),
|
|
120
|
+
isTrue,
|
|
121
|
+
reason: 'the keyed reconnect phase must log its exit (the reported wedge)');
|
|
122
|
+
expect(h.sessions.toSet(), {'home', 'docs-ws', _keyed, 'reader'});
|
|
123
|
+
return h;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// ── LEG 2: the blessed session-bound shape — mount → bind (preflight) → PARENTLESS birth
|
|
128
|
+
// → follow(ref) → second client → typed read + a directive. Zero routing strings. ──
|
|
129
|
+
final home = harness.session('home');
|
|
130
|
+
final estate = await _leg(
|
|
131
|
+
'leg 2: blessed shape (bind → parentless birth → follow → 2nd client → read+directive)',
|
|
132
|
+
() async {
|
|
133
|
+
final homeClient = await HomeClient.bind(home, actor: _owner);
|
|
134
|
+
expect(homeClient.session!.workspace, home.workspace,
|
|
135
|
+
reason: 'inspectable session context');
|
|
136
|
+
final birth = await homeClient.birthEstateWorkspace(
|
|
137
|
+
payload: BirthEstateWorkspacePayload(
|
|
138
|
+
owner: _owner,
|
|
139
|
+
frameworkHash: harness.frameworkHash!,
|
|
140
|
+
lawHash: harness.lawHashes(home.workspace).first,
|
|
141
|
+
bornAt: DateTime.now().toUtc().toIso8601String(),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
expect(birth.workspace, home.workspace,
|
|
145
|
+
reason: 'the parentless birth lands in the SESSION workspace');
|
|
146
|
+
expect(birth.born, hasLength(1));
|
|
147
|
+
expect(birth.birthOutcomes.first['status'], 'born');
|
|
148
|
+
|
|
149
|
+
final estateSession = await harness.bridge.follow(birth.bornRefs.single);
|
|
150
|
+
expect(estateSession.workspace, birth.born.single);
|
|
151
|
+
|
|
152
|
+
final estateClient = await EstateClient.bind(estateSession, actor: _owner);
|
|
153
|
+
final rows = await estateClient.estatesByOwner(owner: _owner);
|
|
154
|
+
expect(rows, hasLength(1), reason: 'the child genesis seed reads back typed');
|
|
155
|
+
final seeded = rows.single;
|
|
156
|
+
final reseed = await estateClient.seedEstateWorkspace(SeedEstateWorkspacePayload(
|
|
157
|
+
owner: seeded.owner!,
|
|
158
|
+
estateName: seeded.estateName!,
|
|
159
|
+
bornAt: seeded.bornAt!,
|
|
160
|
+
));
|
|
161
|
+
expect(reseed.head, isNotEmpty, reason: 'a directive on the followed second client');
|
|
162
|
+
return estateSession;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── LEG 3: local posture — autoSync on a local-only session returns promptly and never
|
|
166
|
+
// monopolizes the plane; zero network fetch attempts (asserted again at the end). ──
|
|
167
|
+
await _leg('leg 3: local-only autoSync completes promptly, plane stays responsive',
|
|
168
|
+
() async {
|
|
169
|
+
await home.autoSync().timeout(const Duration(seconds: 10),
|
|
170
|
+
onTimeout: () => throw StateError(
|
|
171
|
+
'autoSync() on a local-only session did not return within 10s (the deadlock class)'));
|
|
172
|
+
// The plane is NOT blocked: a sibling session still answers.
|
|
173
|
+
final listed = await harness.bridge.listWorkspaces().timeout(const Duration(seconds: 10),
|
|
174
|
+
onTimeout: () =>
|
|
175
|
+
throw StateError('listWorkspaces() hung after a local-only autoSync'));
|
|
176
|
+
expect(listed.map((w) => w['session'].toString()).toSet(),
|
|
177
|
+
containsAll({'home', 'docs-ws', _keyed, 'reader'}));
|
|
178
|
+
await home.stopAutoSync().timeout(const Duration(seconds: 10),
|
|
179
|
+
onTimeout: () => throw StateError('stopAutoSync() hung on a local-only session'));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── LEG 4: acquireSession — concurrent double-acquire single-flights to ONE handle; the
|
|
183
|
+
// credentials callback is NEVER invoked for a plane-known workspace. ──
|
|
184
|
+
await _leg('leg 4: acquireSession (concurrent, credential-free for a known workspace)',
|
|
185
|
+
() async {
|
|
186
|
+
final acquired = await Future.wait([
|
|
187
|
+
harness.bridge.acquireSession('reader',
|
|
188
|
+
ifAbsentCredentials: () async =>
|
|
189
|
+
fail('reader is on the plane — credentials must not be requested')),
|
|
190
|
+
harness.bridge.acquireSession('reader',
|
|
191
|
+
ifAbsentCredentials: () async =>
|
|
192
|
+
fail('reader is on the plane — credentials must not be requested')),
|
|
193
|
+
]);
|
|
194
|
+
expect(identical(acquired[0], acquired[1]), isTrue,
|
|
195
|
+
reason: 'a concurrent double-acquire yields ONE handle');
|
|
196
|
+
expect(identical(acquired[0], harness.session('reader')), isTrue,
|
|
197
|
+
reason: 'the acquired handle IS the mounted session');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── LEG 5: reactive reads on a SESSION — watchById / watch / watchPendingConflicts must
|
|
201
|
+
// all deliver a FIRST SNAPSHOT (the 0.48.x dead-delegation class). ──
|
|
202
|
+
await _leg('leg 5: session reactive reads emit their first snapshot', () async {
|
|
203
|
+
final raw = await estate.query('estatesByOwner', {'owner': _owner});
|
|
204
|
+
final aggId = ((raw.single as Map)['id'] ?? '').toString();
|
|
205
|
+
expect(aggId, isNotEmpty);
|
|
206
|
+
final byId = await estate
|
|
207
|
+
.watchById(aggId)
|
|
208
|
+
.first
|
|
209
|
+
.timeout(const Duration(seconds: 30),
|
|
210
|
+
onTimeout: () => throw StateError('watchById never emitted its first snapshot'));
|
|
211
|
+
expect(byId, hasLength(1));
|
|
212
|
+
final byQuery = await estate
|
|
213
|
+
.watch('estatesByOwner', {'owner': _owner})
|
|
214
|
+
.first
|
|
215
|
+
.timeout(const Duration(seconds: 30),
|
|
216
|
+
onTimeout: () => throw StateError('watch(query) never emitted its first snapshot'));
|
|
217
|
+
expect(byQuery, hasLength(1));
|
|
218
|
+
// The docs-ws session carries the Surfaced law — its pending-conflicts stream must emit a
|
|
219
|
+
// first snapshot (empty is correct; EMITTING at all is the regression under test).
|
|
220
|
+
final conflicts = await harness
|
|
221
|
+
.session('docs-ws')
|
|
222
|
+
.watchPendingConflicts()
|
|
223
|
+
.first
|
|
224
|
+
.timeout(const Duration(seconds: 30),
|
|
225
|
+
onTimeout: () => throw StateError(
|
|
226
|
+
'watchPendingConflicts never emitted (the session-delegation regression)'));
|
|
227
|
+
expect(conflicts, isEmpty);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── LEG 6: the keyed lane end-to-end — an attested read served + SIGNED with the device
|
|
231
|
+
// key the 3b re-open installed. ──
|
|
232
|
+
await _leg('leg 6: attested read from the deviceKeyed workspace', () async {
|
|
233
|
+
final public = harness.devicePublicKey(_keyed);
|
|
234
|
+
expect(public, isNotNull);
|
|
235
|
+
expect(public, isNotEmpty, reason: 'the 3b keygen public half rides the harness');
|
|
236
|
+
final cat = harness.session(_keyed);
|
|
237
|
+
final seeded = await cat.offerCreates(
|
|
238
|
+
domain: 'cat',
|
|
239
|
+
directiveId: 'listCatalogue',
|
|
240
|
+
payload: {
|
|
241
|
+
'listingId': 'L-1',
|
|
242
|
+
'title': 'Walnut desk',
|
|
243
|
+
'priceGbp': '900',
|
|
244
|
+
'listedAt': DateTime.now().toUtc().toIso8601String(),
|
|
245
|
+
},
|
|
246
|
+
actor: 'user:seller',
|
|
247
|
+
);
|
|
248
|
+
expect(seeded.head, isNotEmpty);
|
|
249
|
+
final served = await harness.session('reader').attestedRead(
|
|
250
|
+
from: _keyed,
|
|
251
|
+
queryId: 'listingById',
|
|
252
|
+
args: {'listingId': 'L-1'},
|
|
253
|
+
);
|
|
254
|
+
final entry = (served['entry'] as Map).cast<String, Object?>();
|
|
255
|
+
final attestation = (entry['attestation'] as Map).cast<String, Object?>();
|
|
256
|
+
expect((attestation['public_key'] ?? '').toString(), public,
|
|
257
|
+
reason: 'the read is signed by the re-opened session device key');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── LEG 7: harness diagnostics — assertDomainInstalled, resetWorkspaceCustody, and a
|
|
261
|
+
// mid-test seedWorkspaceFromDeployJson round-trip. ──
|
|
262
|
+
await _leg('leg 7: harness diagnostics (assertDomainInstalled / reset / seed)', () async {
|
|
263
|
+
await harness.assertDomainInstalled(
|
|
264
|
+
workspace: estate.workspace, domain: EstateClient.interface.domain);
|
|
265
|
+
// The negative arm names the active domains.
|
|
266
|
+
await expectLater(
|
|
267
|
+
harness.assertDomainInstalled(workspace: 'docs-ws', domain: 'definitely-not-here'),
|
|
268
|
+
throwsA(isA<NomosTestHarnessException>()));
|
|
269
|
+
// Reset a workspace's local custody to its seed snapshot, then prove it still serves.
|
|
270
|
+
await harness.resetWorkspaceCustody('docs-ws');
|
|
271
|
+
final postReset = await harness
|
|
272
|
+
.session('docs-ws')
|
|
273
|
+
.watchPendingConflicts()
|
|
274
|
+
.first
|
|
275
|
+
.timeout(const Duration(seconds: 30),
|
|
276
|
+
onTimeout: () =>
|
|
277
|
+
throw StateError('the reset docs-ws session never served a read'));
|
|
278
|
+
expect(postReset, isEmpty, reason: 'the reset workspace is back at its seed');
|
|
279
|
+
// Seed a brand-new workspace mid-test from a deploy.json and use it immediately.
|
|
280
|
+
final late = await harness.seedWorkspaceFromDeployJson(
|
|
281
|
+
workspace: 'late-ws', deployJsonPaths: [_docsDeploy]);
|
|
282
|
+
expect(late.workspace, 'late-ws');
|
|
283
|
+
await harness.assertDomainInstalled(workspace: 'late-ws', domain: 'docs');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Local posture postscript (leg 3's zero-network promise, checked over the WHOLE run): no
|
|
287
|
+
// session on this plane ever attempted a network fetch.
|
|
288
|
+
final offending = diagnostics
|
|
289
|
+
.where((l) => l.contains('intent-offers') || l.contains('refusing network fetch'))
|
|
290
|
+
.toList();
|
|
291
|
+
expect(offending, isEmpty,
|
|
292
|
+
reason: 'local-only sessions must perform NO network work — saw:\n${offending.join('\n')}');
|
|
293
|
+
} finally {
|
|
294
|
+
// ── LEG 8: clean disposal within the deadline (a hung teardown is a failure, not a wedge). ──
|
|
295
|
+
await _leg('leg 8: harness disposal within the deadline', () async {
|
|
296
|
+
await harness.dispose().timeout(const Duration(seconds: 30),
|
|
297
|
+
onTimeout: () => throw StateError('harness disposal hung'));
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}, timeout: const Timeout(Duration(minutes: 8)));
|
|
301
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// E2E SEALING FROM DART (task #86 gap 3): the operator-blind encryption handshake driven end-to-end
|
|
2
|
+
// through the Dart sealing-client surface (NomosEncryption — the owner ceremony; WrappedKeyStore —
|
|
3
|
+
// the reader's key custody; the kernel crypto ops over the bridge) against the REAL encrypted-notes
|
|
4
|
+
// law on a local holon. Mirrors bench/scale/e2e_encryption.smoke.mjs leg for leg:
|
|
5
|
+
// reader device key → grantAndWrap (grant + epoch fan-out in ONE ceremony call) → author-seal →
|
|
6
|
+
// write the envelope → the reader unwraps its scope key from the on-chain WrappedKey and reads
|
|
7
|
+
// DECRYPTED → without the key the body stays the opaque envelope → a non-member read is refused.
|
|
8
|
+
@TestOn('vm')
|
|
9
|
+
library;
|
|
10
|
+
|
|
11
|
+
import 'dart:async';
|
|
12
|
+
import 'dart:convert';
|
|
13
|
+
import 'dart:io';
|
|
14
|
+
|
|
15
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
16
|
+
import 'package:test/test.dart';
|
|
17
|
+
|
|
18
|
+
String get _root => '${Directory.current.path}/../..';
|
|
19
|
+
String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
|
|
20
|
+
String get _fixture => '$_root/cloud/web-client/test/fixtures/encrypted-notes';
|
|
21
|
+
String get _deploy => '$_fixture/build/encrypted-notes.deploy.json';
|
|
22
|
+
String get _lockfile => '$_fixture/nomos.stable-ids.json';
|
|
23
|
+
String get _runtimeDir =>
|
|
24
|
+
Platform.environment['NOMOS_DART_RUNTIME'] ??
|
|
25
|
+
'${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
26
|
+
|
|
27
|
+
const _domain = 'encrypted_notes';
|
|
28
|
+
const _scope = 'readNote';
|
|
29
|
+
const _reader = 'reader-ada';
|
|
30
|
+
const _plaintext = 'the secret body';
|
|
31
|
+
|
|
32
|
+
String? _missingPrereq() {
|
|
33
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
34
|
+
if (!File(_deploy).existsSync() || !File(_lockfile).existsSync()) {
|
|
35
|
+
return 'missing encrypted-notes fixture — compile it: (cd cloud/web-client/test/fixtures/encrypted-notes && node ../../../../../dsl/compile_package.mjs)';
|
|
36
|
+
}
|
|
37
|
+
if (!File('$_runtimeDir/holon.wasm').existsSync() ||
|
|
38
|
+
!File('$_runtimeDir/packages.json').existsSync()) {
|
|
39
|
+
return 'warm ~/.holon/runtime first (run any `githolon` command once)';
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Future<T> _leg<T>(String name, Future<T> Function() body) async {
|
|
45
|
+
final sw = Stopwatch()..start();
|
|
46
|
+
// ignore: avoid_print
|
|
47
|
+
print('▶ SEAL $name …');
|
|
48
|
+
try {
|
|
49
|
+
final r = await body();
|
|
50
|
+
// ignore: avoid_print
|
|
51
|
+
print('✔ SEAL $name (${sw.elapsedMilliseconds}ms)');
|
|
52
|
+
return r;
|
|
53
|
+
} catch (e, st) {
|
|
54
|
+
throw TestFailure('SEAL $name FAILED after ${sw.elapsedMilliseconds}ms:\n$e\n$st');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
void main() {
|
|
59
|
+
test('E2E sealing via the Dart harness: keygen → grantAndWrap → seal → write → decrypted read',
|
|
60
|
+
() async {
|
|
61
|
+
final missing = _missingPrereq();
|
|
62
|
+
if (missing != null) {
|
|
63
|
+
markTestSkipped(missing);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
67
|
+
deployJsonPaths: [_deploy],
|
|
68
|
+
workspace: 'sealed-notes',
|
|
69
|
+
nodeEntry: _repoEntry,
|
|
70
|
+
runtimeDir: _runtimeDir,
|
|
71
|
+
phaseTimeout: const Duration(seconds: 120),
|
|
72
|
+
);
|
|
73
|
+
try {
|
|
74
|
+
final s = harness.bridge;
|
|
75
|
+
final lawHash = harness.lawHashes('sealed-notes').first;
|
|
76
|
+
|
|
77
|
+
// The #58 stable sids for the (Note, body) slot — from the committed lockfile, exactly as the
|
|
78
|
+
// generated sealing client bakes them.
|
|
79
|
+
final lock = (jsonDecode(File(_lockfile).readAsStringSync()) as Map);
|
|
80
|
+
final noteAgg = ((((lock['domains'] as Map)[_domain] as Map)['stableIds'] as Map)['aggregates']
|
|
81
|
+
as Map)['Note'] as Map;
|
|
82
|
+
final aggSid = noteAgg['sid'].toString();
|
|
83
|
+
final fieldSid = ((noteAgg['fields'] as Map)['body'] as Map)['sid'].toString();
|
|
84
|
+
|
|
85
|
+
// ── LEG 1: the reader's X25519 device key (secret stays client-side). ──
|
|
86
|
+
final reader = await _leg('leg 1: reader device keygen (X25519, in-kernel)', () async {
|
|
87
|
+
final kp = await s.cryptoKeygen();
|
|
88
|
+
expect((kp['secret'] ?? '').toString(), isNotEmpty);
|
|
89
|
+
expect((kp['public'] ?? '').toString(), isNotEmpty);
|
|
90
|
+
return kp;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── LEG 2: THE OWNER CEREMONY in ONE call — grant the cap + open epoch 0 wrapping the scope
|
|
94
|
+
// key to the resolved roster (the Dart NomosEncryption surface, gap 3's real target). ──
|
|
95
|
+
final scopeKey = base64Encode(
|
|
96
|
+
base64Decode((await s.cryptoKeygen())['secret'].toString()).sublist(0, 32));
|
|
97
|
+
final ceremony = NomosEncryption(
|
|
98
|
+
bridge: s, domain: _domain, domainHash: lawHash, scopes: const [_scope]);
|
|
99
|
+
final epoch0 = await _leg('leg 2: grantAndWrap (grant + epoch fan-out, one ceremony call)',
|
|
100
|
+
() async {
|
|
101
|
+
final r = await ceremony.grantAndWrap(
|
|
102
|
+
principal: _reader,
|
|
103
|
+
publicKey: reader['public'].toString(),
|
|
104
|
+
grantDirective: 'grantReader',
|
|
105
|
+
grantPayload: {
|
|
106
|
+
'principal': _reader,
|
|
107
|
+
'grantedBy': 'owner',
|
|
108
|
+
'grantedAt': DateTime.now().toUtc().toIso8601String(),
|
|
109
|
+
},
|
|
110
|
+
scopeKey: scopeKey,
|
|
111
|
+
);
|
|
112
|
+
expect(r.epoch, 0, reason: 'the first ceremony opens epoch 0');
|
|
113
|
+
expect(r.roster, [_reader], reason: 'the fan-out roster is exactly the granted member');
|
|
114
|
+
expect(r.head, isNotEmpty);
|
|
115
|
+
return r;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── LEG 3: author-seal the body (the wasm seal, sid-bound) and WRITE the envelope. ──
|
|
119
|
+
final envelope = await _leg('leg 3: seal the body → write the envelope', () async {
|
|
120
|
+
final env = await s.cryptoSealField(
|
|
121
|
+
scopeKey: scopeKey,
|
|
122
|
+
scope: _scope,
|
|
123
|
+
epoch: epoch0.epoch,
|
|
124
|
+
aggSid: aggSid,
|
|
125
|
+
fieldSid: fieldSid,
|
|
126
|
+
plaintext: _plaintext,
|
|
127
|
+
);
|
|
128
|
+
expect(env, contains(fieldSid), reason: 'the envelope binds the stable field sid');
|
|
129
|
+
expect(env, contains(_scope));
|
|
130
|
+
await s.offerOutcome(
|
|
131
|
+
domain: _domain,
|
|
132
|
+
directiveId: 'writeNote',
|
|
133
|
+
payload: {'owner': _reader, 'body': env},
|
|
134
|
+
domainHash: lawHash,
|
|
135
|
+
);
|
|
136
|
+
return env;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── LEG 4: the reader's WrappedKeyStore unwraps its scope key from the on-chain WrappedKey
|
|
140
|
+
// with its device secret — the shipped key-custody default. ──
|
|
141
|
+
final keys = await _leg('leg 4: WrappedKeyStore unwraps the on-chain wrapped key', () async {
|
|
142
|
+
final store = WrappedKeyStore(
|
|
143
|
+
bridge: s, principal: _reader, deviceSecret: reader['secret'].toString());
|
|
144
|
+
final current = await store.scopeKey(_scope);
|
|
145
|
+
expect(current, isNotNull, reason: 'the reader holds a wrap for its scope');
|
|
146
|
+
expect(current!.epoch, 0);
|
|
147
|
+
expect(current.key, scopeKey,
|
|
148
|
+
reason: 'the unwrapped scope key IS the ceremony key (the real handoff)');
|
|
149
|
+
return store.readKeys(const [_scope]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── LEG 5: read WITH the key → DECRYPTED; read WITHOUT → the opaque envelope. ──
|
|
153
|
+
await _leg('leg 5: keyed read decrypts; keyless read stays opaque', () async {
|
|
154
|
+
final withKey = await s.query('notesByOwner', {'owner': _reader}, keys, _reader);
|
|
155
|
+
expect(withKey, hasLength(1));
|
|
156
|
+
expect((withKey.single['data'] as Map)['body'], _plaintext,
|
|
157
|
+
reason: 'the key-holder reads plaintext');
|
|
158
|
+
final noKey = await s.query('notesByOwner', {'owner': _reader}, null, _reader);
|
|
159
|
+
final opaque = (noKey.single['data'] as Map)['body'];
|
|
160
|
+
expect(opaque, isNot(_plaintext), reason: 'the operator view never decrypts');
|
|
161
|
+
expect(opaque.toString(), contains(_scope),
|
|
162
|
+
reason: 'the value is the scope-bound ciphertext envelope, sizes/scope visible only');
|
|
163
|
+
expect(opaque.toString(), envelope, reason: 'byte-for-byte the on-chain envelope');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── LEG 6: a NON-MEMBER is refused by the read gate itself (before decryption matters). ──
|
|
167
|
+
await _leg('leg 6: non-member read refused by the read gate', () async {
|
|
168
|
+
await expectLater(
|
|
169
|
+
s.query('notesByOwner', {'owner': _reader},
|
|
170
|
+
[NomosScopeKey(scope: _scope, epoch: 0, key: scopeKey)], 'intruder-mallory'),
|
|
171
|
+
throwsA(predicate((e) => e.toString().contains('read-forbidden'),
|
|
172
|
+
'a read-forbidden refusal')));
|
|
173
|
+
});
|
|
174
|
+
} finally {
|
|
175
|
+
await harness.dispose().timeout(const Duration(seconds: 30),
|
|
176
|
+
onTimeout: () => throw StateError('harness disposal hung'));
|
|
177
|
+
}
|
|
178
|
+
}, timeout: const Timeout(Duration(minutes: 5)));
|
|
179
|
+
}
|