@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -1,2541 +1,177 @@
|
|
|
1
|
-
// Reference kit for the fabric-service/0.1 local service extension
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
export
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
export
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
|
|
179
|
-
// with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
|
|
180
|
-
const name = lockName(this.path);
|
|
181
|
-
if (!name) throw new ServiceError(`No instance lock on ${process.platform}: the kit supports macOS, Linux and Windows.`);
|
|
182
|
-
this.server = net.createServer();
|
|
183
|
-
await new Promise((resolve, reject) => {
|
|
184
|
-
// Windows reports a held pipe as EADDRINUSE (access denied) or, when its holder capped the instances, EBUSY.
|
|
185
|
-
const held = (code) => code === 'EADDRINUSE' || (process.platform === 'win32' && code === 'EBUSY');
|
|
186
|
-
this.server.once('error', (error) => reject(held(error.code) ? new AlreadyRunning(readPid(this.path), this.path) : error));
|
|
187
|
-
this.server.listen(name, resolve);
|
|
188
|
-
});
|
|
189
|
-
this.server.unref();
|
|
190
|
-
atomicWrite(this.path, String(process.pid));
|
|
191
|
-
return this;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
release() {
|
|
195
|
-
if (this.fd !== null) fs.closeSync(this.fd);
|
|
196
|
-
if (this.server) this.server.close();
|
|
197
|
-
this.fd = null;
|
|
198
|
-
this.server = null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** The name the lock is held under on Linux (abstract socket) and Windows (named pipe); null on macOS. */
|
|
203
|
-
export function lockName(lockPath, platform = process.platform) {
|
|
204
|
-
const digest = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
|
205
|
-
if (platform === 'linux') return `\0fabric-service-${digest(path.posix.resolve(lockPath))}`;
|
|
206
|
-
// Windows paths compare without case: C:\Users and c:\users are one lock.
|
|
207
|
-
if (platform === 'win32') return `\\\\.\\pipe\\fabric-service-${digest(path.win32.resolve(lockPath).toLowerCase())}`;
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function readPid(file) {
|
|
212
|
-
try {
|
|
213
|
-
const text = fs.readFileSync(file, 'utf8').trim();
|
|
214
|
-
return /^\d+$/.test(text) ? Number(text) : null;
|
|
215
|
-
} catch {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node. */
|
|
221
|
-
export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node']);
|
|
222
|
-
/** The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action). */
|
|
223
|
-
export const SUPERVISOR_ARG = '--fabric-supervisor';
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* The marker values an argument vector carries after the executable and before `--`. A bare `--fabric-supervisor`
|
|
227
|
-
* followed by nothing, by `--` or by another `--…` flag is a marker with an empty value and consumes nothing.
|
|
228
|
-
*/
|
|
229
|
-
function markerArguments(argv) {
|
|
230
|
-
const values = [];
|
|
231
|
-
if (!Array.isArray(argv)) return values;
|
|
232
|
-
for (let index = 1; index < argv.length; index += 1) {
|
|
233
|
-
const item = argv[index];
|
|
234
|
-
if (item === '--') break;
|
|
235
|
-
if (item === SUPERVISOR_ARG) {
|
|
236
|
-
const next = argv[index + 1];
|
|
237
|
-
if (typeof next === 'string' && !next.startsWith('--')) {
|
|
238
|
-
values.push(next);
|
|
239
|
-
index += 1;
|
|
240
|
-
} else {
|
|
241
|
-
values.push('');
|
|
242
|
-
}
|
|
243
|
-
} else if (typeof item === 'string' && item.startsWith(`${SUPERVISOR_ARG}=`)) {
|
|
244
|
-
values.push(item.slice(SUPERVISOR_ARG.length + 1));
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return values;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* How this copy was started (DEC-0057), from `FABRIC_SERVICE_SUPERVISOR=<manager>` and `--fabric-supervisor <manager>` /
|
|
252
|
-
* `--fabric-supervisor=<manager>` (`env` and `argv` default to this process's; `argv[0]` is the executable or script):
|
|
253
|
-
* `{kind: 'supervised', manager, forms}` when every marker present names the same manager of SUPERVISORS;
|
|
254
|
-
* `{kind: 'conflict', values, forms}` when they disagree (an empty or bare argument beside a variable disagrees with it):
|
|
255
|
-
* the copy exits 75 at once, before it takes the lock; `{kind: 'manual', forms, unreadable?}` with no marker (only an
|
|
256
|
-
* empty variable counts as none) or one that names no manager. Never inferred from the parent process. The contract's
|
|
257
|
-
* `supervisorMarker`.
|
|
258
|
-
*/
|
|
259
|
-
export function supervisorMarker({ env = process.env, argv = process.argv.slice(1) } = {}) {
|
|
260
|
-
const variable = env?.[SUPERVISOR_ENV];
|
|
261
|
-
const fromEnv = typeof variable === 'string' && variable !== '' ? [variable] : [];
|
|
262
|
-
const fromArgs = markerArguments(argv);
|
|
263
|
-
const forms = [...(fromEnv.length ? ['environment'] : []), ...(fromArgs.length ? ['argument'] : [])];
|
|
264
|
-
const values = [...new Set([...fromEnv, ...fromArgs])];
|
|
265
|
-
if (values.length > 1) return { kind: 'conflict', values, forms };
|
|
266
|
-
if (values.length === 0) return { kind: 'manual', forms };
|
|
267
|
-
return SUPERVISORS.includes(values[0]) ? { kind: 'supervised', manager: values[0], forms } : { kind: 'manual', unreadable: values[0], forms };
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/** How long a copy started so may wait for the lock, in seconds: 300 when supervised, else not at all. */
|
|
271
|
-
export const lockWaitBudgetSeconds = (marker) => (marker?.kind === 'supervised' ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
|
|
272
|
-
|
|
273
|
-
/** The one sentence a copy whose markers disagree exits with, naming the markers present. */
|
|
274
|
-
export function markerConflictMessage(marker) {
|
|
275
|
-
const shown = marker.values.map((v) => JSON.stringify(v)).join(', ');
|
|
276
|
-
return marker.forms.length === 2
|
|
277
|
-
? `${SUPERVISOR_ENV} and ${SUPERVISOR_ARG} disagree (${shown}); not starting unsupervised.`
|
|
278
|
-
: `${SUPERVISOR_ARG} is given with different values (${shown}); not starting unsupervised.`;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** True when a supervisor that restarts on exit started this process and said so with the marker. */
|
|
282
|
-
export const supervised = (start) => supervisorMarker(start).kind === 'supervised';
|
|
283
|
-
/** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
|
|
284
|
-
export const supervisedByLaunchd = (start) => supervisorMarker(start).manager === 'launchd';
|
|
285
|
-
|
|
286
|
-
const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078). Markers that
|
|
290
|
-
* disagree exit 75 before the lock. Started by hand, a held lock exits 75 at once. Under a
|
|
291
|
-
* supervisor that restarts on exit (launchd's KeepAlive) an immediate exit is a respawn every ThrottleInterval,
|
|
292
|
-
* forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
|
|
293
|
-
* LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
|
|
294
|
-
* the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
|
|
295
|
-
*/
|
|
296
|
-
export async function holdSingleInstance(dataDir, {
|
|
297
|
-
waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv,
|
|
298
|
-
} = {}) {
|
|
299
|
-
// DEC-0057: markers that disagree exit 75 at once, before the lock, naming them.
|
|
300
|
-
const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
|
|
301
|
-
if (marker.kind === 'conflict') {
|
|
302
|
-
process.stderr.write(`${markerConflictMessage(marker)}\n`);
|
|
303
|
-
return exit(EXIT_ALREADY_RUNNING);
|
|
304
|
-
}
|
|
305
|
-
const wait = waitSeconds ?? lockWaitBudgetSeconds(marker);
|
|
306
|
-
const deadline = clock() + Math.max(0, wait);
|
|
307
|
-
let delay = LOCK_BACKOFF_FIRST_SECONDS;
|
|
308
|
-
let announced = false;
|
|
309
|
-
for (;;) {
|
|
310
|
-
try {
|
|
311
|
-
return await new InstanceLock(dataDir).acquire();
|
|
312
|
-
} catch (error) {
|
|
313
|
-
if (!(error instanceof AlreadyRunning)) throw error;
|
|
314
|
-
const remaining = deadline - clock();
|
|
315
|
-
if (remaining <= 0) {
|
|
316
|
-
process.stderr.write(`${error.message}\n`);
|
|
317
|
-
return exit(EXIT_ALREADY_RUNNING);
|
|
318
|
-
}
|
|
319
|
-
if (!announced) {
|
|
320
|
-
process.stderr.write(`${error.message} Waiting up to ${Math.round(remaining)} s for it to exit.\n`);
|
|
321
|
-
announced = true;
|
|
322
|
-
}
|
|
323
|
-
await sleep(Math.min(delay, remaining));
|
|
324
|
-
delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// --- token and network guard ------------------------------------------------------
|
|
330
|
-
|
|
331
|
-
export function ensureToken(file) {
|
|
332
|
-
if (fs.existsSync(file)) return readToken(file);
|
|
333
|
-
ensurePrivateDir(path.dirname(file));
|
|
334
|
-
const token = crypto.randomBytes(32).toString('base64url');
|
|
335
|
-
atomicWrite(file, token, 0o600);
|
|
336
|
-
return token;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Refuse a symlink, a file owned by someone else, or one others can read. On Windows the rule is
|
|
341
|
-
* service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057): windowsTokenAclProblem over the
|
|
342
|
-
* file's flags, owner and ACEs; a refusal names the SID, never the contents.
|
|
343
|
-
*/
|
|
344
|
-
export function readToken(file) {
|
|
345
|
-
const info = fs.lstatSync(file);
|
|
346
|
-
if (info.isSymbolicLink()) throw new ServiceError(`Token file ${file} is a symlink; refusing it.`);
|
|
347
|
-
if (process.platform === 'win32') {
|
|
348
|
-
const problem = windowsTokenProblem(file, info);
|
|
349
|
-
if (problem) throw new ServiceError(problem);
|
|
350
|
-
} else {
|
|
351
|
-
if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
|
|
352
|
-
if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
|
|
353
|
-
}
|
|
354
|
-
const token = fs.readFileSync(file, 'utf8').trim();
|
|
355
|
-
if (token.length < 16) throw new ServiceError(`Token file ${file} holds no usable token.`);
|
|
356
|
-
return token;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
// --- Windows ACLs (DEC-0032) ------------------------------------------------------
|
|
360
|
-
// Node has no ACL API. Windows PowerShell (5.1, present on every Windows) reads the list through .NET
|
|
361
|
-
// and icacls writes it; both get the path through the environment, never argv, and print no contents.
|
|
362
|
-
|
|
363
|
-
export const SYSTEM_SID = 'S-1-5-18';
|
|
364
|
-
export const ADMINISTRATORS_SID = 'S-1-5-32-544';
|
|
365
|
-
/** A string SID: revision 1, an identifier authority and up to 15 sub-authorities of 32 bits each. */
|
|
366
|
-
const SID_TEXT = /^S-1-\d{1,15}(-\d{1,10}){0,15}$/i;
|
|
367
|
-
/** A readable SID in upper case (SIDs compare case-insensitively on their S), or undefined. */
|
|
368
|
-
const sidOf = (value) => (typeof value === 'string' && SID_TEXT.test(value) && value.split('-').slice(3).every((part) => Number(part) <= 0xffffffff) ? value.toUpperCase() : undefined);
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057, FAC-SEM-079) on what a reader has read: `file` is
|
|
372
|
-
* `{isRegularFile, isReparsePoint, realPathInProfile, ownerSid, aces: [{type, sid, mask}]}`, the mask the ACE's unsigned
|
|
373
|
-
* 32-bit access mask. The first refusal `{problem, at, sid?, message}`, or null when the file may be read: a regular
|
|
374
|
-
* file, not a reparse point, inside the profile; owned by the user, SYSTEM or Administrators; every allow ACE with a
|
|
375
|
-
* non-zero mask naming one of those three; deny ACEs ignored; any other ACE type refused; anything unreadable refused.
|
|
376
|
-
* The contract's `windowsTokenAclProblem`; a refusal names the SID, never the contents.
|
|
377
|
-
*/
|
|
378
|
-
export function windowsTokenAclProblem(file, currentUserSid) {
|
|
379
|
-
const refuse = (problem, at, message, sid) => (sid === undefined ? { problem, at, message } : { problem, at, sid, message });
|
|
380
|
-
const user = sidOf(currentUserSid);
|
|
381
|
-
if (user === undefined) return refuse('unreadable', '/currentUserSid', "the current user's SID is unreadable");
|
|
382
|
-
if (typeof file !== 'object' || file === null || Array.isArray(file)) return refuse('unreadable', '/file', "the token file's metadata is unreadable");
|
|
383
|
-
for (const flag of ['isRegularFile', 'isReparsePoint', 'realPathInProfile']) {
|
|
384
|
-
if (typeof file[flag] !== 'boolean') return refuse('unreadable', `/file/${flag}`, `${flag} is unreadable`);
|
|
385
|
-
}
|
|
386
|
-
if (file.isReparsePoint) return refuse('reparse-point', '/file/isReparsePoint', 'the token file is a reparse point (a symlink or a junction)');
|
|
387
|
-
if (!file.isRegularFile) return refuse('not-regular-file', '/file/isRegularFile', 'the token file is not a regular file');
|
|
388
|
-
if (!file.realPathInProfile) return refuse('outside-profile', '/file/realPathInProfile', "the token file's real path lies outside the user's profile");
|
|
389
|
-
const trusted = new Set([user, SYSTEM_SID, ADMINISTRATORS_SID]);
|
|
390
|
-
const owner = sidOf(file.ownerSid);
|
|
391
|
-
if (owner === undefined) return refuse('unreadable', '/file/ownerSid', 'the owner SID is unreadable');
|
|
392
|
-
if (!trusted.has(owner)) return refuse('owner', '/file/ownerSid', `the token file is owned by ${owner}, not by the current user, SYSTEM or Administrators`, owner);
|
|
393
|
-
if (!Array.isArray(file.aces)) return refuse('unreadable', '/file/aces', 'the ACL is unreadable');
|
|
394
|
-
for (const [index, ace] of file.aces.entries()) {
|
|
395
|
-
const at = `/file/aces/${index}`;
|
|
396
|
-
if (typeof ace !== 'object' || ace === null || Array.isArray(ace)) return refuse('unreadable', at, `ACE ${index} is unreadable`);
|
|
397
|
-
if (typeof ace.type !== 'string') return refuse('unreadable', `${at}/type`, `ACE ${index} has an unreadable type`);
|
|
398
|
-
const trustee = sidOf(ace.sid);
|
|
399
|
-
if (trustee === undefined) return refuse('unreadable', `${at}/sid`, `ACE ${index} names an unreadable SID`);
|
|
400
|
-
if (typeof ace.mask !== 'number' || !Number.isInteger(ace.mask) || ace.mask < 0 || ace.mask > 0xffffffff) return refuse('unreadable', `${at}/mask`, `ACE ${index} has an unreadable access mask`);
|
|
401
|
-
if (ace.type === 'deny') continue;
|
|
402
|
-
if (ace.type !== 'allow') return refuse('ace-type', `${at}/type`, `ACE ${index} for ${trustee} is of a type the rule does not read`, trustee);
|
|
403
|
-
if (ace.mask === 0) continue;
|
|
404
|
-
if (!trusted.has(trustee)) return refuse('ace-sid', `${at}/sid`, `the token file grants access to ${trustee}; only the current user, SYSTEM and Administrators may hold any`, trustee);
|
|
405
|
-
}
|
|
406
|
-
return null;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** windowsTokenAclProblem as one sentence naming the file and the refusal, or null. */
|
|
410
|
-
export function tokenFileProblem(file, metadata, user) {
|
|
411
|
-
const problem = windowsTokenAclProblem(metadata, user);
|
|
412
|
-
return problem ? `Token file ${file} refused: ${problem.message}.` : null;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
// The raw descriptor, through .NET: every ACE's type, its trustee's SID and its access mask as read (a signed int;
|
|
416
|
-
// parseAclReport converts it to the unsigned mask). An inherit-only ACE applies to children only and is left out.
|
|
417
|
-
const ACL_SCRIPT = [
|
|
418
|
-
"$ErrorActionPreference = 'Stop'",
|
|
419
|
-
'$acl = [System.IO.File]::GetAccessControl($env:FABRIC_ACL_PATH)',
|
|
420
|
-
"$raw = New-Object System.Security.AccessControl.RawSecurityDescriptor($acl.GetSecurityDescriptorSddlForm('Owner, Access'))",
|
|
421
|
-
'$dacl = $raw.DiscretionaryAcl',
|
|
422
|
-
"$aces = @(if ($dacl) { $dacl | Where-Object { -not ([int]$_.AceFlags -band 8) } | ForEach-Object { @{ type = [string]$_.AceType; sid = $(if ($_.SecurityIdentifier) { $_.SecurityIdentifier.Value } else { '' }); mask = [int64]$_.AccessMask } } })",
|
|
423
|
-
'$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value',
|
|
424
|
-
'[Console]::Out.Write((ConvertTo-Json -Compress -Depth 4 @{ owner = $raw.Owner.Value; aces = $aces; nullDacl = [bool](-not $dacl); user = $user }))',
|
|
425
|
-
].join('; ');
|
|
426
|
-
|
|
427
|
-
function windowsHelper(command, args, extraEnv = {}) {
|
|
428
|
-
// No PSModulePath: the script uses .NET only, and a module path inherited from PowerShell 7 makes
|
|
429
|
-
// Windows PowerShell load the wrong modules.
|
|
430
|
-
const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k.toLowerCase() !== 'psmodulepath'));
|
|
431
|
-
const result = spawnSync(command, args, { env: { ...env, ...extraEnv }, encoding: 'utf8', windowsHide: true, timeout: 30_000 });
|
|
432
|
-
if (result.error || result.status !== 0) {
|
|
433
|
-
throw new ServiceError(`${command} failed: ${(result.error?.message || result.stderr || result.stdout || '').trim().slice(0, 200)}`);
|
|
434
|
-
}
|
|
435
|
-
return result.stdout;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** .NET's AceType names as the rule's ACE type: allow and deny are read, every other type is refused (DEC-0057). */
|
|
439
|
-
const ACE_TYPES = { AccessAllowed: 'allow', AccessDenied: 'deny', AccessAllowedObject: 'allow-object', AccessDeniedObject: 'deny-object',
|
|
440
|
-
AccessAllowedCallback: 'allow-callback', AccessDeniedCallback: 'deny-callback', AccessAllowedCallbackObject: 'allow-object-callback',
|
|
441
|
-
AccessDeniedCallbackObject: 'deny-object-callback' };
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* The ACL report PowerShell prints, as (owner SID, ACEs `{type, sid, mask}`, current user). The mask arrives signed
|
|
445
|
-
* (GENERIC_READ 0x80000000 is negative in .NET) and is read as the unsigned 32-bit mask; a NULL DACL grants everyone
|
|
446
|
-
* everything and is read as an allow ACE for Everyone (S-1-1-0).
|
|
447
|
-
*/
|
|
448
|
-
export function parseAclReport(text) {
|
|
449
|
-
const report = JSON.parse(text);
|
|
450
|
-
const aces = report.nullDacl ? [{ type: 'allow', sid: 'S-1-1-0', mask: 0x1f01ff }]
|
|
451
|
-
: [].concat(report.aces ?? []).map((ace) => ({ type: ACE_TYPES[ace.type] ?? String(ace.type), sid: ace.sid, mask: Number.isInteger(ace.mask) ? ace.mask >>> 0 : ace.mask }));
|
|
452
|
-
return { owner: report.owner ?? '', aces, user: report.user };
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** (owner SID, the ACEs on the file itself, the current user's SID). */
|
|
456
|
-
export function windowsAcl(file) {
|
|
457
|
-
return parseAclReport(windowsHelper('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ACL_SCRIPT], { FABRIC_ACL_PATH: file }));
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
let userSid = null;
|
|
461
|
-
export function windowsUserSid() {
|
|
462
|
-
if (!userSid) {
|
|
463
|
-
const line = windowsHelper('whoami.exe', ['/user', '/fo', 'csv', '/nh']).trim();
|
|
464
|
-
userSid = (line.match(/"(S-1-[0-9-]+)"\s*$/) ?? [])[1];
|
|
465
|
-
if (!userSid) throw new ServiceError('whoami /user printed no SID.');
|
|
466
|
-
}
|
|
467
|
-
return userSid;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* A protected list (inheritance off): this user, SYSTEM, Administrators, full control; a folder hands it down.
|
|
472
|
-
* `/reset` first drops any explicit entry already there (`/inheritance:r` keeps those), so the three are all that remain.
|
|
473
|
-
*/
|
|
474
|
-
export function windowsProtect(target, { folder }) {
|
|
475
|
-
const grant = folder ? '(OI)(CI)F' : 'F';
|
|
476
|
-
windowsHelper('icacls.exe', [target, '/reset']);
|
|
477
|
-
windowsHelper('icacls.exe', [target, '/inheritance:r', '/grant:r', ...[windowsUserSid(), ...WINDOWS_TRUSTED].map((sid) => `*${sid}:${grant}`)]);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function windowsTokenProblem(file, info) {
|
|
481
|
-
const profile = fs.realpathSync.native(process.env.USERPROFILE || os.homedir()).toLowerCase();
|
|
482
|
-
const real = fs.realpathSync.native(file).toLowerCase();
|
|
483
|
-
const inside = path.win32.relative(profile, real);
|
|
484
|
-
const { owner, aces, user } = windowsAcl(file);
|
|
485
|
-
return tokenFileProblem(file, {
|
|
486
|
-
isRegularFile: info.isFile(), isReparsePoint: info.isSymbolicLink(),
|
|
487
|
-
realPathInProfile: Boolean(inside) && !inside.startsWith('..') && !path.win32.isAbsolute(inside), ownerSid: owner, aces,
|
|
488
|
-
}, user);
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
export function tokenMatches(presented, token, scheme = 'Bearer') {
|
|
492
|
-
if (!presented) return false;
|
|
493
|
-
let value = presented;
|
|
494
|
-
if (scheme === 'Bearer') {
|
|
495
|
-
if (!presented.startsWith('Bearer ')) return false;
|
|
496
|
-
value = presented.slice(7);
|
|
497
|
-
}
|
|
498
|
-
const a = Buffer.from(value.trim());
|
|
499
|
-
const b = Buffer.from(token);
|
|
500
|
-
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
export function checkRequest(port, host, origin, secFetchSite) {
|
|
504
|
-
const hosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
505
|
-
if (!hosts.has(host)) return `Host ${host} is not this service.`;
|
|
506
|
-
if (origin !== undefined && origin !== null && ![...hosts].some((h) => origin === `http://${h}`)) return `Origin ${origin} is not this service.`;
|
|
507
|
-
if (secFetchSite === 'cross-site') return 'Cross-site requests are refused.';
|
|
508
|
-
return null;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// --- descriptor (installer only) -----------------------------------------------------
|
|
512
|
-
|
|
513
|
-
export const descriptorPath = (id, instance = 'default', dir = servicesDir()) => path.join(dir, `${id}.${instance}.json`);
|
|
514
|
-
|
|
515
|
-
export function readDescriptors(dir = servicesDir()) {
|
|
516
|
-
if (!fs.existsSync(dir)) return [];
|
|
517
|
-
return fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort().flatMap((n) => {
|
|
518
|
-
try {
|
|
519
|
-
return [[path.join(dir, n), JSON.parse(fs.readFileSync(path.join(dir, n), 'utf8'))]];
|
|
520
|
-
} catch {
|
|
521
|
-
return [];
|
|
522
|
-
}
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
export function validateDescriptor(d) {
|
|
527
|
-
const problems = [];
|
|
528
|
-
const remote = placementOf(d) === 'remote';
|
|
529
|
-
if (d.placement !== undefined && !PLACEMENTS.includes(d.placement)) problems.push('placement must be local or remote');
|
|
530
|
-
// DEC-0069: an app's descriptor says it is installed, never that it runs; its live origin is in its own liveness file.
|
|
531
|
-
const app = d.lifecycle?.manager === 'person';
|
|
532
|
-
const required = ['protocol', 'id', 'instance', 'name', ...(app ? [] : ['origin']), 'auth', 'lifecycle', 'installedAt', 'installedBy'];
|
|
533
|
-
for (const key of remote ? required : [...required, 'paths']) {
|
|
534
|
-
if (!(key in d)) problems.push(`missing ${key}`);
|
|
535
|
-
}
|
|
536
|
-
if (problems.length) return problems;
|
|
537
|
-
if (d.protocol !== PROTOCOL) problems.push(`protocol must be ${PROTOCOL}`);
|
|
538
|
-
if (!ID.test(d.id)) problems.push(`id must match ${ID}`);
|
|
539
|
-
if (!INSTANCE.test(d.instance)) problems.push(`instance must match ${INSTANCE}`);
|
|
540
|
-
if (remote) problems.push(...remoteOriginProblems(d.origin));
|
|
541
|
-
else if (d.origin !== undefined && !ORIGIN.test(d.origin)) problems.push('origin must be http://127.0.0.1:<port>');
|
|
542
|
-
if (!d.auth?.tokenFile) problems.push('auth.tokenFile is required');
|
|
543
|
-
else if (!LOCAL_PATH.test(String(d.auth.tokenFile))) problems.push('auth.tokenFile must be a local path, never a network share');
|
|
544
|
-
for (const value of [...(d.paths?.data !== undefined ? [d.paths.data] : []), ...(d.paths?.logs ?? [])]) {
|
|
545
|
-
if (!LOCAL_PATH.test(String(value))) problems.push(`paths must be local paths, never a network share: ${JSON.stringify(value)}`);
|
|
546
|
-
}
|
|
547
|
-
if (!(typeof (d.auth?.header ?? 'Authorization') === 'string' && AUTH_HEADER.test(d.auth?.header ?? 'Authorization'))) problems.push(`auth.header must match ${AUTH_HEADER.source}`);
|
|
548
|
-
if (!['Bearer', 'none'].includes(d.auth?.scheme ?? 'Bearer')) problems.push('auth.scheme is Bearer or none');
|
|
549
|
-
if ((d.auth?.header ?? 'Authorization') !== 'Authorization' && (d.auth?.scheme ?? 'Bearer') !== 'none') {
|
|
550
|
-
problems.push('a custom auth header carries the raw token: scheme must be none');
|
|
551
|
-
}
|
|
552
|
-
const life = d.lifecycle ?? {};
|
|
553
|
-
const manager = life.manager;
|
|
554
|
-
if (!MANAGERS.includes(manager)) problems.push('lifecycle.manager must be launchd, systemd, task-scheduler, node, person or none');
|
|
555
|
-
// DEC-0069: `app` is the installed app of a person's descriptor, and nothing else's.
|
|
556
|
-
if (manager === 'person' && life.app === undefined) problems.push("a person's app declares lifecycle.app, the installed app");
|
|
557
|
-
if (life.app !== undefined && manager !== 'person') problems.push('lifecycle.app belongs to manager person only');
|
|
558
|
-
if (life.app !== undefined && !LOCAL_PATH.test(String(life.app))) problems.push('lifecycle.app must be a local path');
|
|
559
|
-
if (manager === 'launchd' && !(life.label && String(life.plist ?? '').endsWith('.plist'))) {
|
|
560
|
-
problems.push('a launchd service declares label and plist');
|
|
561
|
-
}
|
|
562
|
-
if (life.plist !== undefined && !LOCAL_PATH.test(String(life.plist))) problems.push('lifecycle.plist must be a local path');
|
|
563
|
-
// DEC-0057, DEC-0061, DEC-0069: a descriptor names one supervisor; launchd's label and plist never sit beside another.
|
|
564
|
-
for (const field of ['label', 'plist']) {
|
|
565
|
-
if (life[field] !== undefined && NOT_LAUNCHD.includes(manager)) problems.push(`lifecycle.${field} belongs to launchd, not to ${manager}`);
|
|
566
|
-
}
|
|
567
|
-
// DEC-0032: `unit` goes with systemd and `task` with Task Scheduler, and with nothing else.
|
|
568
|
-
for (const [field, owner, pattern] of [['unit', 'systemd', UNIT], ['task', 'task-scheduler', TASK]]) {
|
|
569
|
-
if (manager === owner && life[field] === undefined) problems.push(`a ${owner} service declares ${field}`);
|
|
570
|
-
if (life[field] !== undefined && manager !== owner) problems.push(`lifecycle.${field} belongs to manager ${owner} only`);
|
|
571
|
-
if (life[field] !== undefined && !pattern.test(String(life[field]))) problems.push(`lifecycle.${field} must match ${pattern.source}`);
|
|
572
|
-
}
|
|
573
|
-
if (remote) {
|
|
574
|
-
if (manager !== 'none') problems.push('a remote service is supervised by its platform: lifecycle.manager must be none');
|
|
575
|
-
for (const [field, of] of [['label', 'launchd'], ['plist', 'launchd'], ['unit', 'systemd'], ['task', 'Task Scheduler']]) {
|
|
576
|
-
if (life[field] !== undefined) problems.push(`a remote service has no ${of} ${field}`);
|
|
577
|
-
}
|
|
578
|
-
if (d.commands?.update !== undefined) problems.push('a remote service declares no update command');
|
|
579
|
-
}
|
|
580
|
-
problems.push(...descriptorMemberProblems(d));
|
|
581
|
-
for (const [name, argv] of Object.entries(d.commands ?? {})) {
|
|
582
|
-
if (!['doctor', 'update'].includes(name)) problems.push(`unknown command ${name}`);
|
|
583
|
-
else if (!Array.isArray(argv) || !argv.length || !argv.every((a) => typeof a === 'string')) problems.push(`command ${name} must be an argument array`);
|
|
584
|
-
else if (!LOCAL_PATH.test(argv[0])) problems.push(`command ${name} must start with an absolute or ~/ executable`);
|
|
585
|
-
}
|
|
586
|
-
return problems;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** DEC-0019: `local` unless the descriptor says `remote`. */
|
|
590
|
-
// --- DEC-0061 and DEC-0069: who installed it, how its model calls sign in, offered to others, an app's own descriptor ---
|
|
591
|
-
|
|
592
|
-
/** The organization edition's data classes, least sensitive first (DEC-0061, DEC-0053). */
|
|
593
|
-
export const PEER_DATA_CLASSES = Object.freeze(['public', 'internal', 'confidential', 'restricted']);
|
|
594
|
-
/** The kind of a runner's sign-in, never a credential's value (DEC-0061). */
|
|
595
|
-
export const CREDENTIAL_KINDS = Object.freeze(['subscription', 'key', 'platform-metered', 'none']);
|
|
596
|
-
/** A subscription runs only in its vendor's runner (DEC-0046): runner kind → login. */
|
|
597
|
-
export const SUBSCRIPTION_RUNNERS = Object.freeze({ 'claude-code': 'claude', 'kimi-code': 'kimi', codex: 'chatgpt' });
|
|
598
|
-
const PEER_ENDPOINT = /^https:\/\/((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63})(:([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?(\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*)?$/;
|
|
599
|
-
const RESIDENCY = /^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$/;
|
|
600
|
-
const RUNNER_KIND_NAME = /^[a-z][a-z0-9-]{1,62}$/;
|
|
601
|
-
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
602
|
-
const text = (v, low = 1, high = 200) => typeof v === 'string' && v.length >= low && v.length <= high;
|
|
603
|
-
/** A JSON Pointer token (RFC 6901 §4). */
|
|
604
|
-
const pointer = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* DEC-0061 and DEC-0069 on a descriptor's `installedBy`, `runner` and `peer`, as the schema shapes them, with FAC-SEM-085
|
|
608
|
-
* (a peer on a reserved name) and FAC-SEM-086 (a credential-shaped value in `runner` or `installedBy`, named by its
|
|
609
|
-
* place, never its value).
|
|
610
|
-
*/
|
|
611
|
-
export function descriptorMemberProblems(d) {
|
|
612
|
-
const problems = [];
|
|
613
|
-
const life = isObj(d.lifecycle) ? d.lifecycle : {};
|
|
614
|
-
const by = d.installedBy;
|
|
615
|
-
const places = [];
|
|
616
|
-
if (typeof by === 'string') {
|
|
617
|
-
if (!text(by)) problems.push('installedBy is the installer and its version, 1 to 200 characters');
|
|
618
|
-
places.push(['/installedBy', by]);
|
|
619
|
-
} else if (isObj(by)) {
|
|
620
|
-
for (const k of Object.keys(by)) if (!['by', 'role', 'tool'].includes(k)) problems.push(`installedBy has no member ${k}`);
|
|
621
|
-
if (!['person', 'node', 'organization'].includes(by.by)) problems.push('installedBy.by is person, node or organization');
|
|
622
|
-
if (by.by === 'person' && !['operator', 'administrator'].includes(by.role)) problems.push('installedBy.role is operator or administrator for a person');
|
|
623
|
-
if (by.by !== 'person' && 'role' in by) problems.push('installedBy.role belongs to a person only');
|
|
624
|
-
if (!text(by.tool)) problems.push('installedBy.tool is the installer and its version, 1 to 200 characters');
|
|
625
|
-
for (const [k, v] of Object.entries(by)) places.push([`/installedBy/${pointer(k)}`, v]);
|
|
626
|
-
} else problems.push("installedBy is the installer's name or {by, role?, tool}");
|
|
627
|
-
if (life.manager === 'person' && !(isObj(by) && by.by === 'person')) problems.push("an app's descriptor names who installed it as a person: installedBy {by: person, role, tool} (DEC-0069)");
|
|
628
|
-
let runner = d.runner;
|
|
629
|
-
if ('runner' in d) {
|
|
630
|
-
if (!isObj(runner)) { problems.push('runner is {kind?, credentialKind}'); runner = {}; }
|
|
631
|
-
for (const k of Object.keys(runner)) if (!['kind', 'credentialKind'].includes(k)) problems.push(`runner has no member ${k}`);
|
|
632
|
-
if (!CREDENTIAL_KINDS.includes(runner.credentialKind)) problems.push('runner.credentialKind is subscription, key, platform-metered or none');
|
|
633
|
-
if ('kind' in runner && !(typeof runner.kind === 'string' && RUNNER_KIND_NAME.test(runner.kind))) problems.push("runner.kind is a runner kind's name");
|
|
634
|
-
if (runner.credentialKind === 'subscription' && !Object.hasOwn(SUBSCRIPTION_RUNNERS, runner.kind)) problems.push("a subscription runs only in its vendor's runner: runner.kind claude-code, kimi-code or codex");
|
|
635
|
-
for (const [k, v] of Object.entries(runner)) places.push([`/runner/${pointer(k)}`, v]);
|
|
636
|
-
}
|
|
637
|
-
if ('peer' in d) {
|
|
638
|
-
let peer = d.peer;
|
|
639
|
-
if (!isObj(peer)) { problems.push('peer is {endpoint, custody, residency, maxDataClass}'); peer = {}; }
|
|
640
|
-
for (const k of Object.keys(peer)) if (!['endpoint', 'custody', 'residency', 'maxDataClass'].includes(k)) problems.push(`peer has no member ${k}`);
|
|
641
|
-
const m = typeof peer.endpoint === 'string' && peer.endpoint.length <= 2048 ? PEER_ENDPOINT.exec(peer.endpoint) : null;
|
|
642
|
-
if (!m) problems.push('peer.endpoint is https://<dns-name>[:<port>][/<path>], no query, fragment, userinfo or IP literal');
|
|
643
|
-
else if (RESERVED_HOST.test(m[1])) problems.push(`FAC-SEM-085: a service offered to others cannot be reached at the reserved name ${m[1]}`);
|
|
644
|
-
if (!['organization-managed', 'personal'].includes(peer.custody)) problems.push('peer.custody is organization-managed or personal');
|
|
645
|
-
const r = peer.residency;
|
|
646
|
-
if (!Array.isArray(r) || r.length < 1 || r.length > 32 || new Set(r.map((x) => JSON.stringify(x))).size !== r.length || !r.every((x) => typeof x === 'string' && RESIDENCY.test(x))) {
|
|
647
|
-
problems.push('peer.residency is 1 to 32 distinct ISO 3166-1 alpha-2 codes in upper case, EU admitted, no user-assigned code');
|
|
648
|
-
}
|
|
649
|
-
if (!PEER_DATA_CLASSES.includes(peer.maxDataClass)) problems.push('peer.maxDataClass is public, internal, confidential or restricted');
|
|
650
|
-
if (!('runner' in d)) problems.push('a service offered to others declares runner (DEC-0061)');
|
|
651
|
-
else if (isObj(runner) && runner.credentialKind === 'subscription') problems.push('a service offered to others never runs on a subscription (DEC-0061)');
|
|
652
|
-
}
|
|
653
|
-
for (const [place, value] of places) {
|
|
654
|
-
if (typeof value === 'string' && secretArgument(value)) problems.push(`FAC-SEM-086: ${place} holds a value shaped like a credential; it names a kind, a role or an installer`);
|
|
655
|
-
}
|
|
656
|
-
return problems;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
/**
|
|
660
|
-
* The host's check before it routes a request from anyone but the service's operator (DEC-0061, FAC-SEM-088):
|
|
661
|
-
* `not-offered`, `peer-invalid`, `data-class-unknown` or `above-max-data-class`; null when it may. The contract's `peerRefusal`.
|
|
662
|
-
*/
|
|
663
|
-
export function peerRefusal(descriptor, dataClass) {
|
|
664
|
-
if (!isObj(descriptor) || !isObj(descriptor.peer)) return 'not-offered';
|
|
665
|
-
const max = typeof descriptor.peer.maxDataClass === 'string' ? PEER_DATA_CLASSES.indexOf(descriptor.peer.maxDataClass) : -1;
|
|
666
|
-
if (max < 0) return 'peer-invalid';
|
|
667
|
-
const asked = typeof dataClass === 'string' ? PEER_DATA_CLASSES.indexOf(dataClass) : -1;
|
|
668
|
-
if (asked < 0) return 'data-class-unknown';
|
|
669
|
-
return asked > max ? 'above-max-data-class' : null;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* What an app that writes its own descriptor does with it on a start (DEC-0069): `keep` from a translocated copy (macOS
|
|
674
|
-
* App Translocation); otherwise `write` when there is none, when it is not an app's descriptor, or when its version
|
|
675
|
-
* (`installedBy.tool`) or its path (`lifecycle.app`) differs; `keep` otherwise. The contract's `appDescriptorAction`.
|
|
676
|
-
*/
|
|
677
|
-
export function appDescriptorAction(existing, tool, appPath) {
|
|
678
|
-
if (appPath.includes('/AppTranslocation/')) return 'keep';
|
|
679
|
-
if (!isObj(existing) || !isObj(existing.installedBy)) return 'write';
|
|
680
|
-
const recorded = isObj(existing.lifecycle) ? existing.lifecycle.app : undefined;
|
|
681
|
-
return existing.installedBy.tool === tool && recorded === appPath ? 'keep' : 'write';
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/** Whether a reader treats a descriptor as stale (DEC-0069): an app's descriptor whose installed app it did not find. */
|
|
685
|
-
export function appDescriptorStale(descriptor, appPresent) {
|
|
686
|
-
if (!isObj(descriptor) || !isObj(descriptor.lifecycle) || descriptor.lifecycle.manager !== 'person') return false;
|
|
687
|
-
return !appPresent;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/** Whether `lifecycle.app` of an app's descriptor exists on this machine (a bundle is a folder, an executable a file). */
|
|
691
|
-
export function appPresent(descriptor) {
|
|
692
|
-
const app = isObj(descriptor?.lifecycle) ? descriptor.lifecycle.app : undefined;
|
|
693
|
-
try { return typeof app === 'string' && fs.existsSync(expand(app)); } catch { return false; }
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
/** Fabric's own descriptor (Fabric ADR-0139): `services/fabric.default.json`. */
|
|
697
|
-
export const FABRIC_SERVICE = Object.freeze(['fabric', 'default']);
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* `present` or `absent` for Fabric's button (DEC-0040 FB-5, DEC-0069, Fabric SCN-142): present when Fabric's own
|
|
701
|
-
* descriptor is registered and, for an app's descriptor, its app is still installed; a server sets `data-fabric="absent"`
|
|
702
|
-
* on the button's script otherwise. The button only follows a link, so the liveness file is not read here.
|
|
703
|
-
*/
|
|
704
|
-
export function fabricButtonPresence(dir = servicesDir()) {
|
|
705
|
-
let found;
|
|
706
|
-
try { found = JSON.parse(fs.readFileSync(descriptorPath(...FABRIC_SERVICE, dir), 'utf8')); } catch { return 'absent'; }
|
|
707
|
-
if (!isObj(found) || validateDescriptor(found).length) return 'absent';
|
|
708
|
-
return appDescriptorStale(found, appPresent(found)) ? 'absent' : 'present';
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
export function placementOf(d) {
|
|
712
|
-
return d?.placement === 'remote' ? 'remote' : 'local';
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
/** Problems with a remote origin: https, a public DNS name, an optional port, nothing else. */
|
|
716
|
-
export function remoteOriginProblems(origin) {
|
|
717
|
-
const m = REMOTE_ORIGIN.exec(String(origin ?? ''));
|
|
718
|
-
if (!m) return ['a remote origin must be https://<dns-name>[:<port>] with no path, query or IP literal'];
|
|
719
|
-
if (RESERVED_HOST.test(m[1])) return [`a remote service cannot live on the reserved name ${m[1]}`];
|
|
720
|
-
if (m[2] !== undefined && (Number(m[2]) < 1 || Number(m[2]) > 65535)) return ['the origin port is out of range'];
|
|
721
|
-
return [];
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
export function portOf(origin) {
|
|
725
|
-
const m = ORIGIN.exec(origin);
|
|
726
|
-
if (!m) throw new ServiceError(`Origin ${origin} is not http://127.0.0.1:<port>.`);
|
|
727
|
-
return Number(m[1]);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
export function writeDescriptor(d, dir = servicesDir()) {
|
|
731
|
-
const problems = validateDescriptor(d);
|
|
732
|
-
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
733
|
-
const me = `${d.id}.${d.instance}`;
|
|
734
|
-
// DEC-0019: only a local placement claims a port on this computer; DEC-0069: an app's descriptor names none.
|
|
735
|
-
const port = placementOf(d) === 'remote' || d.origin === undefined ? null : portOf(d.origin);
|
|
736
|
-
for (const [file, other] of readDescriptors(dir)) {
|
|
737
|
-
const key = `${other.id}.${other.instance ?? 'default'}`;
|
|
738
|
-
if (key === me) continue;
|
|
739
|
-
if (port === null) continue; // a remote origin's port is another computer's
|
|
740
|
-
let otherPort = null;
|
|
741
|
-
try { otherPort = portOf(String(other.origin ?? '')); } catch { continue; }
|
|
742
|
-
if (otherPort === port) throw new ServiceError(`Port ${port} is already claimed by ${key} (${file}).`);
|
|
743
|
-
}
|
|
744
|
-
ensurePrivateDir(dir);
|
|
745
|
-
const target = descriptorPath(d.id, d.instance, dir);
|
|
746
|
-
atomicWrite(target, `${JSON.stringify(d, null, 2)}\n`, 0o600);
|
|
747
|
-
return target;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
export function removeDescriptor(id, instance = 'default', dir = servicesDir()) {
|
|
751
|
-
try { fs.unlinkSync(descriptorPath(id, instance, dir)); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
// --- well-known and events ------------------------------------------------------------
|
|
755
|
-
|
|
756
|
-
const THEME_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
757
|
-
const KIT_REF = /^[a-z][a-z0-9-]{0,63}@(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
|
|
758
|
-
|
|
759
|
-
// What makes value not a valid ED-9 declaration (service-dashboard-extension.schema.json).
|
|
760
|
-
export function dashboardExtensionProblems(value) {
|
|
761
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) return ['the declaration is not an object'];
|
|
762
|
-
const out = [];
|
|
763
|
-
const extra = Object.keys(value).filter((k) => !['minWidth', 'appearance', 'themes', 'kit'].includes(k)).sort();
|
|
764
|
-
if (extra.length) out.push(`unknown field(s): ${extra.join(', ')}`);
|
|
765
|
-
const w = value.minWidth;
|
|
766
|
-
if (!Number.isInteger(w)) out.push('minWidth is required and must be a whole number of CSS pixels');
|
|
767
|
-
else if (w < 360 || w > 1280) out.push(`minWidth ${w} is outside 360…1280`);
|
|
768
|
-
if ('appearance' in value) {
|
|
769
|
-
const a = value.appearance;
|
|
770
|
-
if (!Array.isArray(a) || a.length > 2 || new Set(a).size !== a.length || a.some((x) => x !== 'scheme' && x !== 'theme')) out.push('appearance is a list of distinct fields from scheme, theme');
|
|
771
|
-
}
|
|
772
|
-
if ('themes' in value) {
|
|
773
|
-
const t = value.themes;
|
|
774
|
-
if (!Array.isArray(t) || t.length < 1 || t.length > 32 || new Set(t).size !== t.length || t.some((x) => typeof x !== 'string' || !THEME_ID.test(x))) out.push('themes is 1…32 distinct theme ids matching ^[a-z][a-z0-9-]{0,31}$');
|
|
775
|
-
}
|
|
776
|
-
if ('kit' in value && !(typeof value.kit === 'string' && KIT_REF.test(value.kit))) out.push(`kit is name@semver, for example ${DASHBOARD_KIT}`);
|
|
777
|
-
return out;
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
// The `extensions` entry a dashboard built from the kit declares (DEC-0036 ED-9). Declare it only
|
|
781
|
-
// when the page meets ED-1…ED-6 — check_dashboard_layout.mjs measures that.
|
|
782
|
-
export function dashboardExtension({ minWidth = 360, appearance = ['scheme', 'theme'], themes = REFERENCE_THEMES, kit = DASHBOARD_KIT } = {}) {
|
|
783
|
-
const value = { minWidth };
|
|
784
|
-
if (appearance?.length) value.appearance = [...appearance];
|
|
785
|
-
if (themes?.length) value.themes = [...themes];
|
|
786
|
-
if (kit) value.kit = kit;
|
|
787
|
-
const problems = dashboardExtensionProblems(value);
|
|
788
|
-
if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
|
|
789
|
-
return { [DASHBOARD_EXTENSION_KEY]: value };
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
// `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration in
|
|
793
|
-
// it must be valid (dashboardExtension builds one).
|
|
794
|
-
const PROFILE_ID = /^[a-z][a-z0-9-]{0,62}$/;
|
|
795
|
-
const RUNNER_KIND = /^[a-z][a-z0-9-]{1,62}$/;
|
|
796
|
-
const MODEL_ID = /^[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._:-]{0,127}$/;
|
|
797
|
-
export const ROUTES = Object.freeze(['subscription', 'key']);
|
|
798
|
-
/** A usage row's route (DEC-0046, DEC-0061): beside a configuration's two, the hosting platform's own metered models. */
|
|
799
|
-
export const USAGE_ROUTES = Object.freeze([...ROUTES, 'platform-metered']);
|
|
800
|
-
|
|
801
|
-
/** DEC-0046: the well-known `configuration` — {slots, resolved} as service-well-known.schema.json types it. */
|
|
802
|
-
export function configurationProblems(doc) {
|
|
803
|
-
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return ['configuration is an object'];
|
|
804
|
-
const problems = Object.keys(doc).filter((k) => k !== 'slots' && k !== 'resolved').map((k) => `configuration has no member ${k}`);
|
|
805
|
-
const { slots, resolved } = doc;
|
|
806
|
-
if (!Array.isArray(slots) || slots.length > 2 || new Set(slots).size !== slots.length || !slots.every((s) => s === 'runner' || s === 'model')) problems.push('slots is a list of runner and model, each once');
|
|
807
|
-
if (resolved === null || typeof resolved !== 'object' || Array.isArray(resolved)) return [...problems, 'resolved is an object'];
|
|
808
|
-
const checks = {
|
|
809
|
-
from: (x) => ['request', 'agent', 'binding', 'estate'].includes(x),
|
|
810
|
-
runnerProfile: (x) => typeof x === 'string' && PROFILE_ID.test(x),
|
|
811
|
-
modelProfile: (x) => typeof x === 'string' && PROFILE_ID.test(x),
|
|
812
|
-
runnerKind: (x) => typeof x === 'string' && RUNNER_KIND.test(x),
|
|
813
|
-
route: (x) => ROUTES.includes(x),
|
|
814
|
-
models: (x) => Array.isArray(x) && x.length <= 16 && x.every((m) => typeof m === 'string' && MODEL_ID.test(m)),
|
|
815
|
-
};
|
|
816
|
-
for (const [k, v] of Object.entries(resolved)) {
|
|
817
|
-
if (!Object.hasOwn(checks, k)) problems.push(`resolved has no member ${k}`);
|
|
818
|
-
else if (!checks[k](v)) problems.push(`resolved.${k} is not valid`);
|
|
819
|
-
}
|
|
820
|
-
return problems;
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols }) {
|
|
824
|
-
if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
|
|
825
|
-
if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
|
|
826
|
-
if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
|
|
827
|
-
const doc = {
|
|
828
|
-
protocol: PROTOCOL,
|
|
829
|
-
service: { id, instance, name, version, build },
|
|
830
|
-
process: { pid, startedAt },
|
|
831
|
-
status: status === 'ready' && degraded.length ? 'degraded' : status,
|
|
832
|
-
degraded: [...degraded],
|
|
833
|
-
surfaces,
|
|
834
|
-
update: { available: updateAvailable },
|
|
835
|
-
};
|
|
836
|
-
if (summary?.length) doc.summary = summary.slice(0, 6);
|
|
837
|
-
if (extensions && Object.keys(extensions).length) {
|
|
838
|
-
if (DASHBOARD_EXTENSION_KEY in extensions) {
|
|
839
|
-
const problems = dashboardExtensionProblems(extensions[DASHBOARD_EXTENSION_KEY]);
|
|
840
|
-
if (problems.length) throw new ServiceError(`Dashboard declaration: ${problems.join('; ')}.`);
|
|
841
|
-
}
|
|
842
|
-
doc.extensions = { ...extensions };
|
|
843
|
-
}
|
|
844
|
-
if (configuration !== undefined) {
|
|
845
|
-
const problems = configurationProblems(configuration);
|
|
846
|
-
if (problems.length) throw new ServiceError(`configuration: ${problems.join('; ')}.`);
|
|
847
|
-
doc.configuration = configuration;
|
|
848
|
-
}
|
|
849
|
-
if (protocols !== undefined) {
|
|
850
|
-
// DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so a
|
|
851
|
-
// service lists them only once its hosts are pinned at or after it.
|
|
852
|
-
const problems = protocolsFindings(protocols);
|
|
853
|
-
if (problems.length) throw new ServiceError(`protocols: ${problems.map((f) => f.message).join('; ')}.`);
|
|
854
|
-
doc.protocols = [...protocols];
|
|
855
|
-
}
|
|
856
|
-
return doc;
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
// An event about traced work carries traceId and spanId together (fabric-interop/0.1 C3.4 c);
|
|
860
|
-
// traceIds(traceparent) in fabric-interop.mjs gives both.
|
|
861
|
-
// --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data ---------------------------------------
|
|
862
|
-
|
|
863
|
-
/** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
|
|
864
|
-
export const PROTOCOL_REVISIONS = new Map(Object.entries({
|
|
865
|
-
'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'coding-agents': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
|
|
866
|
-
'fabric-activity': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
|
|
867
|
-
'fabric-interop': ['0.1', '0.2'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
|
|
868
|
-
'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
|
|
869
|
-
'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
|
|
870
|
-
memory: ['0.1'], 'pairing-message': ['1'], pipeline: ['0.1'], 'revocation-delta': ['1'], 'session-handoff': ['1'],
|
|
871
|
-
'share-registration': ['1'], 'share-registration-receipt': ['1'], 'support-quality-daily': ['1'], 'window-lease': ['1'],
|
|
872
|
-
}));
|
|
873
|
-
export const MAX_PROTOCOLS = 64;
|
|
874
|
-
const REVISION = /^([a-z][a-z0-9.-]{0,63})\/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})$/;
|
|
875
|
-
|
|
876
|
-
/** `<name>/<revision>` split, or undefined. */
|
|
877
|
-
export function parseRevision(value) {
|
|
878
|
-
const m = typeof value === 'string' ? REVISION.exec(value) : null;
|
|
879
|
-
return m ? { name: m[1], revision: m[2] } : undefined;
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
/** The revisions a receiver implementing `name` up to `newest` accepts: N and, when the register has one, N−1. */
|
|
883
|
-
export function acceptedRevisions(name, newest) {
|
|
884
|
-
const known = PROTOCOL_REVISIONS.get(name);
|
|
885
|
-
if (!known?.length) return [];
|
|
886
|
-
const top = newest === undefined ? known.length - 1 : known.indexOf(newest);
|
|
887
|
-
if (top < 0) return [];
|
|
888
|
-
return known.slice(Math.max(0, top - 1), top + 1).reverse().map((r) => `${name}/${r}`);
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* A receiver's answer to a document naming `received` (DEC-0067, the contract's `negotiateRevision`): `{ok: true}`, the
|
|
893
|
-
* `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted revisions of that
|
|
894
|
-
* protocol (all of them when it names none the receiver reads), or `{fault: 'no-accepted-revisions'}`.
|
|
895
|
-
*/
|
|
896
|
-
export function negotiateRevision(received, accepted) {
|
|
897
|
-
const usable = [...new Set((Array.isArray(accepted) ? accepted : []).filter((e) => parseRevision(e) !== undefined))].slice(0, MAX_PROTOCOLS);
|
|
898
|
-
if (!usable.length) return { fault: 'no-accepted-revisions' };
|
|
899
|
-
if (typeof received === 'string' && usable.includes(received)) return { ok: true };
|
|
900
|
-
const parsed = parseRevision(received);
|
|
901
|
-
const same = parsed ? usable.filter((e) => parseRevision(e)?.name === parsed.name) : [];
|
|
902
|
-
return { error: { code: 'schema-unsupported', message: 'this receiver does not read that protocol revision',
|
|
903
|
-
data: { protocol: typeof received === 'string' ? Array.from(received).slice(0, 80).join('') : '', accepted: same.length ? same : usable } } };
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
/** FAC-SEM-099 (DEC-0067): 1 to 64 entries, each once; at most N and N−1 of one protocol, consecutive in the register. */
|
|
907
|
-
export function protocolsFindings(protocols, prefix = '/protocols') {
|
|
908
|
-
const flag = (instancePath, message) => ({ code: 'FAC-SEM-099', instancePath, message });
|
|
909
|
-
if (!Array.isArray(protocols)) return [flag(prefix, 'protocols is a list of protocol revisions')];
|
|
910
|
-
if (protocols.length === 0 || protocols.length > MAX_PROTOCOLS) return [flag(prefix, `protocols lists 1 to ${MAX_PROTOCOLS} revisions`)];
|
|
911
|
-
const findings = [];
|
|
912
|
-
const byName = new Map();
|
|
913
|
-
const seen = new Set();
|
|
914
|
-
protocols.forEach((entry, i) => {
|
|
915
|
-
const at = `${prefix}/${i}`;
|
|
916
|
-
const parsed = parseRevision(entry);
|
|
917
|
-
if (!parsed) { findings.push(flag(at, 'a protocol revision is <name>/<revision>, such as fabric-service/0.1')); return; }
|
|
918
|
-
if (seen.has(entry)) { findings.push(flag(at, 'each protocol revision once')); return; }
|
|
919
|
-
seen.add(entry);
|
|
920
|
-
const known = PROTOCOL_REVISIONS.get(parsed.name);
|
|
921
|
-
if (known && !known.includes(parsed.revision)) { findings.push(flag(at, `${parsed.name} has no published revision ${parsed.revision}`)); return; }
|
|
922
|
-
const indices = byName.get(parsed.name) ?? [];
|
|
923
|
-
indices.push(known ? known.indexOf(parsed.revision) : -1);
|
|
924
|
-
byName.set(parsed.name, indices);
|
|
925
|
-
if (indices.length > 2) findings.push(flag(at, 'at most 2 revisions of one protocol: N and N−1'));
|
|
926
|
-
else if (known && indices.length === 2 && Math.abs(indices[0] - indices[1]) !== 1) findings.push(flag(at, 'two revisions of one protocol are N and N−1, consecutive in the register'));
|
|
927
|
-
});
|
|
928
|
-
return findings;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
const SCHEMA_URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
|
|
932
|
-
const scalar = (v) => v === null || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && Array.from(v).length <= 500);
|
|
933
|
-
const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
934
|
-
const pointerToken = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
935
|
-
|
|
936
|
-
/** FAC-SEM-101 over an events page (the contract's `feedDataFindings`): no credential's name or shape in an event's data. */
|
|
937
|
-
export function feedDataFindings(page) {
|
|
938
|
-
const findings = [];
|
|
939
|
-
const check = (named, member, at) => {
|
|
940
|
-
if (typeof member !== 'string') return;
|
|
941
|
-
if (named) findings.push({ code: 'FAC-SEM-101', instancePath: at, message: "an event's data puts no string under a credential's name" });
|
|
942
|
-
else if (secretValue(member) || secretUrl(member)) findings.push({ code: 'FAC-SEM-101', instancePath: at, message: "an event's data carries no value shaped like a credential" });
|
|
943
|
-
};
|
|
944
|
-
(Array.isArray(page?.events) ? page.events : []).forEach((event, i) => {
|
|
945
|
-
if (!isPlain(event) || !isPlain(event.data) || !isPlain(event.data.value)) return;
|
|
946
|
-
for (const [key, member] of Object.entries(event.data.value)) {
|
|
947
|
-
const at = `/events/${i}/data/value/${pointerToken(key)}`;
|
|
948
|
-
const named = credentialName(key);
|
|
949
|
-
check(named, member, at);
|
|
950
|
-
if (Array.isArray(member)) member.forEach((item, j) => check(named, item, `${at}/${j}`));
|
|
951
|
-
else if (isPlain(member)) for (const [inner, leaf] of Object.entries(member)) check(named || credentialName(inner), leaf, `${at}/${pointerToken(inner)}`);
|
|
952
|
-
}
|
|
953
|
-
});
|
|
954
|
-
return findings;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
/** DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101. */
|
|
958
|
-
export function feedDataProblems(data) {
|
|
959
|
-
if (!isPlain(data) || Object.keys(data).length !== 2 || !('schema' in data) || !('value' in data)) return ['data is {schema, value}'];
|
|
960
|
-
const problems = [];
|
|
961
|
-
if (!(typeof data.schema === 'string' && SCHEMA_URI.test(data.schema))) problems.push('data.schema is the absolute URI of a payload schema the service publishes');
|
|
962
|
-
const value = data.value;
|
|
963
|
-
if (!isPlain(value) || Object.keys(value).length > 32) return [...problems, 'data.value is an object of at most 32 members'];
|
|
964
|
-
for (const [key, member] of Object.entries(value)) {
|
|
965
|
-
if (scalar(member)) continue;
|
|
966
|
-
if (Array.isArray(member) && member.length <= 32 && member.every(scalar)) continue;
|
|
967
|
-
if (isPlain(member) && Object.keys(member).length <= 32 && Object.values(member).every(scalar)) continue;
|
|
968
|
-
problems.push(`data.value.${key} is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members`);
|
|
969
|
-
}
|
|
970
|
-
if (problems.length) return problems;
|
|
971
|
-
return feedDataFindings({ events: [{ data }] }).map((f) => `FAC-SEM-101: ${f.instancePath.replace('/events/0', '')} ${f.message}`);
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
/** One activity event. `data` (DEC-0070) adds machine-readable fields, `{schema, value}`, never a secret: refused here, so no feed carries one. */
|
|
975
|
-
export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId, data } = {}) {
|
|
976
|
-
if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
|
|
977
|
-
if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
|
|
978
|
-
const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
|
|
979
|
-
if (!sentence) throw new ServiceError('An event needs a sentence.');
|
|
980
|
-
if (link !== undefined && (!link.startsWith('/') || link.startsWith('//'))) throw new ServiceError('link must be a path on this service.');
|
|
981
|
-
if ((traceId === undefined) !== (spanId === undefined)) throw new ServiceError('An event carries traceId and spanId together, or neither.');
|
|
982
|
-
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.');
|
|
983
|
-
const event = { id: String(id), at, kind, level, text: sentence.slice(0, 500) };
|
|
984
|
-
if (subject) event.subject = subject;
|
|
985
|
-
if (link) event.link = link;
|
|
986
|
-
if (notify) event.notify = true;
|
|
987
|
-
if (traceId !== undefined) Object.assign(event, { traceId, spanId });
|
|
988
|
-
if (data !== undefined) {
|
|
989
|
-
const problems = feedDataProblems(data);
|
|
990
|
-
if (problems.length) throw new ServiceError(`Event data: ${problems.join('; ')}.`);
|
|
991
|
-
event.data = { schema: data.schema, value: data.value };
|
|
992
|
-
}
|
|
993
|
-
return event;
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
export function parseLimit(raw) {
|
|
997
|
-
if (raw === undefined || raw === null || raw === '') return EVENTS_DEFAULT_LIMIT;
|
|
998
|
-
const n = Number(raw);
|
|
999
|
-
if (!Number.isInteger(n)) throw new ServiceError('limit must be an integer.');
|
|
1000
|
-
return Math.max(1, Math.min(EVENTS_MAX_LIMIT, n));
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
// fetch(after, limit) returns events with ids greater than `after`, ascending
|
|
1004
|
-
// (or the newest `limit`, ascending, when after is null) — a view over the log you already keep.
|
|
1005
|
-
export async function eventsPage(fetch, after, limit) {
|
|
1006
|
-
const events = (await fetch(after ?? null, limit)).slice(0, limit);
|
|
1007
|
-
return { events, cursor: events.length ? events.at(-1).id : (after ?? null) };
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// --- usage report (DEC-0021) -------------------------------------------------------------
|
|
1011
|
-
|
|
1012
|
-
export const USAGE_PATH = '/fabric/v1/usage';
|
|
1013
|
-
export const USAGE_DAYS = 31;
|
|
1014
|
-
export const COST_BASES = ['provider', 'price-list', 'unknown'];
|
|
1015
|
-
const PROVIDER = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
1016
|
-
const COUNTS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'];
|
|
1017
|
-
|
|
1018
|
-
/**
|
|
1019
|
-
* One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
1020
|
-
* `costUsd` null means it cannot be priced: the report counts it as unpriced, never as $0.
|
|
1021
|
-
*/
|
|
1022
|
-
/**
|
|
1023
|
-
* One model call. `projectId` (a URI, kept in its normal form, DEC-0076) and `taskRunId` (the service's own run id) attribute
|
|
1024
|
-
* it to the project the service ran it for (DEC-0065); they never name a caller, session or person.
|
|
1025
|
-
*/
|
|
1026
|
-
// tokensMeasured: false marks a call whose token counts are a lower bound or unknown (DEC-0091): the report counts it
|
|
1027
|
-
// in unmeasuredCalls and its token totals are a lower bound.
|
|
1028
|
-
export function makeUsageReceipt(provider, model, { inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0, costUsd = null, costBasis, at, route, projectId, taskRunId, tokensMeasured = true } = {}) {
|
|
1029
|
-
if (!PROVIDER.test(provider ?? '')) throw new ServiceError('provider must be a lowercase name such as anthropic or openrouter.');
|
|
1030
|
-
model = String(model ?? '').trim();
|
|
1031
|
-
if (!model || model.length > 128) throw new ServiceError('model must be 1 to 128 characters.');
|
|
1032
|
-
const counts = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
|
|
1033
|
-
for (const [name, value] of Object.entries(counts)) if (!Number.isInteger(value) || value < 0) throw new ServiceError(`${name} must be a non-negative integer.`);
|
|
1034
|
-
if (costUsd !== null && (typeof costUsd !== 'number' || !Number.isFinite(costUsd) || costUsd < 0)) throw new ServiceError('costUsd must be a non-negative number or null.');
|
|
1035
|
-
const basis = costBasis ?? (costUsd === null ? 'unknown' : 'provider');
|
|
1036
|
-
if (!COST_BASES.includes(basis) || (costUsd === null) !== (basis === 'unknown')) throw new ServiceError('costBasis is provider or price-list with a cost, unknown without one.');
|
|
1037
|
-
if (route !== undefined && !USAGE_ROUTES.includes(route)) throw new ServiceError('route is subscription, key or platform-metered (DEC-0046, DEC-0061).');
|
|
1038
|
-
if (tokensMeasured !== true && tokensMeasured !== false) throw new ServiceError('tokensMeasured is true or false.');
|
|
1039
|
-
if (taskRunId !== undefined && projectId === undefined) throw new ServiceError('a task run belongs to a project: give projectId with taskRunId.');
|
|
1040
|
-
if (projectId !== undefined && !(typeof projectId === 'string' && /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(projectId))) throw new ServiceError('projectId is the URI of the project the service ran the call for.');
|
|
1041
|
-
if (taskRunId !== undefined && !(typeof taskRunId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(taskRunId))) throw new ServiceError("taskRunId is the service's own run id: ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$.");
|
|
1042
|
-
return { at: at ?? nowIso(), provider, model, ...counts, costUsd, costBasis: basis, ...(tokensMeasured ? {} : { tokensMeasured: false }), ...(route === undefined ? {} : { route }),
|
|
1043
|
-
...(projectId === undefined ? {} : { projectId: normalIdentifier(projectId) }), ...(taskRunId === undefined ? {} : { taskRunId }) };
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
/** DEC-0065's `byProject`: the window's calls by project (normal form, DEC-0076), each task run once within it. */
|
|
1047
|
-
function byProject(receipts, first, last) {
|
|
1048
|
-
const projects = new Map();
|
|
1049
|
-
const add = (row, r) => { row.calls += 1; if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd; };
|
|
1050
|
-
for (const r of receipts) {
|
|
1051
|
-
const date = String(r.at ?? '').slice(0, 10);
|
|
1052
|
-
if (date < first || date > last || !r.projectId) continue;
|
|
1053
|
-
if (!projects.has(r.projectId)) projects.set(r.projectId, { projectId: r.projectId, calls: 0, unpricedCalls: 0, cost: 0, runs: new Map() });
|
|
1054
|
-
const row = projects.get(r.projectId);
|
|
1055
|
-
add(row, r);
|
|
1056
|
-
if (r.taskRunId) {
|
|
1057
|
-
if (!row.runs.has(r.taskRunId)) row.runs.set(r.taskRunId, { taskRunId: r.taskRunId, calls: 0, unpricedCalls: 0, cost: 0 });
|
|
1058
|
-
add(row.runs.get(r.taskRunId), r);
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
const close = ({ cost, ...rest }) => ({ ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 });
|
|
1062
|
-
return [...projects.keys()].sort().map((key) => {
|
|
1063
|
-
const { runs, ...row } = projects.get(key);
|
|
1064
|
-
const taskRuns = [...runs.keys()].sort().map((id) => close(runs.get(id)));
|
|
1065
|
-
return { ...close(row), ...(taskRuns.length ? { taskRuns } : {}) };
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
1070
|
-
const emptyRow = (extra = {}) => ({ ...extra, calls: 0, unpricedCalls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0 });
|
|
1071
|
-
function closeRow(row) {
|
|
1072
|
-
const { cost, ...rest } = row;
|
|
1073
|
-
return { ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 };
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* The service-usage.schema.json answer: the last 31 UTC days, oldest first, per provider and
|
|
1078
|
-
* model; days without calls are omitted. A row whose calls are all unpriced costs null; totals
|
|
1079
|
-
* are the sums of the model rows (FAC-SEM-025).
|
|
1080
|
-
*/
|
|
1081
|
-
// --- FAC-SEM-025 whole (DEC-0021, DEC-0065, CT-40, DEC-0076): the contract's `usageRules` --------------------------------
|
|
1082
|
-
|
|
1083
|
-
const usageCount = (v) => (Number.isSafeInteger(v) && v >= 0 ? v : 0);
|
|
1084
|
-
const usageCost = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
1085
|
-
const exceeds = (a, b) => a - b > 0.000001 + 1e-9 * Math.max(Math.abs(a), Math.abs(b));
|
|
1086
|
-
const usd = (v) => String(Math.round(v * 1e6) / 1e6);
|
|
1087
|
-
const usageList = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'object' && x !== null && !Array.isArray(x)) : []);
|
|
1088
|
-
const RFC3339_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
1089
|
-
function strictTime(value) {
|
|
1090
|
-
const m = typeof value === 'string' ? RFC3339_TIME.exec(value) : null;
|
|
1091
|
-
if (!m) return NaN;
|
|
1092
|
-
const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
|
|
1093
|
-
const date = new Date(0);
|
|
1094
|
-
date.setUTCFullYear(y, mo - 1, d);
|
|
1095
|
-
if (date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d || h > 23 || mi > 59 || s > 59) return NaN;
|
|
1096
|
-
return Date.parse(value);
|
|
1097
|
-
}
|
|
1098
|
-
function utcDateMs(value) {
|
|
1099
|
-
const m = typeof value === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(value) : null;
|
|
1100
|
-
if (!m) return NaN;
|
|
1101
|
-
const [y, mo, d] = m.slice(1).map(Number);
|
|
1102
|
-
const ms = Date.UTC(y, mo - 1, d);
|
|
1103
|
-
const date = new Date(ms);
|
|
1104
|
-
return date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d ? ms : NaN;
|
|
1105
|
-
}
|
|
1106
|
-
function pricedRow(row, at) {
|
|
1107
|
-
const calls = usageCount(row.calls), unpriced = usageCount(row.unpricedCalls);
|
|
1108
|
-
const flag = (p, message) => [{ code: 'FAC-SEM-025', instancePath: p, message }];
|
|
1109
|
-
if (unpriced > calls) return flag(`${at}/unpricedCalls`, 'more unpriced calls than calls');
|
|
1110
|
-
// DEC-0091: calls whose token count is unknown are some of the calls, never more.
|
|
1111
|
-
if (usageCount(row.unmeasuredCalls) > calls) return flag(`${at}/unmeasuredCalls`, 'more calls with unknown tokens than calls');
|
|
1112
|
-
if (calls > 0 && unpriced === calls && row.costUsd !== null) return flag(`${at}/costUsd`, 'every call is unpriced, so the cost is unknown (null), not a number');
|
|
1113
|
-
if (unpriced < calls && row.costUsd === null) return flag(`${at}/costUsd`, 'priced calls carry a cost; null is only for a row with no priced call');
|
|
1114
|
-
return [];
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
/**
|
|
1118
|
-
* FAC-SEM-025 over a usage report, in the contract's order (`src/usage-rules.ts`): the arithmetic, the window (every
|
|
1119
|
-
* date exists, not after `generatedAt`'s UTC date, at most 30 days before it) and the project attribution (each project
|
|
1120
|
-
* once by its normal form, each task run once; calls, priced calls and known costs bounded). Never throws.
|
|
1121
|
-
*/
|
|
1122
|
-
/** The contract's `toNumber` (src/findings.ts): `Number()` without its throw on an object such as {"toString": 1}. */
|
|
1123
|
-
function toNumber(value) {
|
|
1124
|
-
try {
|
|
1125
|
-
return Number(value);
|
|
1126
|
-
} catch {
|
|
1127
|
-
return Number.NaN;
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
/** The contract's `show`: `String()`, or the value's JSON where `String()` would throw. */
|
|
1132
|
-
function show(value) {
|
|
1133
|
-
if (typeof value === 'string') return value;
|
|
1134
|
-
try {
|
|
1135
|
-
return String(value);
|
|
1136
|
-
} catch {
|
|
1137
|
-
return JSON.stringify(value) ?? typeof value;
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
export function usageFindings(report) {
|
|
1142
|
-
const value = typeof report === 'object' && report !== null && !Array.isArray(report) ? report : {};
|
|
1143
|
-
const findings = [];
|
|
1144
|
-
const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-025', instancePath, message });
|
|
1145
|
-
const days = usageList(value.days);
|
|
1146
|
-
let previous = '';
|
|
1147
|
-
days.forEach((day, i) => {
|
|
1148
|
-
const at = `/days/${i}`;
|
|
1149
|
-
const date = show(day.date ?? '');
|
|
1150
|
-
if (date <= previous) flag(`${at}/date`, `days must run forward without a repeat (${date} after ${previous})`);
|
|
1151
|
-
previous = date;
|
|
1152
|
-
findings.push(...pricedRow(day, at));
|
|
1153
|
-
const models = usageList(day.byModel);
|
|
1154
|
-
models.forEach((m, j) => findings.push(...pricedRow(m, `${at}/byModel/${j}`)));
|
|
1155
|
-
if (!models.length) {
|
|
1156
|
-
const spent = ['calls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens'].some((f) => toNumber(day[f] ?? 0) > 0) || toNumber(day.costUsd ?? 0) > 0;
|
|
1157
|
-
if (spent) flag(`${at}/byModel`, 'a day with calls, tokens or cost names the models they belong to');
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
for (const field of ['calls', 'unpricedCalls', 'unmeasuredCalls', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']) {
|
|
1161
|
-
const sum = models.reduce((n, m) => n + toNumber(m[field] ?? 0), 0);
|
|
1162
|
-
if (toNumber(day[field] ?? 0) !== sum) flag(`${at}/${field}`, `the day's ${field} is not the sum of its models (${show(day[field] ?? 0)} ≠ ${sum})`);
|
|
1163
|
-
}
|
|
1164
|
-
const known = models.map((m) => (typeof m.costUsd === 'number' ? m.costUsd : null)).filter((x) => x !== null).reduce((n, x) => n + x, 0);
|
|
1165
|
-
// The contract's !close(day, known): Infinity or NaN is never close, so it is a finding.
|
|
1166
|
-
if (typeof day.costUsd === 'number' && !(Math.abs(day.costUsd - known) <= 0.000001 + 1e-9 * Math.max(Math.abs(day.costUsd), Math.abs(known)))) flag(`${at}/costUsd`, "the day's cost is not the sum of its models' known costs");
|
|
1167
|
-
});
|
|
1168
|
-
if (days.length) {
|
|
1169
|
-
const generated = strictTime(value.generatedAt);
|
|
1170
|
-
if (Number.isNaN(generated)) flag('/generatedAt', 'generatedAt is an RFC 3339 date-time with an offset, so the window can be read');
|
|
1171
|
-
else {
|
|
1172
|
-
const today = Math.floor(generated / 86_400_000) * 86_400_000;
|
|
1173
|
-
const oldest = today - 30 * 86_400_000;
|
|
1174
|
-
days.forEach((day, i) => {
|
|
1175
|
-
const date = utcDateMs(day.date);
|
|
1176
|
-
if (Number.isNaN(date)) flag(`/days/${i}/date`, "a day's date exists in its month");
|
|
1177
|
-
else if (date > today) flag(`/days/${i}/date`, 'a day is not after the UTC date of generatedAt');
|
|
1178
|
-
else if (date < oldest) flag(`/days/${i}/date`, 'a day is within the last 31 UTC days of generatedAt');
|
|
1179
|
-
});
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
if (Array.isArray(value.byProject)) {
|
|
1183
|
-
const seen = new Map();
|
|
1184
|
-
let projectCalls = 0, projectCost = 0;
|
|
1185
|
-
value.byProject.forEach((project, p) => {
|
|
1186
|
-
if (typeof project !== 'object' || project === null || Array.isArray(project)) return;
|
|
1187
|
-
const at = `/byProject/${p}`;
|
|
1188
|
-
const normal = normalIdentifier(project.projectId);
|
|
1189
|
-
if (normal !== undefined) {
|
|
1190
|
-
if (seen.has(normal)) flag(`${at}/projectId`, `each project is one row: ${show(project.projectId)} names the project ${seen.get(normal)} already names`);
|
|
1191
|
-
else seen.set(normal, show(project.projectId));
|
|
1192
|
-
}
|
|
1193
|
-
projectCalls += usageCount(project.calls);
|
|
1194
|
-
projectCost += usageCost(project.costUsd);
|
|
1195
|
-
findings.push(...pricedRow(project, at));
|
|
1196
|
-
const runs = new Set();
|
|
1197
|
-
let runCalls = 0, runCost = 0, runPriced = 0;
|
|
1198
|
-
usageList(project.taskRuns).forEach((run, r) => {
|
|
1199
|
-
if (typeof run.taskRunId === 'string') {
|
|
1200
|
-
if (runs.has(run.taskRunId)) flag(`${at}/taskRuns/${r}/taskRunId`, 'each task run is one row within its project');
|
|
1201
|
-
runs.add(run.taskRunId);
|
|
1202
|
-
}
|
|
1203
|
-
runCalls += usageCount(run.calls);
|
|
1204
|
-
runCost += usageCost(run.costUsd);
|
|
1205
|
-
runPriced += Math.max(0, usageCount(run.calls) - usageCount(run.unpricedCalls));
|
|
1206
|
-
findings.push(...pricedRow(run, `${at}/taskRuns/${r}`));
|
|
1207
|
-
});
|
|
1208
|
-
const projectPriced = Math.max(0, usageCount(project.calls) - usageCount(project.unpricedCalls));
|
|
1209
|
-
if (runCalls > usageCount(project.calls)) flag(`${at}/taskRuns`, `a project's task runs hold more calls than the project (${runCalls} > ${usageCount(project.calls)})`);
|
|
1210
|
-
else if (runPriced > projectPriced) flag(`${at}/taskRuns`, `a project's task runs hold more priced calls than the project (${runPriced} > ${projectPriced})`);
|
|
1211
|
-
else if (usageCost(project.costUsd) === project.costUsd && exceeds(runCost, usageCost(project.costUsd))) flag(`${at}/taskRuns`, `a project's task runs cost more than the project (${usd(runCost)} > ${usd(usageCost(project.costUsd))})`);
|
|
1212
|
-
});
|
|
1213
|
-
const dayCalls = days.reduce((n, d) => n + usageCount(d.calls), 0);
|
|
1214
|
-
if (projectCalls > dayCalls) flag('/byProject', `the projects hold more calls than the days (${projectCalls} > ${dayCalls})`);
|
|
1215
|
-
const dayCost = days.reduce((n, d) => n + usageCost(d.costUsd), 0);
|
|
1216
|
-
if (exceeds(projectCost, dayCost)) flag('/byProject', `the projects cost more than the days (${usd(projectCost)} > ${usd(dayCost)})`);
|
|
1217
|
-
}
|
|
1218
|
-
return findings;
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
// --- OpenRouter attribution (DEC-0065): the contract's `attributionHeaders`, off by default ------------------------------
|
|
1222
|
-
|
|
1223
|
-
export const OPENROUTER_CATEGORIES = Object.freeze(['cli-agent', 'ide-extension', 'cloud-agent', 'programming-app', 'native-app-builder',
|
|
1224
|
-
'creative-writing', 'video-gen', 'image-gen', 'audio-gen', 'writing-assistant', 'general-chat', 'personal-agent', 'legal', 'roleplay', 'game']);
|
|
1225
|
-
const ATTR_PRODUCT = /^[a-z][a-z0-9-]{0,62}$/;
|
|
1226
|
-
const ATTR_TITLE = /^[\x21-\x7e](?:[\x20-\x7e]{0,98}[\x21-\x7e])?$/;
|
|
1227
|
-
const ATTRIBUTION_HEADER_NAMES = new Set(['http-referer', 'referer', 'x-openrouter-title', 'x-title', 'x-openrouter-categories']);
|
|
1228
|
-
|
|
1229
|
-
/** FAC-SEM-098: a product's setting names a product slug, a printable title and at most two recognized categories. */
|
|
1230
|
-
export function attributionSettingFindings(value) {
|
|
1231
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) return [{ code: 'FAC-SEM-098', instancePath: '', message: 'an attribution setting is an object' }];
|
|
1232
|
-
const findings = [];
|
|
1233
|
-
const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-098', instancePath, message });
|
|
1234
|
-
if (typeof value.product !== 'string' || !ATTR_PRODUCT.test(value.product)) flag('/product', 'the product is a lower-case slug, the last segment of its product page');
|
|
1235
|
-
if (typeof value.title !== 'string' || !ATTR_TITLE.test(value.title)) flag('/title', "the title is the product's full name: printable ASCII, 1 to 100 characters, no space at either end");
|
|
1236
|
-
if (value.categories !== undefined) {
|
|
1237
|
-
if (!Array.isArray(value.categories)) flag('/categories', 'categories are a list');
|
|
1238
|
-
else {
|
|
1239
|
-
if (value.categories.length > 2) flag('/categories', 'at most 2 categories per request');
|
|
1240
|
-
const seen = new Set();
|
|
1241
|
-
value.categories.forEach((c, i) => {
|
|
1242
|
-
if (typeof c !== 'string' || !OPENROUTER_CATEGORIES.includes(c)) flag(`/categories/${i}`, 'a category is one OpenRouter recognizes; it drops others without an error');
|
|
1243
|
-
else if (seen.has(c)) flag(`/categories/${i}`, 'each category once');
|
|
1244
|
-
else seen.add(c);
|
|
1245
|
-
});
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
return findings;
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
function isOpenRouter(url) {
|
|
1252
|
-
try {
|
|
1253
|
-
const u = new URL(url);
|
|
1254
|
-
return u.protocol === 'https:' && u.hostname === 'openrouter.ai' && u.port === '' && u.username === '' && u.password === '';
|
|
1255
|
-
} catch {
|
|
1256
|
-
return false;
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
/**
|
|
1261
|
-
* The headers a product adds to one request (DEC-0065, the contract's `attributionHeaders`), or none with the reason:
|
|
1262
|
-
* `no-setting` (off by default), `invalid-setting`, `not-openrouter`, `forwarded`, `user-set`. Only `HTTP-Referer`,
|
|
1263
|
-
* `X-OpenRouter-Title` and `X-OpenRouter-Categories`; it never reads, moves or returns a key.
|
|
1264
|
-
*/
|
|
1265
|
-
export function attributionHeaders(setting, given) {
|
|
1266
|
-
const request = typeof given === 'object' && given !== null ? given : {};
|
|
1267
|
-
if (setting === undefined || setting === null) return { headers: {}, none: 'no-setting' };
|
|
1268
|
-
if (attributionSettingFindings(setting).length) return { headers: {}, none: 'invalid-setting' };
|
|
1269
|
-
if (typeof request.url !== 'string' || !isOpenRouter(request.url)) return { headers: {}, none: 'not-openrouter' };
|
|
1270
|
-
if (request.originated !== true) return { headers: {}, none: 'forwarded' };
|
|
1271
|
-
const present = typeof request.headers === 'object' && request.headers !== null ? Object.keys(request.headers).map((n) => n.toLowerCase()) : [];
|
|
1272
|
-
if (present.some((n) => ATTRIBUTION_HEADER_NAMES.has(n))) return { headers: {}, none: 'user-set' };
|
|
1273
|
-
const headers = { 'HTTP-Referer': `https://passioncode.ai/${setting.product}/`, 'X-OpenRouter-Title': setting.title };
|
|
1274
|
-
if (setting.categories?.length) headers['X-OpenRouter-Categories'] = setting.categories.join(',');
|
|
1275
|
-
return { headers };
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
/**
|
|
1279
|
-
* `budgets` (DEC-0027) lists every spending limit the service applies, enforced or not. A machine limit with a
|
|
1280
|
-
* calendar `period` and no `spentUsd` is counted here from the receipts, the way `budget` is, so the two never
|
|
1281
|
-
* disagree; a per-order limit (`per_job`, `approval`) gets `spentUsd: null`; every other limit states its own spend.
|
|
1282
|
-
* A report whose limits break FAC-SEM-031 is refused (`usageLimitProblems`).
|
|
1283
|
-
*/
|
|
1284
|
-
export function usageReport(receipts, { id, instance = 'default', now = Date.now(), budget, budgets } = {}) {
|
|
1285
|
-
receipts = Array.from(receipts); // read twice: the days, then a rolling window
|
|
1286
|
-
const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000), last = utcDate(now);
|
|
1287
|
-
const days = new Map();
|
|
1288
|
-
for (const r of receipts) {
|
|
1289
|
-
const date = String(r.at ?? '').slice(0, 10);
|
|
1290
|
-
if (date < first || date > last) continue;
|
|
1291
|
-
const rows = days.get(date) ?? new Map();
|
|
1292
|
-
days.set(date, rows);
|
|
1293
|
-
const k = `${r.provider}\u0000${r.model}\u0000${r.route ?? ''}`;
|
|
1294
|
-
// DEC-0046: rows of one model on two routes stay apart.
|
|
1295
|
-
const row = rows.get(k) ?? emptyRow({ provider: r.provider, model: r.model, ...(r.route ? { route: r.route } : {}), bases: new Set() });
|
|
1296
|
-
rows.set(k, row);
|
|
1297
|
-
row.calls += 1;
|
|
1298
|
-
for (const c of COUNTS) row[c] += Number(r[c] ?? 0);
|
|
1299
|
-
if (r.costUsd === null || r.costUsd === undefined) row.unpricedCalls += 1; else row.cost += r.costUsd;
|
|
1300
|
-
if (r.tokensMeasured === false) row.unmeasuredCalls = (row.unmeasuredCalls ?? 0) + 1; // absent is 0 (DEC-0091)
|
|
1301
|
-
row.bases.add(r.costBasis ?? 'unknown');
|
|
1302
|
-
}
|
|
1303
|
-
const outDays = [...days.keys()].sort().map((date) => {
|
|
1304
|
-
const models = [...days.get(date).values()]
|
|
1305
|
-
.map(({ bases, ...row }) => closeRow({ ...row, costBasis: bases.size === 1 ? [...bases][0] : 'mixed' }))
|
|
1306
|
-
.sort((a, b) => (`${a.provider}\u0000${a.model}\u0000${a.route ?? ''}` < `${b.provider}\u0000${b.model}\u0000${b.route ?? ''}` ? -1 : 1));
|
|
1307
|
-
const day = emptyRow({ date });
|
|
1308
|
-
for (const m of models) {
|
|
1309
|
-
for (const c of ['calls', 'unpricedCalls', ...COUNTS]) day[c] += m[c];
|
|
1310
|
-
if (m.unmeasuredCalls) day.unmeasuredCalls = (day.unmeasuredCalls ?? 0) + m.unmeasuredCalls;
|
|
1311
|
-
day.cost += m.costUsd ?? 0;
|
|
1312
|
-
}
|
|
1313
|
-
return { ...closeRow(day), byModel: models };
|
|
1314
|
-
});
|
|
1315
|
-
const report = { protocol: PROTOCOL, service: { id, instance }, generatedAt: new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z'), currency: 'USD', days: outDays };
|
|
1316
|
-
const projects = byProject(receipts, first, last);
|
|
1317
|
-
if (projects.length) report.byProject = projects;
|
|
1318
|
-
if (budget !== undefined) {
|
|
1319
|
-
if (!['day', 'month'].includes(budget?.period) || typeof budget.limitUsd !== 'number' || !(budget.limitUsd > 0)) throw new ServiceError('budget is {period: day|month, limitUsd > 0}.');
|
|
1320
|
-
report.budget = { period: budget.period, limitUsd: budget.limitUsd, spentUsd: spentIn(outDays, budget.period, last) };
|
|
1321
|
-
}
|
|
1322
|
-
if (budgets !== undefined) {
|
|
1323
|
-
report.budgets = budgets.map((given) => {
|
|
1324
|
-
if (!isPlainObject(given)) throw new ServiceError('Each limit in budgets is an object.');
|
|
1325
|
-
const limit = { ...given };
|
|
1326
|
-
if (!('spentUsd' in limit)) {
|
|
1327
|
-
if (PER_ORDER_KINDS.includes(limit.kind)) limit.spentUsd = null;
|
|
1328
|
-
else if (limit.scope === 'machine' && ['day', 'month'].includes(limit.period)) limit.spentUsd = spentIn(outDays, limit.period, last);
|
|
1329
|
-
else if (limit.scope === 'machine' && isNumber(limit.windowSeconds)) limit.spentUsd = spentSince(receipts, now - limit.windowSeconds * 1000, now);
|
|
1330
|
-
}
|
|
1331
|
-
return limit;
|
|
1332
|
-
});
|
|
1333
|
-
const problems = usageLimitProblems(report);
|
|
1334
|
-
if (problems.length) throw new ServiceError(`The report's limits are not coherent: ${problems.join('; ')}`);
|
|
1335
|
-
}
|
|
1336
|
-
return report;
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
/** Spend in a rolling window, from the receipts' own times; null when every call in it is unpriced. */
|
|
1340
|
-
function spentSince(receipts, start, end) {
|
|
1341
|
-
const iso = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
1342
|
-
const lo = iso(start), hi = iso(end);
|
|
1343
|
-
const inside = [...receipts].filter((r) => String(r.at ?? '') > lo && String(r.at ?? '') <= hi);
|
|
1344
|
-
if (inside.length && inside.every((r) => r.costUsd === null || r.costUsd === undefined)) return null;
|
|
1345
|
-
return Math.round(inside.reduce((n, r) => n + (r.costUsd ?? 0), 0) * 1e6) / 1e6;
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
/** Spend in the current UTC day or month; null when every call in it is unpriced. */
|
|
1349
|
-
function spentIn(outDays, period, last) {
|
|
1350
|
-
const prefix = period === 'day' ? last : last.slice(0, 7);
|
|
1351
|
-
const window = outDays.filter((d) => d.date.startsWith(prefix));
|
|
1352
|
-
const unknown = window.length > 0 && window.every((d) => d.costUsd === null);
|
|
1353
|
-
return unknown ? null : Math.round(window.reduce((n, d) => n + (d.costUsd ?? 0), 0) * 1e6) / 1e6;
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
// --- every spending limit (DEC-0027, FAC-SEM-031) --------------------------------------------
|
|
1357
|
-
|
|
1358
|
-
export const LIMIT_SCOPES = Object.freeze(['machine', 'project', 'pool', 'job']);
|
|
1359
|
-
export const PER_ORDER_KINDS = Object.freeze(['per_job', 'approval']);
|
|
1360
|
-
const LIMIT_ID = /^[a-z0-9][a-z0-9._:-]{0,63}$/;
|
|
1361
|
-
const LIMIT_KIND = /^[a-z][a-z0-9_]{0,31}$/;
|
|
1362
|
-
const LIMIT_FIELDS = ['id', 'scope', 'subject', 'kind', 'period', 'windowSeconds', 'since', 'limitUsd', 'spentUsd', 'enforced', 'tripped'];
|
|
1363
|
-
const TIMESTAMP = /^([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|[+-]([0-9]{2}):([0-9]{2}))$/;
|
|
1364
|
-
|
|
1365
|
-
/** A JSON Schema `date-time` (RFC 3339): a real calendar date and time, `Z` or an offset. */
|
|
1366
|
-
export function isTimestamp(value) {
|
|
1367
|
-
const m = typeof value === 'string' ? value.match(TIMESTAMP) : null;
|
|
1368
|
-
if (!m) return false;
|
|
1369
|
-
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
|
1370
|
-
const date = new Date(Date.UTC(year, month - 1, day));
|
|
1371
|
-
const calendar = date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
1372
|
-
const offset = m[9] === undefined || (Number(m[9]) <= 23 && Number(m[10]) <= 59);
|
|
1373
|
-
return calendar && hour <= 23 && minute <= 59 && second <= 60 && offset;
|
|
1374
|
-
}
|
|
1375
|
-
const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
1376
|
-
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
1377
|
-
|
|
1378
|
-
/**
|
|
1379
|
-
* The `budgets` of a usage report against `service-usage.schema.json` `$defs/limit` and FAC-SEM-031 — the twin of
|
|
1380
|
-
* fabric_service.usage_limit_problems. A breach is not a problem, and an unknown kind is never refused.
|
|
1381
|
-
*/
|
|
1382
|
-
export function usageLimitProblems(report) {
|
|
1383
|
-
const problems = [];
|
|
1384
|
-
const limits = report?.budgets;
|
|
1385
|
-
if (limits === undefined) return problems;
|
|
1386
|
-
if (!Array.isArray(limits) || limits.length > 64) return ['budgets is a list of at most 64 limits'];
|
|
1387
|
-
const seen = new Set();
|
|
1388
|
-
limits.forEach((limit, i) => {
|
|
1389
|
-
const at = `/budgets/${i}`;
|
|
1390
|
-
if (!isPlainObject(limit)) { problems.push(`${at} is not an object`); return; }
|
|
1391
|
-
for (const key of Object.keys(limit)) if (!LIMIT_FIELDS.includes(key)) problems.push(`${at}/${key} is not a limit field`);
|
|
1392
|
-
for (const key of ['id', 'scope', 'kind', 'limitUsd', 'spentUsd', 'enforced']) if (!(key in limit)) problems.push(`${at} is missing ${key}`);
|
|
1393
|
-
if ('id' in limit && !(typeof limit.id === 'string' && LIMIT_ID.test(limit.id))) problems.push(`${at}/id must match ${LIMIT_ID.source}`);
|
|
1394
|
-
if ('scope' in limit && !LIMIT_SCOPES.includes(limit.scope)) problems.push(`${at}/scope must be one of ${LIMIT_SCOPES.join(', ')}`);
|
|
1395
|
-
if ('subject' in limit && !(typeof limit.subject === 'string' && limit.subject.length >= 1 && limit.subject.length <= 128)) problems.push(`${at}/subject is 1 to 128 characters`);
|
|
1396
|
-
if ('kind' in limit && !(typeof limit.kind === 'string' && LIMIT_KIND.test(limit.kind))) problems.push(`${at}/kind must match ${LIMIT_KIND.source}`);
|
|
1397
|
-
if ('period' in limit && !['day', 'month'].includes(limit.period)) problems.push(`${at}/period is day or month`);
|
|
1398
|
-
if ('windowSeconds' in limit && !(Number.isInteger(limit.windowSeconds) && limit.windowSeconds >= 1)) problems.push(`${at}/windowSeconds is a whole number of seconds, at least 1`);
|
|
1399
|
-
if ('since' in limit && !isTimestamp(limit.since)) problems.push(`${at}/since is a UTC timestamp`);
|
|
1400
|
-
if ('limitUsd' in limit && !(limit.limitUsd === null || (isNumber(limit.limitUsd) && limit.limitUsd > 0))) problems.push(`${at}/limitUsd is a dollar amount above zero, or null`);
|
|
1401
|
-
if ('spentUsd' in limit && !(limit.spentUsd === null || (isNumber(limit.spentUsd) && limit.spentUsd >= 0))) problems.push(`${at}/spentUsd is a dollar amount, zero or more, or null`);
|
|
1402
|
-
for (const key of ['enforced', 'tripped']) if (key in limit && typeof limit[key] !== 'boolean') problems.push(`${at}/${key} is true or false`);
|
|
1403
|
-
|
|
1404
|
-
const flag = (where, message) => problems.push(`FAC-SEM-031 ${at}/${where}: ${message}`);
|
|
1405
|
-
const id = String(limit.id ?? '');
|
|
1406
|
-
if (seen.has(id)) flag('id', `limit id ${id} appears twice`);
|
|
1407
|
-
seen.add(id);
|
|
1408
|
-
const named = limit.scope === 'project' || limit.scope === 'pool';
|
|
1409
|
-
if (named && !('subject' in limit)) flag('subject', `a ${limit.scope} limit names its ${limit.scope} in subject`);
|
|
1410
|
-
if (!named && 'subject' in limit) flag('subject', `a ${limit.scope} limit has no subject`);
|
|
1411
|
-
const windows = ['period', 'windowSeconds', 'since'].filter((f) => f in limit);
|
|
1412
|
-
if (windows.length > 1) flag(windows[1], `a limit has one window, not ${windows.join(' and ')}`);
|
|
1413
|
-
const kind = String(limit.kind ?? '');
|
|
1414
|
-
const seconds = isNumber(limit.windowSeconds) ? limit.windowSeconds : null;
|
|
1415
|
-
if (PER_ORDER_KINDS.includes(kind)) {
|
|
1416
|
-
if (windows.length) flag(windows[0], `${kind} bounds one order and has no window`);
|
|
1417
|
-
if (limit.spentUsd !== null) flag('spentUsd', `${kind} bounds one order, so nothing is spent against it (null)`);
|
|
1418
|
-
} else if (kind === 'daily') {
|
|
1419
|
-
if (!(limit.period === 'day' || seconds === 86400)) flag('period', 'a daily limit counts the UTC day (period day) or the last 24 hours (windowSeconds 86400)');
|
|
1420
|
-
} else if (kind === 'monthly') {
|
|
1421
|
-
if (!(limit.period === 'month' || (seconds !== null && seconds >= 28 * 86400 && seconds <= 31 * 86400))) flag('period', 'a monthly limit counts the UTC month (period month) or a rolling 28–31 days (windowSeconds)');
|
|
1422
|
-
} else if (kind === 'velocity') {
|
|
1423
|
-
if (seconds === null) flag('windowSeconds', 'a velocity limit counts a rolling window (windowSeconds)');
|
|
1424
|
-
} else if (kind === 'pool' || kind === 'emergency') {
|
|
1425
|
-
if (!windows.length) flag('period', `a ${kind} limit names the window it counts (period, windowSeconds or since)`);
|
|
1426
|
-
}
|
|
1427
|
-
const known = PER_ORDER_KINDS.includes(kind) || ['daily', 'monthly', 'pool', 'emergency'].includes(kind);
|
|
1428
|
-
if (known && 'limitUsd' in limit && limit.limitUsd === null) flag('limitUsd', `a ${kind} limit is a dollar amount; only a relative limit (velocity) may leave it null`);
|
|
1429
|
-
if (limit.tripped === true && limit.enforced !== true) flag('tripped', 'a limit that is not enforced cannot have stopped work');
|
|
1430
|
-
});
|
|
1431
|
-
const budget = report.budget;
|
|
1432
|
-
if (isPlainObject(budget) && limits.length) {
|
|
1433
|
-
const kind = budget.period === 'day' ? 'daily' : 'monthly';
|
|
1434
|
-
const same = (x, y) => (x === null || y === null ? x === y : isNumber(x) && isNumber(y) && Math.abs(x - y) <= 0.000001);
|
|
1435
|
-
const twin = limits.some((l) => isPlainObject(l) && l.scope === 'machine' && l.enforced === true && l.kind === kind && l.period === budget.period && same(l.limitUsd, budget.limitUsd) && same(l.spentUsd, budget.spentUsd));
|
|
1436
|
-
if (!twin) problems.push(`FAC-SEM-031 /budget: budget repeats one enforced machine ${kind} limit with period ${budget.period} in budgets[], so old and new readers agree`);
|
|
1437
|
-
}
|
|
1438
|
-
return problems;
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
// --- the feed client (DEC-0025, FAC-SEM-036) -------------------------------------------------
|
|
1442
|
-
|
|
1443
|
-
/**
|
|
1444
|
-
* The one header a client sends the service token in, as the descriptor's `auth` declares it: `header` defaults
|
|
1445
|
-
* to Authorization and `scheme` to Bearer; `none` carries the raw token. Never assume `Authorization: Bearer`.
|
|
1446
|
-
*/
|
|
1447
|
-
export function tokenHeader(auth, token) {
|
|
1448
|
-
const declared = isPlainObject(auth) ? auth : {};
|
|
1449
|
-
const name = typeof declared.header === 'string' ? declared.header : 'Authorization';
|
|
1450
|
-
const scheme = typeof declared.scheme === 'string' ? declared.scheme : 'Bearer';
|
|
1451
|
-
return { name, value: scheme === 'none' ? token : `Bearer ${token}` };
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// --- settings backups (DEC-0025, FAC-SEM-035) ------------------------------------------------
|
|
1455
|
-
// The twins of fabric_service.make_settings_backup and the rest; both kits write byte-identical files.
|
|
1456
|
-
|
|
1457
|
-
export const SETTINGS_BACKUP_FORMAT = 'fabric-settings-backup/1';
|
|
1458
|
-
export const SETTINGS_BACKUP_KEEP = 14;
|
|
1459
|
-
export const SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60;
|
|
1460
|
-
const TABLE = /^[a-z][a-z0-9_]{0,62}$/;
|
|
1461
|
-
const SECRET_TABLE = /^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$/;
|
|
1462
|
-
const COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
|
|
1463
|
-
const SECRET_COLUMN = /^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?$/;
|
|
1464
|
-
const REASON = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
1465
|
-
const SHA256 = /^[0-9a-f]{64}$/;
|
|
1466
|
-
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
1467
|
-
const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/;
|
|
1468
|
-
const MAX_CELL_CHARS = 1048576;
|
|
1469
|
-
const MAX_ROW_VALUES = 512;
|
|
1470
|
-
const STALE_TEMP_MS = 60 * 60 * 1000;
|
|
1471
|
-
const BACKUP_NAME = /^settings-[0-9]{8}T[0-9]{12}Z\.json$/;
|
|
1472
|
-
|
|
1473
|
-
function byCodePoint(a, b) {
|
|
1474
|
-
const x = Array.from(a), y = Array.from(b);
|
|
1475
|
-
for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
|
|
1476
|
-
const d = x[i].codePointAt(0) - y[i].codePointAt(0);
|
|
1477
|
-
if (d !== 0) return d;
|
|
1478
|
-
}
|
|
1479
|
-
return x.length - y.length;
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
/**
|
|
1483
|
-
* The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, `", "` and `": "`,
|
|
1484
|
-
* strings escaped as JSON requires and nothing else, integers in decimal — Python's
|
|
1485
|
-
* `json.dumps(value, sort_keys=True, ensure_ascii=False)`. Refuses a fraction, an unsafe integer and non-JSON.
|
|
1486
|
-
*/
|
|
1487
|
-
/** CT-25 (DEC-0052): a lone surrogate in a string or a key has no canonical form — Python cannot encode it as UTF-8. */
|
|
1488
|
-
function settingsString(text) {
|
|
1489
|
-
if (/\p{Surrogate}/u.test(text)) throw new ServiceError('a lone surrogate has no canonical form');
|
|
1490
|
-
return JSON.stringify(text);
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
export function settingsCanonicalJson(value) {
|
|
1494
|
-
if (value === null) return 'null';
|
|
1495
|
-
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
1496
|
-
if (typeof value === 'number') {
|
|
1497
|
-
if (!Number.isSafeInteger(value)) throw new ServiceError(`${value} has no canonical form: a settings backup stores a fraction as its decimal string`);
|
|
1498
|
-
return String(value);
|
|
1499
|
-
}
|
|
1500
|
-
if (typeof value === 'string') return settingsString(value);
|
|
1501
|
-
if (Array.isArray(value)) return `[${value.map(settingsCanonicalJson).join(', ')}]`;
|
|
1502
|
-
if (isPlainObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
|
1503
|
-
return `{${Object.keys(value).sort(byCodePoint).map((key) => `${settingsString(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
|
|
1504
|
-
}
|
|
1505
|
-
throw new ServiceError(`${typeof value} is not a JSON value`);
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
/** The `sha256` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of `tables`. */
|
|
1509
|
-
export const settingsBackupDigest = (tables) => crypto.createHash('sha256').update(settingsCanonicalJson(tables), 'utf8').digest('hex');
|
|
1510
|
-
|
|
1511
|
-
/** Why a reader refuses this settings backup: its shape and FAC-SEM-035. Empty means intact. */
|
|
1512
|
-
export function settingsBackupProblems(doc) {
|
|
1513
|
-
if (!isPlainObject(doc)) return ['a settings backup is a JSON object'];
|
|
1514
|
-
const problems = [];
|
|
1515
|
-
if (doc.format !== SETTINGS_BACKUP_FORMAT) problems.push(`format must be ${SETTINGS_BACKUP_FORMAT}, not ${JSON.stringify(doc.format ?? null)}: a reader refuses a format it does not know`);
|
|
1516
|
-
for (const key of ['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables']) if (!(key in doc)) problems.push(`missing ${key}`);
|
|
1517
|
-
for (const key of Object.keys(doc)) if (!['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables', 'extensions'].includes(key)) problems.push(`${key} is not a settings backup field`);
|
|
1518
|
-
if ('extensions' in doc) {
|
|
1519
|
-
if (!isPlainObject(doc.extensions)) problems.push('extensions is an object');
|
|
1520
|
-
else for (const key of Object.keys(doc.extensions)) if (!URI.test(key)) problems.push(`extensions key ${JSON.stringify(key)} is not a URI`);
|
|
1521
|
-
}
|
|
1522
|
-
if ('service' in doc) {
|
|
1523
|
-
const s = doc.service;
|
|
1524
|
-
if (!isPlainObject(s) || Object.keys(s).sort().join() !== 'id,instance,schema,version') problems.push('service is {id, instance, version, schema}');
|
|
1525
|
-
else {
|
|
1526
|
-
if (!ID.test(String(s.id))) problems.push(`service.id must match ${ID.source}`);
|
|
1527
|
-
if (!INSTANCE.test(String(s.instance))) problems.push(`service.instance must match ${INSTANCE.source}`);
|
|
1528
|
-
if (!(typeof s.version === 'string' && s.version.length <= 64)) problems.push('service.version is a string of at most 64 characters');
|
|
1529
|
-
if (!((Number.isInteger(s.schema) && s.schema >= 0) || (typeof s.schema === 'string' && s.schema.length >= 1 && s.schema.length <= 64))) problems.push('service.schema is a whole number or a short string');
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
if ('created_at' in doc && !isTimestamp(doc.created_at)) problems.push('created_at is a UTC timestamp');
|
|
1533
|
-
if ('reason' in doc && !(typeof doc.reason === 'string' && REASON.test(doc.reason))) problems.push(`reason must match ${REASON.source}`);
|
|
1534
|
-
if ('sha256' in doc && !(typeof doc.sha256 === 'string' && SHA256.test(doc.sha256))) problems.push('sha256 is 64 lowercase hex digits');
|
|
1535
|
-
const tables = isPlainObject(doc.tables) ? doc.tables : {};
|
|
1536
|
-
const counts = isPlainObject(doc.counts) ? doc.counts : {};
|
|
1537
|
-
if ('tables' in doc && !isPlainObject(doc.tables)) problems.push('tables is an object');
|
|
1538
|
-
if ('counts' in doc && !isPlainObject(doc.counts)) problems.push('counts is an object');
|
|
1539
|
-
let canonical = true;
|
|
1540
|
-
for (const name of [...Object.keys(tables), ...Object.keys(counts).filter((n) => !Object.hasOwn(tables, n))]) {
|
|
1541
|
-
if (LONE_SURROGATE.test(name)) canonical = false;
|
|
1542
|
-
if (!TABLE.test(name) || SECRET_TABLE.test(name)) problems.push(`table ${name} is never backed up: it can only hold connections, tokens, secrets, credentials, passwords, login codes or sessions (or its name is not a table name)`);
|
|
1543
|
-
}
|
|
1544
|
-
for (const [name, count] of Object.entries(counts)) if (!(Number.isInteger(count) && count >= 0)) problems.push(`counts.${name} is a whole number`);
|
|
1545
|
-
for (const [name, table] of Object.entries(tables)) {
|
|
1546
|
-
if (!isPlainObject(table) || Object.keys(table).sort().join() !== 'columns,rows') { problems.push(`tables.${name} is {columns, rows}`); continue; }
|
|
1547
|
-
let columns = table.columns;
|
|
1548
|
-
if (!(Array.isArray(columns) && columns.length >= 1 && columns.length <= 512 && new Set(columns.map(String)).size === columns.length)) {
|
|
1549
|
-
problems.push(`tables.${name}.columns is 1 to 512 distinct names`);
|
|
1550
|
-
if (!Array.isArray(columns)) columns = [];
|
|
1551
|
-
}
|
|
1552
|
-
for (const column of columns) {
|
|
1553
|
-
if (!(typeof column === 'string' && COLUMN.test(column)) || SECRET_COLUMN.test(String(column))) problems.push(`column ${name}.${column} is refused: its name says it holds a credential value (name the secret, never store it: secret_name, token_slot)`);
|
|
1554
|
-
}
|
|
1555
|
-
if (!Array.isArray(table.rows)) { problems.push(`tables.${name}.rows is a list`); continue; }
|
|
1556
|
-
table.rows.forEach((row, i) => {
|
|
1557
|
-
const at = `/tables/${name}/rows/${i}`;
|
|
1558
|
-
if (!Array.isArray(row)) { problems.push(`${at} is a list of values`); return; }
|
|
1559
|
-
if (row.length !== columns.length) problems.push(`FAC-SEM-035 ${at}: row has ${row.length} values for ${columns.length} columns`);
|
|
1560
|
-
if (row.length > MAX_ROW_VALUES) problems.push(`${at} holds more than ${MAX_ROW_VALUES} values`);
|
|
1561
|
-
row.forEach((cell, j) => {
|
|
1562
|
-
if (typeof cell === 'number' && !Number.isSafeInteger(cell)) {
|
|
1563
|
-
canonical = false;
|
|
1564
|
-
problems.push(`FAC-SEM-035 ${at}/${j}: a fraction or an unsafe integer has no canonical form; store it as a string`);
|
|
1565
|
-
} else if (typeof cell === 'string' && cell.length > MAX_CELL_CHARS) {
|
|
1566
|
-
problems.push(`${at}/${j} is longer than ${MAX_CELL_CHARS} characters`);
|
|
1567
|
-
} else if (typeof cell === 'string' && LONE_SURROGATE.test(cell)) {
|
|
1568
|
-
canonical = false;
|
|
1569
|
-
problems.push(`FAC-SEM-035 ${at}/${j}: a string with a lone surrogate cannot be encoded as UTF-8`);
|
|
1570
|
-
} else if (!(cell === null || ['boolean', 'number', 'string'].includes(typeof cell))) {
|
|
1571
|
-
canonical = false;
|
|
1572
|
-
problems.push(`${at}/${j} is not a JSON scalar`);
|
|
1573
|
-
}
|
|
1574
|
-
});
|
|
1575
|
-
});
|
|
1576
|
-
if (counts[name] !== table.rows.length) problems.push(`FAC-SEM-035 /counts/${name}: counts says ${counts[name] ?? 'nothing'} for ${table.rows.length} rows`);
|
|
1577
|
-
}
|
|
1578
|
-
for (const name of Object.keys(counts)) if (!Object.hasOwn(tables, name)) problems.push(`FAC-SEM-035 /counts/${name}: counts names ${name}, which the backup does not carry`);
|
|
1579
|
-
if (canonical && isPlainObject(doc.tables)) {
|
|
1580
|
-
let digest = null;
|
|
1581
|
-
try {
|
|
1582
|
-
digest = settingsBackupDigest(tables);
|
|
1583
|
-
} catch (error) {
|
|
1584
|
-
problems.push(`FAC-SEM-035 /tables: the tables have no canonical form (${error.message})`);
|
|
1585
|
-
}
|
|
1586
|
-
if (digest !== null && doc.sha256 !== digest) problems.push('FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it');
|
|
1587
|
-
}
|
|
1588
|
-
return problems;
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
/** Where every service keeps its settings backups — outside its data directory (DEC-0025). */
|
|
1592
|
-
export function settingsBackupRoot(platform = process.platform) {
|
|
1593
|
-
if (platform === 'darwin') return path.join(os.homedir(), 'Library/Application Support/PassionCode');
|
|
1594
|
-
if (platform === 'win32') return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'PassionCode');
|
|
1595
|
-
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'PassionCode');
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
export function settingsBackupDir(id, instance = 'default', root = settingsBackupRoot()) {
|
|
1599
|
-
if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
|
|
1600
|
-
if (!INSTANCE.test(instance)) throw new ServiceError(`instance must match ${INSTANCE}`);
|
|
1601
|
-
return path.join(root, 'settings-backups', id, instance);
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1604
|
-
const microStamp = (ms) => `${new Date(ms).toISOString().slice(0, 23)}000Z`;
|
|
1605
|
-
|
|
1606
|
-
/** A snapshot of the operator's decisions and bindings (the tables on the service's allow-list). Refuses a file a reader would refuse. */
|
|
1607
|
-
export function makeSettingsBackup({ id, instance = 'default', version, schema, tables, reason, now = Date.now() }) {
|
|
1608
|
-
const doc = {
|
|
1609
|
-
format: SETTINGS_BACKUP_FORMAT, service: { id, instance, version, schema }, created_at: microStamp(now), reason, sha256: '',
|
|
1610
|
-
counts: Object.fromEntries(Object.entries(tables).map(([name, table]) => [name, (table?.rows ?? []).length])), tables,
|
|
1611
|
-
};
|
|
1612
|
-
try {
|
|
1613
|
-
doc.sha256 = settingsBackupDigest(tables);
|
|
1614
|
-
} catch (error) {
|
|
1615
|
-
throw new ServiceError(`This snapshot has no canonical form: ${error.message}`);
|
|
1616
|
-
}
|
|
1617
|
-
const problems = settingsBackupProblems(doc);
|
|
1618
|
-
if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
|
|
1619
|
-
return doc;
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
const backupName = (doc) => `settings-${doc.created_at.replace(/[-:]/g, '').replace(/\.(\d+)Z$/, (_, f) => `${(f + '000000').slice(0, 6)}Z`)}.json`;
|
|
1623
|
-
|
|
1624
|
-
/** The snapshot files, newest first. */
|
|
1625
|
-
export function settingsBackups(dir) {
|
|
1626
|
-
if (!fs.existsSync(dir)) return [];
|
|
1627
|
-
return fs.readdirSync(dir).filter((n) => BACKUP_NAME.test(n)).sort().reverse().map((n) => path.join(dir, n));
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
/** The first number written with a fraction or an exponent outside a string (`1.0`, `1e2`); JSON.parse would read
|
|
1631
|
-
* `1.0` as the integer 1, where the Python reader — and the canonical form — sees a fraction. */
|
|
1632
|
-
function fractionLexeme(text) {
|
|
1633
|
-
let inString = false;
|
|
1634
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
1635
|
-
const c = text[i];
|
|
1636
|
-
if (inString) {
|
|
1637
|
-
if (c === '\\') i += 1;
|
|
1638
|
-
else if (c === '"') inString = false;
|
|
1639
|
-
} else if (c === '"') {
|
|
1640
|
-
inString = true;
|
|
1641
|
-
} else if (c === '-' || (c >= '0' && c <= '9')) {
|
|
1642
|
-
const lexeme = text.slice(i).match(/^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)[0];
|
|
1643
|
-
if (/[.eE]/.test(lexeme)) return lexeme;
|
|
1644
|
-
i += lexeme.length - 1;
|
|
1645
|
-
}
|
|
1646
|
-
}
|
|
1647
|
-
return null;
|
|
1648
|
-
}
|
|
1649
|
-
|
|
1650
|
-
/** A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035). */
|
|
1651
|
-
export function readSettingsBackup(file) {
|
|
1652
|
-
let doc;
|
|
1653
|
-
try {
|
|
1654
|
-
const text = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(file));
|
|
1655
|
-
const fraction = fractionLexeme(text);
|
|
1656
|
-
if (fraction) throw new Error(`${fraction} is not an integer: a fraction is stored as its decimal string`);
|
|
1657
|
-
doc = JSON.parse(text);
|
|
1658
|
-
} catch (error) {
|
|
1659
|
-
throw new ServiceError(`Settings backup ${file} cannot be read: ${error.message}`);
|
|
1660
|
-
}
|
|
1661
|
-
const problems = settingsBackupProblems(doc);
|
|
1662
|
-
if (problems.length) throw new ServiceError(`Settings backup ${file} is refused: ${problems.join('; ')}`);
|
|
1663
|
-
return doc;
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
/**
|
|
1667
|
-
* Write a snapshot: atomically, 0600 in a 0700 directory, named by its time. A temporary file an interrupted write
|
|
1668
|
-
* left is removed first; a snapshot whose checksum equals the newest file's is not written again (null). After a
|
|
1669
|
-
* write, all but the `keep` newest are removed (at least 14).
|
|
1670
|
-
*/
|
|
1671
|
-
export function writeSettingsBackup(doc, dir, { keep = SETTINGS_BACKUP_KEEP } = {}) {
|
|
1672
|
-
const problems = settingsBackupProblems(doc);
|
|
1673
|
-
if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
|
|
1674
|
-
ensurePrivateDir(dir);
|
|
1675
|
-
// Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
|
|
1676
|
-
for (const left of fs.readdirSync(dir)) {
|
|
1677
|
-
const at = path.join(dir, left);
|
|
1678
|
-
try {
|
|
1679
|
-
if (left.startsWith('.settings-') && fs.statSync(at).mtimeMs < Date.now() - STALE_TEMP_MS) fs.rmSync(at, { force: true });
|
|
1680
|
-
} catch { /* gone meanwhile */ }
|
|
1681
|
-
}
|
|
1682
|
-
const existing = settingsBackups(dir);
|
|
1683
|
-
if (existing.length) {
|
|
1684
|
-
try {
|
|
1685
|
-
if (readSettingsBackup(existing[0]).sha256 === doc.sha256) return null;
|
|
1686
|
-
} catch { /* a damaged newest file is no reason to skip a good snapshot */ }
|
|
1687
|
-
}
|
|
1688
|
-
const file = path.join(dir, backupName(doc));
|
|
1689
|
-
atomicWrite(file, `${JSON.stringify(doc, null, 2)}\n`, 0o600);
|
|
1690
|
-
// The newest by name, but never the one just written: a clock that stepped back must not delete it.
|
|
1691
|
-
for (const old of settingsBackups(dir).filter((f) => f !== file).slice(Math.max(keep, SETTINGS_BACKUP_KEEP) - 1)) fs.rmSync(old, { force: true });
|
|
1692
|
-
return file;
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
/** True when there is no snapshot or the newest is older than 24 hours. */
|
|
1696
|
-
export function backupDue(dir, now = Date.now()) {
|
|
1697
|
-
const newest = settingsBackups(dir)[0];
|
|
1698
|
-
if (!newest) return true;
|
|
1699
|
-
const m = path.basename(newest).match(/^settings-(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})\d{3}Z\.json$/);
|
|
1700
|
-
// A name no calendar has: take a fresh snapshot rather than trust it (Date.UTC would roll it over).
|
|
1701
|
-
if (!isTimestamp(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`)) return true;
|
|
1702
|
-
const at = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], +m[7]);
|
|
1703
|
-
return (now - at) / 1000 > SETTINGS_BACKUP_MAX_AGE_SECONDS;
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
/** The source of an automatic restore: the newest intact snapshot with content; damaged ones are returned with the reason. */
|
|
1707
|
-
export function newestIntactBackup(dir) {
|
|
1708
|
-
const skipped = [];
|
|
1709
|
-
for (const file of settingsBackups(dir)) {
|
|
1710
|
-
let doc;
|
|
1711
|
-
try {
|
|
1712
|
-
doc = readSettingsBackup(file);
|
|
1713
|
-
} catch (error) {
|
|
1714
|
-
skipped.push({ file, reason: error.message });
|
|
1715
|
-
continue;
|
|
1716
|
-
}
|
|
1717
|
-
if (Object.values(doc.counts).some((n) => n > 0)) return { file, doc, skipped };
|
|
1718
|
-
}
|
|
1719
|
-
return { file: null, doc: null, skipped };
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
/**
|
|
1723
|
-
* What restoring `doc` would do — it adds and never overwrites (DEC-0025). `current[table]` is `{columns, key,
|
|
1724
|
-
* required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan.
|
|
1725
|
-
*/
|
|
1726
|
-
export function restorePlan(doc, current) {
|
|
1727
|
-
const problems = settingsBackupProblems(doc);
|
|
1728
|
-
if (problems.length) throw new ServiceError(`A damaged snapshot is never restored: ${problems.join('; ')}`);
|
|
1729
|
-
const plan = { source: { created_at: doc.created_at, sha256: doc.sha256 }, tables: {}, unknownTables: [] };
|
|
1730
|
-
for (const [name, table] of Object.entries(doc.tables)) {
|
|
1731
|
-
const now = current[name];
|
|
1732
|
-
if (!now) { plan.unknownTables.push(name); continue; }
|
|
1733
|
-
const columns = now.columns ?? [], key = now.key ?? [], required = now.required ?? [];
|
|
1734
|
-
const existing = new Set((now.existing ?? []).map((k) => JSON.stringify([...k])));
|
|
1735
|
-
const shared = table.columns.filter((c) => columns.includes(c));
|
|
1736
|
-
const missing = required.filter((c) => !table.columns.includes(c));
|
|
1737
|
-
const entry = {
|
|
1738
|
-
insert: [], present: 0, droppedColumns: table.columns.filter((c) => !columns.includes(c)),
|
|
1739
|
-
defaultedColumns: columns.filter((c) => !table.columns.includes(c)), missingRequired: missing, refused: [],
|
|
1740
|
-
};
|
|
1741
|
-
const planned = new Set();
|
|
1742
|
-
for (const row of table.rows) {
|
|
1743
|
-
const values = Object.fromEntries(table.columns.map((c, i) => [c, row[i]]));
|
|
1744
|
-
const ident = key.map((c) => values[c] ?? null);
|
|
1745
|
-
if (key.some((c) => !table.columns.includes(c))) entry.refused.push({ key: ident, reason: 'the primary key is not in the snapshot' });
|
|
1746
|
-
else if (planned.has(JSON.stringify(ident))) entry.refused.push({ key: ident, reason: 'the snapshot holds this key twice' });
|
|
1747
|
-
else if (existing.has(JSON.stringify(ident))) entry.present += 1;
|
|
1748
|
-
else if (missing.length) entry.refused.push({ key: ident, reason: `required column ${missing.join(', ')} is not in the snapshot` });
|
|
1749
|
-
else {
|
|
1750
|
-
planned.add(JSON.stringify(ident));
|
|
1751
|
-
entry.insert.push(Object.fromEntries(shared.map((c) => [c, values[c]])));
|
|
1752
|
-
}
|
|
1753
|
-
}
|
|
1754
|
-
plan.tables[name] = entry;
|
|
1755
|
-
}
|
|
1756
|
-
return plan;
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
/** Usage receipts for services that keep none yet: append-only JSON lines, 0600, pruned to the window (LC-12).
|
|
1760
|
-
* One writer process (the service holds its instance lock); every method is synchronous, so calls never interleave. */
|
|
1761
|
-
export class JsonlUsageLedger {
|
|
1762
|
-
constructor(file) { this.file = file; }
|
|
1763
|
-
|
|
1764
|
-
record(receipt) {
|
|
1765
|
-
ensurePrivateDir(path.dirname(this.file));
|
|
1766
|
-
const fd = fs.openSync(this.file, 'a+', 0o600);
|
|
1767
|
-
try {
|
|
1768
|
-
const { size } = fs.fstatSync(fd);
|
|
1769
|
-
const last = Buffer.alloc(1);
|
|
1770
|
-
// A killed writer left a torn line: never glue a receipt to it.
|
|
1771
|
-
const lead = size && fs.readSync(fd, last, 0, 1, size - 1) === 1 && last[0] !== 0x0a ? '\n' : '';
|
|
1772
|
-
fs.writeSync(fd, lead + JSON.stringify(receipt) + '\n');
|
|
1773
|
-
} finally {
|
|
1774
|
-
fs.closeSync(fd);
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
|
|
1778
|
-
receipts() {
|
|
1779
|
-
if (!fs.existsSync(this.file)) return [];
|
|
1780
|
-
const out = [];
|
|
1781
|
-
for (const line of fs.readFileSync(this.file, 'utf8').split('\n')) {
|
|
1782
|
-
if (!line) continue;
|
|
1783
|
-
try { out.push(JSON.parse(line)); } catch { /* a torn last line from a killed writer is skipped */ }
|
|
1784
|
-
}
|
|
1785
|
-
return out;
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
prune(now = Date.now()) {
|
|
1789
|
-
const first = utcDate(now - (USAGE_DAYS - 1) * 86_400_000);
|
|
1790
|
-
const all = this.receipts();
|
|
1791
|
-
const kept = all.filter((r) => String(r.at ?? '').slice(0, 10) >= first);
|
|
1792
|
-
if (kept.length !== all.length) atomicWrite(this.file, kept.map((r) => JSON.stringify(r) + '\n').join(''));
|
|
1793
|
-
return all.length - kept.length;
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
report(options) { return usageReport(this.receipts(), options); }
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
// --- logs (LC-12) ------------------------------------------------------------------------
|
|
1800
|
-
|
|
1801
|
-
/**
|
|
1802
|
-
* One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a 0700
|
|
1803
|
-
* directory. `service.jsonl` -> `.1` ... `.<backups>`; the oldest falls off. Never pass a token,
|
|
1804
|
-
* a cookie, a login code or a request body that may carry one.
|
|
1805
|
-
*/
|
|
1806
|
-
export class RotatingLog {
|
|
1807
|
-
constructor(file, { maxBytes = LOG_MAX_BYTES, backups = LOG_BACKUPS } = {}) {
|
|
1808
|
-
if (maxBytes < 256 || backups < 1) throw new ServiceError('A rotating log needs maxBytes >= 256 and at least one backup.');
|
|
1809
|
-
this.file = file;
|
|
1810
|
-
this.maxBytes = maxBytes;
|
|
1811
|
-
this.backups = backups;
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
line(record) {
|
|
1815
|
-
let data = Buffer.from(`${JSON.stringify(record)}\n`);
|
|
1816
|
-
if (data.length <= this.maxBytes) return data;
|
|
1817
|
-
const cut = { ...record, truncated: true };
|
|
1818
|
-
let message = String(record.message ?? '');
|
|
1819
|
-
while (message) {
|
|
1820
|
-
message = message.slice(0, Math.floor(message.length / 2));
|
|
1821
|
-
cut.message = message;
|
|
1822
|
-
data = Buffer.from(`${JSON.stringify(cut)}\n`);
|
|
1823
|
-
if (data.length <= this.maxBytes) return data;
|
|
1824
|
-
}
|
|
1825
|
-
return Buffer.from(`${JSON.stringify({ at: record.at, level: record.level, message: '', truncated: true })}\n`);
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
write(level, message, fields = {}) {
|
|
1829
|
-
const data = this.line({ at: nowIso(), level: String(level), message: String(message), ...fields });
|
|
1830
|
-
ensurePrivateDir(path.dirname(this.file));
|
|
1831
|
-
let size = 0;
|
|
1832
|
-
try { size = fs.statSync(this.file).size; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
1833
|
-
if (size && size + data.length > this.maxBytes) this.rotate();
|
|
1834
|
-
const fd = fs.openSync(this.file, 'a', 0o600);
|
|
1835
|
-
try { fs.writeSync(fd, data); } finally { fs.closeSync(fd); }
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
rotate() {
|
|
1839
|
-
for (let i = this.backups - 1; i > 0; i -= 1) {
|
|
1840
|
-
if (fs.existsSync(`${this.file}.${i}`)) fs.renameSync(`${this.file}.${i}`, `${this.file}.${i + 1}`);
|
|
1841
|
-
}
|
|
1842
|
-
fs.renameSync(this.file, `${this.file}.1`);
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
|
|
1846
|
-
/**
|
|
1847
|
-
* Call once at start: a launchd stdout file over `maxBytes` is copied to `<file>.1` and truncated
|
|
1848
|
-
* in place (launchd holds it open for append, so a rename would leave it writing to the old name).
|
|
1849
|
-
*/
|
|
1850
|
-
export function capStdoutLog(file, maxBytes = LOG_MAX_BYTES) {
|
|
1851
|
-
let size;
|
|
1852
|
-
try { size = fs.statSync(file).size; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
1853
|
-
if (size <= maxBytes) return false;
|
|
1854
|
-
fs.copyFileSync(file, `${file}.1`);
|
|
1855
|
-
fs.chmodSync(`${file}.1`, 0o600);
|
|
1856
|
-
fs.truncateSync(file, 0);
|
|
1857
|
-
return true;
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
// --- shutdown (LC-01) ----------------------------------------------------------------------
|
|
1861
|
-
|
|
1862
|
-
export class Stopping extends ServiceError {}
|
|
1863
|
-
|
|
1864
|
-
/**
|
|
1865
|
-
* SIGTERM/SIGINT: stop taking new work, drain in-flight work until `deadline` seconds, then hand
|
|
1866
|
-
* over with `onStop(drained)`. A hand-over that never settles is cut by a hard exit
|
|
1867
|
-
* (EXIT_HARD_STOP) `grace` seconds later.
|
|
1868
|
-
*
|
|
1869
|
-
* const drain = new Drain().install((drained) => server.close(() => process.exit(0)),
|
|
1870
|
-
* { onStopping: () => log.append('service.stopping', 'info', 'Stopping.') });
|
|
1871
|
-
* await drain.work(async () => { ... }); // rejects with Stopping once a stop has begun
|
|
1872
|
-
*/
|
|
1873
|
-
export class Drain {
|
|
1874
|
-
constructor({ deadline = DRAIN_SECONDS, grace = DRAIN_GRACE_SECONDS } = {}) {
|
|
1875
|
-
this.deadline = deadline;
|
|
1876
|
-
this.grace = grace;
|
|
1877
|
-
this.inflight = 0;
|
|
1878
|
-
this.stopping = false;
|
|
1879
|
-
this.waiters = new Set();
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
async work(fn) {
|
|
1883
|
-
if (this.stopping) throw new Stopping('The service is stopping and takes no new work.');
|
|
1884
|
-
this.inflight += 1;
|
|
1885
|
-
try {
|
|
1886
|
-
return await fn();
|
|
1887
|
-
} finally {
|
|
1888
|
-
this.inflight -= 1;
|
|
1889
|
-
if (this.inflight === 0) for (const wake of this.waiters) wake();
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
/** Refuse new work from now on; false when a stop had already begun. */
|
|
1894
|
-
requestStop() {
|
|
1895
|
-
if (this.stopping) return false;
|
|
1896
|
-
this.stopping = true;
|
|
1897
|
-
return true;
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
/** Resolves true when in-flight work finished inside `timeoutSeconds`, false otherwise. */
|
|
1901
|
-
wait(timeoutSeconds) {
|
|
1902
|
-
if (this.inflight === 0) return Promise.resolve(true);
|
|
1903
|
-
return new Promise((resolve) => {
|
|
1904
|
-
const wake = () => { clearTimeout(timer); this.waiters.delete(wake); resolve(true); };
|
|
1905
|
-
const timer = setTimeout(() => { this.waiters.delete(wake); resolve(false); }, timeoutSeconds * 1000);
|
|
1906
|
-
this.waiters.add(wake);
|
|
1907
|
-
});
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
install(onStop, { onStopping, signals = ['SIGTERM', 'SIGINT'] } = {}) {
|
|
1911
|
-
const handler = () => {
|
|
1912
|
-
if (!this.requestStop()) return; // a second signal changes nothing: the hard exit holds the deadline
|
|
1913
|
-
setTimeout(() => process.exit(EXIT_HARD_STOP), (this.deadline + this.grace) * 1000).unref();
|
|
1914
|
-
(async () => {
|
|
1915
|
-
try { await onStopping?.(); } catch (error) { process.stderr.write(`${error?.stack ?? error}\n`); }
|
|
1916
|
-
await onStop(await this.wait(this.deadline));
|
|
1917
|
-
})();
|
|
1918
|
-
};
|
|
1919
|
-
for (const signal of signals) process.on(signal, handler);
|
|
1920
|
-
return this;
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
// --- releases (LC-11, LC-15) -----------------------------------------------------------------
|
|
1925
|
-
|
|
1926
|
-
/**
|
|
1927
|
-
* Keep the release the job runs and the one before it; remove older release directories, newest
|
|
1928
|
-
* first by modification time. `current` (a release or a symlink to one) is never removed, even
|
|
1929
|
-
* after a rollback. Symlinks and files are left alone. Returns the removed paths.
|
|
1930
|
-
*/
|
|
1931
|
-
export function pruneReleases(releasesDir, { keep = 2, current } = {}) {
|
|
1932
|
-
if (keep < 2) throw new ServiceError('keep at least the current release and the one before it (keep >= 2).');
|
|
1933
|
-
if (!fs.existsSync(releasesDir)) return [];
|
|
1934
|
-
const releases = fs.readdirSync(releasesDir, { withFileTypes: true })
|
|
1935
|
-
.filter((e) => e.isDirectory() && !e.isSymbolicLink())
|
|
1936
|
-
.map((e) => path.join(releasesDir, e.name))
|
|
1937
|
-
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
1938
|
-
const keepers = [];
|
|
1939
|
-
if (current) {
|
|
1940
|
-
const resolved = fs.realpathSync(current);
|
|
1941
|
-
keepers.push(...releases.filter((p) => fs.realpathSync(p) === resolved));
|
|
1942
|
-
}
|
|
1943
|
-
for (const release of releases) {
|
|
1944
|
-
if (keepers.length >= keep) break;
|
|
1945
|
-
if (!keepers.includes(release)) keepers.push(release);
|
|
1946
|
-
}
|
|
1947
|
-
const removed = releases.filter((p) => !keepers.includes(p));
|
|
1948
|
-
for (const release of removed) fs.rmSync(release, { recursive: true, force: true });
|
|
1949
|
-
return removed;
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
|
-
// --- operator login -------------------------------------------------------------------
|
|
1953
|
-
|
|
1954
|
-
/** DEC-0039 (service.md "The session's lifetime"): the longest a service may keep an operator session. */
|
|
1955
|
-
export const OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60;
|
|
1956
|
-
export const OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60;
|
|
1957
|
-
|
|
1958
|
-
// Compare bytes, not UTF-16 units: a non-ASCII value of the right length must be a refusal, not a RangeError.
|
|
1959
|
-
const safeEqual = (a, b) => {
|
|
1960
|
-
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
|
|
1961
|
-
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
1962
|
-
};
|
|
1963
|
-
const sessionBound = (value, longest) => {
|
|
1964
|
-
if (typeof value !== 'number' || !Number.isFinite(value)) throw new ServiceError('a session bound must be a finite number of seconds.');
|
|
1965
|
-
return Math.min(Math.trunc(value), longest);
|
|
1966
|
-
};
|
|
1967
|
-
|
|
1968
|
-
// Single-use login codes, and operator sessions with the DEC-0039 lifetime: a session ends 12 h after its
|
|
1969
|
-
// last use, 7 days after the code was redeemed, on logout, and when the descriptor token rotates. The server
|
|
1970
|
-
// enforces all four: the cookie carries a signed claim (session id, sign-in time, token revision) and the
|
|
1971
|
-
// code store keeps a record per session (last use, logout). Rotating the key with revokeAll() ends every one.
|
|
1972
|
-
export class LoginCodes {
|
|
1973
|
-
// `stateDir` — a local service keeps codes, sessions and the session key in files. An online service
|
|
1974
|
-
// (DEC-0019) usually has no durable disk: pass `stateDir = null` with `{ store, key }` — `store` like
|
|
1975
|
-
// `new MemoryCodeStore()` and `key` a Buffer from a platform secret. A memory store forgets codes AND
|
|
1976
|
-
// sessions on a restart, so a deploy signs the operator out (the host signs in again on the page's 401);
|
|
1977
|
-
// pass a durable store to keep sessions. `token` is the descriptor token, or a function returning the
|
|
1978
|
-
// current one: a session begun under one token is refused once it rotates. A service MAY shorten
|
|
1979
|
-
// `idleSeconds` or `maxSeconds`, never lengthen them.
|
|
1980
|
-
constructor(stateDir, ttlSeconds = 120, { store = null, key = null, token = null,
|
|
1981
|
-
idleSeconds = OPERATOR_SESSION_IDLE_SECONDS, maxSeconds = OPERATOR_SESSION_MAX_SECONDS, clock = () => Date.now() / 1000 } = {}) {
|
|
1982
|
-
this.ttl = Math.min(ttlSeconds, 120);
|
|
1983
|
-
this.idleSeconds = sessionBound(idleSeconds, OPERATOR_SESSION_IDLE_SECONDS);
|
|
1984
|
-
this.maxSeconds = sessionBound(maxSeconds, OPERATOR_SESSION_MAX_SECONDS);
|
|
1985
|
-
this.store = store;
|
|
1986
|
-
this.fixedKey = key;
|
|
1987
|
-
this.token = token;
|
|
1988
|
-
this.clock = clock;
|
|
1989
|
-
if (stateDir) {
|
|
1990
|
-
this.dir = ensurePrivateDir(stateDir);
|
|
1991
|
-
this.keyPath = path.join(this.dir, 'session.key');
|
|
1992
|
-
this.codesPath = path.join(this.dir, 'login-codes.json');
|
|
1993
|
-
} else if (!store || !key) {
|
|
1994
|
-
throw new ServiceError('LoginCodes without a state directory needs { store, key }.');
|
|
1995
|
-
}
|
|
1996
|
-
if (key && Buffer.from(key).length < 32) throw new ServiceError('the session key must be at least 32 bytes.');
|
|
1997
|
-
}
|
|
1998
|
-
|
|
1999
|
-
key() {
|
|
2000
|
-
if (this.fixedKey) return Buffer.from(this.fixedKey);
|
|
2001
|
-
if (!fs.existsSync(this.keyPath)) atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
|
|
2002
|
-
return fs.readFileSync(this.keyPath);
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
load() {
|
|
2006
|
-
if (this.store) return this.store.load();
|
|
2007
|
-
try { return JSON.parse(fs.readFileSync(this.codesPath, 'utf8')); } catch { return {}; }
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
save(codes) {
|
|
2011
|
-
const horizon = this.clock() - 3600;
|
|
2012
|
-
const kept = Object.fromEntries(Object.entries(codes).filter(([, v]) => (v.expires ?? 0) > horizon));
|
|
2013
|
-
if (this.store) { this.store.save(kept); return; }
|
|
2014
|
-
atomicWrite(this.codesPath, JSON.stringify(kept), 0o600);
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
issue() {
|
|
2018
|
-
const code = crypto.randomBytes(24).toString('base64url');
|
|
2019
|
-
const expires = this.clock() + this.ttl;
|
|
2020
|
-
const codes = this.load();
|
|
2021
|
-
codes[crypto.createHash('sha256').update(code).digest('hex')] = { expires, used: false };
|
|
2022
|
-
this.save(codes);
|
|
2023
|
-
return { url: `/fabric/v1/login?code=${code}`, expiresAt: new Date(expires * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z') };
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
// The code is recorded as used BEFORE it is honoured, and burned even when expired.
|
|
2027
|
-
redeem(code) {
|
|
2028
|
-
if (!code || !CODE.test(code)) return null;
|
|
2029
|
-
const digest = crypto.createHash('sha256').update(code).digest('hex');
|
|
2030
|
-
const codes = this.load();
|
|
2031
|
-
const entry = codes[digest];
|
|
2032
|
-
if (!entry || entry.used) return null;
|
|
2033
|
-
entry.used = true;
|
|
2034
|
-
this.save(codes);
|
|
2035
|
-
const now = this.clock();
|
|
2036
|
-
if (entry.expires < now) return null;
|
|
2037
|
-
const session = crypto.randomBytes(18).toString('base64url');
|
|
2038
|
-
const signedInAt = Math.trunc(now);
|
|
2039
|
-
codes[LoginCodes.recordKey(session)] = { expires: signedInAt + this.maxSeconds, signedInAt, lastUsedAt: now };
|
|
2040
|
-
this.save(codes);
|
|
2041
|
-
const claim = `v2.${session}.${signedInAt}.${this.tokenRevision()}`;
|
|
2042
|
-
return `${claim}.${this.mac(claim)}`;
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
/** True while the session is live; a use within the bounds refreshes the idle bound. */
|
|
2046
|
-
sessionValid(value) {
|
|
2047
|
-
const parsed = this.parse(value);
|
|
2048
|
-
if (!parsed) return false;
|
|
2049
|
-
const [session, signedInAt] = parsed;
|
|
2050
|
-
const now = this.clock();
|
|
2051
|
-
if (now >= signedInAt + this.maxSeconds) return false;
|
|
2052
|
-
const codes = this.load();
|
|
2053
|
-
const record = codes[LoginCodes.recordKey(session)];
|
|
2054
|
-
if (!record || record.signedInAt !== signedInAt || record.loggedOutAt != null) return false;
|
|
2055
|
-
const last = Number(record.lastUsedAt ?? 0);
|
|
2056
|
-
if (now >= last + this.idleSeconds) return false;
|
|
2057
|
-
if (now - last >= Math.min(60, Math.max(1, Math.trunc(this.idleSeconds / 10)))) {
|
|
2058
|
-
record.lastUsedAt = now;
|
|
2059
|
-
this.save(codes);
|
|
2060
|
-
}
|
|
2061
|
-
return true;
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
|
-
/** End this session on the server; clear the browser's copy with expiredSessionCookieHeader(). */
|
|
2065
|
-
logout(value) {
|
|
2066
|
-
const parsed = this.parse(value);
|
|
2067
|
-
if (!parsed) return false;
|
|
2068
|
-
const codes = this.load();
|
|
2069
|
-
const record = codes[LoginCodes.recordKey(parsed[0])];
|
|
2070
|
-
if (!record || record.signedInAt !== parsed[1] || record.loggedOutAt != null) return false;
|
|
2071
|
-
record.loggedOutAt = this.clock();
|
|
2072
|
-
this.save(codes);
|
|
2073
|
-
return true;
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
parse(value) {
|
|
2077
|
-
if (typeof value !== 'string' || !/^[\x21-\x7e]*$/.test(value)) return null;
|
|
2078
|
-
const parts = value.split('.');
|
|
2079
|
-
if (parts.length !== 5 || parts[0] !== 'v2' || !parts[1] || !/^\d+$/.test(parts[2])) return null;
|
|
2080
|
-
if (!safeEqual(parts[4], this.mac(parts.slice(0, 4).join('.')))) return null;
|
|
2081
|
-
if (!safeEqual(parts[3], this.tokenRevision())) return null;
|
|
2082
|
-
return [parts[1], Number(parts[2])];
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
mac(claim) { return crypto.createHmac('sha256', this.key()).update(claim).digest('hex'); }
|
|
2086
|
-
|
|
2087
|
-
tokenRevision() {
|
|
2088
|
-
const token = typeof this.token === 'function' ? this.token() : this.token;
|
|
2089
|
-
if (!token) return '-';
|
|
2090
|
-
return crypto.createHmac('sha256', this.key()).update(`token-revision\0${token}`).digest('hex').slice(0, 16);
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
|
-
static recordKey(session) { return `s:${crypto.createHash('sha256').update(session).digest('hex')}`; }
|
|
2094
|
-
|
|
2095
|
-
revokeAll() {
|
|
2096
|
-
if (this.fixedKey) throw new ServiceError('a platform-held session key is rotated on the platform, not here.');
|
|
2097
|
-
atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
|
|
2098
|
-
}
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
/** DEC-0019: a code store held in memory — for an online service with no durable disk. */
|
|
2102
|
-
export class MemoryCodeStore {
|
|
2103
|
-
constructor() { this.codes = {}; }
|
|
2104
|
-
load() { return JSON.parse(JSON.stringify(this.codes)); }
|
|
2105
|
-
save(codes) { this.codes = JSON.parse(JSON.stringify(codes)); }
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
/** The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner. */
|
|
2109
|
-
export const sessionCookieHeader = (value, maxAge = OPERATOR_SESSION_MAX_SECONDS) => `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
|
|
2110
|
-
/** DEC-0019: the remote cookie — `__Host-` name, Secure, no Domain, Path=/. */
|
|
2111
|
-
export const remoteSessionCookieHeader = (value, maxAge = OPERATOR_SESSION_MAX_SECONDS) => `${REMOTE_SESSION_COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
|
|
2112
|
-
/** The header that clears the browser's session cookie, sent with a logout. */
|
|
2113
|
-
export const expiredSessionCookieHeader = (remote = false) => (remote ? remoteSessionCookieHeader : sessionCookieHeader)('', 0);
|
|
2114
|
-
|
|
2115
|
-
/**
|
|
2116
|
-
* DEC-0019: the request guard of an online service. `origin` is the service's own origin
|
|
2117
|
-
* (`https://agent.example.com`). `forwardedProto` is the platform-set scheme where TLS ends
|
|
2118
|
-
* before the process (`x-forwarded-proto`); pass `undefined` when the process terminates TLS.
|
|
2119
|
-
* Returns the refusal sentence, or null.
|
|
2120
|
-
*/
|
|
2121
|
-
export function checkRemoteRequest(origin, host, requestOrigin, secFetchSite, forwardedProto) {
|
|
2122
|
-
const own = new URL(origin);
|
|
2123
|
-
if (String(host ?? '').toLowerCase() !== own.host) return `Host ${host} is not this service.`;
|
|
2124
|
-
if (forwardedProto !== undefined && forwardedProto !== null && String(forwardedProto).split(',')[0].trim() !== 'https') return 'This service answers over https only.';
|
|
2125
|
-
if (requestOrigin !== undefined && requestOrigin !== null && requestOrigin !== own.origin) return `Origin ${requestOrigin} is not this service.`;
|
|
2126
|
-
if (secFetchSite === 'cross-site') return 'Cross-site requests are refused.';
|
|
2127
|
-
return null;
|
|
2128
|
-
}
|
|
2129
|
-
|
|
2130
|
-
/**
|
|
2131
|
-
* DEC-0019: may this request read the well-known document? A local service answers anyone (the
|
|
2132
|
-
* loopback guard already ran); a remote one only the bearer of the service token. A `false`
|
|
2133
|
-
* answer is a `401` with an EMPTY body — nothing about the service is disclosed.
|
|
2134
|
-
*/
|
|
2135
|
-
export function wellKnownAllowed(placement, authorization, token, scheme = 'Bearer') {
|
|
2136
|
-
return placement !== 'remote' || tokenMatches(authorization, token, scheme);
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
/** The token directory a host-side installer uses for remote services on this computer. */
|
|
2140
|
-
export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
|
|
2141
|
-
return path.join(path.dirname(dir), 'tokens', `${id}.${instance}.token`);
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
/**
|
|
2145
|
-
* DEC-0019, installer side: register an online service on THIS computer. Writes the token file
|
|
2146
|
-
* (0600, never printed) and the descriptor; the same token must be set on the hosting platform as
|
|
2147
|
-
* a secret. Returns the descriptor path. `token` is read from the caller — a file or stdin —
|
|
2148
|
-
* never from an argument vector.
|
|
2149
|
-
*/
|
|
2150
|
-
/**
|
|
2151
|
-
* DEC-0019, installer side: register an online service on THIS computer — the token file (0600, never printed) and the
|
|
2152
|
-
* descriptor. DEC-0061: `installedBy` defaults to `{by: 'person', role: 'operator', tool: 'fabric-service register-remote'}`
|
|
2153
|
-
* (a person runs this); `runner` (`{kind?, credentialKind}`) says how its model calls sign in, as a kind and never a
|
|
2154
|
-
* credential; `peer` (`{endpoint, custody, residency, maxDataClass}`) offers it to others and needs `runner`.
|
|
2155
|
-
*/
|
|
2156
|
-
export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(),
|
|
2157
|
-
installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer }) {
|
|
2158
|
-
if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
|
|
2159
|
-
const tokenFile = remoteTokenFile(id, instance, dir);
|
|
2160
|
-
const d = {
|
|
2161
|
-
protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
|
|
2162
|
-
auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
|
|
2163
|
-
installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
|
|
2164
|
-
};
|
|
2165
|
-
const problems = validateDescriptor(d);
|
|
2166
|
-
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
2167
|
-
ensurePrivateDir(path.dirname(tokenFile));
|
|
2168
|
-
atomicWrite(tokenFile, String(token).trim(), 0o600);
|
|
2169
|
-
return writeDescriptor(d, dir);
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
export function cookieValue(header, name = SESSION_COOKIE) {
|
|
2173
|
-
for (const part of String(header ?? '').split(';')) {
|
|
2174
|
-
const [k, ...rest] = part.trim().split('=');
|
|
2175
|
-
if (k === name) return rest.join('=');
|
|
2176
|
-
}
|
|
2177
|
-
return null;
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
// --- systemd and Task Scheduler (DEC-0032, service.md "Supervisor") -------------------------
|
|
2181
|
-
// The twins of fabric_service.systemd_unit / task_xml and their install and uninstall. `run(argv)`
|
|
2182
|
-
// returns spawnSync's {status, stdout, stderr}; the tests pass a recorder instead of the real tools.
|
|
2183
|
-
|
|
2184
|
-
const defaultRun = (argv) => spawnSync(argv[0], argv.slice(1), { encoding: 'utf8', windowsHide: true });
|
|
2185
|
-
|
|
2186
|
-
function plain(value, what) {
|
|
2187
|
-
// A supervisor file is line- and XML-structured: no control character in anything written into it.
|
|
2188
|
-
if (/[\x00-\x1f\x7f]/.test(value)) throw new ServiceError(`${what} holds a control character (a line break, a tab, a NUL); a supervisor file cannot carry it.`);
|
|
2189
|
-
return value;
|
|
2190
|
-
}
|
|
2191
|
-
|
|
2192
|
-
/** The supervisor file's writer writes the marker itself; one already in the arguments could only disagree with it. */
|
|
2193
|
-
function noMarkerArgument(argv) {
|
|
2194
|
-
if (markerArguments(argv).length) throw new ServiceError(`${SUPERVISOR_ARG} is written by the supervisor file's writer; leave it out of the program arguments.`);
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
function noSecretInSupervisorFile(argv, environment) {
|
|
2198
|
-
// No supervisor file carries a secret (service.md): not in an argument, an environment name or its value.
|
|
2199
|
-
for (const arg of argv) {
|
|
2200
|
-
if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a supervisor file never does (read it from the token file or a secret reference).');
|
|
2201
|
-
}
|
|
2202
|
-
for (const [name, value] of Object.entries(environment ?? {})) {
|
|
2203
|
-
if (credentialName(name) || secretValue(String(value))) throw new ServiceError(`Environment ${name} looks like a credential; a supervisor file never carries one.`);
|
|
2204
|
-
}
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
function check(result, what) {
|
|
2208
|
-
if (result?.status !== 0) throw new ServiceError(`${what} failed: ${String(result?.stderr || result?.stdout || result?.error?.message || '').trim().slice(0, 200)}`);
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
const systemdQuote = (value) => `"${plain(value, 'An argument').replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%').replace(/\$/g, '$$$$')}"`;
|
|
2212
|
-
|
|
2213
|
-
/**
|
|
2214
|
-
* A systemd user unit (DEC-0032): Restart=always, RestartSec=10, TimeoutStopSec above the drain time,
|
|
2215
|
-
* WantedBy=default.target. The service is told who supervises it (FABRIC_SERVICE_SUPERVISOR=systemd).
|
|
2216
|
-
*/
|
|
2217
|
-
export function systemdUnit(unit, programArguments, { workingDirectory, environment = {}, description, stopTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
|
|
2218
|
-
if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
|
|
2219
|
-
const argv = programArguments.map(String);
|
|
2220
|
-
if (!argv.length || !argv[0].startsWith('/')) throw new ServiceError('A systemd unit starts an absolute executable.');
|
|
2221
|
-
if (!(stopTimeout > DRAIN_SECONDS)) throw new ServiceError(`TimeoutStopSec must outlast the ${DRAIN_SECONDS} s drain.`);
|
|
2222
|
-
const env = { ...environment };
|
|
2223
|
-
for (const name of Object.keys(env)) if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new ServiceError(`Environment name ${JSON.stringify(name)} is not a variable name.`);
|
|
2224
|
-
noSecretInSupervisorFile(argv, env);
|
|
2225
|
-
noMarkerArgument(programArguments.map(String));
|
|
2226
|
-
env[SUPERVISOR_ENV] = 'systemd'; // the unit's writer writes the marker, whatever the caller passed
|
|
2227
|
-
const cwd = plain(String(workingDirectory), 'The working directory');
|
|
2228
|
-
if (!cwd.startsWith('/')) throw new ServiceError("A systemd unit's working directory is absolute.");
|
|
2229
|
-
return [
|
|
2230
|
-
'[Unit]', `Description=${plain(description ?? unit, 'The description').replace(/%/g, '%%')}`, '',
|
|
2231
|
-
'[Service]', 'Type=simple', `ExecStart=${argv.map(systemdQuote).join(' ')}`,
|
|
2232
|
-
// Not quoted: systemd reads WorkingDirectory= as the rest of the line, quotes included.
|
|
2233
|
-
`WorkingDirectory=${cwd.replace(/%/g, '%%')}`,
|
|
2234
|
-
...Object.keys(env).sort().map((k) => `Environment=${systemdQuote(`${k}=${env[k]}`)}`),
|
|
2235
|
-
'Restart=always', 'RestartSec=10', `TimeoutStopSec=${Math.trunc(stopTimeout)}`, 'KillMode=mixed', '',
|
|
2236
|
-
'[Install]', 'WantedBy=default.target', '',
|
|
2237
|
-
].join('\n');
|
|
2238
|
-
}
|
|
2239
|
-
|
|
2240
|
-
const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
|
|
2241
|
-
|
|
2242
|
-
/**
|
|
2243
|
-
* Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
|
|
2244
|
-
* The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
|
|
2245
|
-
* new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
|
|
2246
|
-
*/
|
|
2247
|
-
export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
|
|
2248
|
-
if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
|
|
2249
|
-
if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
|
|
2250
|
-
fs.mkdirSync(unitDir, { recursive: true });
|
|
2251
|
-
const file = path.join(unitDir, unit);
|
|
2252
|
-
const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
|
|
2253
|
-
atomicWrite(file, text, 0o644);
|
|
2254
|
-
check(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
|
|
2255
|
-
if (state === 'disabled' && !forceEnable) return file;
|
|
2256
|
-
check(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
|
|
2257
|
-
check(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
|
|
2258
|
-
return file;
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
/** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
|
|
2262
|
-
export function systemdUninstall(unit, { unitDir = systemdDir(), run = defaultRun, platform = process.platform } = {}) {
|
|
2263
|
-
if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
|
|
2264
|
-
if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
|
|
2265
|
-
run(['systemctl', '--user', 'disable', '--now', unit]);
|
|
2266
|
-
fs.rmSync(path.join(unitDir, unit), { force: true });
|
|
2267
|
-
check(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
|
-
/** One command line from arguments, quoted the way the Microsoft C runtime parses it (Python's list2cmdline). */
|
|
2271
|
-
export function windowsCommandLine(args) {
|
|
2272
|
-
return args.map((arg) => {
|
|
2273
|
-
const quote = arg === '' || /[ \t]/.test(arg);
|
|
2274
|
-
let out = quote ? '"' : '';
|
|
2275
|
-
let slashes = 0;
|
|
2276
|
-
for (const c of arg) {
|
|
2277
|
-
if (c === '\\') { slashes += 1; continue; }
|
|
2278
|
-
if (c === '"') { out += '\\'.repeat(slashes * 2 + 1) + '"'; slashes = 0; continue; }
|
|
2279
|
-
out += '\\'.repeat(slashes) + c;
|
|
2280
|
-
slashes = 0;
|
|
2281
|
-
}
|
|
2282
|
-
return out + '\\'.repeat(quote ? slashes * 2 : slashes) + (quote ? '"' : '');
|
|
2283
|
-
}).join(' ');
|
|
2284
|
-
}
|
|
2285
|
-
|
|
2286
|
-
const xml = (text) => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
2287
|
-
|
|
2288
|
-
/**
|
|
2289
|
-
* A per-user Scheduled Task (DEC-0032): a logon trigger, restart on failure every minute, no execution
|
|
2290
|
-
* time limit, run only when the user is logged on, created without administrator rights.
|
|
2291
|
-
*/
|
|
2292
|
-
export function taskXml(task, programArguments, { workingDirectory, description, user } = {}) {
|
|
2293
|
-
if (!TASK.test(task)) throw new ServiceError(`task must match ${TASK.source}`);
|
|
2294
|
-
const argv = programArguments.map(String);
|
|
2295
|
-
if (!argv.length || !/^[A-Za-z]:[\\/]/.test(argv[0])) throw new ServiceError('A task starts a drive-absolute executable (C:\\...).');
|
|
2296
|
-
for (const arg of argv) plain(arg, 'An argument');
|
|
2297
|
-
plain(String(workingDirectory), 'The working directory');
|
|
2298
|
-
plain(description ?? task, 'The description');
|
|
2299
|
-
noSecretInSupervisorFile(argv, {});
|
|
2300
|
-
noMarkerArgument(argv);
|
|
2301
|
-
// A task's Exec action carries no environment, so the marker is an argument (DEC-0057), before any `--`.
|
|
2302
|
-
const cut = argv.includes('--') ? argv.indexOf('--') : argv.length;
|
|
2303
|
-
argv.splice(cut, 0, SUPERVISOR_ARG, 'task-scheduler');
|
|
2304
|
-
const who = user ?? (process.env.USERNAME ? `${process.env.USERDOMAIN ?? ''}\\${process.env.USERNAME}` : '');
|
|
2305
|
-
if (!who) throw new ServiceError('Name the user the task runs as (DOMAIN\\user); USERNAME is not set.');
|
|
2306
|
-
plain(who, 'The user');
|
|
2307
|
-
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
2308
|
-
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
2309
|
-
<RegistrationInfo><Description>${xml(description ?? task)}</Description><URI>${xml(task)}</URI></RegistrationInfo>
|
|
2310
|
-
<Triggers><LogonTrigger><Enabled>true</Enabled><UserId>${xml(who)}</UserId></LogonTrigger></Triggers>
|
|
2311
|
-
<Principals><Principal id="Author"><UserId>${xml(who)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>
|
|
2312
|
-
<Settings>
|
|
2313
|
-
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
2314
|
-
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
2315
|
-
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
2316
|
-
<StartWhenAvailable>true</StartWhenAvailable>
|
|
2317
|
-
<Enabled>true</Enabled>
|
|
2318
|
-
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
2319
|
-
<RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
|
|
2320
|
-
</Settings>
|
|
2321
|
-
<Actions Context="Author"><Exec><Command>${xml(argv[0])}</Command><Arguments>${xml(windowsCommandLine(argv.slice(1)))}</Arguments><WorkingDirectory>${xml(workingDirectory)}</WorkingDirectory></Exec></Actions>
|
|
2322
|
-
</Task>
|
|
2323
|
-
`;
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Enabled>/;
|
|
2327
|
-
|
|
2328
|
-
/**
|
|
2329
|
-
* Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
|
|
2330
|
-
* is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
|
|
2331
|
-
*/
|
|
2332
|
-
export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false } = {}) {
|
|
2333
|
-
if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
|
|
2334
|
-
const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
|
|
2335
|
-
const existed = query?.status === 0;
|
|
2336
|
-
const keepOff = existed && !forceEnable && TASK_DISABLED.test(String(query?.stdout ?? ''));
|
|
2337
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-task-'));
|
|
2338
|
-
const file = path.join(dir, 'task.xml');
|
|
2339
|
-
try {
|
|
2340
|
-
// schtasks reads the encoding the declaration names: UTF-16 little-endian with its byte order mark.
|
|
2341
|
-
fs.writeFileSync(file, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xmlText, 'utf16le')]));
|
|
2342
|
-
check(run(['schtasks', '/Create', '/TN', task, '/XML', file, '/F']), 'schtasks /Create');
|
|
2343
|
-
} finally {
|
|
2344
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
2345
|
-
}
|
|
2346
|
-
if (keepOff) {
|
|
2347
|
-
check(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
|
|
2348
|
-
return;
|
|
2349
|
-
}
|
|
2350
|
-
if (existed) run(['schtasks', '/End', '/TN', task]);
|
|
2351
|
-
check(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
/** End the running task, then delete it. */
|
|
2355
|
-
export function taskUninstall(task, { run = defaultRun, platform = process.platform } = {}) {
|
|
2356
|
-
if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
|
|
2357
|
-
run(['schtasks', '/End', '/TN', task]);
|
|
2358
|
-
check(run(['schtasks', '/Delete', '/TN', task, '/F']), 'schtasks /Delete');
|
|
2359
|
-
}
|
|
2360
|
-
|
|
2361
|
-
// --- launchd (macOS; lifecycle.md "Install, upgrade, uninstall", LC-14; FAA-04) ------------------------------------
|
|
2362
|
-
// The twins of fabric_service.launchd_plist / launchd_loaded / launchd_override / launchd_install / launchd_uninstall.
|
|
2363
|
-
// `run(argv)` returns spawnSync's {status, stdout, stderr}; the tests pass a launchd stand-in, never the real launchctl.
|
|
2364
|
-
|
|
2365
|
-
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,250}$/;
|
|
2366
|
-
const DEFAULT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
|
|
2367
|
-
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2368
|
-
|
|
2369
|
-
function launchdLabel(label) {
|
|
2370
|
-
if (!LABEL.test(String(label))) throw new ServiceError(`label must match ${LABEL.source}`);
|
|
2371
|
-
return String(label);
|
|
2372
|
-
}
|
|
2373
|
-
|
|
2374
|
-
function plistValue(value, depth) {
|
|
2375
|
-
// plistlib.dumps's layout, byte for byte: one tab per level, keys sorted, & < > escaped, empty containers self-closed.
|
|
2376
|
-
const pad = '\t'.repeat(depth);
|
|
2377
|
-
if (value === true || value === false) return `${pad}<${value}/>\n`;
|
|
2378
|
-
if (Number.isInteger(value)) return `${pad}<integer>${value}</integer>\n`;
|
|
2379
|
-
if (typeof value === 'string') return `${pad}<string>${plain(value, 'A plist string').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</string>\n`;
|
|
2380
|
-
if (Array.isArray(value)) return value.length ? `${pad}<array>\n${value.map((v) => plistValue(v, depth + 1)).join('')}${pad}</array>\n` : `${pad}<array/>\n`;
|
|
2381
|
-
const keys = Object.keys(value).sort();
|
|
2382
|
-
if (!keys.length) return `${pad}<dict/>\n`;
|
|
2383
|
-
const inner = keys.map((k) => `${pad}\t<key>${plain(k, 'A plist key').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</key>\n${plistValue(value[k], depth + 1)}`).join('');
|
|
2384
|
-
return `${pad}<dict>\n${inner}${pad}</dict>\n`;
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
/**
|
|
2388
|
-
* The launchd job: RunAtLoad + KeepAlive true + ThrottleInterval 10, ProcessType Standard, ExitTimeOut above the drain.
|
|
2389
|
-
* FABRIC_SERVICE_SUPERVISOR=launchd tells the lock to back off instead of exiting into a respawn loop (DEC-0057).
|
|
2390
|
-
* No secret is written: an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or an
|
|
2391
|
-
* argument shaped like a credential is refused. Returns the XML text, the same bytes Python's plistlib writes.
|
|
2392
|
-
*/
|
|
2393
|
-
export function launchdPlist(label, programArguments, { workingDirectory, stdoutPath, environment = {}, exitTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
|
|
2394
|
-
launchdLabel(label);
|
|
2395
|
-
const argv = programArguments.map(String);
|
|
2396
|
-
if (!argv.length) throw new ServiceError('A launchd job starts a program; ProgramArguments is empty.');
|
|
2397
|
-
if (!(exitTimeout > DRAIN_SECONDS)) throw new ServiceError(`ExitTimeOut must outlast the ${DRAIN_SECONDS} s drain.`);
|
|
2398
|
-
if (workingDirectory === undefined || stdoutPath === undefined) throw new ServiceError('A launchd job names its working directory and its log file.');
|
|
2399
|
-
const env = { ...environment };
|
|
2400
|
-
for (const [name, value] of Object.entries(env)) {
|
|
2401
|
-
if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
|
|
2402
|
-
if (secretValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
|
|
2403
|
-
}
|
|
2404
|
-
for (const arg of argv) {
|
|
2405
|
-
if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a plist never does (read it from the token file or a secret reference).');
|
|
2406
|
-
}
|
|
2407
|
-
noMarkerArgument(argv);
|
|
2408
|
-
if (env.PATH === undefined) env.PATH = DEFAULT_PATH;
|
|
2409
|
-
env[SUPERVISOR_ENV] = 'launchd'; // the plist's writer writes the marker, whatever the caller passed
|
|
2410
|
-
const job = {
|
|
2411
|
-
Label: label,
|
|
2412
|
-
ProgramArguments: argv,
|
|
2413
|
-
WorkingDirectory: String(workingDirectory),
|
|
2414
|
-
RunAtLoad: true,
|
|
2415
|
-
KeepAlive: true,
|
|
2416
|
-
ThrottleInterval: 10,
|
|
2417
|
-
ExitTimeOut: Math.trunc(exitTimeout),
|
|
2418
|
-
// Standard, never Background: macOS starves a Background job under load and every host then reports an outage.
|
|
2419
|
-
ProcessType: 'Standard',
|
|
2420
|
-
StandardOutPath: String(stdoutPath),
|
|
2421
|
-
StandardErrorPath: String(stdoutPath),
|
|
2422
|
-
EnvironmentVariables: env,
|
|
2423
|
-
};
|
|
2424
|
-
return '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
2425
|
-
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
2426
|
-
+ `<plist version="1.0">\n${plistValue(job, 0)}</plist>\n`;
|
|
2427
|
-
}
|
|
2428
|
-
|
|
2429
|
-
function launchdDomain(platform, uid) {
|
|
2430
|
-
// DEC-0032: launchd is macOS's supervisor; Linux has systemd and Windows Task Scheduler.
|
|
2431
|
-
if (platform !== 'darwin') throw new ServiceError('launchd is the supervisor on macOS only; use systemdInstall on Linux and taskInstall on Windows.');
|
|
2432
|
-
return `gui/${uid ?? process.getuid()}`;
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
/** True when launchd has the job loaded (`launchctl print gui/<uid>/<label>` answers). */
|
|
2436
|
-
export function launchdLoaded(label, { run = defaultRun, platform = process.platform, uid } = {}) {
|
|
2437
|
-
return run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`])?.status === 0;
|
|
2438
|
-
}
|
|
2439
|
-
|
|
2440
|
-
const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
|
|
2441
|
-
|
|
2442
|
-
/**
|
|
2443
|
-
* The operator's launchd override for `label`: 'disabled', 'enabled', null when none is recorded (a first install), or
|
|
2444
|
-
* 'unknown' when the table cannot be read. macOS 14+ prints `=> disabled|enabled`, older releases `=> true|false`.
|
|
2445
|
-
*/
|
|
2446
|
-
export function launchdOverride(label, { run = defaultRun, platform = process.platform, uid } = {}) {
|
|
2447
|
-
const result = run(['launchctl', 'print-disabled', launchdDomain(platform, uid)]);
|
|
2448
|
-
if (result?.status !== 0) return 'unknown';
|
|
2449
|
-
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
2450
|
-
const match = OVERRIDE_ROW.exec(line);
|
|
2451
|
-
if (match && match[1] === label) return ['disabled', 'true'].includes(match[2]) ? 'disabled' : 'enabled';
|
|
2452
|
-
}
|
|
2453
|
-
return null;
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
async function wellKnownOf(origin) {
|
|
2457
|
-
try {
|
|
2458
|
-
const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
|
|
2459
|
-
return response.ok ? await response.json() : null;
|
|
2460
|
-
} catch {
|
|
2461
|
-
return null;
|
|
2462
|
-
}
|
|
2463
|
-
}
|
|
2464
|
-
|
|
2465
|
-
/**
|
|
2466
|
-
* Write and lint the plist, bootout and WAIT for the unload, bootstrap (retrying launchd's EIO 5 times), then poll the
|
|
2467
|
-
* well-known document until it answers with this identity; resolves to that document.
|
|
2468
|
-
*
|
|
2469
|
-
* The operator's intent wins (LC-14): only a first install (no override recorded) enables the label. A label the
|
|
2470
|
-
* operator disabled stays disabled and unloaded — the plist is still rewritten, so the new release is what starts once
|
|
2471
|
-
* they turn it back on — and the answer is {disabled: true, label, plist, loaded}. `forceEnable` is for an operator
|
|
2472
|
-
* who asked for the service to be switched on.
|
|
2473
|
-
*/
|
|
2474
|
-
export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
|
|
2475
|
-
run = defaultRun, platform = process.platform, uid, fetchWellKnown = wellKnownOf, sleep = pause } = {}) {
|
|
2476
|
-
const domain = launchdDomain(platform, uid);
|
|
2477
|
-
launchdLabel(label);
|
|
2478
|
-
if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
|
|
2479
|
-
const opts = { run, platform, uid };
|
|
2480
|
-
atomicWrite(plistPath, plistText, 0o644);
|
|
2481
|
-
const lint = run(['plutil', '-lint', String(plistPath)]);
|
|
2482
|
-
if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
|
|
2483
|
-
const target = `${domain}/${label}`;
|
|
2484
|
-
const override = launchdOverride(label, opts);
|
|
2485
|
-
if (forceEnable || override === null) run(['launchctl', 'enable', target]);
|
|
2486
|
-
else if (override === 'disabled') return { disabled: true, label, plist: String(plistPath), loaded: launchdLoaded(label, opts) };
|
|
2487
|
-
run(['launchctl', 'bootout', target]);
|
|
2488
|
-
const unloaded = Date.now() + 15_000;
|
|
2489
|
-
while (launchdLoaded(label, opts) && Date.now() < unloaded) await sleep(250);
|
|
2490
|
-
let last = '';
|
|
2491
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
2492
|
-
const result = run(['launchctl', 'bootstrap', domain, String(plistPath)]);
|
|
2493
|
-
if (result?.status === 0) break;
|
|
2494
|
-
last = String(result?.stderr || result?.stdout || '').trim();
|
|
2495
|
-
if (attempt === 4) throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
|
|
2496
|
-
await sleep(1000);
|
|
2497
|
-
}
|
|
2498
|
-
const deadline = Date.now() + timeout * 1000;
|
|
2499
|
-
while (Date.now() < deadline) {
|
|
2500
|
-
const doc = await fetchWellKnown(origin);
|
|
2501
|
-
if (doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
|
|
2502
|
-
await sleep(500);
|
|
2503
|
-
}
|
|
2504
|
-
throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
/**
|
|
2508
|
-
* Symmetric with launchdInstall (LC-14): bootout and WAIT until the job is gone, remove the plist, and reset the
|
|
2509
|
-
* override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
|
|
2510
|
-
* fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go). A job still
|
|
2511
|
-
* loaded at `timeout` seconds throws, leaving the plist and the data in place.
|
|
2512
|
-
*/
|
|
2513
|
-
export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
|
|
2514
|
-
const domain = launchdDomain(platform, uid);
|
|
2515
|
-
launchdLabel(label);
|
|
2516
|
-
if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
|
|
2517
|
-
const dirs = purge ? serviceDirs(serviceId, platform) : {};
|
|
2518
|
-
const opts = { run, platform, uid };
|
|
2519
|
-
const target = `${domain}/${label}`;
|
|
2520
|
-
run(['launchctl', 'bootout', target]);
|
|
2521
|
-
const deadline = Date.now() + timeout * 1000;
|
|
2522
|
-
while (launchdLoaded(label, opts)) {
|
|
2523
|
-
if (Date.now() >= deadline) throw new ServiceError(`launchd job ${label} is still loaded ${timeout} s after bootout; nothing was removed.`);
|
|
2524
|
-
await sleep(100);
|
|
2525
|
-
}
|
|
2526
|
-
let plistRemoved = true;
|
|
2527
|
-
try {
|
|
2528
|
-
fs.unlinkSync(plistPath);
|
|
2529
|
-
} catch (error) {
|
|
2530
|
-
if (error.code !== 'ENOENT') throw error;
|
|
2531
|
-
plistRemoved = false;
|
|
2532
|
-
}
|
|
2533
|
-
run(['launchctl', 'enable', target]);
|
|
2534
|
-
const purged = [];
|
|
2535
|
-
for (const dir of Object.values(dirs)) {
|
|
2536
|
-
try { fs.lstatSync(dir); } catch { continue; }
|
|
2537
|
-
fs.rmSync(dir, { recursive: true, force: true }); // a symlinked folder: the link goes, never what it points at
|
|
2538
|
-
purged.push(dir);
|
|
2539
|
-
}
|
|
2540
|
-
return { unloaded: true, plistRemoved, override: 'enabled', purged };
|
|
2541
|
-
}
|
|
1
|
+
// Reference kit for the fabric-service/0.1 local service extension, Node: the facade (the twin of fabric_service.py).
|
|
2
|
+
// Since 0.40.0 (FAA-65) the kit is a set of modules and this file re-exports everything it exported at 0.39.1, so
|
|
3
|
+
// `import * as k from './fabric-service.mjs'` keeps working. Install it with `fabric_kit.py install <dir> --lang node`,
|
|
4
|
+
// which copies this facade with the modules it needs (kit.json lists them). Node 20+, no dependency.
|
|
5
|
+
export {
|
|
6
|
+
PROTOCOL,
|
|
7
|
+
EXTENSION_KEY,
|
|
8
|
+
SUPERVISOR_ENV,
|
|
9
|
+
DRAIN_SECONDS,
|
|
10
|
+
ServiceError,
|
|
11
|
+
nowIso,
|
|
12
|
+
expand,
|
|
13
|
+
} from './fabric-base.mjs';
|
|
14
|
+
export {
|
|
15
|
+
WINDOWS_TRUSTED,
|
|
16
|
+
SYSTEM_SID,
|
|
17
|
+
ADMINISTRATORS_SID,
|
|
18
|
+
windowsTokenAclProblem,
|
|
19
|
+
tokenFileProblem,
|
|
20
|
+
parseAclReport,
|
|
21
|
+
windowsAcl,
|
|
22
|
+
windowsUserSid,
|
|
23
|
+
windowsProtect,
|
|
24
|
+
tokenMatches,
|
|
25
|
+
checkRequest,
|
|
26
|
+
} from './fabric-platform.mjs';
|
|
27
|
+
export {
|
|
28
|
+
servicesDir,
|
|
29
|
+
serviceDirs,
|
|
30
|
+
ensurePrivateDir,
|
|
31
|
+
atomicWrite,
|
|
32
|
+
} from './fabric-paths.mjs';
|
|
33
|
+
export {
|
|
34
|
+
PROTOCOL_REVISIONS,
|
|
35
|
+
MAX_PROTOCOLS,
|
|
36
|
+
parseRevision,
|
|
37
|
+
acceptedRevisions,
|
|
38
|
+
negotiateRevision,
|
|
39
|
+
protocolsFindings,
|
|
40
|
+
feedDataFindings,
|
|
41
|
+
feedDataProblems,
|
|
42
|
+
} from './fabric-protocols.mjs';
|
|
43
|
+
export {
|
|
44
|
+
EXIT_ALREADY_RUNNING,
|
|
45
|
+
LOCK_WAIT_SUPERVISED_SECONDS,
|
|
46
|
+
LOCK_BACKOFF_FIRST_SECONDS,
|
|
47
|
+
LOCK_BACKOFF_MAX_SECONDS,
|
|
48
|
+
AlreadyRunning,
|
|
49
|
+
InstanceLock,
|
|
50
|
+
lockName,
|
|
51
|
+
SUPERVISORS,
|
|
52
|
+
SUPERVISOR_ARG,
|
|
53
|
+
supervisorMarker,
|
|
54
|
+
lockWaitBudgetSeconds,
|
|
55
|
+
markerConflictMessage,
|
|
56
|
+
supervised,
|
|
57
|
+
supervisedByLaunchd,
|
|
58
|
+
holdSingleInstance,
|
|
59
|
+
} from './fabric-lock.mjs';
|
|
60
|
+
export {
|
|
61
|
+
ROUTES,
|
|
62
|
+
USAGE_ROUTES,
|
|
63
|
+
USAGE_PATH,
|
|
64
|
+
USAGE_DAYS,
|
|
65
|
+
COST_BASES,
|
|
66
|
+
makeUsageReceipt,
|
|
67
|
+
usageFindings,
|
|
68
|
+
OPENROUTER_CATEGORIES,
|
|
69
|
+
attributionSettingFindings,
|
|
70
|
+
attributionHeaders,
|
|
71
|
+
usageReport,
|
|
72
|
+
LIMIT_SCOPES,
|
|
73
|
+
PER_ORDER_KINDS,
|
|
74
|
+
isTimestamp,
|
|
75
|
+
usageLimitProblems,
|
|
76
|
+
JsonlUsageLedger,
|
|
77
|
+
} from './fabric-usage.mjs';
|
|
78
|
+
export {
|
|
79
|
+
SETTINGS_BACKUP_FORMAT,
|
|
80
|
+
SETTINGS_BACKUP_KEEP,
|
|
81
|
+
SETTINGS_BACKUP_MAX_AGE_SECONDS,
|
|
82
|
+
settingsCanonicalJson,
|
|
83
|
+
settingsBackupDigest,
|
|
84
|
+
settingsBackupProblems,
|
|
85
|
+
settingsBackupRoot,
|
|
86
|
+
settingsBackupDir,
|
|
87
|
+
makeSettingsBackup,
|
|
88
|
+
settingsBackups,
|
|
89
|
+
readSettingsBackup,
|
|
90
|
+
writeSettingsBackup,
|
|
91
|
+
backupDue,
|
|
92
|
+
newestIntactBackup,
|
|
93
|
+
restorePlan,
|
|
94
|
+
} from './fabric-backups.mjs';
|
|
95
|
+
export {
|
|
96
|
+
DASHBOARD_EXTENSION_KEY,
|
|
97
|
+
DASHBOARD_KIT,
|
|
98
|
+
REFERENCE_THEMES,
|
|
99
|
+
STATUSES,
|
|
100
|
+
PLACEMENTS,
|
|
101
|
+
MANAGERS,
|
|
102
|
+
descriptorPath,
|
|
103
|
+
readDescriptors,
|
|
104
|
+
validateDescriptor,
|
|
105
|
+
PEER_DATA_CLASSES,
|
|
106
|
+
CREDENTIAL_KINDS,
|
|
107
|
+
SUBSCRIPTION_RUNNERS,
|
|
108
|
+
descriptorMemberProblems,
|
|
109
|
+
peerRefusal,
|
|
110
|
+
appDescriptorAction,
|
|
111
|
+
appDescriptorStale,
|
|
112
|
+
appPresent,
|
|
113
|
+
FABRIC_SERVICE,
|
|
114
|
+
fabricButtonPresence,
|
|
115
|
+
placementOf,
|
|
116
|
+
remoteOriginProblems,
|
|
117
|
+
portOf,
|
|
118
|
+
writeDescriptor,
|
|
119
|
+
removeDescriptor,
|
|
120
|
+
dashboardExtensionProblems,
|
|
121
|
+
dashboardExtension,
|
|
122
|
+
configurationProblems,
|
|
123
|
+
buildWellKnown,
|
|
124
|
+
} from './fabric-descriptor.mjs';
|
|
125
|
+
export {
|
|
126
|
+
SESSION_COOKIE,
|
|
127
|
+
REMOTE_SESSION_COOKIE,
|
|
128
|
+
ensureToken,
|
|
129
|
+
readToken,
|
|
130
|
+
tokenHeader,
|
|
131
|
+
OPERATOR_SESSION_IDLE_SECONDS,
|
|
132
|
+
OPERATOR_SESSION_MAX_SECONDS,
|
|
133
|
+
LoginCodes,
|
|
134
|
+
MemoryCodeStore,
|
|
135
|
+
sessionCookieHeader,
|
|
136
|
+
remoteSessionCookieHeader,
|
|
137
|
+
expiredSessionCookieHeader,
|
|
138
|
+
checkRemoteRequest,
|
|
139
|
+
wellKnownAllowed,
|
|
140
|
+
remoteTokenFile,
|
|
141
|
+
registerRemote,
|
|
142
|
+
cookieValue,
|
|
143
|
+
} from './fabric-auth.mjs';
|
|
144
|
+
export {
|
|
145
|
+
LEVELS,
|
|
146
|
+
EVENTS_DEFAULT_LIMIT,
|
|
147
|
+
EVENTS_MAX_LIMIT,
|
|
148
|
+
makeEvent,
|
|
149
|
+
parseLimit,
|
|
150
|
+
eventsPage,
|
|
151
|
+
} from './fabric-events.mjs';
|
|
152
|
+
export {
|
|
153
|
+
DRAIN_GRACE_SECONDS,
|
|
154
|
+
EXIT_HARD_STOP,
|
|
155
|
+
LOG_MAX_BYTES,
|
|
156
|
+
LOG_BACKUPS,
|
|
157
|
+
RotatingLog,
|
|
158
|
+
capStdoutLog,
|
|
159
|
+
Stopping,
|
|
160
|
+
Drain,
|
|
161
|
+
} from './fabric-logs.mjs';
|
|
162
|
+
export {
|
|
163
|
+
DEFAULT_EXIT_TIMEOUT,
|
|
164
|
+
pruneReleases,
|
|
165
|
+
systemdUnit,
|
|
166
|
+
systemdInstall,
|
|
167
|
+
systemdUninstall,
|
|
168
|
+
windowsCommandLine,
|
|
169
|
+
taskXml,
|
|
170
|
+
taskInstall,
|
|
171
|
+
taskUninstall,
|
|
172
|
+
launchdPlist,
|
|
173
|
+
launchdLoaded,
|
|
174
|
+
launchdOverride,
|
|
175
|
+
launchdInstall,
|
|
176
|
+
launchdUninstall,
|
|
177
|
+
} from './fabric-supervisors.mjs';
|