@githolon/dsl 0.77.1 → 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 +124 -1
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +2 -2
- package/dart/test/errors_test.dart +52 -0
- package/dart/test/parent_signer_recovery_test.dart +134 -0
- 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
|
|
@@ -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.78.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
|
|
@@ -1229,6 +1229,7 @@ class NomosBridge {
|
|
|
1229
1229
|
List<Map<String, Object?>>? attestations,
|
|
1230
1230
|
bool? force,
|
|
1231
1231
|
String? session,
|
|
1232
|
+
String? authorSecret,
|
|
1232
1233
|
}) =>
|
|
1233
1234
|
offerOutcome(
|
|
1234
1235
|
domain: domain,
|
|
@@ -1239,6 +1240,7 @@ class NomosBridge {
|
|
|
1239
1240
|
attestations: attestations,
|
|
1240
1241
|
force: force,
|
|
1241
1242
|
session: session,
|
|
1243
|
+
authorSecret: authorSecret,
|
|
1242
1244
|
);
|
|
1243
1245
|
|
|
1244
1246
|
/// THE ONE OUTCOME-RETURNING OFFER LANE — [offerDirective] answering the full typed [NomosOfferOutcome]
|
|
@@ -1255,6 +1257,9 @@ class NomosBridge {
|
|
|
1255
1257
|
List<Map<String, Object?>>? attestations,
|
|
1256
1258
|
bool? force,
|
|
1257
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,
|
|
1258
1263
|
}) async {
|
|
1259
1264
|
final Map<String, Object?> r;
|
|
1260
1265
|
// OFFER SPAN TAXONOMY (bridge legs): stamp the Dart-side send/receive around the op; the runner
|
|
@@ -1274,6 +1279,7 @@ class NomosBridge {
|
|
|
1274
1279
|
if (attestations != null) 'attestations': attestations,
|
|
1275
1280
|
if (force != null) 'force': force,
|
|
1276
1281
|
if (session != null) 'session': session,
|
|
1282
|
+
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
1277
1283
|
}));
|
|
1278
1284
|
} catch (e) {
|
|
1279
1285
|
throw await _wrapDomainNotInstalled(e,
|
|
@@ -1692,6 +1698,87 @@ class NomosBridge {
|
|
|
1692
1698
|
if (authToken != null) 'authToken': authToken,
|
|
1693
1699
|
}));
|
|
1694
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
|
+
|
|
1695
1782
|
/// VERIFY_CHAIN — the kernel's from-genesis self-verification of THIS holon's folded ledger (read-only:
|
|
1696
1783
|
/// contiguity → every intent re-admitted through the ONE gate against the state folded from the prefix). For
|
|
1697
1784
|
/// a WARRANTED home this RE-FIRES the parent-attested-birth cert door on the cold-replay lane — so a
|
|
@@ -2258,6 +2345,42 @@ class NomosSession extends NomosBridge {
|
|
|
2258
2345
|
@override
|
|
2259
2346
|
Future<void> dispose() async {}
|
|
2260
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
|
+
|
|
2261
2384
|
// NO per-method Stream/watch/snapshot overrides live here anymore — the base class routes ALL event
|
|
2262
2385
|
// dispatch + plane state through [_eventPlane] (and law-scopes compat/onLawChanged via [_sessionName]),
|
|
2263
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
|
|
|
@@ -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'));
|
|
@@ -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
|
+
}
|
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).
|