@githolon/dsl 0.77.0 → 0.78.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 +22 -0
- package/dart/lib/src/webview_bridge.dart +189 -2
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +2 -2
- package/dart/test/acquire_session_test.dart +1 -1
- package/dart/test/bundle_version_pairing_test.dart +40 -0
- package/dart/test/errors_test.dart +52 -0
- package/dart/test/fixtures/wedge/wedge-entry.mjs +1 -1
- package/dart/test/parent_signer_recovery_test.dart +134 -0
- package/dart/test/stale_bundle_test.dart +65 -0
- package/dart/test/webview_bridge_test.dart +2 -2
- package/dart_types/CHANGELOG.md +7 -0
- package/dart_types/lib/src/errors.dart +138 -0
- package/dart_types/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/src/compile_package_main.ts +2 -2
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## 0.54.0
|
|
2
|
+
|
|
3
|
+
- **Parent-signer recovery** (the reborn-parent scar): `ensureParentSignerEnrollment(...)` —
|
|
4
|
+
idempotent verify-and-repair of `identity:<uid>#signer` on a configured parent (one relation read
|
|
5
|
+
when healthy; lawful repair via the discovered signing directive — enrollSigner pre-warrant, the
|
|
6
|
+
keyless enrollFirstDevice lane on a warranted parent); `parentSignerEnrollment(...)` posture read;
|
|
7
|
+
`devicePublicOf(secret)`.
|
|
8
|
+
- **`canAuthor(directiveId, ...)` preflight** — evaluates the gate's premises read-only and returns
|
|
9
|
+
the typed verdict ({actor, keyHash, cause}) BEFORE the user fills a form;
|
|
10
|
+
`NomosSession.canAuthorTyped` throws the typed error.
|
|
11
|
+
- Gate refusals classify into `nomos.authz.*` typed errors (see nomos_types 0.4.0). Requires the
|
|
12
|
+
paired runner bundle >= 0.78.0.
|
|
13
|
+
|
|
14
|
+
## 0.53.0
|
|
15
|
+
|
|
16
|
+
- **Stale-baked-bundle detection.** The runner bundle stamps its release version into the `ready`
|
|
17
|
+
frame (build-time injected — it cannot lie); the bridge refuses an older or pre-stamp bundle at
|
|
18
|
+
boot with the typed `StaleRunnerBundle` naming found/expected and the `flutter clean` remedy.
|
|
19
|
+
Flutter silently keeping an old baked bundle across a pub upgrade is now a one-line error at app
|
|
20
|
+
start instead of behavioral archaeology. `allowStaleRunnerBundle: true` is the deliberate
|
|
21
|
+
mixed-version escape hatch. Requires the paired runner bundle >= 0.77.1 (nomos_flutter 0.53.7).
|
|
22
|
+
|
|
1
23
|
## 0.52.0
|
|
2
24
|
|
|
3
25
|
- **Offer ack decoupled from watch fan-out** (co2's 2.08s 3×3-grid report): the durable acknowledgement
|
|
@@ -43,6 +43,34 @@ typedef NomosEngine = NomosBridge;
|
|
|
43
43
|
/// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
|
|
44
44
|
const int kWebviewBridgeVersion = 1;
|
|
45
45
|
|
|
46
|
+
/// THE EXPECTED RUNNER-BUNDLE VERSION FLOOR — the @githolon/client release this nomos_client version
|
|
47
|
+
/// ships alongside. The bundle stamps its own version into the ready frame; anything OLDER (or a
|
|
48
|
+
/// pre-stamp bundle, which reports nothing) is a stale baked asset and fails loudly at boot.
|
|
49
|
+
/// MUST move with the paired @githolon/client release — a release-hygiene test pairs it with the
|
|
50
|
+
/// runner stamp (the pubspec-pins pattern).
|
|
51
|
+
const String kExpectedRunnerBundleVersion = '0.78.0';
|
|
52
|
+
|
|
53
|
+
/// A STALE BAKED RUNNER BUNDLE: the app's built assets carry an older nomos-runner.js than this
|
|
54
|
+
/// nomos_client release ships — Flutter's build cache kept it across a pub upgrade. Thrown from
|
|
55
|
+
/// [NomosBridge.ready] at boot (loud, immediate, named) instead of letting one-version-old runtime
|
|
56
|
+
/// behavior masquerade as framework bugs.
|
|
57
|
+
class StaleRunnerBundle implements Exception {
|
|
58
|
+
StaleRunnerBundle({required this.found, required this.expected});
|
|
59
|
+
|
|
60
|
+
/// The bundle's self-reported version (null = a pre-stamp bundle, definitionally stale).
|
|
61
|
+
final String? found;
|
|
62
|
+
|
|
63
|
+
/// The minimum version this nomos_client release expects ([kExpectedRunnerBundleVersion]).
|
|
64
|
+
final String expected;
|
|
65
|
+
|
|
66
|
+
@override
|
|
67
|
+
String toString() =>
|
|
68
|
+
'StaleRunnerBundle: the baked nomos-runner.js is ${found ?? "un-stamped (pre-0.77.1)"} but this '
|
|
69
|
+
'nomos_client expects >= $expected. Flutter kept an old bundle across the upgrade — run '
|
|
70
|
+
'`flutter clean` and rebuild (verify with shasum of flutter_assets/.../nomos-runner.js), or pass '
|
|
71
|
+
'NomosBridge(..., allowStaleRunnerBundle: true) for a deliberate mixed-version setup.';
|
|
72
|
+
}
|
|
73
|
+
|
|
46
74
|
/// The platform channel to the WebView runner: send a JSON-shaped message; receive the runner's messages.
|
|
47
75
|
/// Web impl jsifies/dartifies over `dart:js_util`; mobile impl JSON-encodes over a WebView JS-channel. The
|
|
48
76
|
/// bridge treats messages as plain `Map<String, Object?>` — the transport owns the channel serialization.
|
|
@@ -743,13 +771,37 @@ class NomosBridge {
|
|
|
743
771
|
final String _traceId = _newTraceId();
|
|
744
772
|
late final StreamSubscription<Map<String, Object?>> _sub;
|
|
745
773
|
|
|
746
|
-
NomosBridge(this._transport) {
|
|
774
|
+
NomosBridge(this._transport, {this.allowStaleRunnerBundle = false}) {
|
|
747
775
|
_sub = _transport.messages.listen(_onMessage);
|
|
748
776
|
}
|
|
749
777
|
|
|
778
|
+
/// Escape hatch for DELIBERATE mixed-version setups (framework dev, A/B probes): accept a runner
|
|
779
|
+
/// bundle older than [kExpectedRunnerBundleVersion] instead of failing [ready].
|
|
780
|
+
final bool allowStaleRunnerBundle;
|
|
781
|
+
|
|
782
|
+
/// null = acceptable; otherwise the typed boot error. "unbundled-dev" (the in-repo source lane)
|
|
783
|
+
/// always passes; a missing stamp means a pre-stamp (definitionally stale) bundle.
|
|
784
|
+
StaleRunnerBundle? _staleBundleError(Object? bundleVersion) {
|
|
785
|
+
final v = bundleVersion is String ? bundleVersion : null;
|
|
786
|
+
if (v == 'unbundled-dev') return null;
|
|
787
|
+
if (v != null && _semverAtLeast(v, kExpectedRunnerBundleVersion)) return null;
|
|
788
|
+
return StaleRunnerBundle(found: v, expected: kExpectedRunnerBundleVersion);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
static bool _semverAtLeast(String v, String min) {
|
|
792
|
+
List<int> parse(String s) =>
|
|
793
|
+
s.split('-').first.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
|
794
|
+
final a = parse(v), b = parse(min);
|
|
795
|
+
for (var i = 0; i < 3; i++) {
|
|
796
|
+
final x = i < a.length ? a[i] : 0, y = i < b.length ? b[i] : 0;
|
|
797
|
+
if (x != y) return x > y;
|
|
798
|
+
}
|
|
799
|
+
return true;
|
|
800
|
+
}
|
|
801
|
+
|
|
750
802
|
/// A SESSION VIEW's super-constructor: shares the parent's [_transport] but does NOT subscribe (the parent
|
|
751
803
|
/// owns the one message loop + all correlation). Only [NomosSession] uses it.
|
|
752
|
-
NomosBridge._view(this._transport);
|
|
804
|
+
NomosBridge._view(this._transport) : allowStaleRunnerBundle = false;
|
|
753
805
|
|
|
754
806
|
static Random _traceRandom() {
|
|
755
807
|
try {
|
|
@@ -798,6 +850,18 @@ class NomosBridge {
|
|
|
798
850
|
}
|
|
799
851
|
final event = m['event'];
|
|
800
852
|
if (event == 'ready') {
|
|
853
|
+
// THE STALE-BAKED-BUNDLE CHECK: Flutter's build system can silently keep an OLD runner bundle
|
|
854
|
+
// baked into an app across a correct pub upgrade — invisible version skew that cost a real
|
|
855
|
+
// tenant two archaeology hunts. The bundle stamps its release version into the ready frame;
|
|
856
|
+
// an older-than-expected (or missing — i.e. pre-stamp) bundle fails LOUDLY at boot with the
|
|
857
|
+
// remedy, instead of misbehaving subtly at runtime. "unbundled-dev" (the in-repo source lane
|
|
858
|
+
// the test harness drives) always passes; `allowStaleRunnerBundle: true` on the constructor is
|
|
859
|
+
// the deliberate-mixed-versions escape hatch.
|
|
860
|
+
final stale = _staleBundleError(m['bundleVersion']);
|
|
861
|
+
if (stale != null && !allowStaleRunnerBundle) {
|
|
862
|
+
if (!_ready.isCompleted) _ready.completeError(stale);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
801
865
|
if (!_ready.isCompleted) _ready.complete();
|
|
802
866
|
return;
|
|
803
867
|
}
|
|
@@ -1165,6 +1229,7 @@ class NomosBridge {
|
|
|
1165
1229
|
List<Map<String, Object?>>? attestations,
|
|
1166
1230
|
bool? force,
|
|
1167
1231
|
String? session,
|
|
1232
|
+
String? authorSecret,
|
|
1168
1233
|
}) =>
|
|
1169
1234
|
offerOutcome(
|
|
1170
1235
|
domain: domain,
|
|
@@ -1175,6 +1240,7 @@ class NomosBridge {
|
|
|
1175
1240
|
attestations: attestations,
|
|
1176
1241
|
force: force,
|
|
1177
1242
|
session: session,
|
|
1243
|
+
authorSecret: authorSecret,
|
|
1178
1244
|
);
|
|
1179
1245
|
|
|
1180
1246
|
/// THE ONE OUTCOME-RETURNING OFFER LANE — [offerDirective] answering the full typed [NomosOfferOutcome]
|
|
@@ -1191,6 +1257,9 @@ class NomosBridge {
|
|
|
1191
1257
|
List<Map<String, Object?>>? attestations,
|
|
1192
1258
|
bool? force,
|
|
1193
1259
|
String? session,
|
|
1260
|
+
// Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead
|
|
1261
|
+
// of the connect-time one — the parent-directed lanes (sibling births after a signer repair) use it.
|
|
1262
|
+
String? authorSecret,
|
|
1194
1263
|
}) async {
|
|
1195
1264
|
final Map<String, Object?> r;
|
|
1196
1265
|
// OFFER SPAN TAXONOMY (bridge legs): stamp the Dart-side send/receive around the op; the runner
|
|
@@ -1210,6 +1279,7 @@ class NomosBridge {
|
|
|
1210
1279
|
if (attestations != null) 'attestations': attestations,
|
|
1211
1280
|
if (force != null) 'force': force,
|
|
1212
1281
|
if (session != null) 'session': session,
|
|
1282
|
+
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
1213
1283
|
}));
|
|
1214
1284
|
} catch (e) {
|
|
1215
1285
|
throw await _wrapDomainNotInstalled(e,
|
|
@@ -1628,6 +1698,87 @@ class NomosBridge {
|
|
|
1628
1698
|
if (authToken != null) 'authToken': authToken,
|
|
1629
1699
|
}));
|
|
1630
1700
|
|
|
1701
|
+
/// Derive the device key's base64 PUBLIC half from its base64 SECRET seed, inside the LOCAL kernel
|
|
1702
|
+
/// (the secret never leaves the device). The recovery lanes use this when only the secret was
|
|
1703
|
+
/// persisted (the device-key store keeps just the seed) — e.g. to mint a device-BOUND IdP token
|
|
1704
|
+
/// (`authTokenForDevice(publicKey)`) for the keyless re-enroll lane.
|
|
1705
|
+
Future<String> devicePublicOf(String secret) async =>
|
|
1706
|
+
(_asMap(await _call('devicePublicOf', {'secret': secret}))['public'] ?? '')
|
|
1707
|
+
.toString();
|
|
1708
|
+
|
|
1709
|
+
/// PARENT-AUTHORITY POSTURE (read-only diagnostic): is this device key a LIVE `signer` for
|
|
1710
|
+
/// `principal` on `parent`'s chain (`identity:<principal>#signer@key:<sha256(publicKey)>`)? One O(1)
|
|
1711
|
+
/// relation read over the open gateway; nothing is authored. Returns
|
|
1712
|
+
/// `{parent, principal, keyHash, object, relation, enrolled, revoked, checkedAt}`.
|
|
1713
|
+
Future<Map<String, Object?>> parentSignerEnrollment(
|
|
1714
|
+
{required String parent,
|
|
1715
|
+
required String principal,
|
|
1716
|
+
String? keyHash,
|
|
1717
|
+
String? publicKey,
|
|
1718
|
+
String? authToken}) async =>
|
|
1719
|
+
_asMap(await _call('parentSignerEnrollment', {
|
|
1720
|
+
'parent': parent,
|
|
1721
|
+
'principal': principal,
|
|
1722
|
+
if (keyHash != null) 'keyHash': keyHash,
|
|
1723
|
+
if (publicKey != null) 'publicKey': publicKey,
|
|
1724
|
+
if (authToken != null) 'authToken': authToken,
|
|
1725
|
+
}));
|
|
1726
|
+
|
|
1727
|
+
/// IDEMPOTENT PARENT-SIGNER RECOVERY — ensure this device key is a live `signer` on `parent`,
|
|
1728
|
+
/// lawfully restoring the tuple a parent custody REBIRTH lost (the device key + the born home stay
|
|
1729
|
+
/// valid; no home rebirth, no hand-posted secrets). Already enrolled ⇒ pure no-op (`repaired:false`).
|
|
1730
|
+
/// Absent ⇒ re-authors the enrollment via the parent's OWN installed signing directive, DISCOVERED
|
|
1731
|
+
/// from its law (never a hardcoded framework domain — a tenant-composed `co2_platform` works);
|
|
1732
|
+
/// `domain`/`directiveId` override the discovery. A REVOKED key is never silently re-enrolled.
|
|
1733
|
+
/// TWO LAWFUL LANES, kernel-decided: a WARRANTED parent (the reborn-platform case) admits a fresh
|
|
1734
|
+
/// device-signer only via the keyless `enrollFirstDevice` — pass the device-bound [idToken]
|
|
1735
|
+
/// (+ [verifiedVia], the parent's AuthProvider issuer); a PRE-WARRANT parent admits the founding
|
|
1736
|
+
/// `enrollSigner` (no token needed).
|
|
1737
|
+
/// Returns `{parent, principal, keyHash, enrolled, repaired, domain?, directiveId?, checkedAt}`.
|
|
1738
|
+
Future<Map<String, Object?>> ensureParentSignerEnrollment(
|
|
1739
|
+
{required String parent,
|
|
1740
|
+
required String principal,
|
|
1741
|
+
String? publicKey,
|
|
1742
|
+
String? authorSecret,
|
|
1743
|
+
String? domain,
|
|
1744
|
+
String? directiveId,
|
|
1745
|
+
String? authToken,
|
|
1746
|
+
String? idToken,
|
|
1747
|
+
String? verifiedVia}) async =>
|
|
1748
|
+
_asMap(await _call('ensureParentSignerEnrollment', {
|
|
1749
|
+
'parent': parent,
|
|
1750
|
+
'principal': principal,
|
|
1751
|
+
if (publicKey != null) 'publicKey': publicKey,
|
|
1752
|
+
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
1753
|
+
if (domain != null) 'domain': domain,
|
|
1754
|
+
if (directiveId != null) 'directiveId': directiveId,
|
|
1755
|
+
if (authToken != null) 'authToken': authToken,
|
|
1756
|
+
if (idToken != null) 'idToken': idToken,
|
|
1757
|
+
if (verifiedVia != null) 'verifiedVia': verifiedVia,
|
|
1758
|
+
}));
|
|
1759
|
+
|
|
1760
|
+
/// AUTHORING PREFLIGHT — evaluate the SAME premises the kernel's authorization gate judges,
|
|
1761
|
+
/// READ-ONLY, and return a typed verdict WITHOUT authoring — so the app catches a doomed write
|
|
1762
|
+
/// (e.g. a reborn parent missing this device's signer tuple) BEFORE the user fills a form.
|
|
1763
|
+
/// `ws` targets another workspace (pass the PARENT to preflight a sibling birth). Returns
|
|
1764
|
+
/// `{ok, ws, directiveId, actor, keyHash, cause, causes, domain?, relation?, object?, error?, checkedAt}`
|
|
1765
|
+
/// where `cause` ∈ {directive-not-found, no-signing-key, missing-signer-enrollment, revoked-signer,
|
|
1766
|
+
/// missing-role, null}. Use [canAuthorTyped] on a [NomosSession] for the typed-error variant.
|
|
1767
|
+
Future<Map<String, Object?>> canAuthor(String directiveId,
|
|
1768
|
+
{String? ws,
|
|
1769
|
+
String? principal,
|
|
1770
|
+
String? publicKey,
|
|
1771
|
+
String? keyHash,
|
|
1772
|
+
String? authToken}) async =>
|
|
1773
|
+
_asMap(await _call('canAuthor', {
|
|
1774
|
+
'directiveId': directiveId,
|
|
1775
|
+
if (ws != null) 'ws': ws,
|
|
1776
|
+
if (principal != null) 'principal': principal,
|
|
1777
|
+
if (publicKey != null) 'publicKey': publicKey,
|
|
1778
|
+
if (keyHash != null) 'keyHash': keyHash,
|
|
1779
|
+
if (authToken != null) 'authToken': authToken,
|
|
1780
|
+
}));
|
|
1781
|
+
|
|
1631
1782
|
/// VERIFY_CHAIN — the kernel's from-genesis self-verification of THIS holon's folded ledger (read-only:
|
|
1632
1783
|
/// contiguity → every intent re-admitted through the ONE gate against the state folded from the prefix). For
|
|
1633
1784
|
/// a WARRANTED home this RE-FIRES the parent-attested-birth cert door on the cold-replay lane — so a
|
|
@@ -2194,6 +2345,42 @@ class NomosSession extends NomosBridge {
|
|
|
2194
2345
|
@override
|
|
2195
2346
|
Future<void> dispose() async {}
|
|
2196
2347
|
|
|
2348
|
+
/// TYPED AUTHORING PREFLIGHT — [NomosBridge.canAuthor], returning the raw verdict when the write
|
|
2349
|
+
/// would admit and THROWING the matching typed error when it would not: a missing parent signer
|
|
2350
|
+
/// tuple throws [ParentSignerEnrollmentMissing] (its `recommendedAction` names the ONE remedy —
|
|
2351
|
+
/// [ensureParentSignerEnrollment]); any other failing premise throws [AuthzRefusedError] carrying
|
|
2352
|
+
/// the evaluated {workspace, actor, keyHash, object, relation, cause}. Nothing is authored.
|
|
2353
|
+
Future<Map<String, Object?>> canAuthorTyped(String directiveId,
|
|
2354
|
+
{String? ws,
|
|
2355
|
+
String? principal,
|
|
2356
|
+
String? publicKey,
|
|
2357
|
+
String? keyHash,
|
|
2358
|
+
String? authToken}) async {
|
|
2359
|
+
final v = await canAuthor(directiveId,
|
|
2360
|
+
ws: ws, principal: principal, publicKey: publicKey, keyHash: keyHash, authToken: authToken);
|
|
2361
|
+
if (v['ok'] == true) return v;
|
|
2362
|
+
final cause = v['cause']?.toString();
|
|
2363
|
+
final target = v['ws']?.toString() ?? ws ?? workspace;
|
|
2364
|
+
final info = NomosErrorInfo(
|
|
2365
|
+
code: cause == 'missing-signer-enrollment'
|
|
2366
|
+
? 'nomos.authz.signer_enrollment_missing'
|
|
2367
|
+
: 'nomos.authz.refused',
|
|
2368
|
+
message:
|
|
2369
|
+
'canAuthor($directiveId) on $target: premise "$cause" failed for ${v['actor'] ?? principal ?? '(unknown actor)'}',
|
|
2370
|
+
workspace: target,
|
|
2371
|
+
operation: 'canAuthor',
|
|
2372
|
+
attributes: {
|
|
2373
|
+
if (v['actor'] != null) 'nomos.authz.actor': v['actor'],
|
|
2374
|
+
if (v['keyHash'] != null) 'nomos.authz.keyHash': v['keyHash'],
|
|
2375
|
+
if (v['object'] != null) 'nomos.authz.object': v['object'],
|
|
2376
|
+
if (v['relation'] != null) 'nomos.authz.relation': v['relation'],
|
|
2377
|
+
'nomos.authz.directiveId': directiveId,
|
|
2378
|
+
if (cause != null) 'nomos.authz.cause': cause,
|
|
2379
|
+
},
|
|
2380
|
+
);
|
|
2381
|
+
throw nomosErrorFromInfo(info);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2197
2384
|
// NO per-method Stream/watch/snapshot overrides live here anymore — the base class routes ALL event
|
|
2198
2385
|
// dispatch + plane state through [_eventPlane] (and law-scopes compat/onLawChanged via [_sessionName]),
|
|
2199
2386
|
// and the watch ops still carry this session's workspace because their registration goes through the
|
package/dart/pubspec.lock
CHANGED
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.54.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -20,7 +20,7 @@ dependencies:
|
|
|
20
20
|
# The pure-data core (wire types, offer codec, NomosReads, schema validation, error
|
|
21
21
|
# info). nomos_client depends on and re-exports it, so existing imports are unchanged.
|
|
22
22
|
# For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
|
|
23
|
-
nomos_types: ^0.
|
|
23
|
+
nomos_types: ^0.4.0
|
|
24
24
|
json_schema: ^5.2.2
|
|
25
25
|
crypto: ^3.0.0
|
|
26
26
|
|
|
@@ -17,7 +17,7 @@ class FakeRunner implements BridgeTransport {
|
|
|
17
17
|
|
|
18
18
|
FakeRunner({this.resident = const []}) {
|
|
19
19
|
scheduleMicrotask(
|
|
20
|
-
() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
|
|
20
|
+
() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready', 'bundleVersion': 'unbundled-dev'}));
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
int get connectCount => sent.where((m) => m['op'] == 'connect').length;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// THE VERSION-PAIRING TRIPWIRE (the pubspec-pins pattern): kExpectedRunnerBundleVersion must equal
|
|
2
|
+
// the @githolon/client version this repo is about to release (cloud/web-client/package.json) — a
|
|
3
|
+
// client release without the Dart floor bump (or vice versa) fails the suite, so the stale-bundle
|
|
4
|
+
// check can neither lag (missing real staleness) nor lead (refusing the current bundle).
|
|
5
|
+
import 'dart:convert';
|
|
6
|
+
import 'dart:io';
|
|
7
|
+
|
|
8
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
9
|
+
import 'package:test/test.dart';
|
|
10
|
+
|
|
11
|
+
void main() {
|
|
12
|
+
test('kExpectedRunnerBundleVersion pairs with cloud/web-client/package.json', () {
|
|
13
|
+
final f = File('${Directory.current.path}/../../cloud/web-client/package.json');
|
|
14
|
+
if (!f.existsSync()) {
|
|
15
|
+
markTestSkipped('monorepo layout not present (published-package test run)');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
final v = (jsonDecode(f.readAsStringSync()) as Map)['version'] as String;
|
|
19
|
+
if (kExpectedRunnerBundleVersion == v) return; // released state: exact pair
|
|
20
|
+
// PENDING-RELEASE state: the floor may lead package.json by exactly the bump a queued
|
|
21
|
+
// changeset will apply (the changesets flow bumps package.json only when the version PR
|
|
22
|
+
// merges). Anything else — lagging, or leading with no changeset queued — is drift.
|
|
23
|
+
final changesets = Directory('${Directory.current.path}/../../.changeset')
|
|
24
|
+
.listSync()
|
|
25
|
+
.whereType<File>()
|
|
26
|
+
.where((c) => c.path.endsWith('.md') && !c.path.endsWith('README.md'))
|
|
27
|
+
.where((c) => c.readAsStringSync().contains('@githolon/client'));
|
|
28
|
+
expect(changesets, isNotEmpty,
|
|
29
|
+
reason: 'kExpectedRunnerBundleVersion ($kExpectedRunnerBundleVersion) != @githolon/client '
|
|
30
|
+
'($v) and NO client changeset is queued — bump the floor WITH the client release '
|
|
31
|
+
'(the stale-bundle floor must move with the bundle it ships beside)');
|
|
32
|
+
List<int> parse(String x) =>
|
|
33
|
+
x.split('.').map((p) => int.tryParse(p) ?? 0).toList();
|
|
34
|
+
final a = parse(kExpectedRunnerBundleVersion), b = parse(v);
|
|
35
|
+
final leads = a[0] > b[0] ||
|
|
36
|
+
(a[0] == b[0] && (a[1] > b[1] || (a[1] == b[1] && a[2] > b[2])));
|
|
37
|
+
expect(leads, isTrue,
|
|
38
|
+
reason: 'the floor must LEAD (never lag) package.json while a release is queued');
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -68,6 +68,58 @@ void main() {
|
|
|
68
68
|
expect(l.recommendedAction, NomosRecommendedAction.retry);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
test('authz signer_enrollment_missing mints ParentSignerEnrollmentMissing with the ONE remedy', () {
|
|
72
|
+
final e = nomosErrorFromInfo(info(
|
|
73
|
+
'nomos.authz.signer_enrollment_missing',
|
|
74
|
+
workspace: 'co2-platform',
|
|
75
|
+
attrs: {
|
|
76
|
+
'nomos.authz.actor': 'user:u-1',
|
|
77
|
+
'nomos.authz.keyHash': 'abab',
|
|
78
|
+
'nomos.authz.object': 'identity:u-1',
|
|
79
|
+
'nomos.authz.directiveId': 'birthEstateWorkspace',
|
|
80
|
+
},
|
|
81
|
+
));
|
|
82
|
+
expect(e, isA<NomosBridgeError>());
|
|
83
|
+
expect(e, isA<AuthzRefusedError>());
|
|
84
|
+
expect(e, isA<ParentSignerEnrollmentMissing>());
|
|
85
|
+
final p = e as ParentSignerEnrollmentMissing;
|
|
86
|
+
expect(p.cause, 'missing-signer-enrollment');
|
|
87
|
+
expect(p.workspace, 'co2-platform');
|
|
88
|
+
expect(p.actor, 'user:u-1');
|
|
89
|
+
expect(p.keyHash, 'abab');
|
|
90
|
+
expect(p.object, 'identity:u-1');
|
|
91
|
+
expect(p.relation, 'signer');
|
|
92
|
+
expect(p.directiveId, 'birthEstateWorkspace');
|
|
93
|
+
expect(p.recommendedAction, NomosRecommendedAction.ensureParentSignerEnrollment);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('authz cause=missing-signer-enrollment routes to ParentSignerEnrollmentMissing even under the generic code', () {
|
|
97
|
+
final e = nomosErrorFromInfo(info(
|
|
98
|
+
'nomos.authz.refused',
|
|
99
|
+
attrs: {'nomos.authz.cause': 'missing-signer-enrollment', 'nomos.authz.actor': 'user:u-9'},
|
|
100
|
+
));
|
|
101
|
+
expect(e, isA<ParentSignerEnrollmentMissing>());
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('authz missing_role stays AuthzRefusedError — role-absent is DISTINGUISHED from signer-absent', () {
|
|
105
|
+
final e = nomosErrorFromInfo(info(
|
|
106
|
+
'nomos.authz.missing_role',
|
|
107
|
+
workspace: 'ws-1',
|
|
108
|
+
attrs: {
|
|
109
|
+
'nomos.authz.cause': 'missing-role',
|
|
110
|
+
'nomos.authz.relation': 'creator',
|
|
111
|
+
'nomos.authz.object': 'workspace:self',
|
|
112
|
+
'nomos.authz.actor': 'user:u-2',
|
|
113
|
+
},
|
|
114
|
+
));
|
|
115
|
+
expect(e, isA<AuthzRefusedError>());
|
|
116
|
+
expect(e, isNot(isA<ParentSignerEnrollmentMissing>()));
|
|
117
|
+
final a = e as AuthzRefusedError;
|
|
118
|
+
expect(a.cause, 'missing-role');
|
|
119
|
+
expect(a.relation, 'creator');
|
|
120
|
+
expect(a.object, 'workspace:self');
|
|
121
|
+
});
|
|
122
|
+
|
|
71
123
|
test('custody.* still mints CustodyError (unchanged), unknown codes stay generic', () {
|
|
72
124
|
expect(nomosErrorFromInfo(info('nomos.custody.missing_object')), isA<CustodyError>());
|
|
73
125
|
final g = nomosErrorFromInfo(info('nomos.bridge.error'));
|
|
@@ -7,7 +7,7 @@ import { createInterface } from "node:readline";
|
|
|
7
7
|
const post = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
8
8
|
const wedgeOp = process.env.NOMOS_WEDGE_OP || "connect"; // default: wedge the KEYED reconnect (co2's shape)
|
|
9
9
|
const wedgeOnlyKeyed = process.env.NOMOS_WEDGE_ONLY_KEYED !== "0";
|
|
10
|
-
post({ v: 1, event: "ready" });
|
|
10
|
+
post({ v: 1, event: "ready", bundleVersion: "unbundled-dev" });
|
|
11
11
|
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
12
12
|
rl.on("line", (line) => {
|
|
13
13
|
const s = line.trim();
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// PARENT-SIGNER RECOVERY — the Dart surface leg (offline, the real runner + wasm).
|
|
2
|
+
//
|
|
3
|
+
// The co2 gap: after a parent custody rebirth the device key stays valid but the parent's chain no
|
|
4
|
+
// longer carries `identity:<uid>#signer` — and there was NO published Dart recovery path. This proves
|
|
5
|
+
// the new surfaces end-to-end over the node runner:
|
|
6
|
+
// · `canAuthor` / `canAuthorTyped` — the read-only preflight returns the TYPED verdict
|
|
7
|
+
// (cause `missing-signer-enrollment` → ParentSignerEnrollmentMissing) BEFORE any authoring;
|
|
8
|
+
// · `ensureParentSignerEnrollment` — idempotent verify-and-repair on the parent (repairs when the
|
|
9
|
+
// tuple is absent, pure no-op when live) with the signing directive DISCOVERED from the law;
|
|
10
|
+
// · `parentSignerEnrollment` — the diagnostic posture read;
|
|
11
|
+
// · after the repair, canAuthor flips ok and a SIGNED birthEstateWorkspace admits.
|
|
12
|
+
// (The warranted-reborn-parent kernel legs — the exact refusal + the keyless enrollFirstDevice
|
|
13
|
+
// recovery — are proven on the wasm by bench/scale/parent_signer_recovery.smoke.mjs.)
|
|
14
|
+
@TestOn('vm')
|
|
15
|
+
library;
|
|
16
|
+
|
|
17
|
+
import 'dart:io';
|
|
18
|
+
|
|
19
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
20
|
+
import 'package:nomos_types/nomos_types.dart'
|
|
21
|
+
show ParentSignerEnrollmentMissing, NomosRecommendedAction;
|
|
22
|
+
import 'package:test/test.dart';
|
|
23
|
+
|
|
24
|
+
String get _root => '${Directory.current.path}/../..';
|
|
25
|
+
String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
|
|
26
|
+
String get _deploy =>
|
|
27
|
+
'$_root/bench/scale/fixtures/estate-birth-signed/build/estate-birth-signed.deploy.json';
|
|
28
|
+
String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
29
|
+
|
|
30
|
+
const _ws = 'estate-birth-signed';
|
|
31
|
+
const _uid = 'sample-owner';
|
|
32
|
+
|
|
33
|
+
String? _missingPrereq() {
|
|
34
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
35
|
+
if (!File(_deploy).existsSync()) {
|
|
36
|
+
return 'compile the fixture: cd bench/scale/fixtures/estate-birth-signed && node ../../../../dsl/compile_package.mjs';
|
|
37
|
+
}
|
|
38
|
+
if (!File('$_runtimeDir/holon.wasm').existsSync()) {
|
|
39
|
+
return 'warm ~/.holon/runtime first (run any `githolon` command once)';
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
void main() {
|
|
45
|
+
test(
|
|
46
|
+
'canAuthor preflights the missing parent signer TYPED, ensureParentSignerEnrollment repairs it, '
|
|
47
|
+
'and the signed birth then admits', () async {
|
|
48
|
+
final skip = _missingPrereq();
|
|
49
|
+
if (skip != null) {
|
|
50
|
+
markTestSkipped(skip);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
54
|
+
deployJsonPaths: [_deploy],
|
|
55
|
+
nodeEntry: _repoEntry,
|
|
56
|
+
runtimeDir: _runtimeDir,
|
|
57
|
+
workspace: _ws,
|
|
58
|
+
);
|
|
59
|
+
try {
|
|
60
|
+
final session = harness.bridge.session(_ws);
|
|
61
|
+
final device = await session.mintDeviceKey();
|
|
62
|
+
final pub = device['public']!.toString();
|
|
63
|
+
final secret = device['secret']!.toString();
|
|
64
|
+
|
|
65
|
+
// devicePublicOf re-derives the public half from the persisted seed (the Dart key store keeps
|
|
66
|
+
// only the secret) — the recovery lanes depend on this.
|
|
67
|
+
expect(await session.devicePublicOf(secret), pub);
|
|
68
|
+
|
|
69
|
+
// 1. THE PREFLIGHT, broken state: the verdict names the failing premise BEFORE any authoring.
|
|
70
|
+
final v0 = await session.canAuthor('birthEstateWorkspace',
|
|
71
|
+
ws: _ws, principal: _uid, publicKey: pub);
|
|
72
|
+
expect(v0['ok'], isNot(true));
|
|
73
|
+
expect(v0['cause'], 'missing-signer-enrollment');
|
|
74
|
+
expect(v0['actor'], 'user:$_uid');
|
|
75
|
+
expect((v0['keyHash'] ?? '').toString(), hasLength(64));
|
|
76
|
+
|
|
77
|
+
// ...and the TYPED variant throws the ONE error with the ONE remedy.
|
|
78
|
+
await expectLater(
|
|
79
|
+
session.canAuthorTyped('birthEstateWorkspace', ws: _ws, principal: _uid, publicKey: pub),
|
|
80
|
+
throwsA(isA<ParentSignerEnrollmentMissing>()
|
|
81
|
+
.having((e) => e.recommendedAction, 'recommendedAction',
|
|
82
|
+
NomosRecommendedAction.ensureParentSignerEnrollment)
|
|
83
|
+
.having((e) => e.workspace, 'workspace', _ws)),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// 2. THE POSTURE READ agrees (diagnostic; nothing authored).
|
|
87
|
+
final posture0 = await session.parentSignerEnrollment(
|
|
88
|
+
parent: _ws, principal: _uid, publicKey: pub);
|
|
89
|
+
expect(posture0['enrolled'], false);
|
|
90
|
+
expect(posture0['object'], 'identity:$_uid');
|
|
91
|
+
expect(posture0['relation'], 'signer');
|
|
92
|
+
|
|
93
|
+
// 3. THE REPAIR — idempotent; the signing directive is DISCOVERED from the installed law
|
|
94
|
+
// (this fixture composes it under domain "home", NOT the framework "workspaces").
|
|
95
|
+
final r1 = await session.ensureParentSignerEnrollment(
|
|
96
|
+
parent: _ws, principal: _uid, publicKey: pub, authorSecret: secret);
|
|
97
|
+
expect(r1['enrolled'], true);
|
|
98
|
+
expect(r1['repaired'], true);
|
|
99
|
+
expect(r1['domain'], 'home',
|
|
100
|
+
reason: 'discovery must find the tenant-composed domain, never assume "workspaces"');
|
|
101
|
+
|
|
102
|
+
// 4. Re-run = pure no-op (the idempotence contract).
|
|
103
|
+
final r2 = await session.ensureParentSignerEnrollment(
|
|
104
|
+
parent: _ws, principal: _uid, publicKey: pub, authorSecret: secret);
|
|
105
|
+
expect(r2['enrolled'], true);
|
|
106
|
+
expect(r2['repaired'], false);
|
|
107
|
+
|
|
108
|
+
// 5. The preflight flips OK…
|
|
109
|
+
final v1 = await session.canAuthor('birthEstateWorkspace',
|
|
110
|
+
ws: _ws, principal: _uid, publicKey: pub);
|
|
111
|
+
expect(v1['ok'], true, reason: 'causes: ${v1['causes']}');
|
|
112
|
+
|
|
113
|
+
// 6. …and the SIGNED sibling birth ADMITS (per-offer signing key — the repaired device).
|
|
114
|
+
final lawHash = harness.lawHashes(_ws).first;
|
|
115
|
+
final outcome = await session.offerCreates(
|
|
116
|
+
domain: 'home',
|
|
117
|
+
directiveId: 'birthEstateWorkspace',
|
|
118
|
+
payload: {
|
|
119
|
+
'owner': 'user:$_uid',
|
|
120
|
+
'frameworkHash': harness.frameworkHash,
|
|
121
|
+
'lawHash': lawHash,
|
|
122
|
+
'bornAt': '2026-07-12T00:00:00Z',
|
|
123
|
+
},
|
|
124
|
+
actor: 'user:$_uid',
|
|
125
|
+
authorSecret: secret,
|
|
126
|
+
);
|
|
127
|
+
expect(outcome.head, isNotEmpty);
|
|
128
|
+
expect(outcome.born, isNotEmpty,
|
|
129
|
+
reason: 'the estate child births after the signer repair');
|
|
130
|
+
} finally {
|
|
131
|
+
await harness.dispose();
|
|
132
|
+
}
|
|
133
|
+
}, timeout: const Timeout(Duration(minutes: 3)));
|
|
134
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// THE STALE-BAKED-BUNDLE CHECK (the countermeasure to the co2 incidents where Flutter's build
|
|
2
|
+
// cache silently kept an old nomos-runner.js across a correct pub upgrade). ready carries the
|
|
3
|
+
// bundle's self-stamped version; the bridge refuses stale/pre-stamp bundles LOUDLY at boot.
|
|
4
|
+
import 'dart:async';
|
|
5
|
+
|
|
6
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
7
|
+
import 'package:test/test.dart';
|
|
8
|
+
|
|
9
|
+
class _StampedRunner implements BridgeTransport {
|
|
10
|
+
_StampedRunner(this.stamp) {
|
|
11
|
+
scheduleMicrotask(() => _incoming.add({
|
|
12
|
+
'v': kWebviewBridgeVersion,
|
|
13
|
+
'event': 'ready',
|
|
14
|
+
if (stamp != null) 'bundleVersion': stamp,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
final String? stamp;
|
|
18
|
+
final _incoming = StreamController<Map<String, Object?>>.broadcast();
|
|
19
|
+
@override
|
|
20
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
21
|
+
@override
|
|
22
|
+
void send(Map<String, Object?> message) {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
void main() {
|
|
26
|
+
test('a PRE-STAMP bundle (no bundleVersion) fails ready with StaleRunnerBundle', () async {
|
|
27
|
+
final b = NomosBridge(_StampedRunner(null));
|
|
28
|
+
await expectLater(b.ready, throwsA(isA<StaleRunnerBundle>()));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('an OLDER stamp fails ready, and the error names found/expected + the remedy', () async {
|
|
32
|
+
final b = NomosBridge(_StampedRunner('0.53.1'));
|
|
33
|
+
Object? err;
|
|
34
|
+
try {
|
|
35
|
+
await b.ready;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
err = e;
|
|
38
|
+
}
|
|
39
|
+
expect(err, isA<StaleRunnerBundle>());
|
|
40
|
+
final s = err.toString();
|
|
41
|
+
expect(s, contains('0.53.1'));
|
|
42
|
+
expect(s, contains(kExpectedRunnerBundleVersion));
|
|
43
|
+
expect(s, contains('flutter clean'));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the CURRENT stamp passes', () async {
|
|
47
|
+
final b = NomosBridge(_StampedRunner(kExpectedRunnerBundleVersion));
|
|
48
|
+
await b.ready; // completes
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a NEWER stamp passes (forward-compatible)', () async {
|
|
52
|
+
final b = NomosBridge(_StampedRunner('99.0.0'));
|
|
53
|
+
await b.ready;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('unbundled-dev (the in-repo source lane) always passes', () async {
|
|
57
|
+
final b = NomosBridge(_StampedRunner('unbundled-dev'));
|
|
58
|
+
await b.ready;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('allowStaleRunnerBundle is the deliberate escape hatch', () async {
|
|
62
|
+
final b = NomosBridge(_StampedRunner('0.1.0'), allowStaleRunnerBundle: true);
|
|
63
|
+
await b.ready;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -20,7 +20,7 @@ class FakeRunner implements BridgeTransport {
|
|
|
20
20
|
FakeRunner() {
|
|
21
21
|
// the runner announces readiness at construction.
|
|
22
22
|
scheduleMicrotask(
|
|
23
|
-
() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
|
|
23
|
+
() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready', 'bundleVersion': 'unbundled-dev'}));
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
@override
|
|
@@ -183,7 +183,7 @@ class ReplayTransport implements BridgeTransport {
|
|
|
183
183
|
Map<String, Object?>? lastRequest;
|
|
184
184
|
ReplayTransport(this.result) {
|
|
185
185
|
scheduleMicrotask(
|
|
186
|
-
() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
|
|
186
|
+
() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready', 'bundleVersion': 'unbundled-dev'}));
|
|
187
187
|
}
|
|
188
188
|
@override
|
|
189
189
|
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
package/dart_types/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 0.4.0
|
|
2
|
+
|
|
3
|
+
- Typed authorization diagnosis errors: `AuthzRefusedError` ({workspace, actor, keyHash, object,
|
|
4
|
+
relation, directiveId, cause}) and `ParentSignerEnrollmentMissing` (the one remedy named:
|
|
5
|
+
`ensureParentSignerEnrollment`) — "valid user, but this device cannot prove it" and "user lacks
|
|
6
|
+
the role" are now distinct typed diagnoses. `NomosRecommendedAction.ensureParentSignerEnrollment`.
|
|
7
|
+
|
|
1
8
|
## 0.3.0
|
|
2
9
|
|
|
3
10
|
- `SurfacedValue<T>` — the typed read surface for `Surfaced(inner)` merge-driver fields: sealed
|
|
@@ -23,6 +23,7 @@ import 'error_info.dart' show NomosBridgeError, NomosErrorInfo;
|
|
|
23
23
|
NomosBridgeError nomosErrorFromInfo(NomosErrorInfo info) {
|
|
24
24
|
if (info.code.startsWith('nomos.custody.')) return CustodyError.fromInfo(info);
|
|
25
25
|
if (info.code.startsWith('nomos.law.')) return LawMismatchError.fromInfo(info);
|
|
26
|
+
if (info.code.startsWith('nomos.authz.')) return AuthzRefusedError.fromInfo(info);
|
|
26
27
|
if (info.code.startsWith('nomos.auth.')) return AuthError.fromInfo(info);
|
|
27
28
|
if (info.code.startsWith('nomos.load.')) return LoadError.fromInfo(info);
|
|
28
29
|
return NomosBridgeError(info.message, info: info);
|
|
@@ -56,6 +57,11 @@ class NomosRecommendedAction {
|
|
|
56
57
|
/// Re-open the holon / reconnect fresh (the loaded runtime is unusable).
|
|
57
58
|
static const reopen = 'reopen';
|
|
58
59
|
|
|
60
|
+
/// Restore the device signer on the parent workspace (`ensureParentSignerEnrollment` /
|
|
61
|
+
/// [NomosScope.home] repairs it automatically on reopen) — the ONE remedy for
|
|
62
|
+
/// [ParentSignerEnrollmentMissing] (a reborn parent that lost the signer tuple).
|
|
63
|
+
static const ensureParentSignerEnrollment = 'ensureParentSignerEnrollment';
|
|
64
|
+
|
|
59
65
|
/// No automatic remedy — surface to a human.
|
|
60
66
|
static const none = 'none';
|
|
61
67
|
|
|
@@ -76,6 +82,8 @@ class NomosRecommendedAction {
|
|
|
76
82
|
return 'retry the operation';
|
|
77
83
|
case reopen:
|
|
78
84
|
return 'reopen the holon (reconnect fresh)';
|
|
85
|
+
case ensureParentSignerEnrollment:
|
|
86
|
+
return 'restore the device signer on the parent (ensureParentSignerEnrollment / NomosScope.home repairs it on reopen)';
|
|
79
87
|
case none:
|
|
80
88
|
return 'no automatic remedy — inspect the error';
|
|
81
89
|
default:
|
|
@@ -175,6 +183,136 @@ class LawMismatchError extends NomosBridgeError {
|
|
|
175
183
|
String toString() => 'LawMismatchError($code): $mismatchMessage';
|
|
176
184
|
}
|
|
177
185
|
|
|
186
|
+
/// AUTHZ REFUSAL — the kernel's relation/signer gate refused an authored intent (`nomos.authz.*`).
|
|
187
|
+
/// ONE structured diagnosis instead of hand-comparing relation tuples across two workspaces: it names
|
|
188
|
+
/// the evaluated [workspace], [actor], [keyHash], [object], [relation] and the [cause] the gate
|
|
189
|
+
/// distinguished:
|
|
190
|
+
/// - `missing-signer-enrollment` — the signature is valid but `identity:<actor>#signer@key:<keyHash>`
|
|
191
|
+
/// is absent on the judging workspace (the reborn-parent case) → [ParentSignerEnrollmentMissing].
|
|
192
|
+
/// - `missing-role` — the signer binding held (or was not required) but the directive's
|
|
193
|
+
/// `.requires(<relation>)` tuple is absent for the actor on [object].
|
|
194
|
+
/// These fields ride the UNSEALED presentation lanes only (verdict / 422 body / bridge errorInfo) —
|
|
195
|
+
/// never the sealed chain bytes.
|
|
196
|
+
class AuthzRefusedError extends NomosBridgeError {
|
|
197
|
+
/// The stable machine code, e.g. `nomos.authz.signer_enrollment_missing`.
|
|
198
|
+
final String code;
|
|
199
|
+
|
|
200
|
+
/// The workspace whose gate judged the offer (e.g. the PARENT for a sibling birth).
|
|
201
|
+
final String? workspace;
|
|
202
|
+
|
|
203
|
+
/// The evaluated actor (`user:<uid>`).
|
|
204
|
+
final String? actor;
|
|
205
|
+
|
|
206
|
+
/// sha256(device public key) — the on-chain `key:<hash>` subject the gate checked.
|
|
207
|
+
final String? keyHash;
|
|
208
|
+
|
|
209
|
+
/// The Zanzibar object checked (e.g. `identity:<uid>` / `workspace:self`).
|
|
210
|
+
final String? object;
|
|
211
|
+
|
|
212
|
+
/// The required relation (`signer` for the enrollment premise; the `.requires(...)` relation otherwise).
|
|
213
|
+
final String? relation;
|
|
214
|
+
|
|
215
|
+
/// The refused directive, when known.
|
|
216
|
+
final String? directiveId;
|
|
217
|
+
|
|
218
|
+
/// WHICH premise failed: `missing-signer-enrollment` | `missing-role` | `revoked-signer` | …
|
|
219
|
+
final String? cause;
|
|
220
|
+
|
|
221
|
+
/// The named remedy.
|
|
222
|
+
final String recommendedAction;
|
|
223
|
+
|
|
224
|
+
AuthzRefusedError({
|
|
225
|
+
required this.code,
|
|
226
|
+
required String message,
|
|
227
|
+
this.workspace,
|
|
228
|
+
this.actor,
|
|
229
|
+
this.keyHash,
|
|
230
|
+
this.object,
|
|
231
|
+
this.relation,
|
|
232
|
+
this.directiveId,
|
|
233
|
+
this.cause,
|
|
234
|
+
this.recommendedAction = NomosRecommendedAction.none,
|
|
235
|
+
NomosErrorInfo? info,
|
|
236
|
+
}) : super(message, info: info);
|
|
237
|
+
|
|
238
|
+
factory AuthzRefusedError.fromInfo(NomosErrorInfo info) {
|
|
239
|
+
String? attr(String name) {
|
|
240
|
+
final v = info.attributes['nomos.authz.$name'] ?? info.attributes[name];
|
|
241
|
+
return v?.toString();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
final cause = attr('cause');
|
|
245
|
+
final fields = (
|
|
246
|
+
workspace: info.workspace,
|
|
247
|
+
actor: attr('actor'),
|
|
248
|
+
keyHash: attr('keyHash') ?? attr('key_hash'),
|
|
249
|
+
object: attr('object'),
|
|
250
|
+
relation: attr('relation'),
|
|
251
|
+
directiveId: attr('directiveId') ?? attr('directive_id') ?? attr('directive'),
|
|
252
|
+
);
|
|
253
|
+
if (info.code == 'nomos.authz.signer_enrollment_missing' || cause == 'missing-signer-enrollment') {
|
|
254
|
+
return ParentSignerEnrollmentMissing(
|
|
255
|
+
message: info.message,
|
|
256
|
+
workspace: fields.workspace,
|
|
257
|
+
actor: fields.actor,
|
|
258
|
+
keyHash: fields.keyHash,
|
|
259
|
+
object: fields.object,
|
|
260
|
+
directiveId: fields.directiveId,
|
|
261
|
+
info: info,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return AuthzRefusedError(
|
|
265
|
+
code: info.code,
|
|
266
|
+
message: info.message,
|
|
267
|
+
workspace: fields.workspace,
|
|
268
|
+
actor: fields.actor,
|
|
269
|
+
keyHash: fields.keyHash,
|
|
270
|
+
object: fields.object,
|
|
271
|
+
relation: fields.relation,
|
|
272
|
+
directiveId: fields.directiveId,
|
|
273
|
+
cause: cause,
|
|
274
|
+
recommendedAction: attr('recommendedAction') ?? attr('recommended_action') ?? NomosRecommendedAction.none,
|
|
275
|
+
info: info,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
@override
|
|
280
|
+
String toString() =>
|
|
281
|
+
'AuthzRefusedError($code${workspace != null ? ', workspace: $workspace' : ''}'
|
|
282
|
+
'${actor != null ? ', actor: $actor' : ''}${keyHash != null ? ', keyHash: $keyHash' : ''}'
|
|
283
|
+
'${object != null ? ', object: $object' : ''}${relation != null ? ', relation: $relation' : ''}'
|
|
284
|
+
'${cause != null ? ', cause: $cause' : ''}, recommendedAction: $recommendedAction): $message';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/// PARENT SIGNER ENROLLMENT MISSING (`nomos.authz.signer_enrollment_missing`) — the device key is valid
|
|
288
|
+
/// and enrolled in the home, but the judging PARENT's chain (e.g. after a custody REBIRTH) no longer
|
|
289
|
+
/// carries the `identity:<actor>#signer@key:<keyHash>` tuple, so a correctly-signed offer (a sibling
|
|
290
|
+
/// birth like `birthEstateWorkspace`) is refused. THE ONE SUPPORTED REMEDY:
|
|
291
|
+
/// `session.ensureParentSignerEnrollment(...)` — [NomosScope.home] runs it automatically on reopen.
|
|
292
|
+
class ParentSignerEnrollmentMissing extends AuthzRefusedError {
|
|
293
|
+
ParentSignerEnrollmentMissing({
|
|
294
|
+
required String message,
|
|
295
|
+
String? workspace,
|
|
296
|
+
String? actor,
|
|
297
|
+
String? keyHash,
|
|
298
|
+
String? object,
|
|
299
|
+
String? directiveId,
|
|
300
|
+
NomosErrorInfo? info,
|
|
301
|
+
}) : super(
|
|
302
|
+
code: 'nomos.authz.signer_enrollment_missing',
|
|
303
|
+
message: message,
|
|
304
|
+
workspace: workspace,
|
|
305
|
+
actor: actor,
|
|
306
|
+
keyHash: keyHash,
|
|
307
|
+
object: object,
|
|
308
|
+
relation: 'signer',
|
|
309
|
+
directiveId: directiveId,
|
|
310
|
+
cause: 'missing-signer-enrollment',
|
|
311
|
+
recommendedAction: NomosRecommendedAction.ensureParentSignerEnrollment,
|
|
312
|
+
info: info,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
178
316
|
/// AUTH — the auth-provider token was missing/malformed/expired/rejected, or its issuer does not match the
|
|
179
317
|
/// workspace's law-declared key (`nomos.auth.*`, e.g. `nomos.auth.issuer_mismatch`, `nomos.auth.token_invalid`,
|
|
180
318
|
/// `nomos.auth.token_expired`). REMEDY is always `reauth`: acquire a fresh token and reconnect.
|
package/dart_types/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
|
|
12
12
|
no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
|
|
13
13
|
reactive read engine, schema validation, and data-only error/trace types.
|
|
14
|
-
version: 0.
|
|
14
|
+
version: 0.4.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.78.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",
|
|
@@ -924,11 +924,11 @@ const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
|
|
|
924
924
|
* just the floor generated code needs — a lagging pin forces tenants into dependency_overrides. */
|
|
925
925
|
// 0.3.0: SurfacedValue<T>/SurfacedObservation/PendingConflictRow — generated Surfaced-field typing +
|
|
926
926
|
// the pendingConflicts read rows.
|
|
927
|
-
const NOMOS_TYPES_DEP = "^0.
|
|
927
|
+
const NOMOS_TYPES_DEP = "^0.4.0";
|
|
928
928
|
// 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
|
|
929
929
|
// DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
|
|
930
930
|
// REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
|
|
931
|
-
const NOMOS_CLIENT_DEP = "^0.
|
|
931
|
+
const NOMOS_CLIENT_DEP = "^0.54.0";
|
|
932
932
|
// DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
|
|
933
933
|
// and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
|
|
934
934
|
// land back in dependency_overrides (the exact co2 report this closes, third occurrence).
|