@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -0,0 +1,288 @@
1
+ // Who may call a service: the descriptor token, the network guard, the operator's one-time login and session, remote placement (DEC-0019), the feed client's token header.
2
+ // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_auth.py. Node 20+, no dependency.
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { PROTOCOL, ServiceError, nowIso } from './fabric-base.mjs';
7
+ import { tokenMatches, windowsTokenProblem } from './fabric-platform.mjs';
8
+ import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
9
+ import { isPlainObject } from './fabric-usage.mjs';
10
+ import { validateDescriptor, writeDescriptor } from './fabric-descriptor.mjs';
11
+
12
+ export const SESSION_COOKIE = 'fabric_session';
13
+
14
+ // DEC-0019: a remote placement's cookie is host-bound and HTTPS-only.
15
+ export const REMOTE_SESSION_COOKIE = '__Host-fabric_session';
16
+
17
+ const CODE = /^[A-Za-z0-9_-]{16,256}$/;
18
+
19
+ export function ensureToken(file) {
20
+ if (fs.existsSync(file)) return readToken(file);
21
+ ensurePrivateDir(path.dirname(file));
22
+ const token = crypto.randomBytes(32).toString('base64url');
23
+ atomicWrite(file, token, 0o600);
24
+ return token;
25
+ }
26
+
27
+ /**
28
+ * Refuse a symlink, a file owned by someone else, or one others can read. On Windows the rule is
29
+ * service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057): windowsTokenAclProblem over the
30
+ * file's flags, owner and ACEs; a refusal names the SID, never the contents.
31
+ */
32
+ export function readToken(file) {
33
+ const info = fs.lstatSync(file);
34
+ if (info.isSymbolicLink()) throw new ServiceError(`Token file ${file} is a symlink; refusing it.`);
35
+ if (process.platform === 'win32') {
36
+ const problem = windowsTokenProblem(file, info);
37
+ if (problem) throw new ServiceError(problem);
38
+ } else {
39
+ if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new ServiceError(`Token file ${file} belongs to another user.`);
40
+ if (info.mode & 0o077) throw new ServiceError(`Token file ${file} is readable by others; set mode 0600.`);
41
+ }
42
+ const token = fs.readFileSync(file, 'utf8').trim();
43
+ if (token.length < 16) throw new ServiceError(`Token file ${file} holds no usable token.`);
44
+ return token;
45
+ }
46
+
47
+ /**
48
+ * The one header a client sends the service token in, as the descriptor's `auth` declares it: `header` defaults
49
+ * to Authorization and `scheme` to Bearer; `none` carries the raw token. Never assume `Authorization: Bearer`.
50
+ */
51
+ export function tokenHeader(auth, token) {
52
+ const declared = isPlainObject(auth) ? auth : {};
53
+ const name = typeof declared.header === 'string' ? declared.header : 'Authorization';
54
+ const scheme = typeof declared.scheme === 'string' ? declared.scheme : 'Bearer';
55
+ return { name, value: scheme === 'none' ? token : `Bearer ${token}` };
56
+ }
57
+
58
+ /** DEC-0039 (service.md "The session's lifetime"): the longest a service may keep an operator session. */
59
+ export const OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60;
60
+
61
+ export const OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60;
62
+
63
+ // Compare bytes, not UTF-16 units: a non-ASCII value of the right length must be a refusal, not a RangeError.
64
+ const safeEqual = (a, b) => {
65
+ const x = Buffer.from(String(a)), y = Buffer.from(String(b));
66
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
67
+ };
68
+
69
+ const sessionBound = (value, longest) => {
70
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new ServiceError('a session bound must be a finite number of seconds.');
71
+ return Math.min(Math.trunc(value), longest);
72
+ };
73
+
74
+ // Single-use login codes, and operator sessions with the DEC-0039 lifetime: a session ends 12 h after its
75
+ // last use, 7 days after the code was redeemed, on logout, and when the descriptor token rotates. The server
76
+ // enforces all four: the cookie carries a signed claim (session id, sign-in time, token revision) and the
77
+ // code store keeps a record per session (last use, logout). Rotating the key with revokeAll() ends every one.
78
+ export class LoginCodes {
79
+ // `stateDir` — a local service keeps codes, sessions and the session key in files. An online service
80
+ // (DEC-0019) usually has no durable disk: pass `stateDir = null` with `{ store, key }` — `store` like
81
+ // `new MemoryCodeStore()` and `key` a Buffer from a platform secret. A memory store forgets codes AND
82
+ // sessions on a restart, so a deploy signs the operator out (the host signs in again on the page's 401);
83
+ // pass a durable store to keep sessions. `token` is the descriptor token, or a function returning the
84
+ // current one: a session begun under one token is refused once it rotates. A service MAY shorten
85
+ // `idleSeconds` or `maxSeconds`, never lengthen them.
86
+ constructor(stateDir, ttlSeconds = 120, { store = null, key = null, token = null,
87
+ idleSeconds = OPERATOR_SESSION_IDLE_SECONDS, maxSeconds = OPERATOR_SESSION_MAX_SECONDS, clock = () => Date.now() / 1000 } = {}) {
88
+ this.ttl = Math.min(ttlSeconds, 120);
89
+ this.idleSeconds = sessionBound(idleSeconds, OPERATOR_SESSION_IDLE_SECONDS);
90
+ this.maxSeconds = sessionBound(maxSeconds, OPERATOR_SESSION_MAX_SECONDS);
91
+ this.store = store;
92
+ this.fixedKey = key;
93
+ this.token = token;
94
+ this.clock = clock;
95
+ if (stateDir) {
96
+ this.dir = ensurePrivateDir(stateDir);
97
+ this.keyPath = path.join(this.dir, 'session.key');
98
+ this.codesPath = path.join(this.dir, 'login-codes.json');
99
+ } else if (!store || !key) {
100
+ throw new ServiceError('LoginCodes without a state directory needs { store, key }.');
101
+ }
102
+ if (key && Buffer.from(key).length < 32) throw new ServiceError('the session key must be at least 32 bytes.');
103
+ }
104
+
105
+ key() {
106
+ if (this.fixedKey) return Buffer.from(this.fixedKey);
107
+ if (!fs.existsSync(this.keyPath)) atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
108
+ return fs.readFileSync(this.keyPath);
109
+ }
110
+
111
+ load() {
112
+ if (this.store) return this.store.load();
113
+ try { return JSON.parse(fs.readFileSync(this.codesPath, 'utf8')); } catch { return {}; }
114
+ }
115
+
116
+ save(codes) {
117
+ const horizon = this.clock() - 3600;
118
+ const kept = Object.fromEntries(Object.entries(codes).filter(([, v]) => (v.expires ?? 0) > horizon));
119
+ if (this.store) { this.store.save(kept); return; }
120
+ atomicWrite(this.codesPath, JSON.stringify(kept), 0o600);
121
+ }
122
+
123
+ issue() {
124
+ const code = crypto.randomBytes(24).toString('base64url');
125
+ const expires = this.clock() + this.ttl;
126
+ const codes = this.load();
127
+ codes[crypto.createHash('sha256').update(code).digest('hex')] = { expires, used: false };
128
+ this.save(codes);
129
+ return { url: `/fabric/v1/login?code=${code}`, expiresAt: new Date(expires * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z') };
130
+ }
131
+
132
+ // The code is recorded as used BEFORE it is honoured, and burned even when expired.
133
+ redeem(code) {
134
+ if (!code || !CODE.test(code)) return null;
135
+ const digest = crypto.createHash('sha256').update(code).digest('hex');
136
+ const codes = this.load();
137
+ const entry = codes[digest];
138
+ if (!entry || entry.used) return null;
139
+ entry.used = true;
140
+ this.save(codes);
141
+ const now = this.clock();
142
+ if (entry.expires < now) return null;
143
+ const session = crypto.randomBytes(18).toString('base64url');
144
+ const signedInAt = Math.trunc(now);
145
+ codes[LoginCodes.recordKey(session)] = { expires: signedInAt + this.maxSeconds, signedInAt, lastUsedAt: now };
146
+ this.save(codes);
147
+ const claim = `v2.${session}.${signedInAt}.${this.tokenRevision()}`;
148
+ return `${claim}.${this.mac(claim)}`;
149
+ }
150
+
151
+ /** True while the session is live; a use within the bounds refreshes the idle bound. */
152
+ sessionValid(value) {
153
+ const parsed = this.parse(value);
154
+ if (!parsed) return false;
155
+ const [session, signedInAt] = parsed;
156
+ const now = this.clock();
157
+ if (now >= signedInAt + this.maxSeconds) return false;
158
+ const codes = this.load();
159
+ const record = codes[LoginCodes.recordKey(session)];
160
+ if (!record || record.signedInAt !== signedInAt || record.loggedOutAt != null) return false;
161
+ const last = Number(record.lastUsedAt ?? 0);
162
+ if (now >= last + this.idleSeconds) return false;
163
+ if (now - last >= Math.min(60, Math.max(1, Math.trunc(this.idleSeconds / 10)))) {
164
+ record.lastUsedAt = now;
165
+ this.save(codes);
166
+ }
167
+ return true;
168
+ }
169
+
170
+ /** End this session on the server; clear the browser's copy with expiredSessionCookieHeader(). */
171
+ logout(value) {
172
+ const parsed = this.parse(value);
173
+ if (!parsed) return false;
174
+ const codes = this.load();
175
+ const record = codes[LoginCodes.recordKey(parsed[0])];
176
+ if (!record || record.signedInAt !== parsed[1] || record.loggedOutAt != null) return false;
177
+ record.loggedOutAt = this.clock();
178
+ this.save(codes);
179
+ return true;
180
+ }
181
+
182
+ parse(value) {
183
+ if (typeof value !== 'string' || !/^[\x21-\x7e]*$/.test(value)) return null;
184
+ const parts = value.split('.');
185
+ if (parts.length !== 5 || parts[0] !== 'v2' || !parts[1] || !/^\d+$/.test(parts[2])) return null;
186
+ if (!safeEqual(parts[4], this.mac(parts.slice(0, 4).join('.')))) return null;
187
+ if (!safeEqual(parts[3], this.tokenRevision())) return null;
188
+ return [parts[1], Number(parts[2])];
189
+ }
190
+
191
+ mac(claim) { return crypto.createHmac('sha256', this.key()).update(claim).digest('hex'); }
192
+
193
+ tokenRevision() {
194
+ const token = typeof this.token === 'function' ? this.token() : this.token;
195
+ if (!token) return '-';
196
+ return crypto.createHmac('sha256', this.key()).update(`token-revision\0${token}`).digest('hex').slice(0, 16);
197
+ }
198
+
199
+ static recordKey(session) { return `s:${crypto.createHash('sha256').update(session).digest('hex')}`; }
200
+
201
+ revokeAll() {
202
+ if (this.fixedKey) throw new ServiceError('a platform-held session key is rotated on the platform, not here.');
203
+ atomicWrite(this.keyPath, crypto.randomBytes(32), 0o600);
204
+ }
205
+ }
206
+
207
+ /** DEC-0019: a code store held in memory — for an online service with no durable disk. */
208
+ export class MemoryCodeStore {
209
+ constructor() { this.codes = {}; }
210
+ load() { return JSON.parse(JSON.stringify(this.codes)); }
211
+ save(codes) { this.codes = JSON.parse(JSON.stringify(codes)); }
212
+ }
213
+
214
+ /** The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner. */
215
+ export const sessionCookieHeader = (value, maxAge = OPERATOR_SESSION_MAX_SECONDS) => `${SESSION_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
216
+
217
+ /** DEC-0019: the remote cookie — `__Host-` name, Secure, no Domain, Path=/. */
218
+ export const remoteSessionCookieHeader = (value, maxAge = OPERATOR_SESSION_MAX_SECONDS) => `${REMOTE_SESSION_COOKIE}=${value}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`;
219
+
220
+ /** The header that clears the browser's session cookie, sent with a logout. */
221
+ export const expiredSessionCookieHeader = (remote = false) => (remote ? remoteSessionCookieHeader : sessionCookieHeader)('', 0);
222
+
223
+ /**
224
+ * DEC-0019: the request guard of an online service. `origin` is the service's own origin
225
+ * (`https://agent.example.com`). `forwardedProto` is the platform-set scheme where TLS ends
226
+ * before the process (`x-forwarded-proto`); pass `undefined` when the process terminates TLS.
227
+ * Returns the refusal sentence, or null.
228
+ */
229
+ export function checkRemoteRequest(origin, host, requestOrigin, secFetchSite, forwardedProto) {
230
+ const own = new URL(origin);
231
+ // A default port, written or not, names the same host (finding 15): the URL drops it from `own`; drop it from the request too.
232
+ const dropped = (value) => { const port = { 'https:': ':443', 'http:': ':80' }[own.protocol]; return port && value.endsWith(port) ? value.slice(0, -port.length) : value; };
233
+ if (dropped(String(host ?? '').toLowerCase()) !== own.host) return `Host ${host} is not this service.`;
234
+ if (forwardedProto !== undefined && forwardedProto !== null && String(forwardedProto).split(',')[0].trim() !== 'https') return 'This service answers over https only.';
235
+ if (requestOrigin !== undefined && requestOrigin !== null && dropped(String(requestOrigin)) !== own.origin) return `Origin ${requestOrigin} is not this service.`;
236
+ if (secFetchSite === 'cross-site') return 'Cross-site requests are refused.';
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * DEC-0019: may this request read the well-known document? A local service answers anyone (the
242
+ * loopback guard already ran); a remote one only the bearer of the service token. A `false`
243
+ * answer is a `401` with an EMPTY body — nothing about the service is disclosed.
244
+ */
245
+ export function wellKnownAllowed(placement, authorization, token, scheme = 'Bearer') {
246
+ return placement !== 'remote' || tokenMatches(authorization, token, scheme);
247
+ }
248
+
249
+ /** The token directory a host-side installer uses for remote services on this computer. */
250
+ export function remoteTokenFile(id, instance = 'default', dir = servicesDir()) {
251
+ return path.join(path.dirname(dir), 'tokens', `${id}.${instance}.token`);
252
+ }
253
+
254
+ /**
255
+ * DEC-0019, installer side: register an online service on THIS computer. Writes the token file
256
+ * (0600, never printed) and the descriptor; the same token must be set on the hosting platform as
257
+ * a secret. Returns the descriptor path. `token` is read from the caller — a file or stdin —
258
+ * never from an argument vector.
259
+ */
260
+ /**
261
+ * DEC-0019, installer side: register an online service on THIS computer — the token file (0600, never printed) and the
262
+ * descriptor. DEC-0061: `installedBy` defaults to `{by: 'person', role: 'operator', tool: 'fabric-service register-remote'}`
263
+ * (a person runs this); `runner` (`{kind?, credentialKind}`) says how its model calls sign in, as a kind and never a
264
+ * credential; `peer` (`{endpoint, custody, residency, maxDataClass}`) offers it to others and needs `runner`.
265
+ */
266
+ export function registerRemote({ id, instance = 'default', name, summary, origin, token, doctor, dir = servicesDir(),
267
+ installedBy = { by: 'person', role: 'operator', tool: 'fabric-service register-remote' }, runner, peer }) {
268
+ if (!token || String(token).trim().length < 16) throw new ServiceError('the service token must be at least 16 characters.');
269
+ const tokenFile = remoteTokenFile(id, instance, dir);
270
+ const d = {
271
+ protocol: PROTOCOL, id, instance, name, ...(summary ? { summary } : {}), placement: 'remote', origin,
272
+ auth: { tokenFile }, lifecycle: { manager: 'none' }, ...(doctor ? { commands: { doctor } } : {}),
273
+ installedAt: nowIso(), installedBy, ...(runner ? { runner: { ...runner } } : {}), ...(peer ? { peer: { ...peer } } : {}),
274
+ };
275
+ const problems = validateDescriptor(d);
276
+ if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
277
+ ensurePrivateDir(path.dirname(tokenFile));
278
+ atomicWrite(tokenFile, String(token).trim(), 0o600);
279
+ return writeDescriptor(d, dir);
280
+ }
281
+
282
+ export function cookieValue(header, name = SESSION_COOKIE) {
283
+ for (const part of String(header ?? '').split(';')) {
284
+ const [k, ...rest] = part.trim().split('=');
285
+ if (k === name) return rest.join('=');
286
+ }
287
+ return null;
288
+ }
@@ -0,0 +1,327 @@
1
+ // Settings backups (DEC-0025, FAC-SEM-035): canonical form, digest, write, list, check, restore.
2
+ // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_backups.py. Node 20+, no dependency.
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { ID, INSTANCE, ServiceError } from './fabric-base.mjs';
8
+ import { atomicWrite, ensurePrivateDir } from './fabric-paths.mjs';
9
+ import { scalar } from './fabric-protocols.mjs';
10
+ import { isPlainObject, isTimestamp } from './fabric-usage.mjs';
11
+
12
+ // The twins of fabric_service.make_settings_backup and the rest; both kits write byte-identical files.
13
+ export const SETTINGS_BACKUP_FORMAT = 'fabric-settings-backup/1';
14
+
15
+ export const SETTINGS_BACKUP_KEEP = 14;
16
+
17
+ export const SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60;
18
+
19
+ const TABLE = /^[a-z][a-z0-9_]{0,62}$/;
20
+
21
+ const SECRET_TABLE = /^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$/;
22
+
23
+ const COLUMN = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/;
24
+
25
+ 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?$/;
26
+
27
+ const REASON = /^[a-z][a-z0-9_.-]{0,63}$/;
28
+
29
+ const SHA256 = /^[0-9a-f]{64}$/;
30
+
31
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
32
+
33
+ export const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/;
34
+
35
+ const MAX_CELL_CHARS = 1048576;
36
+
37
+ const MAX_ROW_VALUES = 512;
38
+
39
+ const STALE_TEMP_MS = 60 * 60 * 1000;
40
+
41
+ const BACKUP_NAME = /^settings-[0-9]{8}T[0-9]{12}Z\.json$/;
42
+
43
+ function byCodePoint(a, b) {
44
+ const x = Array.from(a), y = Array.from(b);
45
+ for (let i = 0; i < Math.min(x.length, y.length); i += 1) {
46
+ const d = x[i].codePointAt(0) - y[i].codePointAt(0);
47
+ if (d !== 0) return d;
48
+ }
49
+ return x.length - y.length;
50
+ }
51
+
52
+ /**
53
+ * The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, `", "` and `": "`,
54
+ * strings escaped as JSON requires and nothing else, integers in decimal — Python's
55
+ * `json.dumps(value, sort_keys=True, ensure_ascii=False)`. Refuses a fraction, an unsafe integer and non-JSON.
56
+ */
57
+ /** CT-25 (DEC-0052): a lone surrogate in a string or a key has no canonical form — Python cannot encode it as UTF-8. */
58
+ function settingsString(text) {
59
+ if (/\p{Surrogate}/u.test(text)) throw new ServiceError('a lone surrogate has no canonical form');
60
+ return JSON.stringify(text);
61
+ }
62
+
63
+ export function settingsCanonicalJson(value) {
64
+ if (value === null) return 'null';
65
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
66
+ if (typeof value === 'number') {
67
+ if (!Number.isSafeInteger(value)) throw new ServiceError(`${value} has no canonical form: a settings backup stores a fraction as its decimal string`);
68
+ return String(value);
69
+ }
70
+ if (typeof value === 'string') return settingsString(value);
71
+ if (Array.isArray(value)) return `[${value.map(settingsCanonicalJson).join(', ')}]`;
72
+ if (isPlainObject(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value))) {
73
+ return `{${Object.keys(value).sort(byCodePoint).map((key) => `${settingsString(key)}: ${settingsCanonicalJson(value[key])}`).join(', ')}}`;
74
+ }
75
+ throw new ServiceError(`${typeof value} is not a JSON value`);
76
+ }
77
+
78
+ /** The `sha256` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of `tables`. */
79
+ export const settingsBackupDigest = (tables) => crypto.createHash('sha256').update(settingsCanonicalJson(tables), 'utf8').digest('hex');
80
+
81
+ /** Why a reader refuses this settings backup: its shape and FAC-SEM-035. Empty means intact. */
82
+ export function settingsBackupProblems(doc) {
83
+ if (!isPlainObject(doc)) return ['a settings backup is a JSON object'];
84
+ const problems = [];
85
+ 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`);
86
+ for (const key of ['format', 'service', 'created_at', 'reason', 'sha256', 'counts', 'tables']) if (!(key in doc)) problems.push(`missing ${key}`);
87
+ 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`);
88
+ if ('extensions' in doc) {
89
+ if (!isPlainObject(doc.extensions)) problems.push('extensions is an object');
90
+ else for (const key of Object.keys(doc.extensions)) if (!URI.test(key)) problems.push(`extensions key ${JSON.stringify(key)} is not a URI`);
91
+ }
92
+ if ('service' in doc) {
93
+ const s = doc.service;
94
+ if (!isPlainObject(s) || Object.keys(s).sort().join() !== 'id,instance,schema,version') problems.push('service is {id, instance, version, schema}');
95
+ else {
96
+ if (!ID.test(String(s.id))) problems.push(`service.id must match ${ID.source}`);
97
+ if (!INSTANCE.test(String(s.instance))) problems.push(`service.instance must match ${INSTANCE.source}`);
98
+ if (!(typeof s.version === 'string' && s.version.length <= 64)) problems.push('service.version is a string of at most 64 characters');
99
+ 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');
100
+ }
101
+ }
102
+ if ('created_at' in doc && !isTimestamp(doc.created_at)) problems.push('created_at is a UTC timestamp');
103
+ if ('reason' in doc && !(typeof doc.reason === 'string' && REASON.test(doc.reason))) problems.push(`reason must match ${REASON.source}`);
104
+ if ('sha256' in doc && !(typeof doc.sha256 === 'string' && SHA256.test(doc.sha256))) problems.push('sha256 is 64 lowercase hex digits');
105
+ const tables = isPlainObject(doc.tables) ? doc.tables : {};
106
+ const counts = isPlainObject(doc.counts) ? doc.counts : {};
107
+ if ('tables' in doc && !isPlainObject(doc.tables)) problems.push('tables is an object');
108
+ if ('counts' in doc && !isPlainObject(doc.counts)) problems.push('counts is an object');
109
+ let canonical = true;
110
+ for (const name of [...Object.keys(tables), ...Object.keys(counts).filter((n) => !Object.hasOwn(tables, n))]) {
111
+ if (LONE_SURROGATE.test(name)) canonical = false;
112
+ 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)`);
113
+ }
114
+ for (const [name, count] of Object.entries(counts)) if (!(Number.isInteger(count) && count >= 0)) problems.push(`counts.${name} is a whole number`);
115
+ for (const [name, table] of Object.entries(tables)) {
116
+ if (!isPlainObject(table) || Object.keys(table).sort().join() !== 'columns,rows') { problems.push(`tables.${name} is {columns, rows}`); continue; }
117
+ let columns = table.columns;
118
+ if (!(Array.isArray(columns) && columns.length >= 1 && columns.length <= 512 && new Set(columns.map(String)).size === columns.length)) {
119
+ problems.push(`tables.${name}.columns is 1 to 512 distinct names`);
120
+ if (!Array.isArray(columns)) columns = [];
121
+ }
122
+ for (const column of columns) {
123
+ 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)`);
124
+ }
125
+ if (!Array.isArray(table.rows)) { problems.push(`tables.${name}.rows is a list`); continue; }
126
+ table.rows.forEach((row, i) => {
127
+ const at = `/tables/${name}/rows/${i}`;
128
+ if (!Array.isArray(row)) { problems.push(`${at} is a list of values`); return; }
129
+ if (row.length !== columns.length) problems.push(`FAC-SEM-035 ${at}: row has ${row.length} values for ${columns.length} columns`);
130
+ if (row.length > MAX_ROW_VALUES) problems.push(`${at} holds more than ${MAX_ROW_VALUES} values`);
131
+ row.forEach((cell, j) => {
132
+ if (typeof cell === 'number' && !Number.isSafeInteger(cell)) {
133
+ canonical = false;
134
+ problems.push(`FAC-SEM-035 ${at}/${j}: a fraction or an unsafe integer has no canonical form; store it as a string`);
135
+ } else if (typeof cell === 'string' && cell.length > MAX_CELL_CHARS) {
136
+ problems.push(`${at}/${j} is longer than ${MAX_CELL_CHARS} characters`);
137
+ } else if (typeof cell === 'string' && LONE_SURROGATE.test(cell)) {
138
+ canonical = false;
139
+ problems.push(`FAC-SEM-035 ${at}/${j}: a string with a lone surrogate cannot be encoded as UTF-8`);
140
+ } else if (!(cell === null || ['boolean', 'number', 'string'].includes(typeof cell))) {
141
+ canonical = false;
142
+ problems.push(`${at}/${j} is not a JSON scalar`);
143
+ }
144
+ });
145
+ });
146
+ if (counts[name] !== table.rows.length) problems.push(`FAC-SEM-035 /counts/${name}: counts says ${counts[name] ?? 'nothing'} for ${table.rows.length} rows`);
147
+ }
148
+ 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`);
149
+ if (canonical && isPlainObject(doc.tables)) {
150
+ let digest = null;
151
+ try {
152
+ digest = settingsBackupDigest(tables);
153
+ } catch (error) {
154
+ problems.push(`FAC-SEM-035 /tables: the tables have no canonical form (${error.message})`);
155
+ }
156
+ 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');
157
+ }
158
+ return problems;
159
+ }
160
+
161
+ /** Where every service keeps its settings backups — outside its data directory (DEC-0025). */
162
+ export function settingsBackupRoot(platform = process.platform) {
163
+ if (platform === 'darwin') return path.join(os.homedir(), 'Library/Application Support/PassionCode');
164
+ if (platform === 'win32') return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'PassionCode');
165
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'PassionCode');
166
+ }
167
+
168
+ export function settingsBackupDir(id, instance = 'default', root = settingsBackupRoot()) {
169
+ if (!ID.test(id)) throw new ServiceError(`Service id ${id} must match ${ID}.`);
170
+ if (!INSTANCE.test(instance)) throw new ServiceError(`instance must match ${INSTANCE}`);
171
+ return path.join(root, 'settings-backups', id, instance);
172
+ }
173
+
174
+ const microStamp = (ms) => `${new Date(ms).toISOString().slice(0, 23)}000Z`;
175
+
176
+ /** A snapshot of the operator's decisions and bindings (the tables on the service's allow-list). Refuses a file a reader would refuse. */
177
+ export function makeSettingsBackup({ id, instance = 'default', version, schema, tables, reason, now = Date.now() }) {
178
+ const doc = {
179
+ format: SETTINGS_BACKUP_FORMAT, service: { id, instance, version, schema }, created_at: microStamp(now), reason, sha256: '',
180
+ counts: Object.fromEntries(Object.entries(tables).map(([name, table]) => [name, (table?.rows ?? []).length])), tables,
181
+ };
182
+ try {
183
+ doc.sha256 = settingsBackupDigest(tables);
184
+ } catch (error) {
185
+ throw new ServiceError(`This snapshot has no canonical form: ${error.message}`);
186
+ }
187
+ const problems = settingsBackupProblems(doc);
188
+ if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
189
+ return doc;
190
+ }
191
+
192
+ const backupName = (doc) => `settings-${doc.created_at.replace(/[-:]/g, '').replace(/\.(\d+)Z$/, (_, f) => `${(f + '000000').slice(0, 6)}Z`)}.json`;
193
+
194
+ /** The snapshot files, newest first. */
195
+ export function settingsBackups(dir) {
196
+ if (!fs.existsSync(dir)) return [];
197
+ return fs.readdirSync(dir).filter((n) => BACKUP_NAME.test(n)).sort().reverse().map((n) => path.join(dir, n));
198
+ }
199
+
200
+ /** The first number written with a fraction or an exponent outside a string (`1.0`, `1e2`); JSON.parse would read
201
+ * `1.0` as the integer 1, where the Python reader — and the canonical form — sees a fraction. */
202
+ function fractionLexeme(text) {
203
+ let inString = false;
204
+ for (let i = 0; i < text.length; i += 1) {
205
+ const c = text[i];
206
+ if (inString) {
207
+ if (c === '\\') i += 1;
208
+ else if (c === '"') inString = false;
209
+ } else if (c === '"') {
210
+ inString = true;
211
+ } else if (c === '-' || (c >= '0' && c <= '9')) {
212
+ const lexeme = text.slice(i).match(/^-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)[0];
213
+ if (/[.eE]/.test(lexeme)) return lexeme;
214
+ i += lexeme.length - 1;
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /** A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035). */
221
+ export function readSettingsBackup(file) {
222
+ let doc;
223
+ try {
224
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(file));
225
+ const fraction = fractionLexeme(text);
226
+ if (fraction) throw new Error(`${fraction} is not an integer: a fraction is stored as its decimal string`);
227
+ doc = JSON.parse(text);
228
+ } catch (error) {
229
+ throw new ServiceError(`Settings backup ${file} cannot be read: ${error.message}`);
230
+ }
231
+ const problems = settingsBackupProblems(doc);
232
+ if (problems.length) throw new ServiceError(`Settings backup ${file} is refused: ${problems.join('; ')}`);
233
+ return doc;
234
+ }
235
+
236
+ /**
237
+ * Write a snapshot: atomically, 0600 in a 0700 directory, named by its time. A temporary file an interrupted write
238
+ * left is removed first; a snapshot whose checksum equals the newest file's is not written again (null). After a
239
+ * write, all but the `keep` newest are removed (at least 14).
240
+ */
241
+ export function writeSettingsBackup(doc, dir, { keep = SETTINGS_BACKUP_KEEP } = {}) {
242
+ const problems = settingsBackupProblems(doc);
243
+ if (problems.length) throw new ServiceError(`This snapshot would be refused: ${problems.join('; ')}`);
244
+ ensurePrivateDir(dir);
245
+ // Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
246
+ for (const left of fs.readdirSync(dir)) {
247
+ const at = path.join(dir, left);
248
+ try {
249
+ if (left.startsWith('.settings-') && fs.statSync(at).mtimeMs < Date.now() - STALE_TEMP_MS) fs.rmSync(at, { force: true });
250
+ } catch { /* gone meanwhile */ }
251
+ }
252
+ const existing = settingsBackups(dir);
253
+ if (existing.length) {
254
+ try {
255
+ if (readSettingsBackup(existing[0]).sha256 === doc.sha256) return null;
256
+ } catch { /* a damaged newest file is no reason to skip a good snapshot */ }
257
+ }
258
+ const file = path.join(dir, backupName(doc));
259
+ atomicWrite(file, `${JSON.stringify(doc, null, 2)}\n`, 0o600);
260
+ // The newest by name, but never the one just written: a clock that stepped back must not delete it.
261
+ for (const old of settingsBackups(dir).filter((f) => f !== file).slice(Math.max(keep, SETTINGS_BACKUP_KEEP) - 1)) fs.rmSync(old, { force: true });
262
+ return file;
263
+ }
264
+
265
+ /** True when there is no snapshot or the newest is older than 24 hours. */
266
+ export function backupDue(dir, now = Date.now()) {
267
+ const newest = settingsBackups(dir)[0];
268
+ if (!newest) return true;
269
+ 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$/);
270
+ // A name no calendar has: take a fresh snapshot rather than trust it (Date.UTC would roll it over).
271
+ if (!isTimestamp(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`)) return true;
272
+ const at = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], +m[7]);
273
+ return (now - at) / 1000 > SETTINGS_BACKUP_MAX_AGE_SECONDS;
274
+ }
275
+
276
+ /** The source of an automatic restore: the newest intact snapshot with content; damaged ones are returned with the reason. */
277
+ export function newestIntactBackup(dir) {
278
+ const skipped = [];
279
+ for (const file of settingsBackups(dir)) {
280
+ let doc;
281
+ try {
282
+ doc = readSettingsBackup(file);
283
+ } catch (error) {
284
+ skipped.push({ file, reason: error.message });
285
+ continue;
286
+ }
287
+ if (Object.values(doc.counts).some((n) => n > 0)) return { file, doc, skipped };
288
+ }
289
+ return { file: null, doc: null, skipped };
290
+ }
291
+
292
+ /**
293
+ * What restoring `doc` would do — it adds and never overwrites (DEC-0025). `current[table]` is `{columns, key,
294
+ * required, existing}` (existing primary keys as arrays). The twin of fabric_service.restore_plan.
295
+ */
296
+ export function restorePlan(doc, current) {
297
+ const problems = settingsBackupProblems(doc);
298
+ if (problems.length) throw new ServiceError(`A damaged snapshot is never restored: ${problems.join('; ')}`);
299
+ const plan = { source: { created_at: doc.created_at, sha256: doc.sha256 }, tables: {}, unknownTables: [] };
300
+ for (const [name, table] of Object.entries(doc.tables)) {
301
+ const now = current[name];
302
+ if (!now) { plan.unknownTables.push(name); continue; }
303
+ const columns = now.columns ?? [], key = now.key ?? [], required = now.required ?? [];
304
+ const existing = new Set((now.existing ?? []).map((k) => JSON.stringify([...k])));
305
+ const shared = table.columns.filter((c) => columns.includes(c));
306
+ const missing = required.filter((c) => !table.columns.includes(c));
307
+ const entry = {
308
+ insert: [], present: 0, droppedColumns: table.columns.filter((c) => !columns.includes(c)),
309
+ defaultedColumns: columns.filter((c) => !table.columns.includes(c)), missingRequired: missing, refused: [],
310
+ };
311
+ const planned = new Set();
312
+ for (const row of table.rows) {
313
+ const values = Object.fromEntries(table.columns.map((c, i) => [c, row[i]]));
314
+ const ident = key.map((c) => values[c] ?? null);
315
+ if (key.some((c) => !table.columns.includes(c))) entry.refused.push({ key: ident, reason: 'the primary key is not in the snapshot' });
316
+ else if (planned.has(JSON.stringify(ident))) entry.refused.push({ key: ident, reason: 'the snapshot holds this key twice' });
317
+ else if (existing.has(JSON.stringify(ident))) entry.present += 1;
318
+ else if (missing.length) entry.refused.push({ key: ident, reason: `required column ${missing.join(', ')} is not in the snapshot` });
319
+ else {
320
+ planned.add(JSON.stringify(ident));
321
+ entry.insert.push(Object.fromEntries(shared.map((c) => [c, values[c]])));
322
+ }
323
+ }
324
+ plan.tables[name] = entry;
325
+ }
326
+ return plan;
327
+ }
@@ -0,0 +1,47 @@
1
+ // What every module of the Node service kit shares: the protocol name, ServiceError, ids, nowIso and expand.
2
+ // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_base.py. Node 20+, no dependency.
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ // Reference kit for the fabric-service/0.1 local service extension — Node.js 20+, no dependencies.
7
+ // The Node twin of fabric_service.py: same rules, same file formats, interoperable locks.
8
+ // Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015; the remote
9
+ // placement — an online agent or dashboard at an https origin — DEC-0019; the usage report DEC-0021).
10
+ export const PROTOCOL = 'fabric-service/0.1';
11
+
12
+ export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
13
+
14
+ // Lifecycle rules: references/lifecycle.md (the rule for this kit's users; it implements the public
15
+ // contract's service specification, Lifecycle), the same values as fabric_service.py.
16
+ // A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
17
+ export const SUPERVISOR_ENV = 'FABRIC_SERVICE_SUPERVISOR';
18
+
19
+ // LC-01: SIGTERM reaches exit in at most 10 s with work in flight — drain 8 s, hard exit 2 s later.
20
+ export const DRAIN_SECONDS = 8;
21
+
22
+ export const ID = /^[a-z][a-z0-9-]{1,62}$/;
23
+
24
+ export const INSTANCE = /^[a-z][a-z0-9-]{0,31}$/;
25
+
26
+ // DEC-0032: a local path starts with ~/ (or ~\ on Windows), / (never //) or a drive letter; a network share never does.
27
+ // DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
28
+ export const LOCAL_PATH = /^(~[\\/]|\/(?![\\/])|[A-Za-z]:[\\/])/;
29
+
30
+ export const UNIT = /^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$/;
31
+
32
+ export const TASK = /^\\[A-Za-z0-9 ._\\-]{1,254}$/;
33
+
34
+ export class ServiceError extends Error {}
35
+
36
+ export const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
37
+
38
+ /** Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be absolute,
39
+ * and a network share is never a local path (DEC-0032). */
40
+ export function expand(p, platform = process.platform) {
41
+ if (!LOCAL_PATH.test(String(p))) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
42
+ const out = /^~[\\/]/.test(p) ? path.join(os.homedir(), p.slice(2)) : p;
43
+ // On Windows an absolute path names its drive: `/x` is relative to the current drive (the Python kit refuses it too).
44
+ const absolute = platform === 'win32' ? /^[A-Za-z]:[\\/]/.test(out) : path.posix.isAbsolute(out);
45
+ if (!absolute) throw new ServiceError(`Path ${p} must be absolute or start with ~/.`);
46
+ return out;
47
+ }