@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
@@ -11,7 +11,7 @@
11
11
  // Node's own crypto: ES256 through createPublicKey (JWK) and verify (DER), EdDSA through Ed25519.
12
12
 
13
13
  import crypto from 'node:crypto';
14
- import { jcs } from './fabric-delegation.mjs';
14
+ import { jcs } from './fabric-jsvalue.mjs';
15
15
  import { normalIdentifier } from './fabric-identifiers.mjs';
16
16
  import { secretValue } from './fabric-secrets.mjs';
17
17
 
@@ -23,28 +23,7 @@ export const CLOCK_SKEW_SECONDS = 60;
23
23
  export const ASSERTION_ALGORITHMS = Object.freeze(['ES256', 'EdDSA']);
24
24
  export const FLAGS = Object.freeze({ UP: 0x01, UV: 0x04, BE: 0x08, BS: 0x10, AT: 0x40, ED: 0x80 });
25
25
 
26
- const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
27
- const isString = (value) => typeof value === 'string';
28
- const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
29
- /** The object items of an array with their index as written, so an instancePath names the item even after a non-object. */
30
- const indexed = (value) => (Array.isArray(value) ? value.flatMap((item, index) => (isObject(item) ? [[item, index]] : [])) : []);
31
- /** The contract's `show`: String(value), or its JSON where String() throws (an object whose own `toString` is data). */
32
- const show = (value) => {
33
- if (typeof value === 'string') return value;
34
- try {
35
- return String(value);
36
- } catch {
37
- return JSON.stringify(value) ?? typeof value;
38
- }
39
- };
40
- /** The contract's `toNumber`: Number(value), or NaN where Number() throws. */
41
- const toNumber = (value) => {
42
- try {
43
- return Number(value);
44
- } catch {
45
- return Number.NaN;
46
- }
47
- };
26
+ import { isObject, isString, objects as list, indexed, show, toNumber, keyOf } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
48
27
  /** DEC-0082: the personal context, and an organization's context `org:<orgId>` (the contract's `context-route.ts`). */
49
28
  export const PERSONAL_CONTEXT = 'personal';
50
29
  export const ORG_CONTEXT_PREFIX = 'org:';
@@ -59,43 +38,15 @@ export function parseContext(value) {
59
38
  }
60
39
  /** A placement's or a secret placement's context: its `context`, `personal` when absent (DEC-0082). */
61
40
  const contextOf = (entry) => (entry.context === undefined ? PERSONAL_CONTEXT : entry.context);
62
- /** A collision-free key over JSON values. */
63
- const keyOf = (...parts) => JSON.stringify(parts);
64
- const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest();
41
+ import { sha256, isSmallOrderOrMalformed as smallOrder, isValidP256Key } from './fabric-crypto.mjs'; // the core's (FAA-65)
42
+ export { isValidP256Key };
65
43
  const B64URL = /^[A-Za-z0-9_-]+$/;
66
44
  const bytes = (text) => Buffer.from(text, 'base64url');
67
45
  const canonical = (text) => Buffer.from(text, 'base64url').toString('base64url') === text;
68
46
 
69
- const SMALL_ORDER = new Set([
70
- '0000000000000000000000000000000000000000000000000000000000000000', '0100000000000000000000000000000000000000000000000000000000000000',
71
- '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05', 'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
72
- 'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f', 'edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
73
- 'eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
74
- ]);
75
- /** The contract's isSmallOrderKey: a key that is not 32 bytes counts as one too. */
76
- function smallOrder(raw) {
77
- if (raw.length !== 32) return true;
78
- const cleared = Buffer.from(raw);
79
- cleared[31] &= 0x7f;
80
- return SMALL_ORDER.has(cleared.toString('hex'));
81
- }
82
47
 
83
- // ---- P-256 (DEC-0053; SEC 1 v2 §3.2.2) ----
84
-
85
- const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
86
- const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
87
- const toBig = (part) => BigInt(`0x${Buffer.from(part).toString('hex')}`);
88
-
89
- /** Whether `raw` is a valid uncompressed P-256 public key: 0x04 ‖ X ‖ Y, both below p, on the curve. */
90
- export function isValidP256Key(raw) {
91
- if (raw.length !== 65 || raw[0] !== 0x04) return false;
92
- const x = toBig(raw.subarray(1, 33));
93
- const y = toBig(raw.subarray(33, 65));
94
- if (x >= P || y >= P) return false;
95
- const left = (y * y) % P;
96
- const right = (((x * x) % P) * x - 3n * x + B) % P;
97
- return left === ((right % P) + P) % P;
98
- }
48
+
49
+
99
50
 
100
51
  // ---- WebAuthn Level 3 §7.2, offline ----
101
52
 
@@ -180,17 +131,8 @@ export const estateChallengeInput = (digest) => Buffer.from(jcs({ type: ESTATE_C
180
131
  /** The WebAuthn challenge a signer passes to navigator.credentials.get(): SHA-256 of estateChallengeInput. */
181
132
  export const estateChallenge = (digest) => sha256(estateChallengeInput(digest));
182
133
 
183
- const RFC3339 = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
184
- /** An RFC 3339 date-time as milliseconds, or NaN: `T`, an offset and a day that exists; a space and `:60` refused (ER-10). */
185
- export function estateTime(value) {
186
- const match = isString(value) ? RFC3339.exec(value) : null;
187
- if (!match) return Number.NaN;
188
- const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
189
- const date = new Date(0);
190
- date.setUTCFullYear(year, month - 1, day);
191
- if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59 || second > 59) return Number.NaN;
192
- return Date.parse(value);
193
- }
134
+ import { parseRfc3339Ms as estateTime, clockMs as clockOf } from './fabric-time.mjs'; // the core's (FAA-65)
135
+ export { estateTime };
194
136
  const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
195
137
  /** `sign.` and at least two more labels, lower case: a signing RP ID is never a bare `sign.<tld>` (DEC-0063). */
196
138
  const SIGNING_RP = new RegExp(`^sign(?:\\.${LABEL}){2,}$`);
@@ -220,7 +162,6 @@ function signingCredential(credential, path, flag) {
220
162
  }
221
163
 
222
164
  const revocationKey = (entry) => `${String(entry.kind)} ${String(entry.ref)}`;
223
- const clockOf = (at) => (at instanceof Date ? at.getTime() : (typeof at === 'number' && Number.isFinite(at) ? at * 1000 : Number.NaN));
224
165
 
225
166
  /**
226
167
  * The reference verifier, in the order of estate-record.md's ER table. `at` is the clock (a Date, or epoch seconds); pass
@@ -297,6 +238,7 @@ export function verifyEstateRecord(record, trust) {
297
238
  * undefined when the record places no such agent there in that context.
298
239
  */
299
240
  export function placementTrust(record, machineId, agentRef, context = PERSONAL_CONTEXT) {
241
+ if (!isObject(record)) return undefined; // a record that is no object places nothing, as in the Python twin
300
242
  const machine = list(record.machines).find((entry) => entry.id === machineId);
301
243
  const placement = list(machine?.packages).find((entry) => entry.agentRef === agentRef && contextOf(entry) === context);
302
244
  if (!placement) return undefined;
@@ -437,7 +379,8 @@ function keys(value, flag) {
437
379
  if (deviceKeys.filter((key) => key.use === 'sig').length !== 1) flag(`${at}/deviceKeys`, 'a machine has exactly one signing device key, <id>#sig');
438
380
  if (deviceKeys.filter((key) => key.use === 'kem').length > 1) flag(`${at}/deviceKeys`, 'a machine has at most one key-agreement device key, <id>#kem');
439
381
  const seen = new Set();
440
- deviceKeys.forEach((key, k) => {
382
+ // The index is the key's position in the list as written, non-objects counted, as the contract and the Python twin name it.
383
+ indexed(machine.deviceKeys).forEach(([key, k]) => {
441
384
  if (key.kid !== `${show(machine.id)}#${show(key.use)}`) flag(`${at}/deviceKeys/${k}/kid`, 'a device key is listed as <machine id>#sig or <machine id>#kem, by its role');
442
385
  // A key with no canonical fingerprint could be read as two keys by two readers, and no key reference names it (DEC-0084).
443
386
  if (deviceKeyFingerprint(key) === undefined) flag(`${at}/deviceKeys/${k}/publicKey`, "a device key's octets are written in canonical padded base64, the one form a key reference can name (DEC-0084)");
@@ -0,0 +1,52 @@
1
+ // A service's events (DEC-0029): making them, paging them and the JSONL event log.
2
+ // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_events.py. Node 20+, no dependency.
3
+ import { ServiceError } from './fabric-base.mjs';
4
+ import { feedDataProblems } from './fabric-protocols.mjs';
5
+
6
+ export const LEVELS = ['info', 'notice', 'warning', 'error'];
7
+
8
+ export const EVENTS_DEFAULT_LIMIT = 50;
9
+
10
+ export const EVENTS_MAX_LIMIT = 200;
11
+
12
+ const KIND = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$/;
13
+
14
+ const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
15
+
16
+ const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
17
+
18
+ /** One activity event. `data` (DEC-0070) adds machine-readable fields, `{schema, value}`, never a secret: refused here, so no feed carries one. */
19
+ export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId, data } = {}) {
20
+ if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
21
+ if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
22
+ const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
23
+ if (!sentence) throw new ServiceError('An event needs a sentence.');
24
+ if (link !== undefined && (!link.startsWith('/') || link.startsWith('//'))) throw new ServiceError('link must be a path on this service.');
25
+ if ((traceId === undefined) !== (spanId === undefined)) throw new ServiceError('An event carries traceId and spanId together, or neither.');
26
+ if (traceId !== undefined && !(TRACE_ID.test(traceId) && SPAN_ID.test(spanId))) throw new ServiceError('traceId is 32 and spanId 16 lowercase hex characters, not all zeros.');
27
+ const event = { id: String(id), at, kind, level, text: sentence.slice(0, 500) };
28
+ if (subject) event.subject = subject;
29
+ if (link) event.link = link;
30
+ if (notify) event.notify = true;
31
+ if (traceId !== undefined) Object.assign(event, { traceId, spanId });
32
+ if (data !== undefined) {
33
+ const problems = feedDataProblems(data);
34
+ if (problems.length) throw new ServiceError(`Event data: ${problems.join('; ')}.`);
35
+ event.data = { schema: data.schema, value: data.value };
36
+ }
37
+ return event;
38
+ }
39
+
40
+ export function parseLimit(raw) {
41
+ if (raw === undefined || raw === null || raw === '') return EVENTS_DEFAULT_LIMIT;
42
+ const n = Number(raw);
43
+ if (!Number.isInteger(n)) throw new ServiceError('limit must be an integer.');
44
+ return Math.max(1, Math.min(EVENTS_MAX_LIMIT, n));
45
+ }
46
+
47
+ // fetch(after, limit) returns events with ids greater than `after`, ascending
48
+ // (or the newest `limit`, ascending, when after is null) — a view over the log you already keep.
49
+ export async function eventsPage(fetch, after, limit) {
50
+ const events = (await fetch(after ?? null, limit)).slice(0, limit);
51
+ return { events, cursor: events.length ? events.at(-1).id : (after ?? null) };
52
+ }
@@ -0,0 +1,231 @@
1
+ // An outbound HTTP client for a service: one deadline across every attempt, bounded retries with jittered backoff,
2
+ // `Retry-After` honoured up to a cap, a response size cap, and no secret in an error. The twin of fabric_http.py:
3
+ // the same rules, the same cases (test/fixtures/kit-http-cases.json).
4
+ //
5
+ // const response = await request('GET', 'https://api.example/v1/items', { headers: {...}, deadlineS: 20, retries: 2 });
6
+ // response.status, response.headers['content-type'], response.json()
7
+ //
8
+ // What is retried: a status in RETRY_STATUSES or a connection error, at most `retries` times, and only when repeating
9
+ // is safe: an idempotent method or an `Idempotency-Key` header; any other request only on 429. The wait before retry n
10
+ // (from 0) is min(maxBackoffS, backoffBaseS * 2**n) * (0.5 + rand()); a `Retry-After` replaces it up to
11
+ // maxRetryAfterS. A wait past the deadline is not taken. Plain http is refused unless the host is loopback or
12
+ // `insecure`. An error names the method and the redacted URL, never a header or a body.
13
+ //
14
+ // The transport is injected in tests: async ({ method, url, headers, body, timeoutS, maxBytes }) =>
15
+ // { status, headers, body: Uint8Array }, throwing on a connection error (a `TimeoutError` name is a timeout) or an
16
+ // HttpError. Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
17
+
18
+ export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
19
+ export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
20
+ export const DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
21
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
22
+
23
+ export class HttpError extends Error {
24
+ constructor(message, { status = null, url = '', attempts = 0, retryAfterS = null, body = new Uint8Array(0),
25
+ kind = 'http' } = {}) {
26
+ super(message);
27
+ this.name = 'HttpError';
28
+ this.status = status;
29
+ this.url = url;
30
+ this.attempts = attempts;
31
+ this.retryAfterS = retryAfterS;
32
+ this.body = body.slice(0, 2048);
33
+ this.kind = kind; // http, connection, timeout, too-large, refused
34
+ }
35
+ }
36
+
37
+ export class Response {
38
+ constructor(status, headers, body, { attempts = 1, url = '' } = {}) {
39
+ this.status = status;
40
+ this.headers = Object.fromEntries(Object.entries(headers).map(([k, v]) => [String(k).toLowerCase(), String(v)]));
41
+ this.body = body;
42
+ this.attempts = attempts;
43
+ this.url = url;
44
+ }
45
+
46
+ get ok() { return this.status >= 200 && this.status < 300; }
47
+
48
+ text() { return new TextDecoder().decode(this.body); }
49
+
50
+ json() { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(this.body)); }
51
+ }
52
+
53
+ // The URL as it may appear in an error or a log line: scheme, host, port and path; no userinfo, query or fragment.
54
+ export function redactUrl(url) {
55
+ let parts;
56
+ try { parts = new URL(String(url)); } catch { return '<unparseable url>'; }
57
+ return `${parts.protocol}//${parts.host}${parts.pathname}`;
58
+ }
59
+
60
+ function isLoopback(host) {
61
+ const h = host.replace(/^\[|\]$/g, '');
62
+ if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') return true;
63
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h);
64
+ }
65
+
66
+ // Refuse what this client never sends: another scheme, no host, credentials in the URL, plain http off loopback.
67
+ export function checkUrl(url, { insecure = false } = {}) {
68
+ let parts;
69
+ try { parts = new URL(String(url)); } catch {
70
+ throw new HttpError('the URL does not parse', { url: redactUrl(url), kind: 'refused' });
71
+ }
72
+ if (!['http:', 'https:'].includes(parts.protocol) || !parts.hostname) {
73
+ throw new HttpError('only http and https URLs with a host are sent', { url: redactUrl(url), kind: 'refused' });
74
+ }
75
+ if (parts.username || parts.password || /^[a-z]+:\/*[^/?#]*@/i.test(String(url).trim())) {
76
+ throw new HttpError('credentials in a URL are refused: send them in a header', { url: redactUrl(url), kind: 'refused' });
77
+ }
78
+ if (parts.protocol === 'http:' && !insecure && !isLoopback(parts.hostname)) {
79
+ throw new HttpError('plain http to a host that is not loopback is refused (insecure to allow)',
80
+ { url: redactUrl(url), kind: 'refused' });
81
+ }
82
+ }
83
+
84
+ const IMF_FIXDATE = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d\d) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d\d):(\d\d):(\d\d) GMT$/;
85
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
86
+
87
+ // Seconds a `Retry-After` asks for: a non-negative integer, or an IMF-fixdate read from `now` (epoch seconds),
88
+ // never below 0; null otherwise.
89
+ export function retryAfterS(value, { now } = {}) {
90
+ if (value === null || value === undefined) return null;
91
+ const text = String(value).trim();
92
+ if (/^[0-9]{1,10}$/.test(text)) return Number(text);
93
+ const m = IMF_FIXDATE.exec(text);
94
+ if (!m) return null;
95
+ const [day, month, year, hh, mm, ss] = [Number(m[2]), MONTHS.indexOf(m[3]), Number(m[4]), Number(m[5]),
96
+ Number(m[6]), Number(m[7])];
97
+ const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
98
+ if (hh > 23 || mm > 59 || ss > 60 || day < 1 || day > lastDay || year < 1000) return null; // Date.UTC maps 0-99
99
+ const when = Date.UTC(year, month, day, hh, mm, Math.min(ss, 59)) / 1000;
100
+ return Math.max(0, when - (now ?? Date.now() / 1000));
101
+ }
102
+
103
+ // Compact JSON in UTF-8, NaN and Infinity refused (JSON.stringify would write null): the twin of json_bytes.
104
+ export function jsonBytes(value) {
105
+ const text = JSON.stringify(value, (key, v) => {
106
+ if (typeof v === 'number' && !Number.isFinite(v)) {
107
+ throw new HttpError('the body holds a number JSON cannot write (NaN or Infinity)', { kind: 'refused' });
108
+ }
109
+ return v;
110
+ });
111
+ return new TextEncoder().encode(text);
112
+ }
113
+
114
+ export function backoffS(attempt, { baseS, maxS, rand }) {
115
+ return Math.min(maxS, baseS * 2 ** attempt) * (0.5 + rand);
116
+ }
117
+
118
+ export function mayRetry(method, headers, status) {
119
+ if (status !== null && !RETRY_STATUSES.includes(status)) return false;
120
+ const safe = IDEMPOTENT_METHODS.includes(String(method).toUpperCase())
121
+ || Object.keys(headers).some((k) => k.toLowerCase() === 'idempotency-key');
122
+ return safe || status === 429;
123
+ }
124
+
125
+ function checkHeaders(headers) {
126
+ const out = {};
127
+ for (const [name, value] of Object.entries(headers)) {
128
+ const v = String(value);
129
+ if (!TOKEN.test(String(name))) throw new HttpError('a header name is not an HTTP token', { kind: 'refused' });
130
+ if (/[\r\n\0]/.test(v)) throw new HttpError('a header value carries a line break or NUL', { kind: 'refused' });
131
+ out[String(name)] = v;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ // The default transport: fetch, no redirects followed, the body read to one byte past maxBytes.
137
+ export async function fetchTransport({ method, url, headers, body, timeoutS, maxBytes }) {
138
+ const res = await fetch(url, { method, headers, body: body ?? undefined, redirect: 'manual',
139
+ signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutS * 1000))) });
140
+ const chunks = [];
141
+ let size = 0;
142
+ if (res.body) {
143
+ const reader = res.body.getReader();
144
+ try {
145
+ while (size <= maxBytes) {
146
+ const { done, value } = await reader.read();
147
+ if (done) break;
148
+ chunks.push(value);
149
+ size += value.length;
150
+ }
151
+ } finally {
152
+ await reader.cancel().catch(() => {});
153
+ }
154
+ }
155
+ const all = new Uint8Array(size);
156
+ let at = 0;
157
+ for (const c of chunks) { all.set(c, at); at += c.length; }
158
+ return { status: res.status, headers: Object.fromEntries(res.headers.entries()), body: all.slice(0, maxBytes + 1) };
159
+ }
160
+
161
+ const sleepMs = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
162
+
163
+ export async function request(method, url, {
164
+ headers = {}, body = null, deadlineS = 30, retries = 2, maxBytes = DEFAULT_MAX_BYTES, backoffBaseS = 0.5,
165
+ maxBackoffS = 8, maxRetryAfterS = 30, insecure = false, transport = fetchTransport, sleep = sleepMs,
166
+ clock = () => performance.now() / 1000, rand = Math.random,
167
+ } = {}) {
168
+ method = String(method).toUpperCase();
169
+ if (!TOKEN.test(method)) throw new HttpError('the method is not an HTTP token', { kind: 'refused' });
170
+ if (!Number.isInteger(retries) || retries < 0 || retries > 10) {
171
+ throw new HttpError('retries is an integer from 0 to 10', { kind: 'refused' });
172
+ }
173
+ if (!(deadlineS > 0 && maxBytes > 0)) throw new HttpError('deadlineS and maxBytes are positive', { kind: 'refused' });
174
+ checkUrl(url, { insecure });
175
+ const sent = checkHeaders(headers ?? {});
176
+ let data = null;
177
+ if (body === null || body === undefined) data = null;
178
+ else if (body instanceof Uint8Array) data = body;
179
+ else if (body instanceof ArrayBuffer) data = new Uint8Array(body);
180
+ else if (typeof body === 'string') data = new TextEncoder().encode(body);
181
+ else if (typeof body === 'object') {
182
+ data = jsonBytes(body);
183
+ if (!Object.keys(sent).some((k) => k.toLowerCase() === 'content-type')) sent['Content-Type'] = 'application/json';
184
+ } else throw new HttpError('the body is bytes, a string, an object or an array', { kind: 'refused' });
185
+ const where = redactUrl(url);
186
+ const end = clock() + deadlineS;
187
+ let attempt = 0;
188
+ for (;;) {
189
+ const left = end - clock();
190
+ if (left <= 0) {
191
+ throw new HttpError(`${method} ${where}: the deadline passed before an answer`,
192
+ { url: where, attempts: attempt, kind: 'timeout' });
193
+ }
194
+ attempt += 1;
195
+ let got = null;
196
+ let failure = null;
197
+ try {
198
+ got = await transport({ method, url, headers: { ...sent }, body: data, timeoutS: left, maxBytes });
199
+ } catch (err) {
200
+ if (err instanceof HttpError) throw err;
201
+ failure = err?.name === 'TimeoutError' || err?.name === 'AbortError'
202
+ ? new HttpError(`${method} ${where}: timed out`, { url: where, attempts: attempt, kind: 'timeout' })
203
+ : new HttpError(`${method} ${where}: connection error (${err?.name ?? 'Error'})`,
204
+ { url: where, attempts: attempt, kind: 'connection' });
205
+ }
206
+ let answer = null;
207
+ let wait;
208
+ if (failure === null) {
209
+ if (got.body.length > maxBytes) {
210
+ throw new HttpError(`${method} ${where}: the answer is over ${maxBytes} bytes`,
211
+ { status: got.status, url: where, attempts: attempt, kind: 'too-large' });
212
+ }
213
+ answer = new Response(got.status, got.headers, got.body, { attempts: attempt, url: where });
214
+ if (!mayRetry(method, sent, answer.status) || attempt > retries) return answer;
215
+ wait = retryAfterS(answer.headers['retry-after'] ?? null);
216
+ if (wait !== null) {
217
+ if (wait > maxRetryAfterS) return answer;
218
+ } else {
219
+ wait = backoffS(attempt - 1, { baseS: backoffBaseS, maxS: maxBackoffS, rand: rand() });
220
+ }
221
+ } else {
222
+ if (!mayRetry(method, sent, null) || attempt > retries) throw failure;
223
+ wait = backoffS(attempt - 1, { baseS: backoffBaseS, maxS: maxBackoffS, rand: rand() });
224
+ }
225
+ if (clock() + wait >= end) {
226
+ if (answer !== null) return answer;
227
+ throw failure;
228
+ }
229
+ await sleep(wait);
230
+ }
231
+ }
@@ -147,8 +147,8 @@ const ERROR_CODE = /^[a-z][a-z0-9-]{1,63}$/;
147
147
  const MAX_STATUS_MESSAGE = 500;
148
148
  const MAX_ERROR_MESSAGE = 2000;
149
149
  const codePoints = (value) => [...value].length;
150
- const isString = (value) => typeof value === 'string';
151
- const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
150
+ import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
151
+ import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
152
152
  const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
153
153
 
154
154
  /** A UTC instant as milliseconds, or NaN: the pattern and a day that exists, as the contract's strictTime reads it. */
@@ -347,6 +347,15 @@ export function urlRequest(message, url) {
347
347
  }
348
348
 
349
349
  // --- C3.2 jobs: the same file format as fabric_interop.JobStore ------------------------------
350
+ const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
351
+
352
+ /** What a key is kept beside (FAA-77): sha256 of the RFC 8785 form of [capability, input]; an input with none is refused. */
353
+ export function intentDigest(capability, input) {
354
+ let form;
355
+ try { form = jcs([capability, input]); } catch { throw new InteropError("A job's input with an idempotency key needs a JSON form (RFC 8785)."); }
356
+ return `sha256:${crypto.createHash('sha256').update(form, 'utf8').digest('hex')}`;
357
+ }
358
+
350
359
  export class JobStore {
351
360
  constructor(dir) { this.dir = dir; }
352
361
 
@@ -370,36 +379,63 @@ export class JobStore {
370
379
 
371
380
  // With idempotencyKey the key is kept per caller (the authenticated caller, DEC-0087): a key that already names a job
372
381
  // starts nothing and throws, unless repeat() answers 'run'; then the key names the new job.
382
+ // The key is kept beside the intent's digest (FAA-77): a key that names a job of another intent is idempotency-conflict.
383
+ // A key file a kit before 0.41.0 wrote carries no intent: it is answered with its job as 0.40.0 answered it (see repeat).
373
384
  create(capability, input, { traceparent, pollIntervalMs, caller = null, idempotencyKey, now, limitHolds, approvalGranted = false } = {}) {
385
+ let intent = null;
374
386
  if (idempotencyKey !== undefined) {
375
- const decision = this.repeat(caller, idempotencyKey, now ?? nowIso(), { limitHolds, approvalGranted });
387
+ intent = intentDigest(capability, input);
388
+ const decision = this.repeat(caller, idempotencyKey, now ?? nowIso(), { limitHolds, approvalGranted, capability, input });
389
+ if (decision.decision === 'conflict') throw new InteropError(`idempotency-conflict: key ${idempotencyKey} names a job of another intent; send the same capability and input, or a new key.`);
376
390
  if (decision.decision === 'same') throw new InteropError(`Key ${idempotencyKey} already names job ${decision.job.id}; answer with that job (JobStore.repeat).`);
377
391
  }
378
392
  const id = `job_${crypto.randomBytes(12).toString('hex')}`;
379
393
  const job = { id, status: 'working' };
380
394
  if (pollIntervalMs) job.pollIntervalMs = Math.trunc(pollIntervalMs);
381
395
  this.save({ job, private: { capability, input, traceparent: parseTraceparent(traceparent) ? traceparent : null } });
382
- if (idempotencyKey !== undefined) atomicWrite(this.keyFile(caller, idempotencyKey), `${JSON.stringify({ job: id })}\n`, 0o600);
396
+ if (idempotencyKey !== undefined) atomicWrite(this.keyFile(caller, idempotencyKey), `${JSON.stringify({ job: id, intent })}\n`, 0o600);
383
397
  return { job: { id, status: 'working' } };
384
398
  }
385
399
 
386
400
  keyFile(caller, key) {
387
401
  if (typeof key !== 'string' || key.length === 0) throw new InteropError('An idempotency key is a non-empty string.');
402
+ if (LONE_SURROGATE.test(key) || (typeof caller === 'string' && LONE_SURROGATE.test(caller))) throw new InteropError('An idempotency key and its caller are Unicode text; a lone surrogate is refused.');
388
403
  const dir = path.join(this.dir, 'keys');
389
404
  ensurePrivateDir(dir);
390
405
  return path.join(dir, `${crypto.createHash('sha256').update(JSON.stringify([caller ?? null, key]), 'utf8').digest('hex')}.json`);
391
406
  }
392
407
 
393
- // The answer to a key this caller sent before: {decision: 'new'|'same'|'run', job}.
394
- repeat(caller, key, now, options = {}) {
395
- let id;
396
- try { id = JSON.parse(fs.readFileSync(this.keyFile(caller, key), 'utf8')).job; } catch (e) {
397
- if (e.code === 'ENOENT') return { decision: 'new', job: null };
398
- throw new InteropError(`Key ${key} is unreadable on disk.`);
408
+ keyRecord(caller, key) {
409
+ let record;
410
+ try { record = JSON.parse(fs.readFileSync(this.keyFile(caller, key), 'utf8')); } catch (e) {
411
+ if (e.code === 'ENOENT') return null;
412
+ if (e instanceof InteropError) throw e;
399
413
  }
414
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) throw new InteropError(`Key ${key} is unreadable on disk.`);
415
+ return record;
416
+ }
417
+
418
+ // The answer to a key this caller sent before: {decision: 'new'|'same'|'run'|'conflict', job}. 'same': answer with
419
+ // that job; 'run': the intent runs again, once; 'conflict': given options.capability (and options.input), the key was
420
+ // kept beside another intent (idempotency-conflict, FAA-77). A key file a kit before 0.41.0 wrote holds only
421
+ // {"job": id}, no intent: it never answers 'conflict', and 'run' only for the intent its job stored; otherwise 'same'.
422
+ repeat(caller, key, now, options = {}) {
423
+ const record = this.keyRecord(caller, key);
424
+ if (record === null) return { decision: 'new', job: null };
425
+ const id = record.job;
426
+ if (typeof id !== 'string') throw new InteropError(`Key ${key} is unreadable on disk.`);
400
427
  let job;
401
428
  try { job = this.get(id); } catch (e) { if (e instanceof UnknownJob) return { decision: 'new', job: null }; throw e; }
402
- return { decision: repeatedKeyDecision(job, now, options), job };
429
+ let decision = repeatedKeyDecision(job, now, options);
430
+ if (options.capability === undefined || options.capability === null) return { decision, job };
431
+ const intent = intentDigest(options.capability, options.input ?? null);
432
+ if (Object.hasOwn(record, 'intent')) return { decision: record.intent !== intent ? 'conflict' : decision, job };
433
+ // A 0.40.0 key runs only the intent its job stored (an input with no JSON form is refused, as the Python kit refuses it).
434
+ if (decision === 'run') {
435
+ const stored = this.load(id).private ?? {};
436
+ if (intentDigest(stored.capability ?? null, stored.input ?? null) !== intent) decision = 'same';
437
+ }
438
+ return { decision, job };
403
439
  }
404
440
 
405
441
  get(jobId) { return { ...this.load(jobId).job }; }
@@ -448,11 +484,9 @@ export class JobStore {
448
484
  // A job a limit paused and its provider will resume: working, the pause grammar, pollIntervalMs reaching the reset.
449
485
  pause(jobId, { resetsAt, text } = {}) {
450
486
  const statusMessage = pauseMessage({ resetsAt, text });
451
- if (resetsAt !== undefined) {
452
- const wait = utcTime(resetsAt) - Date.now();
453
- if (wait > 0) { const record = this.load(jobId); record.job.pollIntervalMs = Math.trunc(Math.min(wait, 3600000)); this.save(record); }
454
- }
455
- return this.transition(jobId, 'working', { statusMessage });
487
+ const wait = resetsAt === undefined ? 0 : utcTime(resetsAt) - Date.now();
488
+ // One write, after the terminal check: a terminal job does not change, pollIntervalMs included (finding 11).
489
+ return this.transition(jobId, 'working', { statusMessage, pollIntervalMs: wait > 0 ? Math.trunc(Math.min(wait, 3600000)) : undefined });
456
490
  }
457
491
 
458
492
  // A job a limit stopped and its provider will not resume: failed, quota-exhausted, data {retryable, resetsAt?}.
@@ -0,0 +1,67 @@
1
+ // JavaScript's view of JSON values, once for the whole Node kit (FAA-65, layer L0); the twin of fabric_jsvalue.py.
2
+ // The contract's answers are computed by TypeScript; every reader in the kit takes these helpers from here:
3
+ // isObject, isString, objects (an array's object items), indexed (with their index as written), show (the
4
+ // contract's `show`), toNumber (the contract's `toNumber`), keyOf, jsonEqual (the contract's `jsonEqual`) and jcs
5
+ // (RFC 8785). Node 20+, no dependency; nothing here imports another kit module.
6
+
7
+ /** A plain JSON object (not null, not an array). */
8
+ export const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ export const isString = (value) => typeof value === 'string';
10
+ /** The object items of an array; [] for anything else. */
11
+ export const objects = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
12
+ /** The object items of an array with their index as written, so an instancePath names the item even after a non-object. */
13
+ export const indexed = (value) => (Array.isArray(value) ? value.flatMap((item, index) => (isObject(item) ? [[item, index]] : [])) : []);
14
+
15
+ /** The contract's `show`: String(value), or its JSON where String() throws (an object whose own `toString` is data). */
16
+ export function show(value) {
17
+ if (typeof value === 'string') return value;
18
+ try {
19
+ return String(value);
20
+ } catch {
21
+ return JSON.stringify(value) ?? typeof value;
22
+ }
23
+ }
24
+
25
+ /** The contract's `toNumber`: Number(value), or NaN where Number() throws. */
26
+ export function toNumber(value) {
27
+ try {
28
+ return Number(value);
29
+ } catch {
30
+ return Number.NaN;
31
+ }
32
+ }
33
+
34
+ /** A collision-free key over JSON values. */
35
+ export const keyOf = (...parts) => JSON.stringify(parts);
36
+
37
+ /** The contract's jsonEqual: arrays in order, objects by their members whatever the order, anything else by ===. */
38
+ export function jsonEqual(a, b) {
39
+ 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]));
40
+ if (isObject(a) || isObject(b)) {
41
+ if (!isObject(a) || !isObject(b)) return false;
42
+ const keys = Object.keys(a);
43
+ return keys.length === Object.keys(b).length && keys.every((key) => Object.hasOwn(b, key) && jsonEqual(a[key], b[key]));
44
+ }
45
+ return a === b;
46
+ }
47
+
48
+ /** RFC 8785: members sorted by UTF-16 code units, numbers in ECMAScript form; refuses what has no canonical form. */
49
+ export function jcs(value) {
50
+ if (value === null || typeof value === 'boolean') return JSON.stringify(value);
51
+ if (typeof value === 'number') {
52
+ if (!Number.isFinite(value)) throw new TypeError('a non-finite number has no canonical form');
53
+ return JSON.stringify(value);
54
+ }
55
+ if (typeof value === 'string') {
56
+ if (/\p{Surrogate}/u.test(value)) throw new TypeError('a lone surrogate has no canonical form');
57
+ return JSON.stringify(value);
58
+ }
59
+ if (Array.isArray(value)) return `[${Array.from(value, (item) => jcs(item)).join(',')}]`;
60
+ if (isObject(value)) {
61
+ const prototype = Object.getPrototypeOf(value);
62
+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError('only plain JSON objects have a canonical form');
63
+ const keys = Object.keys(value).sort();
64
+ return `{${keys.map((key) => `${jcs(key)}:${jcs(value[key])}`).join(',')}}`;
65
+ }
66
+ throw new TypeError(`${typeof value} has no canonical form`);
67
+ }