@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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 (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -21,14 +21,9 @@ 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)
25
+ import { clockMs as clockOf } from './fabric-time.mjs'; // the core's (FAA-65)
26
26
  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
27
  const REGISTER = new Map([
33
28
  [ENROLL_TOKEN_TYPE, { fields: ENROLL_TOKEN_FIELDS, check: (f) => matches(ESTATE_SCOPE, f.scope) && matches(SHA256, f.genesis) && f.purpose === ENROLL_PURPOSE
34
29
  && (f.ceiling === null || matches(SHA256, f.ceiling)) && Number.isFinite(estateTime(f.notBefore)) && Number.isFinite(estateTime(f.expiresAt)) && matches(NONCE32, f.nonce) && f.uses === 1 }],
@@ -52,9 +47,12 @@ function statementFields(token) {
52
47
  return signingStatement(ENROLL_TOKEN_TYPE, fields) ? fields : undefined;
53
48
  }
54
49
 
55
- /** The reference verifier of an enroll-token/1 under trust {record, genesis, at: Date}; the first failure answers. */
50
+ /**
51
+ * The reference verifier of an enroll-token/1 under trust {record, genesis, at}; `at` is a Date or epoch seconds, as
52
+ * verifyEstateRecord and the Python twin take it. The first failure answers.
53
+ */
56
54
  export function verifyEnrollToken(token, trust) {
57
- const at = isObject(trust) && trust.at instanceof Date ? trust.at.getTime() : Number.NaN;
55
+ const at = isObject(trust) ? clockOf(trust.at) : Number.NaN;
58
56
  if (!Number.isFinite(at)) return { ok: false, problem: 'no-clock' };
59
57
  const fields = statementFields(token);
60
58
  const record = trust.record;
@@ -180,15 +178,6 @@ const sha = (text) => crypto.createHash('sha256').update(Buffer.from(text, 'utf8
180
178
  const uint40 = (digest, offset) => digest.readUIntBE(offset, 5);
181
179
  const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
182
180
  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
181
 
193
182
  /** The machine a key list belongs to, or undefined (one sig key, at most one kem key, one machine id, valid keys). */
194
183
  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,9 +136,10 @@ 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
- if (raw.length !== 32) return { problem: 'unusable-key' };
141
+ // D4: exactly the 43 characters that write 32 bytes (Buffer reads padding, `+` and stray characters leniently).
142
+ if (raw.length !== 32 || raw.toString('base64url') !== jwk.x) return { problem: 'unusable-key' };
140
143
  if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // before any verification (DEC-0045, CT-29)
141
144
  try {
142
145
  return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw, x: jwk.x };
@@ -145,22 +148,8 @@ function keyFor(jwks, kid) {
145
148
  }
146
149
  }
147
150
 
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
- }
151
+ import { isSmallOrderKey } from './fabric-crypto.mjs'; // the core's (FAA-65)
152
+ export { isSmallOrderKey };
164
153
 
165
154
  const hasKeys = (jwks) => isObject(jwks) && Array.isArray(jwks.keys) && jwks.keys.length > 0;
166
155
 
@@ -189,7 +178,7 @@ export function chainDepth(act) {
189
178
  let node = act;
190
179
  while (node !== undefined) {
191
180
  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;
181
+ if (!isObject(node) || !isNonEmptyString(node.sub)) return Number.POSITIVE_INFINITY;
193
182
  depth += 1;
194
183
  if (depth > 64) return depth;
195
184
  node = node.act;
@@ -251,7 +240,7 @@ function delegationShape(payload) {
251
240
 
252
241
  function approvalShape(payload) {
253
242
  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;
243
+ if (!isNonEmptyString(iss) || !isNonEmptyString(digest) || !isNonEmptyString(aud) || !isNonEmptyString(approver) || !isNonEmptyString(jti) || !isTime(iat) || !isTime(exp)) return undefined;
255
244
  if (method !== 'prompt' && method !== 'operator-channel') return undefined; // DEC-0045: `device` is removed
256
245
  return payload;
257
246
  }
@@ -292,7 +281,7 @@ function estateProblem({ estate, estateApplied, estateRevocations }, kid, x, now
292
281
  export function verifyDelegation(token, { audience, jwks, now, capability, effect, issuer, readOnly, estate, estateApplied, estateRevocations } = {}) {
293
282
  // Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
294
283
  if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
295
- if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
284
+ if (!isNonEmptyString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
296
285
  if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
297
286
  const checked = authentic(token, jwks, DELEGATION_TYP);
298
287
  if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'unauthenticated', checked.problem);
@@ -320,7 +309,7 @@ export function verifyApproval(approval, { audience, jwks, now, actionDigest: ex
320
309
  if (approval === undefined || approval === null || approval === '') return refuse('approval-required', 'no-approval', { actionDigest: expected });
321
310
  if (!isObject(jtiStore) || typeof jtiStore.consume !== 'function') return refuse('unknown-state', 'no-jti-store');
322
311
  if (!readableClock(now)) return refuse('unknown-state', 'no-clock');
323
- if (!isString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
312
+ if (!isNonEmptyString(issuer)) return refuse('issuer-unavailable', 'no-trusted-issuer');
324
313
  if (!hasKeys(jwks)) return refuse('issuer-unavailable', 'no-published-keys');
325
314
  const checked = authentic(approval, jwks, APPROVAL_TYP);
326
315
  if ('problem' in checked) return refuse(checked.issuer ? 'issuer-unavailable' : 'approval-invalid', checked.problem);
@@ -404,26 +393,6 @@ export function readDelegationToken(headers) {
404
393
  return undefined;
405
394
  }
406
395
 
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
396
 
428
397
  /** The digest an approval binds: sha256: and the hex SHA-256 of the action's RFC 8785 form. */
429
398
  export const actionDigest = (action) => `sha256:${crypto.createHash('sha256').update(jcs(action), 'utf8').digest('hex')}`;
@@ -492,7 +461,7 @@ export const TIERS = Object.freeze(['T0', 'T1', 'T2']);
492
461
  * with no delegation. A present token is verified at every tier. `issuer` (auth.delegation.issuer) is required.
493
462
  */
494
463
  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)');
464
+ if (!isNonEmptyString(issuer)) throw new Error('the gate needs the issuer it trusts (auth.delegation.issuer)');
496
465
  return async (tool, headers, tier = 'T2') => {
497
466
  const effect = effects?.[tool];
498
467
  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.2';
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
+ }