@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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.
Files changed (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -21,14 +21,8 @@ const ESTATE_SCOPE = /^estate:[A-Za-z0-9][A-Za-z0-9._:~-]{0,127}$/;
21
21
  const NONCE32 = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
22
22
  const ASSERTION_MEMBERS = 'alg,authenticatorData,clientDataJSON,credentialId,format,sig';
23
23
 
24
- const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
25
- const isString = (value) => typeof value === 'string';
24
+ import { isObject, isString, objects as list, show, jsonEqual } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
26
25
  const matches = (pattern, value) => isString(value) && pattern.test(value);
27
- const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
28
- const show = (value) => {
29
- if (typeof value === 'string') return value;
30
- try { return String(value); } catch { return JSON.stringify(value) ?? typeof value; }
31
- };
32
26
  const REGISTER = new Map([
33
27
  [ENROLL_TOKEN_TYPE, { fields: ENROLL_TOKEN_FIELDS, check: (f) => matches(ESTATE_SCOPE, f.scope) && matches(SHA256, f.genesis) && f.purpose === ENROLL_PURPOSE
34
28
  && (f.ceiling === null || matches(SHA256, f.ceiling)) && Number.isFinite(estateTime(f.notBefore)) && Number.isFinite(estateTime(f.expiresAt)) && matches(NONCE32, f.nonce) && f.uses === 1 }],
@@ -180,15 +174,6 @@ const sha = (text) => crypto.createHash('sha256').update(Buffer.from(text, 'utf8
180
174
  const uint40 = (digest, offset) => digest.readUIntBE(offset, 5);
181
175
  const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
182
176
  const isRole = (value) => value === 'new' || value === 'trusted';
183
- function jsonEqual(a, b) {
184
- if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]));
185
- if (isObject(a) || isObject(b)) {
186
- if (!isObject(a) || !isObject(b)) return false;
187
- const keys = Object.keys(a);
188
- return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual(a[key], b[key]));
189
- }
190
- return a === b;
191
- }
192
177
 
193
178
  /** The machine a key list belongs to, or undefined (one sig key, at most one kem key, one machine id, valid keys). */
194
179
  export function machineOfKeys(keys) {
@@ -0,0 +1,41 @@
1
+ // The Node kit's cryptography helpers, once (FAA-65, layer L1); the twin of fabric_crypto.py. Signature verification
2
+ // itself is Node's (`crypto.verify`); what lives here is what the runtime does not refuse on its own: keys of small
3
+ // order (DEC-0045) and P-256 points off the curve, plus sha256. Node 20+, no dependency.
4
+ import crypto from 'node:crypto';
5
+
6
+ export const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest();
7
+
8
+ // The small-order points of edwards25519 and their non-canonical encodings, compared with the sign bit cleared
9
+ // (libsodium's has_small_order list). Under such a key, R = [S]B verifies for a quarter or more of all messages, and
10
+ // Node before 26 lets OpenSSL accept it: the kit refuses these keys itself rather than trust the runtime.
11
+ export const SMALL_ORDER_ENCODINGS = Object.freeze([
12
+ '0000000000000000000000000000000000000000000000000000000000000000', '0100000000000000000000000000000000000000000000000000000000000000',
13
+ '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
14
+ 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
15
+ 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
16
+ ]);
17
+ const cleared = (raw) => { const copy = Buffer.from(raw); copy[31] &= 0x7f; return copy.toString('hex'); };
18
+
19
+ /** Whether a 32-byte Ed25519 public key is a point of small order (in any encoding); a key of another length is not one. */
20
+ export function isSmallOrderKey(key) {
21
+ if (!Buffer.isBuffer(key) || key.length !== 32) return false;
22
+ return SMALL_ORDER_ENCODINGS.includes(cleared(key));
23
+ }
24
+
25
+ /** The contract's isSmallOrderKey as the estate record reads a hub key: a key that is not 32 bytes counts as one too. */
26
+ export const isSmallOrderOrMalformed = (raw) => raw.length !== 32 || SMALL_ORDER_ENCODINGS.includes(cleared(raw));
27
+
28
+ const P256_P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
29
+ const P256_B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
30
+ const toBig = (part) => BigInt(`0x${Buffer.from(part).toString('hex')}`);
31
+
32
+ /** Whether `raw` is a valid uncompressed P-256 public key: 0x04 ‖ X ‖ Y, both below p, on the curve. */
33
+ export function isValidP256Key(raw) {
34
+ if (raw.length !== 65 || raw[0] !== 0x04) return false;
35
+ const x = toBig(raw.subarray(1, 33));
36
+ const y = toBig(raw.subarray(33, 65));
37
+ if (x >= P256_P || y >= P256_P) return false;
38
+ const left = (y * y) % P256_P;
39
+ const right = (((x * x) % P256_P) * x - 3n * x + P256_B) % P256_P;
40
+ return left === ((right % P256_P) + P256_P) % P256_P;
41
+ }
@@ -67,8 +67,10 @@ const SCOPE = /^[a-z][a-z0-9._-]{1,127}( [a-z][a-z0-9._-]{1,127})*$/;
67
67
  const SPEND_WINDOWS = ['total', 'task', 'project', 'day', 'week', 'month'];
68
68
  const isUri = (value) => typeof value === 'string' && URI.test(value);
69
69
  const isAmount = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
70
- const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
71
- const isString = (value) => typeof value === 'string' && value.length > 0;
70
+ import { jcs, isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
71
+ export { jcs };
72
+ /** A string with at least one character (the token's claims refuse an empty one). */
73
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
72
74
  const isTime = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 0;
73
75
 
74
76
  /** Whether a JSON text repeats a member name in one object: refused, never resolved (RFC 7515 §5.2, RFC 8725 §3.2). */
@@ -122,7 +124,7 @@ function parseCompact(token) {
122
124
  function headerProblem(header, typ) {
123
125
  if (header.alg !== 'EdDSA') return 'alg-not-eddsa';
124
126
  if (header.typ !== typ) return 'wrong-typ';
125
- if (!isString(header.kid)) return 'no-kid';
127
+ if (!isNonEmptyString(header.kid)) return 'no-kid';
126
128
  for (const member of ['crit', 'jku', 'jwk', 'x5u', 'x5c']) if (header[member] !== undefined) return `header-${member}`;
127
129
  return undefined;
128
130
  }
@@ -134,7 +136,7 @@ function keyFor(jwks, kid) {
134
136
  if (!jwk) return { problem: 'unknown-key' };
135
137
  // A key published for another use or algorithm is never a candidate (RFC 7517 §4.2, §4.4).
136
138
  if ((jwk.use !== undefined && jwk.use !== 'sig') || (jwk.alg !== undefined && jwk.alg !== 'EdDSA')) return { problem: 'key-not-for-signing' };
137
- if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !isString(jwk.x) || jwk.d !== undefined) return { problem: 'unusable-key' };
139
+ if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !isNonEmptyString(jwk.x) || jwk.d !== undefined) return { problem: 'unusable-key' };
138
140
  const raw = Buffer.from(jwk.x, 'base64url');
139
141
  if (raw.length !== 32) return { problem: 'unusable-key' };
140
142
  if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // before any verification (DEC-0045, CT-29)
@@ -145,22 +147,8 @@ function keyFor(jwks, kid) {
145
147
  }
146
148
  }
147
149
 
148
- // The small-order points of edwards25519 and their non-canonical encodings, compared with the sign bit cleared
149
- // (libsodium's has_small_order list). Under such a key, R = [S]B verifies for a quarter or more of all messages, and
150
- // Node before 26 lets OpenSSL accept it: the kit refuses these keys itself rather than trust the runtime.
151
- const SMALL_ORDER = [
152
- '0000000000000000000000000000000000000000000000000000000000000000', '0100000000000000000000000000000000000000000000000000000000000000',
153
- '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
154
- 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
155
- 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
156
- ];
157
- /** Whether a 32-byte Ed25519 public key is a point of small order (in any encoding). */
158
- export function isSmallOrderKey(key) {
159
- if (!Buffer.isBuffer(key) || key.length !== 32) return false;
160
- const cleared = Buffer.from(key);
161
- cleared[31] &= 0x7f;
162
- return SMALL_ORDER.includes(cleared.toString('hex'));
163
- }
150
+ import { isSmallOrderKey } from './fabric-crypto.mjs'; // the core's (FAA-65)
151
+ export { isSmallOrderKey };
164
152
 
165
153
  const hasKeys = (jwks) => isObject(jwks) && Array.isArray(jwks.keys) && jwks.keys.length > 0;
166
154
 
@@ -189,7 +177,7 @@ export function chainDepth(act) {
189
177
  let node = act;
190
178
  while (node !== undefined) {
191
179
  if (depth > 0 && isPartnerActor(node)) return depth + 1; // a partner actor ends the chain, never begins it
192
- if (!isObject(node) || !isString(node.sub)) return Number.POSITIVE_INFINITY;
180
+ if (!isObject(node) || !isNonEmptyString(node.sub)) return Number.POSITIVE_INFINITY;
193
181
  depth += 1;
194
182
  if (depth > 64) return depth;
195
183
  node = node.act;
@@ -251,7 +239,7 @@ function delegationShape(payload) {
251
239
 
252
240
  function approvalShape(payload) {
253
241
  const { iss, actionDigest: digest, aud, approver, method, iat, exp, jti } = payload;
254
- if (!isString(iss) || !isString(digest) || !isString(aud) || !isString(approver) || !isString(jti) || !isTime(iat) || !isTime(exp)) return undefined;
242
+ if (!isNonEmptyString(iss) || !isNonEmptyString(digest) || !isNonEmptyString(aud) || !isNonEmptyString(approver) || !isNonEmptyString(jti) || !isTime(iat) || !isTime(exp)) return undefined;
255
243
  if (method !== 'prompt' && method !== 'operator-channel') return undefined; // DEC-0045: `device` is removed
256
244
  return payload;
257
245
  }
@@ -292,7 +280,7 @@ function estateProblem({ estate, estateApplied, estateRevocations }, kid, x, now
292
280
  export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly, estate, estateApplied, estateRevocations } = {}) {
293
281
  // Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
294
282
  if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
295
- if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
283
+ if (!isNonEmptyString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
296
284
  if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
297
285
  const checked = authentic(token, jwks, DELEGATION_TYP);
298
286
  if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'unauthenticated', checked.problem);
@@ -320,7 +308,7 @@ export function verifyApproval(approval, { audience, jwks, now, actionDigest: ex
320
308
  if (approval === undefined || approval === null || approval === '') return refuse('approval-required', 'no-approval', { actionDigest: expected });
321
309
  if (!isObject(jtiStore) || typeof jtiStore.consume !== 'function') return refuse('unknown-state', 'no-jti-store');
322
310
  if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
323
- if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
311
+ if (!isNonEmptyString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
324
312
  if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
325
313
  const checked = authentic(approval, jwks, APPROVAL_TYP);
326
314
  if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'approval-invalid', checked.problem);
@@ -404,26 +392,6 @@ export function readDelegationToken(headers) {
404
392
  return undefined;
405
393
  }
406
394
 
407
- /** RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form; refuses what has no canonical form. */
408
- export function jcs(value) {
409
- if (value === null || typeof value === 'boolean') return JSON.stringify(value);
410
- if (typeof value === 'number') {
411
- if (!Number.isFinite(value)) throw new TypeError('a non-finite number has no canonical form');
412
- return JSON.stringify(value);
413
- }
414
- if (typeof value === 'string') {
415
- if (/\p{Surrogate}/u.test(value)) throw new TypeError('a lone surrogate has no canonical form');
416
- return JSON.stringify(value);
417
- }
418
- if (Array.isArray(value)) return `[${Array.from(value, (item) => jcs(item)).join(',')}]`;
419
- if (isObject(value)) {
420
- const prototype = Object.getPrototypeOf(value);
421
- if (prototype !== Object.prototype && prototype !== null) throw new TypeError('only plain JSON objects have a canonical form');
422
- const keys = Object.keys(value).sort();
423
- return `{${keys.map((key) => `${jcs(key)}:${jcs(value[key])}`).join(',')}}`;
424
- }
425
- throw new TypeError(`${typeof value} has no canonical form`);
426
- }
427
395
 
428
396
  /** The digest an approval binds: sha256: and the hex SHA-256 of the action's RFC 8785 form. */
429
397
  export const actionDigest = (action) => `sha256:${crypto.createHash('sha256').update(jcs(action), 'utf8').digest('hex')}`;
@@ -492,7 +460,7 @@ export const TIERS = Object.freeze(['T0', 'T1', 'T2']);
492
460
  * with no delegation. A present token is verified at every tier. `issuer` (auth.delegation.issuer) is required.
493
461
  */
494
462
  export function delegationGate({ audience, jwks, effects, issuer, readOnly = false, clock = () => Date.now() / 1000, estate, estateApplied, estateRevocations }) {
495
- if (!isString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
463
+ if (!isNonEmptyString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
496
464
  return async (tool, headers, tier = 'T2') => {
497
465
  const effect = effects?.[tool];
498
466
  if (!EFFECT_CLASSES.includes(effect)) return refuse('unknown-state', 'no-declared-effect');
@@ -0,0 +1,387 @@
1
+ // How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069), build identity and the well-known document.
2
+ // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
6
+ import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
7
+ import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
8
+ import { protocolsFindings } from './fabric-protocols.mjs';
9
+ import { ROUTES } from './fabric-usage.mjs';
10
+
11
+ // The embedded dashboard declaration (fabric-dashboard/0.1, DEC-0036 ED-9), under the well-known
12
+ // document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
13
+ export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
14
+
15
+ export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.1';
16
+
17
+ export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
18
+
19
+ export const STATUSES = ['starting', 'ready', 'degraded', 'stopping'];
20
+
21
+ export const PLACEMENTS = ['local', 'remote'];
22
+
23
+ const ORIGIN = /^http:\/\/127\.0\.0\.1:([0-9]{3,5})$/;
24
+
25
+ const REMOTE_ORIGIN = /^https:\/\/((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(?::([0-9]{1,5}))?$/;
26
+
27
+ const RESERVED_HOST = /(^|\.)(localhost|local|internal|home\.arpa|lan|localdomain)$/;
28
+
29
+ // DEC-0061: `node` (Project Observatory's node supervises its children, no supervisor file); DEC-0069: `person` (an app a
30
+ // person installs and starts; its descriptor says it is installed, never that it runs).
31
+ export const MANAGERS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node', 'person', 'none']);
32
+
33
+ /** Managers that write no launchd label or plist (DEC-0057, DEC-0061, DEC-0069). */
34
+ const NOT_LAUNCHD = Object.freeze(['systemd', 'task-scheduler', 'node', 'person']);
35
+
36
+ const AUTH_HEADER = /^[A-Za-z][A-Za-z0-9-]{0,63}$/;
37
+
38
+ export const descriptorPath = (id, instance = 'default', dir = servicesDir()) => path.join(dir, `${id}.${instance}.json`);
39
+
40
+ export function readDescriptors(dir = servicesDir()) {
41
+ if (!fs.existsSync(dir)) return [];
42
+ return fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort().flatMap((n) => {
43
+ try {
44
+ return [[path.join(dir, n), JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))]];
45
+ } catch {
46
+ return [];
47
+ }
48
+ });
49
+ }
50
+
51
+ export function validateDescriptor(d) {
52
+ const problems = [];
53
+ const remote = placementOf(d) === 'remote';
54
+ if (d.placement !== undefined && !PLACEMENTS.includes(d.placement)) problems.push('placement must be local or remote');
55
+ // DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
56
+ const app = d.lifecycle?.manager === 'person';
57
+ const required = ['protocol', 'id', 'instance', 'name', ...(app ? [] : ['origin']), 'auth', 'lifecycle', 'installedAt', 'installedBy'];
58
+ for (const key of remote ? required : [...required, 'paths']) {
59
+ if (!(key in d)) problems.push(`missing ${key}`);
60
+ }
61
+ if (problems.length) return problems;
62
+ if (d.protocol !== PROTOCOL) problems.push(`protocol must be ${PROTOCOL}`);
63
+ if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
64
+ if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
65
+ if (remote) problems.push(...remoteOriginProblems(d.origin));
66
+ else if (d.origin !== undefined && !ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
67
+ if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
68
+ else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
69
+ for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
70
+ if (!LOCAL_PATH.test(String(value))) problems.push(`paths must be local paths, never a network share: ${JSON.stringify(value)}`);
71
+ }
72
+ if (!(typeof (d.auth?.header ?? 'Authorization') === 'string' && AUTH_HEADER.test(d.auth?.header ?? 'Authorization'))) problems.push(`auth.header must match ${AUTH_HEADER.source}`);
73
+ if (!['Bearer', 'none'].includes(d.auth?.scheme ?? 'Bearer')) problems.push('auth.scheme is Bearer or none');
74
+ if ((d.auth?.header ?? 'Authorization') !== 'Authorization' && (d.auth?.scheme ?? 'Bearer') !== 'none') {
75
+ problems.push('a custom auth header carries the raw token: scheme must be none');
76
+ }
77
+ const life = d.lifecycle ?? {};
78
+ const manager = life.manager;
79
+ if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none');
80
+ // DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
81
+ if (manager === 'person' && life.app === undefined) problems.push("a person's app declares lifecycle.app, the installed app");
82
+ if (life.app !== undefined && manager !== 'person') problems.push('lifecycle.app belongs to manager person only');
83
+ if (life.app !== undefined && !LOCAL_PATH.test(String(life.app))) problems.push('lifecycle.app must be a local path');
84
+ if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
85
+ problems.push('a launchd service declares label and plist');
86
+ }
87
+ if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
88
+ // DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
89
+ for (const field of ['label', 'plist']) {
90
+ if (life[field] !== undefined && NOT_LAUNCHD.includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
91
+ }
92
+ // DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
93
+ for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
94
+ if (manager === owner && life[field] === undefined) problems.push(`a ${owner} service declares ${field}`);
95
+ if (life[field] !== undefined && manager !== owner) problems.push(`lifecycle.${field} belongs to manager ${owner} only`);
96
+ if (life[field] !== undefined && !pattern.test(String(life[field]))) problems.push(`lifecycle.${field} must match ${pattern.source}`);
97
+ }
98
+ if (remote) {
99
+ if (manager !== 'none') problems.push('a remote service is supervised by its platform: lifecycle.manager must be none');
100
+ for (const [field, of] of [['label', 'launchd'], ['plist', 'launchd'], ['unit', 'systemd'], ['task', 'Task Scheduler']]) {
101
+ if (life[field] !== undefined) problems.push(`a remote service has no ${of} ${field}`);
102
+ }
103
+ if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
104
+ }
105
+ problems.push(...descriptorMemberProblems(d));
106
+ for (const [name, argv] of Object.entries(d.commands ?? {})) {
107
+ if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
108
+ else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
109
+ else if (!LOCAL_PATH.test(argv[0])) problems.push(`command ${name} must start with an absolute or ~/ executable`);
110
+ }
111
+ return problems;
112
+ }
113
+
114
+ /** DEC-0019: `local` unless the descriptor says `remote`. */
115
+
116
+ /** The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053). */
117
+ export const PEER_DATA_CLASSES = Object.freeze(['public', 'internal', 'confidential', 'restricted']);
118
+
119
+ /** The kind of a runner's sign-in, never a credential's value (DEC-0061). */
120
+ export const CREDENTIAL_KINDS = Object.freeze(['subscription', 'key', 'platform-metered', 'none']);
121
+
122
+ /** A subscription runs only in its vendor's runner (DEC-0046): runner kind → login. */
123
+ export const SUBSCRIPTION_RUNNERS = Object.freeze({ 'claude-code': 'claude', 'kimi-code': 'kimi', codex: 'chatgpt' });
124
+
125
+ const PEER_ENDPOINT = /^https:\/\/((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/;
126
+
127
+ const RESIDENCY = /^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$/;
128
+
129
+ const RUNNER_KIND_NAME = /^[a-z][a-z0-9-]{1,62}$/;
130
+
131
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
132
+
133
+ const textOfLength = (v, low = 1, high = 200) => typeof v === 'string' && v.length >= low && v.length <= high;
134
+
135
+ /** A JSON Pointer token (RFC 6901 §4). */
136
+ const pointer = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
137
+
138
+ /**
139
+ * DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with FAC-SEM-085
140
+ * (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`, named by its
141
+ * place, never its value).
142
+ */
143
+ export function descriptorMemberProblems(d) {
144
+ const problems = [];
145
+ const life = isObj(d.lifecycle) ? d.lifecycle : {};
146
+ const by = d.installedBy;
147
+ const places = [];
148
+ if (typeof by === 'string') {
149
+ if (!textOfLength(by)) problems.push('installedBy is the installer and its version, 1 to 200 characters');
150
+ places.push(['/installedBy', by]);
151
+ } else if (isObj(by)) {
152
+ for (const k of Object.keys(by)) if (!['by', 'role', 'tool'].includes(k)) problems.push(`installedBy has no member ${k}`);
153
+ if (!['person', 'node', 'organization'].includes(by.by)) problems.push('installedBy.by is person, node or organization');
154
+ if (by.by === 'person' && !['operator', 'administrator'].includes(by.role)) problems.push('installedBy.role is operator or administrator for a person');
155
+ if (by.by !== 'person' && 'role' in by) problems.push('installedBy.role belongs to a person only');
156
+ if (!textOfLength(by.tool)) problems.push('installedBy.tool is the installer and its version, 1 to 200 characters');
157
+ for (const [k, v] of Object.entries(by)) places.push([`/installedBy/${pointer(k)}`, v]);
158
+ } else problems.push("installedBy is the installer's name or {by, role?, tool}");
159
+ if (life.manager === 'person' && !(isObj(by) && by.by === 'person')) problems.push("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)");
160
+ let runner = d.runner;
161
+ if ('runner' in d) {
162
+ if (!isObj(runner)) { problems.push('runner is {kind?, credentialKind}'); runner = {}; }
163
+ for (const k of Object.keys(runner)) if (!['kind', 'credentialKind'].includes(k)) problems.push(`runner has no member ${k}`);
164
+ if (!CREDENTIAL_KINDS.includes(runner.credentialKind)) problems.push('runner.credentialKind is subscription, key, platform-metered or none');
165
+ if ('kind' in runner && !(typeof runner.kind === 'string' && RUNNER_KIND_NAME.test(runner.kind))) problems.push("runner.kind is a runner kind's name");
166
+ if (runner.credentialKind === 'subscription' && !Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind)) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
167
+ for (const [k, v] of Object.entries(runner)) places.push([`/runner/${pointer(k)}`, v]);
168
+ }
169
+ if ('peer' in d) {
170
+ let peer = d.peer;
171
+ if (!isObj(peer)) { problems.push('peer is {endpoint, custody, residency, maxDataClass}'); peer = {}; }
172
+ for (const k of Object.keys(peer)) if (!['endpoint', 'custody', 'residency', 'maxDataClass'].includes(k)) problems.push(`peer has no member ${k}`);
173
+ const m = typeof peer.endpoint === 'string' && peer.endpoint.length <= 2048 ? PEER_ENDPOINT.exec(peer.endpoint) : null;
174
+ if (!m) problems.push('peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal');
175
+ else if (RESERVED_HOST.test(m[1])) problems.push(`FAC-SEM-085: a service offered to others cannot be reached at the reserved name ${m[1]}`);
176
+ if (!['organization-managed', 'personal'].includes(peer.custody)) problems.push('peer.custody is organization-managed or personal');
177
+ const r = peer.residency;
178
+ if (!Array.isArray(r) || r.length < 1 || r.length > 32 || new Set(r.map((x) => JSON.stringify(x))).size !== r.length || !r.every((x) => typeof x === 'string' && RESIDENCY.test(x))) {
179
+ problems.push('peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code');
180
+ }
181
+ if (!PEER_DATA_CLASSES.includes(peer.maxDataClass)) problems.push('peer.maxDataClass is public, internal, confidential or restricted');
182
+ if (!('runner' in d)) problems.push('a service offered to others declares runner (DEC-0061)');
183
+ else if (isObj(runner) && runner.credentialKind === 'subscription') problems.push('a service offered to others never runs on a subscription (DEC-0061)');
184
+ }
185
+ for (const [place, value] of places) {
186
+ if (typeof value === 'string' && secretArgument(value)) problems.push(`FAC-SEM-086: ${place} holds a value shaped like a credential; it names a kind, a role or an installer`);
187
+ }
188
+ return problems;
189
+ }
190
+
191
+ /**
192
+ * The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
193
+ * `not-offered`, `peer-invalid`, `data-class-unknown` or `above-max-data-class`; null when it may. The contract's `peerRefusal`.
194
+ */
195
+ export function peerRefusal(descriptor, dataClass) {
196
+ if (!isObj(descriptor) || !isObj(descriptor.peer)) return 'not-offered';
197
+ const max = typeof descriptor.peer.maxDataClass === 'string' ? PEER_DATA_CLASSES.indexOf(descriptor.peer.maxDataClass) : -1;
198
+ if (max < 0) return 'peer-invalid';
199
+ const asked = typeof dataClass === 'string' ? PEER_DATA_CLASSES.indexOf(dataClass) : -1;
200
+ if (asked < 0) return 'data-class-unknown';
201
+ return asked > max ? 'above-max-data-class' : null;
202
+ }
203
+
204
+ /**
205
+ * What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` from a translocated copy (macOS
206
+ * App Translocation); otherwise `write` when there is none, when it is not an app's descriptor, or when its version
207
+ * (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's `appDescriptorAction`.
208
+ */
209
+ export function appDescriptorAction(existing, tool, appPath) {
210
+ if (appPath.includes('/AppTranslocation/')) return 'keep';
211
+ if (!isObj(existing) || !isObj(existing.installedBy)) return 'write';
212
+ const recorded = isObj(existing.lifecycle) ? existing.lifecycle.app : undefined;
213
+ return existing.installedBy.tool === tool && recorded === appPath ? 'keep' : 'write';
214
+ }
215
+
216
+ /** Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find. */
217
+ export function appDescriptorStale(descriptor, appPresent) {
218
+ if (!isObj(descriptor) || !isObj(descriptor.lifecycle) || descriptor.lifecycle.manager !== 'person') return false;
219
+ return !appPresent;
220
+ }
221
+
222
+ /** Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file). */
223
+ export function appPresent(descriptor) {
224
+ const app = isObj(descriptor?.lifecycle) ? descriptor.lifecycle.app : undefined;
225
+ try { return typeof app === 'string' && fs.existsSync(expand(app)); } catch { return false; }
226
+ }
227
+
228
+ /** Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`. */
229
+ export const FABRIC_SERVICE = Object.freeze(['fabric', 'default']);
230
+
231
+ /**
232
+ * `present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
233
+ * descriptor is registered and, for an app's descriptor, its app is still installed; a server sets `data-fabric="absent"`
234
+ * on the button's script otherwise. The button only follows a link, so the liveness file is not read here.
235
+ */
236
+ export function fabricButtonPresence(dir = servicesDir()) {
237
+ let found;
238
+ try { found = JSON.parse(fs.readFileSync(descriptorPath(...FABRIC_SERVICE, dir), 'utf8')); } catch { return 'absent'; }
239
+ if (!isObj(found) || validateDescriptor(found).length) return 'absent';
240
+ return appDescriptorStale(found, appPresent(found)) ? 'absent' : 'present';
241
+ }
242
+
243
+ export function placementOf(d) {
244
+ return d?.placement === 'remote' ? 'remote' : 'local';
245
+ }
246
+
247
+ /** Problems with a remote origin: https, a public DNS name, an optional port, nothing else. */
248
+ export function remoteOriginProblems(origin) {
249
+ const m = REMOTE_ORIGIN.exec(String(origin ?? ''));
250
+ if (!m) return ['a remote origin must be https://<dns-name>[:<port>] with no path, query or IP literal'];
251
+ if (RESERVED_HOST.test(m[1])) return [`a remote service cannot live on the reserved name ${m[1]}`];
252
+ if (m[2] !== undefined && (Number(m[2]) < 1 || Number(m[2]) > 65535)) return ['the origin port is out of range'];
253
+ return [];
254
+ }
255
+
256
+ export function portOf(origin) {
257
+ const m = ORIGIN.exec(origin);
258
+ if (!m) throw new ServiceError(`Origin ${origin} is not http://127.0.0.1:<port>.`);
259
+ return Number(m[1]);
260
+ }
261
+
262
+ export function writeDescriptor(d, dir = servicesDir()) {
263
+ const problems = validateDescriptor(d);
264
+ if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
265
+ const me = `${d.id}.${d.instance}`;
266
+ // DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
267
+ const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
268
+ for (const [file, other] of readDescriptors(dir)) {
269
+ const key = `${other.id}.${other.instance ?? 'default'}`;
270
+ if (key === me) continue;
271
+ if (port === null) continue; // a remote origin's port is another computer's
272
+ let otherPort = null;
273
+ try { otherPort = portOf(String(other.origin ?? '')); } catch { continue; }
274
+ if (otherPort === port) throw new ServiceError(`Port ${port} is already claimed by ${key} (${file}).`);
275
+ }
276
+ ensurePrivateDir(dir);
277
+ const target = descriptorPath(d.id, d.instance, dir);
278
+ atomicWrite(target, `${JSON.stringify(d, null, 2)}\n`, 0o600);
279
+ return target;
280
+ }
281
+
282
+ export function removeDescriptor(id, instance = 'default', dir = servicesDir()) {
283
+ try { fs.unlinkSync(descriptorPath(id, instance, dir)); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
284
+ }
285
+
286
+ const THEME_ID = /^[a-z][a-z0-9-]{0,31}$/;
287
+
288
+ const KIT_REF = /^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
289
+
290
+ // What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json).
291
+ export function dashboardExtensionProblems(value) {
292
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return ['the declaration is not an object'];
293
+ const out = [];
294
+ const extra = Object.keys(value).filter((k) => !['minWidth', 'appearance', 'themes', 'kit'].includes(k)).sort();
295
+ if (extra.length) out.push(`unknown field(s): ${extra.join(', ')}`);
296
+ const w = value.minWidth;
297
+ if (!Number.isInteger(w)) out.push('minWidth is required and must be a whole number of CSS pixels');
298
+ else if (w < 360 || w > 1280) out.push(`minWidth ${w} is outside 360…1280`);
299
+ if ('appearance' in value) {
300
+ const a = value.appearance;
301
+ if (!Array.isArray(a) || a.length > 2 || new Set(a).size !== a.length || a.some((x) => x !== 'scheme' && x !== 'theme')) out.push('appearance is a list of distinct fields from scheme, theme');
302
+ }
303
+ if ('themes' in value) {
304
+ const t = value.themes;
305
+ if (!Array.isArray(t) || t.length < 1 || t.length > 32 || new Set(t).size !== t.length || t.some((x) => typeof x !== 'string' || !THEME_ID.test(x))) out.push('themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$');
306
+ }
307
+ if ('kit' in value && !(typeof value.kit === 'string' && KIT_REF.test(value.kit))) out.push(`kit is name@semver, for example ${DASHBOARD_KIT}`);
308
+ return out;
309
+ }
310
+
311
+ // The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
312
+ // when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that.
313
+ export function dashboardExtension({ minWidth = 360, appearance = ['scheme', 'theme'], themes = REFERENCE_THEMES, kit = DASHBOARD_KIT } = {}) {
314
+ const value = { minWidth };
315
+ if (appearance?.length) value.appearance = [...appearance];
316
+ if (themes?.length) value.themes = [...themes];
317
+ if (kit) value.kit = kit;
318
+ const problems = dashboardExtensionProblems(value);
319
+ if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
320
+ return { [DASHBOARD_EXTENSION_KEY]: value };
321
+ }
322
+
323
+ // `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration in
324
+ // it must be valid (dashboardExtension builds one).
325
+ const PROFILE_ID = /^[a-z][a-z0-9-]{0,62}$/;
326
+
327
+ const RUNNER_KIND = /^[a-z][a-z0-9-]{1,62}$/;
328
+
329
+ const MODEL_ID = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._:-]{0,127}$/;
330
+
331
+ /** DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it. */
332
+ export function configurationProblems(doc) {
333
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return ['configuration is an object'];
334
+ const problems = Object.keys(doc).filter((k) => k !== 'slots' && k !== 'resolved').map((k) => `configuration has no member ${k}`);
335
+ const { slots, resolved } = doc;
336
+ if (!Array.isArray(slots) || slots.length > 2 || new Set(slots).size !== slots.length || !slots.every((s) => s === 'runner' || s === 'model')) problems.push('slots is a list of runner and model, each once');
337
+ if (resolved === null || typeof resolved !== 'object' || Array.isArray(resolved)) return [...problems, 'resolved is an object'];
338
+ const checks = {
339
+ from: (x) => ['request', 'agent', 'binding', 'estate'].includes(x),
340
+ runnerProfile: (x) => typeof x === 'string' && PROFILE_ID.test(x),
341
+ modelProfile: (x) => typeof x === 'string' && PROFILE_ID.test(x),
342
+ runnerKind: (x) => typeof x === 'string' && RUNNER_KIND.test(x),
343
+ route: (x) => ROUTES.includes(x),
344
+ models: (x) => Array.isArray(x) && x.length <= 16 && x.every((m) => typeof m === 'string' && MODEL_ID.test(m)),
345
+ };
346
+ for (const [k, v] of Object.entries(resolved)) {
347
+ if (!Object.hasOwn(checks, k)) problems.push(`resolved has no member ${k}`);
348
+ else if (!checks[k](v)) problems.push(`resolved.${k} is not valid`);
349
+ }
350
+ return problems;
351
+ }
352
+
353
+ export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols }) {
354
+ if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
355
+ if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
356
+ if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
357
+ const doc = {
358
+ protocol: PROTOCOL,
359
+ service: { id, instance, name, version, build },
360
+ process: { pid, startedAt },
361
+ status: status === 'ready' && degraded.length ? 'degraded' : status,
362
+ degraded: [...degraded],
363
+ surfaces,
364
+ update: { available: updateAvailable },
365
+ };
366
+ if (summary?.length) doc.summary = summary.slice(0, 6);
367
+ if (extensions && Object.keys(extensions).length) {
368
+ if (DASHBOARD_EXTENSION_KEY in extensions) {
369
+ const problems = dashboardExtensionProblems(extensions[DASHBOARD_EXTENSION_KEY]);
370
+ if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
371
+ }
372
+ doc.extensions = { ...extensions };
373
+ }
374
+ if (configuration !== undefined) {
375
+ const problems = configurationProblems(configuration);
376
+ if (problems.length) throw new ServiceError(`configuration: ${problems.join('; ')}.`);
377
+ doc.configuration = configuration;
378
+ }
379
+ if (protocols !== undefined) {
380
+ // DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so a
381
+ // service lists them only once its hosts are pinned at or after it.
382
+ const problems = protocolsFindings(protocols);
383
+ if (problems.length) throw new ServiceError(`protocols: ${problems.map((f) => f.message).join('; ')}.`);
384
+ doc.protocols = [...protocols];
385
+ }
386
+ return doc;
387
+ }