@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.80.2

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 (83) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/bin/fabric-agent-adapter.js +26 -4
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +41 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +5 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +38 -9
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +35 -2
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +20 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +46 -6
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +340 -24
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -1
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  69. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  70. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  71. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  72. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  73. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -0,0 +1,344 @@
1
+ // The call queue and the load report of DEC-0104 (contract src/agent-network.ts, FAC-SEM-131 to FAC-SEM-133), as the
2
+ // contract's reference answers them: a node that hosts a job-backed agent queues its jobs per (agentRef, context) with
3
+ // these and reports its load as service-load/1 under extensions/load/0.1. Part of the Node service kit (FAA-289, layer
4
+ // L3); the twin of fabric_load.py. Node 20+, no dependency. Nothing here throws on what it reads, except
5
+ // loadExtension, which refuses a declaration a reader would treat as not declared.
6
+ //
7
+ // - jobCallerKey(accepted, credential): the opaque key of a job's caller (an accepted delegation's principal, its
8
+ // outermost actor and a partner organization ending the chain; else the credential the node knows the caller by).
9
+ // - nextQueuedJob(queue, running, limits): the job the node starts next, or undefined (fairness between callers).
10
+ // - admitJob(queue, running, job, limits, retryAfterSeconds): {admit: true}, or the queue-full refusal with its data.
11
+ // - queueStatusMessage(position, length), readQueuePosition(statusMessage): `queued <position> of <length>`.
12
+ // - jobQueueRules(value): FAC-SEM-131 over {queue, running, limits, started}.
13
+ // - loadReportRules(report): FAC-SEM-132 on a service-load/1 report; loadReportProblems(report): its schema.
14
+ // - loadView(wellKnown), loadExtensionRules(wellKnown): the declaration as a reader reads it, and FAC-SEM-133.
15
+ // - loadExtension({ path, windowSeconds, limits }): the `extensions` entry a service declares.
16
+ //
17
+ // The kit has no JSON Schema validator: the two shapes are written here from the schemas as the contract's validator
18
+ // reads them (lengths in code points, date-time as ajv-formats' full mode). Times in the queue are UTC instants
19
+ // (interop-common#/$defs/utcInstant); times in the report are RFC 3339 read strictly (the contract's strictTime). The
20
+ // wording of a schema problem is the kit's, not ajv's.
21
+ import { ServiceError } from './fabric-base.mjs';
22
+ import { dateTime } from './fabric-connections.mjs'; // ajv-formats' date-time, as the contract's validator reads it
23
+ import { isObject, isString, objects, show } from './fabric-jsvalue.mjs';
24
+ import { parseRfc3339Ms } from './fabric-time.mjs'; // the contract's strictTime
25
+
26
+ /**
27
+ * @typedef {{ id: string, caller: string, at: string }} QueuedJob A job waiting at the hosting node: its id, its
28
+ * caller's key (jobCallerKey) and when it arrived (a UTC instant).
29
+ * @typedef {{ id: string, caller?: string }} RunningJob A job running: its id and caller.
30
+ * @typedef {{ concurrency: number, perCaller: number, queueMax: number }} QueueLimits The queue's limits per
31
+ * (agentRef, context), as the load extension declares them.
32
+ * @typedef {{ admit: true } | { admit: false, error: { code: 'queue-full', message: string,
33
+ * data: { retryable: true, retryAfterSeconds: number } } }} Admission
34
+ * @typedef {{ code: string, instancePath: string, message: string }} Finding
35
+ * @typedef {{ declared: boolean, valid: boolean, load: { path: string, windowSeconds: number, limits: QueueLimits } | null,
36
+ * errors: string[] }} LoadView
37
+ */
38
+
39
+ export const LOAD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/load/0.1';
40
+ export const LOAD_REPORT_TYPE = 'service-load/1';
41
+ // The refusal code, the same as fabric-interop-rules.mjs's QUEUE_FULL (a test holds them equal); not imported because
42
+ // the facade fabric-service.mjs, which re-exports this module, comes before the interop rules in the kit's order.
43
+ const QUEUE_FULL = 'queue-full';
44
+ export const QUEUE_FULL_MESSAGE = "the agent's queue for this context is full: start again later";
45
+ /** The longest queue a status message names (`queued <position> of <length>`, six digits). */
46
+ export const QUEUE_LENGTH_MAX = 999999;
47
+ export const DEFAULT_RETRY_AFTER_SECONDS = 60;
48
+
49
+ const UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[0-5]\d(?:\.\d{1,3})?Z$/;
50
+ const QUEUED = /^queued ([1-9]\d{0,5}) of ([1-9]\d{0,5})$/;
51
+ const ORIGIN_PATH = /^\/(?!\/)[^\s]*$/u;
52
+ const CONTEXT = /^(?:personal|org:[A-Za-z0-9][A-Za-z0-9_-]{0,127})$/;
53
+ const KEY_PATH = `/extensions/${LOAD_EXTENSION_KEY.replace(/~/g, '~0').replace(/\//g, '~1')}`;
54
+ const LIMITS = [['concurrency', 1, 1024], ['perCaller', 1, 1024], ['queueMax', 0, 100000]];
55
+ const ROW_COUNTS = ['queued', 'running', 'completed', 'failed'];
56
+
57
+ /** An own member, or undefined. */
58
+ const member = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
59
+ /** A UTC instant (`T`, `Z`, at most three fraction digits, a day that exists) in milliseconds, else NaN. */
60
+ const utcMs = (value) => (isString(value) && UTC_INSTANT.test(value) ? parseRfc3339Ms(value) : Number.NaN);
61
+ const strictMs = (value) => (isString(value) ? parseRfc3339Ms(value) : Number.NaN);
62
+ const codePoints = (value) => [...value].length;
63
+
64
+ // --- The call queue (fabric-interop, DEC-0104) ---
65
+
66
+ /**
67
+ * The key of a job's caller at a node that queues (DEC-0104), as the contract's jobCallerKey: for a call whose
68
+ * delegation the node accepted (verifyDelegation's answer), its `principal` and `caller` and the partner organization
69
+ * when the chain ends in one (DEC-0090); for a call with no token, the credential the node knows the caller by (a
70
+ * gate's pass with `principal: null` is that case too). A refused verdict has no key, and neither has an unreadable
71
+ * call: undefined. The key is opaque; only equality matters.
72
+ * @param {unknown} accepted
73
+ * @param {unknown} credential
74
+ * @returns {string | undefined}
75
+ */
76
+ export function jobCallerKey(accepted, credential) {
77
+ if (isObject(accepted) && !(member(accepted, 'ok') === true && member(accepted, 'principal') === null)) {
78
+ const principal = member(accepted, 'principal');
79
+ const caller = member(accepted, 'caller');
80
+ if (member(accepted, 'ok') !== true || !isString(principal) || principal === '' || !isString(caller) || caller === '') return undefined;
81
+ const given = member(accepted, 'chain');
82
+ const chain = Array.isArray(given) ? given : [];
83
+ const last = chain[chain.length - 1];
84
+ const partner = chain.length > 1 && isString(last) && last.startsWith('org:') ? [last] : [];
85
+ return JSON.stringify(['delegated', principal, caller, ...partner]);
86
+ }
87
+ return isString(credential) && credential !== '' ? JSON.stringify(['credential', credential]) : undefined;
88
+ }
89
+
90
+ const limitsOk = (limits) => isObject(limits)
91
+ && [member(limits, 'concurrency'), member(limits, 'perCaller')].every((n) => Number.isSafeInteger(n) && n >= 1)
92
+ && Number.isSafeInteger(member(limits, 'queueMax')) && limits.queueMax >= 0;
93
+ const waitingJob = (job) => isObject(job) && isString(member(job, 'id')) && isString(member(job, 'caller')) && Number.isFinite(utcMs(member(job, 'at')));
94
+
95
+ /**
96
+ * The job the hosting node starts next for one (agentRef, context), or undefined (DEC-0104). Nothing starts while
97
+ * `concurrency` jobs run (every running job holds a slot, its caller readable or not). Of the waiting jobs whose caller
98
+ * runs fewer than `perCaller`, the one whose caller runs the fewest wins; between those callers, the one whose oldest
99
+ * waiting job arrived first; within a caller, its oldest. Arrival ties break by id (UTF-16 order).
100
+ * @param {unknown} queue
101
+ * @param {unknown} running
102
+ * @param {unknown} limits
103
+ * @returns {string | undefined}
104
+ */
105
+ export function nextQueuedJob(queue, running, limits) {
106
+ if (!limitsOk(limits)) return undefined;
107
+ if ((Array.isArray(running) ? running.length : 0) >= limits.concurrency) return undefined;
108
+ const runningOf = new Map();
109
+ for (const job of objects(running)) if (isString(member(job, 'caller'))) runningOf.set(job.caller, (runningOf.get(job.caller) ?? 0) + 1);
110
+ const waiting = objects(queue)
111
+ .filter((job) => waitingJob(job) && (runningOf.get(job.caller) ?? 0) < limits.perCaller)
112
+ .sort((a, b) => utcMs(a.at) - utcMs(b.at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
113
+ let best;
114
+ for (const job of waiting) if (best === undefined || (runningOf.get(job.caller) ?? 0) < (runningOf.get(best.caller) ?? 0)) best = job;
115
+ return best?.id;
116
+ }
117
+
118
+ /**
119
+ * Whether a start is admitted (DEC-0104): it is when the job would start at once (nextQueuedJob over the queue with it
120
+ * added answers its id), or when fewer than `queueMax` jobs wait; otherwise {admit: false, error} with the queue-full
121
+ * refusal and its `retryAfterSeconds` (1 to 3600, else 60). Unreadable limits or job refuse.
122
+ * @param {unknown} queue
123
+ * @param {unknown} running
124
+ * @param {unknown} job
125
+ * @param {unknown} limits
126
+ * @param {unknown} retryAfterSeconds
127
+ * @returns {Admission}
128
+ */
129
+ export function admitJob(queue, running, job, limits, retryAfterSeconds) {
130
+ const waiting = objects(queue);
131
+ if (limitsOk(limits) && waitingJob(job) && (nextQueuedJob([...waiting, job], running, limits) === job.id || waiting.length < limits.queueMax)) return { admit: true };
132
+ const after = Number.isSafeInteger(retryAfterSeconds) && retryAfterSeconds >= 1 && retryAfterSeconds <= 3600 ? retryAfterSeconds : DEFAULT_RETRY_AFTER_SECONDS;
133
+ return { admit: false, error: { code: QUEUE_FULL, message: QUEUE_FULL_MESSAGE, data: { retryable: true, retryAfterSeconds: after } } };
134
+ }
135
+
136
+ /**
137
+ * The `statusMessage` of a waiting job (DEC-0104): `queued <position> of <length>`, 1 is next, 1 <= position <= length
138
+ * <= 999999; undefined for anything else.
139
+ * @param {unknown} position
140
+ * @param {unknown} length
141
+ * @returns {string | undefined}
142
+ */
143
+ export const queueStatusMessage = (position, length) => (Number.isSafeInteger(position) && Number.isSafeInteger(length)
144
+ && position >= 1 && position <= length && length <= QUEUE_LENGTH_MAX ? `queued ${position} of ${length}` : undefined);
145
+
146
+ /**
147
+ * A waiting job's place, read from its `statusMessage`, or undefined when it is not the queue's grammar.
148
+ * @param {unknown} statusMessage
149
+ * @returns {{ position: number, length: number } | undefined}
150
+ */
151
+ export function readQueuePosition(statusMessage) {
152
+ const match = isString(statusMessage) ? QUEUED.exec(statusMessage) : null;
153
+ if (!match) return undefined;
154
+ const position = Number(match[1]);
155
+ const length = Number(match[2]);
156
+ return position >= 1 && position <= length ? { position, length } : undefined;
157
+ }
158
+
159
+ const finding = (code, instancePath, message) => ({ code, instancePath, message });
160
+
161
+ /**
162
+ * FAC-SEM-131 on {queue, running, limits, started} (`started`: the id the node started, or null): it is nextQueuedJob's.
163
+ * @param {unknown} value
164
+ * @returns {Finding[]}
165
+ */
166
+ export function jobQueueRules(value) {
167
+ const want = nextQueuedJob(member(value, 'queue'), member(value, 'running'), member(value, 'limits')) ?? null;
168
+ if ((member(value, 'started') ?? null) === want) return [];
169
+ return [finding('FAC-SEM-131', '/started', want === null ? 'nothing may start now' : `the job to start is ${show(want)}`)];
170
+ }
171
+
172
+ // --- The load report (DEC-0104) ---
173
+
174
+ /**
175
+ * FAC-SEM-132 on a service-load/1 report: windows in order one `windowSeconds` apart, none after `generatedAt`, one
176
+ * row per context, p50 at most p95, a run time only where a job ended.
177
+ * @param {unknown} report
178
+ * @returns {Finding[]}
179
+ */
180
+ export function loadReportRules(report) {
181
+ const findings = [];
182
+ const flag = (instancePath, message) => findings.push(finding('FAC-SEM-132', instancePath, message));
183
+ const seconds = member(report, 'windowSeconds');
184
+ const step = Number.isSafeInteger(seconds) ? seconds * 1000 : Number.NaN;
185
+ const generated = strictMs(member(report, 'generatedAt'));
186
+ let previous;
187
+ objects(member(report, 'windows')).forEach((window, index) => {
188
+ const start = strictMs(member(window, 'start'));
189
+ if (start > generated) flag(`/windows/${index}/start`, 'a window starts after the report was generated');
190
+ if (previous !== undefined && start - previous !== step) flag(`/windows/${index}/start`, 'windows are not in order one windowSeconds apart');
191
+ previous = start;
192
+ const seen = new Set();
193
+ objects(member(window, 'byContext')).forEach((row, at) => {
194
+ const path = `/windows/${index}/byContext/${at}`;
195
+ const context = member(row, 'context');
196
+ if (isString(context) && seen.has(context)) flag(`${path}/context`, `context ${show(context)} appears twice in one window`);
197
+ if (isString(context)) seen.add(context);
198
+ for (const name of ['waitMs', 'runMs']) {
199
+ const pct = member(row, name);
200
+ if (isObject(pct) && typeof member(pct, 'p50') === 'number' && typeof member(pct, 'p95') === 'number' && pct.p50 > pct.p95) flag(`${path}/${name}`, 'p50 is above p95');
201
+ }
202
+ if (isObject(member(row, 'runMs')) && member(row, 'completed') === 0 && member(row, 'failed') === 0) flag(`${path}/runMs`, 'a run time with no job ended in the window');
203
+ });
204
+ });
205
+ return findings;
206
+ }
207
+
208
+ /** JSON Schema's integer with its bounds, as ajv reads it: a number with no fraction, never a boolean. */
209
+ function integer(value, low, high, at, errors) {
210
+ if (typeof value !== 'number' || !Number.isFinite(value) || value % 1 !== 0) errors.push(`${at} type`);
211
+ else if (value < low) errors.push(`${at} minimum`);
212
+ else if (high !== undefined && value > high) errors.push(`${at} maximum`);
213
+ }
214
+
215
+ function members(value, allowed, required, at, errors) {
216
+ for (const key of Object.keys(value)) if (!allowed.includes(key)) errors.push(`${at}/${key} additionalProperties`);
217
+ for (const key of required) if (!Object.hasOwn(value, key)) errors.push(`${at || '/'} required ${key}`);
218
+ }
219
+
220
+ function timestamp(value, at, errors) {
221
+ if (!isString(value)) errors.push(`${at} type`);
222
+ else if (!dateTime(value)) errors.push(`${at} format`);
223
+ }
224
+
225
+ function percentiles(value, at, errors) {
226
+ if (!isObject(value)) {
227
+ errors.push(`${at} type`);
228
+ return;
229
+ }
230
+ members(value, ['p50', 'p95'], ['p50', 'p95'], at, errors);
231
+ for (const name of ['p50', 'p95']) if (Object.hasOwn(value, name)) integer(value[name], 0, undefined, `${at}/${name}`, errors);
232
+ }
233
+
234
+ function rowProblems(row, at, errors) {
235
+ if (!isObject(row)) {
236
+ errors.push(`${at} type`);
237
+ return;
238
+ }
239
+ members(row, ['context', ...ROW_COUNTS, 'waitMs', 'runMs'], ['context', ...ROW_COUNTS], at, errors);
240
+ if (Object.hasOwn(row, 'context') && !(isString(row.context) && CONTEXT.test(row.context))) errors.push(`${at}/context ${isString(row.context) ? 'pattern' : 'type'}`);
241
+ for (const name of ROW_COUNTS) if (Object.hasOwn(row, name)) integer(row[name], 0, undefined, `${at}/${name}`, errors);
242
+ for (const name of ['waitMs', 'runMs']) if (Object.hasOwn(row, name)) percentiles(row[name], `${at}/${name}`, errors);
243
+ }
244
+
245
+ /**
246
+ * service-load.schema.json: what makes `report` not a service-load/1 report, each as `<path> <keyword>`; empty for a
247
+ * valid one. Counts and durations only: a member the schema does not name (a caller, a job id) is refused.
248
+ * @param {unknown} report
249
+ * @returns {string[]}
250
+ */
251
+ export function loadReportProblems(report) {
252
+ if (!isObject(report)) return ['/ type'];
253
+ const errors = [];
254
+ const names = ['type', 'generatedAt', 'windowSeconds', 'windows'];
255
+ members(report, names, names, '', errors);
256
+ if (Object.hasOwn(report, 'type') && report.type !== LOAD_REPORT_TYPE) errors.push('/type const');
257
+ if (Object.hasOwn(report, 'generatedAt')) timestamp(report.generatedAt, '/generatedAt', errors);
258
+ if (Object.hasOwn(report, 'windowSeconds')) integer(report.windowSeconds, 60, 86400, '/windowSeconds', errors);
259
+ if (Object.hasOwn(report, 'windows') && !Array.isArray(report.windows)) errors.push('/windows type');
260
+ else if (Object.hasOwn(report, 'windows')) {
261
+ if (report.windows.length > 168) errors.push('/windows maxItems');
262
+ report.windows.forEach((window, index) => {
263
+ const at = `/windows/${index}`;
264
+ if (!isObject(window)) {
265
+ errors.push(`${at} type`);
266
+ return;
267
+ }
268
+ members(window, ['start', 'byContext'], ['start', 'byContext'], at, errors);
269
+ if (Object.hasOwn(window, 'start')) timestamp(window.start, `${at}/start`, errors);
270
+ if (Object.hasOwn(window, 'byContext') && !Array.isArray(window.byContext)) errors.push(`${at}/byContext type`);
271
+ else if (Object.hasOwn(window, 'byContext')) {
272
+ if (window.byContext.length > 64) errors.push(`${at}/byContext maxItems`);
273
+ window.byContext.forEach((row, rowIndex) => rowProblems(row, `${at}/byContext/${rowIndex}`, errors));
274
+ }
275
+ });
276
+ }
277
+ return errors;
278
+ }
279
+
280
+ /** service-load-extension.schema.json, each problem as `<path> <keyword>`; empty for a valid declaration. */
281
+ function declarationProblems(value) {
282
+ if (!isObject(value)) return ['/ type'];
283
+ const errors = [];
284
+ members(value, ['path', 'windowSeconds', 'limits'], ['path', 'windowSeconds', 'limits'], '', errors);
285
+ if (Object.hasOwn(value, 'path')) {
286
+ if (!isString(value.path)) errors.push('/path type');
287
+ else {
288
+ if (codePoints(value.path) > 256) errors.push('/path maxLength'); // the originPath's own 2048 is wider
289
+ if (!ORIGIN_PATH.test(value.path)) errors.push('/path pattern');
290
+ }
291
+ }
292
+ if (Object.hasOwn(value, 'windowSeconds')) integer(value.windowSeconds, 60, 86400, '/windowSeconds', errors);
293
+ if (Object.hasOwn(value, 'limits')) {
294
+ const { limits } = value;
295
+ if (!isObject(limits)) errors.push('/limits type');
296
+ else {
297
+ const names = LIMITS.map(([name]) => name);
298
+ members(limits, names, names, '/limits', errors);
299
+ for (const [name, low, high] of LIMITS) if (Object.hasOwn(limits, name)) integer(limits[name], low, high, `/limits/${name}`, errors);
300
+ }
301
+ }
302
+ return errors;
303
+ }
304
+
305
+ /**
306
+ * The load declaration as a reader reads it (DEC-0104): `declared` when the extension key is present, `valid` when its
307
+ * value is the schema's, `load` the declaration when valid (else null) and `errors` its problems. A reader treats an
308
+ * invalid value as not declared and reports it (FAC-SEM-133).
309
+ * @param {unknown} wellKnown
310
+ * @returns {LoadView}
311
+ */
312
+ export function loadView(wellKnown) {
313
+ const extensions = member(wellKnown, 'extensions');
314
+ if (!isObject(extensions) || !Object.hasOwn(extensions, LOAD_EXTENSION_KEY)) return { declared: false, valid: false, load: null, errors: [] };
315
+ const value = extensions[LOAD_EXTENSION_KEY];
316
+ const errors = declarationProblems(value);
317
+ return { declared: true, valid: errors.length === 0, load: errors.length === 0 ? value : null, errors };
318
+ }
319
+
320
+ /**
321
+ * FAC-SEM-133 on a well-known document: a value under the load extension key is valid against
322
+ * service-load-extension.schema.json; one finding at the key otherwise.
323
+ * @param {unknown} wellKnown
324
+ * @returns {Finding[]}
325
+ */
326
+ export function loadExtensionRules(wellKnown) {
327
+ const view = loadView(wellKnown);
328
+ if (!view.declared || view.valid) return [];
329
+ return [finding('FAC-SEM-133', KEY_PATH, `the load declaration is invalid (${view.errors.join('; ')}); a reader treats it as not declared and reports it (DEC-0104)`)];
330
+ }
331
+
332
+ /**
333
+ * The `extensions` entry a service declares (DEC-0104): where it serves its service-load/1 report (an origin path,
334
+ * served under the descriptor token like the usage report), the report's window and the queue's limits per
335
+ * (agentRef, context). Throws ServiceError on a declaration a reader would treat as not declared.
336
+ * @param {{ path: string, windowSeconds: number, limits: QueueLimits }} declaration
337
+ * @returns {Record<string, { path: string, windowSeconds: number, limits: QueueLimits }>}
338
+ */
339
+ export function loadExtension({ path, windowSeconds, limits } = {}) {
340
+ const value = { path, windowSeconds, limits: isObject(limits) ? { ...limits } : limits };
341
+ const problems = declarationProblems(value);
342
+ if (problems.length) throw new ServiceError(`Load declaration: ${problems.join('; ')}.`);
343
+ return { [LOAD_EXTENSION_KEY]: value };
344
+ }
@@ -45,7 +45,7 @@ import http from 'node:http';
45
45
  import net from 'node:net';
46
46
  import os from 'node:os';
47
47
 
48
- export const PEER_OWNER_VERSION = '1.1.0+kit.3'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
48
+ export const PEER_OWNER_VERSION = '1.1.0+kit.4'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
49
49
  export const THIS_ACCOUNT = 'this-account';
50
50
  export const ANOTHER_ACCOUNT = 'another-account';
51
51
  export const SYSTEM = 'system';
@@ -154,6 +154,10 @@ export function parseNetstat(text) {
154
154
  const at = names.indexOf('process:pid');
155
155
  const leading = at - names.slice(0, at).filter((w) => w === 'Address').length;
156
156
  const trailing = names.length - at - 1;
157
+ // The caller's own connection is in any table it can see: a header and no tcp row is the restricted table (FAA-273).
158
+ if (!lines.slice(header + 1).some((line) => (line.split(/\s+/).filter(Boolean)[0] ?? '').startsWith('tcp'))) {
159
+ throw new TableUnreadable(`${TABLE_RESTRICTED}: netstat printed only its header to this process`);
160
+ }
157
161
  const rows = [];
158
162
  for (const line of lines.slice(header + 1)) {
159
163
  const tok = line.split(/\s+/).filter(Boolean);
@@ -43,6 +43,10 @@ export {
43
43
  } from './fabric-protocols.mjs';
44
44
  export { ACTIONS_EXTENSION_KEY, actionDecision, actionRules, actionsExtension, actionsExtensionRules, actionsView, decisionShape } from './fabric-actions.mjs';
45
45
  export { CONNECTIONS_EXTENSION_KEY, connectionsExtension, connectionsRules, connectionsView } from './fabric-connections.mjs';
46
+ export { // the call queue and the load report (DEC-0104, FAA-289)
47
+ DEFAULT_RETRY_AFTER_SECONDS, LOAD_EXTENSION_KEY, LOAD_REPORT_TYPE, QUEUE_FULL_MESSAGE, QUEUE_LENGTH_MAX, admitJob, jobCallerKey, jobQueueRules,
48
+ loadExtension, loadExtensionRules, loadReportProblems, loadReportRules, loadView, nextQueuedJob, queueStatusMessage, readQueuePosition,
49
+ } from './fabric-load.mjs';
46
50
  export {
47
51
  EXIT_ALREADY_RUNNING,
48
52
  LOCK_WAIT_SUPERVISED_SECONDS,
@@ -118,7 +122,9 @@ export {
118
122
  fabricButtonPresence,
119
123
  placementOf,
120
124
  remoteOriginProblems,
125
+ portListenerRefusal,
121
126
  portOf,
127
+ portOwner,
122
128
  NO_SOURCE_REPOSITORY,
123
129
  namesRepository,
124
130
  sourceRefusal,
@@ -115,12 +115,33 @@ export function systemdUnit(unit, programArguments, { workingDirectory, environm
115
115
 
116
116
  const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
117
117
 
118
+ // FAA-253: the well-known answer at `origin`, polled until this OS account's listener answers as serviceId.instance, as
119
+ // launchdInstall does (the pinned contract's Install row: every supervisor polls). Another account's listener, or one
120
+ // whose owner cannot be told, is never a successful install (DEC-0154 HA-2).
121
+ async function pollInstalled(origin, { serviceId, instance = 'default', timeout = 40, fetchWellKnown = wellKnownOf, sleep = pause, clock = Date.now } = {}) {
122
+ if (!serviceId) throw new ServiceError(`polling the install at ${origin} needs the serviceId it must answer as`);
123
+ const deadline = clock() + Math.max(0, timeout) * 1000;
124
+ let foreign = null;
125
+ for (;;) {
126
+ const doc = await fetchWellKnown(origin);
127
+ foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
128
+ if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
129
+ if (clock() >= deadline) break;
130
+ await sleep(500);
131
+ }
132
+ if (foreign) throw new ServiceError(`${origin} is not this service's: ${foreign} (DEC-0154 HA-2); the port is another OS account's or its owner cannot be told.`);
133
+ throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
134
+ }
135
+
118
136
  /**
119
137
  * Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
120
138
  * The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
121
- * new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
139
+ * new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on). With `origin`
140
+ * (and `serviceId`) it then polls the well-known answer as launchdInstall does and resolves to the unit file once this
141
+ * account's listener answers (FAA-253); without `origin` it stays synchronous.
122
142
  */
123
- export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
143
+ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false,
144
+ origin, serviceId, instance, timeout, fetchWellKnown, sleep, clock } = {}) {
124
145
  if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
125
146
  if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
126
147
  fs.mkdirSync(unitDir, { recursive: true });
@@ -128,10 +149,14 @@ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defau
128
149
  const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
129
150
  atomicWrite(file, text, 0o644);
130
151
  checkRun(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
131
- if (state === 'disabled' && !forceEnable) return file;
132
- checkRun(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
133
- checkRun(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
134
- return file;
152
+ const started = !(state === 'disabled' && !forceEnable);
153
+ if (started) {
154
+ checkRun(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
155
+ checkRun(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
156
+ }
157
+ if (origin === undefined) return file;
158
+ // FAA-253: with an origin it resolves once this account's listener answers; a unit kept off is not polled.
159
+ return started ? pollInstalled(origin, { serviceId, instance, timeout, fetchWellKnown, sleep, clock }).then(() => file) : Promise.resolve(file);
135
160
  }
136
161
 
137
162
  /** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
@@ -203,9 +228,12 @@ const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Ena
203
228
 
204
229
  /**
205
230
  * Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
206
- * is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
231
+ * is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`. With `origin` (and
232
+ * `serviceId`) it then polls the well-known answer as launchdInstall does and resolves to it (FAA-253); a task kept off
233
+ * resolves to undefined, unpolled. Without `origin` it stays synchronous.
207
234
  */
208
- export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
235
+ export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false,
236
+ origin, serviceId, instance, timeout, fetchWellKnown, sleep, clock } = {}) {
209
237
  if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
210
238
  const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
211
239
  const existed = query?.status === 0;
@@ -221,10 +249,11 @@ export function taskInstall(task, xmlText, { run = defaultRun, platform = proces
221
249
  }
222
250
  if (keepOff) {
223
251
  checkRun(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
224
- return;
252
+ return origin === undefined ? undefined : Promise.resolve(undefined);
225
253
  }
226
254
  if (existed) run(['schtasks', '/End', '/TN', task]);
227
255
  checkRun(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
256
+ return origin === undefined ? undefined : pollInstalled(origin, { serviceId, instance, timeout, fetchWellKnown, sleep, clock });
228
257
  }
229
258
 
230
259
  /** End the running task, then delete it. */
@@ -70,7 +70,14 @@ function byProject(receipts, first, last) {
70
70
  const close = ({ cost, ...rest }) => ({ ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 });
71
71
  return [...projects.keys()].sort().map((key) => {
72
72
  const { runs, ...row } = projects.get(key);
73
- const taskRuns = [...runs.keys()].sort().map((id) => close(runs.get(id)));
73
+ // A run's cost rounds as the project's does but never above its exact cost: each run and the project round apart,
74
+ // so many runs rounding up summed past the project and failed FAC-SEM-025 (board request f5753c5a).
75
+ const closeRun = (run) => {
76
+ const out = close(run);
77
+ if (out.costUsd !== null && out.costUsd > run.cost) out.costUsd = Math.round((out.costUsd - 0.000001) * 1e6) / 1e6;
78
+ return out;
79
+ };
80
+ const taskRuns = [...runs.keys()].sort().map((id) => closeRun(runs.get(id)));
74
81
  return { ...close(row), ...(taskRuns.length ? { taskRuns } : {}) };
75
82
  });
76
83
  }
@@ -168,6 +168,7 @@ class Responder(SlotKeeper):
168
168
  self.state = "idle"
169
169
  self.wait_until = 0.0
170
170
  self.stopping = False
171
+ self.shutting_down, self.draining = threading.Event(), None # shutdown began; it found work to drain (FAA-186)
171
172
  self.claim_keys: Dict[str, str] = {}
172
173
  self._probing: set = set() # moved slots whose move-back claim lost its answer: asked again under the same key
173
174
  self._said: Dict[str, str] = {}
@@ -982,8 +983,8 @@ class Responder(SlotKeeper):
982
983
  def shutdown(self, drain: float = DRAIN_SECONDS) -> None:
983
984
  """Stop claiming, give running work up to `drain` seconds to finish and be completed, then leave the session
984
985
  (its DELETE releases the slots). What is still open stays in the checkpoint for the next start."""
985
- self.stopping = True
986
- work = self.current
986
+ self.stopping, work = True, self.current
987
+ self.draining = work is not None; self.shutting_down.set() # noqa: E702 - published before it waits (FAA-186)
987
988
  if work is not None and not work.done.wait(drain):
988
989
  work.ctx.stopped.set()
989
990
  if work is not None and work.done.is_set() and self.session is not None:
@@ -247,3 +247,5 @@ class SlotKeeper:
247
247
  self._retry_later(capability, entries or [], exc)
248
248
  if exc.code in SESSION_CODES:
249
249
  raise
250
+ if exc.code == "idempotency_window_expired":
251
+ self._status() # the epoch moved: read it before the retry, which no held slot would do (FAA-186)
@@ -4,8 +4,10 @@
4
4
  `degraded[]`. Part of the service kit (FAA-62, layer L3). Standard library only."""
5
5
  from __future__ import annotations
6
6
 
7
+ import bisect
7
8
  import ipaddress
8
9
  import re
10
+ import unicodedata
9
11
  import urllib.parse
10
12
  from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
11
13
 
@@ -173,6 +175,92 @@ _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
173
175
  _FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
174
176
 
175
177
 
178
+ # FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
179
+ # the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
180
+ # test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
181
+ # code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
182
+ # classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
183
+ # Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
184
+ _IDNA_DISALLOWED = (
185
+ (0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
186
+ (0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
187
+ (0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
188
+ (0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
189
+ (0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
190
+ (0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
191
+ (0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
192
+ (0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
193
+ (0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
194
+ (0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
195
+ (0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
196
+ (0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
197
+ (0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
198
+ (0x100000, 0x10fffd),
199
+ )
200
+ _IDNA_IGNORED = (
201
+ (0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
202
+ (0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
203
+ )
204
+ _IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
205
+ _U32 = unicodedata.ucd_3_2_0
206
+ _RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
207
+ _JOINERS = "\u200c\u200d"
208
+
209
+
210
+ def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
211
+ i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
212
+ return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
213
+
214
+
215
+ def _known(c: str) -> bool:
216
+ return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
217
+
218
+
219
+ def _joiner_refused(label: str, i: int) -> bool:
220
+ """CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
221
+ (`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
222
+ in its label has none for certain."""
223
+ return all(c.isascii() for j, c in enumerate(label) if j != i)
224
+
225
+
226
+ def _bidi_refused(label: str) -> bool:
227
+ """RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
228
+ not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
229
+ if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
230
+ return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
231
+ k = [_U32.bidirectional(c) for c in label]
232
+ if not any(x in ("R", "AL", "AN") for x in k):
233
+ return False
234
+ body = list(k)
235
+ while body and body[-1] == "NSM":
236
+ body.pop()
237
+ if not body or body[-1] not in ("R", "AL", "EN", "AN"):
238
+ return True
239
+ return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
240
+
241
+
242
+ def _idna_host(host: str) -> Optional[str]:
243
+ """None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
244
+ as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
245
+ if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
246
+ return None
247
+ kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
248
+ if kept == "":
249
+ return None # nothing left: an empty host
250
+ if not all(_known(c) for c in kept):
251
+ return host # a code point this Python's Unicode does not know: the contextual rules are not read
252
+ mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
253
+ for label in _IDNA_DOTS.split(mapped):
254
+ first = label[:1]
255
+ if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
256
+ return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
257
+ if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
258
+ return None
259
+ if _bidi_refused(label):
260
+ return None
261
+ return _IDNA_DOTS.sub(".", mapped)
262
+
263
+
176
264
  def _ipv4_number(part: str) -> Optional[int]:
177
265
  if part == "":
178
266
  return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
@@ -198,10 +286,16 @@ def _domain_ok(host: str) -> bool:
198
286
  return False
199
287
  if any(c in _FORBIDDEN_DOMAIN for c in host):
200
288
  return False
201
- # A non-ASCII host goes through UTS46 in WHATWG; this module does not port it. When unsure, the host is taken as
202
- # valid, so its URL's credentials are read: the doubt then hides a list rather than publish one (round-2 reading).
289
+ # A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
290
+ # the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
291
+ # (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
203
292
  if any(ord(c) > 0x7f for c in host):
204
- return True
293
+ mapped = _idna_host(host)
294
+ if mapped is None:
295
+ return False
296
+ if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
297
+ return True
298
+ host = mapped
205
299
  labels = host.split(".")
206
300
  if labels and labels[-1] == "" and len(labels) > 1:
207
301
  labels = labels[:-1]