@knitli/openapi-mcp 0.0.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 (116) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +362 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +249 -0
  6. package/dist/compile.d.ts +14 -0
  7. package/dist/compile.js +132 -0
  8. package/dist/compiler.d.ts +13 -0
  9. package/dist/compiler.js +7 -0
  10. package/dist/conformance/fixtures.d.ts +24 -0
  11. package/dist/conformance/fixtures.js +119 -0
  12. package/dist/conformance/index.d.ts +2 -0
  13. package/dist/conformance/index.js +2 -0
  14. package/dist/conformance/runtime-suite.d.ts +31 -0
  15. package/dist/conformance/runtime-suite.js +343 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/load.d.ts +31 -0
  19. package/dist/load.js +20 -0
  20. package/dist/operations.d.ts +27 -0
  21. package/dist/operations.js +231 -0
  22. package/dist/permissions.d.ts +31 -0
  23. package/dist/permissions.js +89 -0
  24. package/dist/release/compile-release.d.ts +33 -0
  25. package/dist/release/compile-release.js +1620 -0
  26. package/dist/release/load-v4.d.ts +29 -0
  27. package/dist/release/load-v4.js +297 -0
  28. package/dist/release/manifest-builder.d.ts +19 -0
  29. package/dist/release/manifest-builder.js +28 -0
  30. package/dist/release/publish.d.ts +45 -0
  31. package/dist/release/publish.js +346 -0
  32. package/dist/release/reference-map.d.ts +53 -0
  33. package/dist/release/reference-map.js +347 -0
  34. package/dist/release/schema-v4.d.ts +5 -0
  35. package/dist/release/schema-v4.js +91 -0
  36. package/dist/runtime/action-permit.d.ts +11 -0
  37. package/dist/runtime/action-permit.js +1 -0
  38. package/dist/runtime/classify.d.ts +16 -0
  39. package/dist/runtime/classify.js +279 -0
  40. package/dist/runtime/credential-binding.d.ts +3 -0
  41. package/dist/runtime/credential-binding.js +101 -0
  42. package/dist/runtime/digest.d.ts +8 -0
  43. package/dist/runtime/digest.js +80 -0
  44. package/dist/runtime/errors.d.ts +14 -0
  45. package/dist/runtime/errors.js +15 -0
  46. package/dist/runtime/index.d.ts +20 -0
  47. package/dist/runtime/index.js +14 -0
  48. package/dist/runtime/manifest.d.ts +33 -0
  49. package/dist/runtime/manifest.js +489 -0
  50. package/dist/runtime/prepared-call.d.ts +39 -0
  51. package/dist/runtime/prepared-call.js +566 -0
  52. package/dist/runtime/references.d.ts +7 -0
  53. package/dist/runtime/references.js +197 -0
  54. package/dist/runtime/runtime.d.ts +25 -0
  55. package/dist/runtime/runtime.js +1272 -0
  56. package/dist/runtime/schema-keywords.d.ts +2 -0
  57. package/dist/runtime/schema-keywords.js +30 -0
  58. package/dist/runtime/schema-resolver.d.ts +10 -0
  59. package/dist/runtime/schema-resolver.js +237 -0
  60. package/dist/runtime/serialize.d.ts +12 -0
  61. package/dist/runtime/serialize.js +877 -0
  62. package/dist/runtime/store.d.ts +52 -0
  63. package/dist/runtime/store.js +582 -0
  64. package/dist/runtime/strict-json.d.ts +19 -0
  65. package/dist/runtime/strict-json.js +539 -0
  66. package/dist/runtime/types.d.ts +457 -0
  67. package/dist/runtime/types.js +1 -0
  68. package/dist/runtime/verify-record.d.ts +6 -0
  69. package/dist/runtime/verify-record.js +492 -0
  70. package/dist/runtime/versions.d.ts +63 -0
  71. package/dist/runtime/versions.js +86 -0
  72. package/dist/safety.d.ts +28 -0
  73. package/dist/safety.js +60 -0
  74. package/dist/schema.d.ts +5 -0
  75. package/dist/schema.js +52 -0
  76. package/dist/schemas.d.ts +9 -0
  77. package/dist/schemas.js +16 -0
  78. package/dist/sign.d.ts +20 -0
  79. package/dist/sign.js +55 -0
  80. package/dist/sqlite/auth.d.ts +33 -0
  81. package/dist/sqlite/auth.js +973 -0
  82. package/dist/sqlite/catalog-store.d.ts +20 -0
  83. package/dist/sqlite/catalog-store.js +370 -0
  84. package/dist/sqlite/destination-guard.d.ts +24 -0
  85. package/dist/sqlite/destination-guard.js +140 -0
  86. package/dist/sqlite/generation-store.d.ts +12 -0
  87. package/dist/sqlite/generation-store.js +721 -0
  88. package/dist/sqlite/guarded-fetch.d.ts +24 -0
  89. package/dist/sqlite/guarded-fetch.js +626 -0
  90. package/dist/sqlite/index.d.ts +5 -0
  91. package/dist/sqlite/index.js +5 -0
  92. package/dist/sqlite/oauth-resource.d.ts +2 -0
  93. package/dist/sqlite/oauth-resource.js +15 -0
  94. package/dist/sqlite/release-completion.d.ts +5 -0
  95. package/dist/sqlite/release-completion.js +80 -0
  96. package/dist/stdio/action-broker.d.ts +15 -0
  97. package/dist/stdio/action-broker.js +136 -0
  98. package/dist/stdio/authorizer.d.ts +30 -0
  99. package/dist/stdio/authorizer.js +484 -0
  100. package/dist/stdio/config.d.ts +77 -0
  101. package/dist/stdio/config.js +144 -0
  102. package/dist/stdio/exact-policy.d.ts +41 -0
  103. package/dist/stdio/exact-policy.js +361 -0
  104. package/dist/stdio/index.d.ts +6 -0
  105. package/dist/stdio/index.js +148 -0
  106. package/dist/stdio/render.d.ts +8 -0
  107. package/dist/stdio/render.js +253 -0
  108. package/dist/stdio/request-state.d.ts +18 -0
  109. package/dist/stdio/request-state.js +79 -0
  110. package/dist/stdio/search.d.ts +10 -0
  111. package/dist/stdio/search.js +76 -0
  112. package/dist/stdio/server.d.ts +29 -0
  113. package/dist/stdio/server.js +309 -0
  114. package/dist/types.d.ts +32 -0
  115. package/dist/types.js +1 -0
  116. package/package.json +131 -0
@@ -0,0 +1,721 @@
1
+ import { constants } from "node:fs";
2
+ import { link, lstat, open, readdir, realpath, rename, unlink, } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { canonicalJsonBounded, parseJsonStrict, } from "../runtime/strict-json.js";
6
+ const pathQueues = new Map();
7
+ const digestPattern = /^[0-9a-f]{64}$/;
8
+ const identityPattern = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
9
+ const stateKeys = [
10
+ "activeGeneration",
11
+ "activeManifestDigest",
12
+ "consumedRollbackAuthorizationIds",
13
+ "highestGeneration",
14
+ "highestManifestDigest",
15
+ "revision",
16
+ ];
17
+ const mutexMarker = "knitli.openapi-mcp.generation-mutex.v1";
18
+ const mutexTable = "knitli_generation_mutex";
19
+ const mutexTimeoutMs = 2_000;
20
+ const mutexInitializationGraceMs = 500;
21
+ const maximumMutexBytes = 1024 * 1024;
22
+ const maximumStateBytes = 16 * 1024 * 1024;
23
+ class MutexLinkCountError extends Error {
24
+ constructor(label) {
25
+ super(`Generation state ${label} must have exactly one link`);
26
+ }
27
+ }
28
+ /** Stable bounded failure when another process retains the generation mutex. */
29
+ export class GenerationStoreContentionError extends Error {
30
+ constructor() {
31
+ super("Generation state mutex contention exceeded its bounded deadline");
32
+ this.name = "GenerationStoreContentionError";
33
+ }
34
+ }
35
+ function failure(message, cause) {
36
+ return new Error(`Generation state ${message}`, { cause });
37
+ }
38
+ function mutexFailure(message) {
39
+ return failure(`mutex ${message}`);
40
+ }
41
+ function exactObject(value, expected, label) {
42
+ if (typeof value !== "object" || value === null || Array.isArray(value))
43
+ throw failure(`${label} is invalid`);
44
+ const prototype = Object.getPrototypeOf(value);
45
+ if (prototype !== null && prototype !== Object.prototype)
46
+ throw failure(`${label} prototype is invalid`);
47
+ const actual = Reflect.ownKeys(value);
48
+ if (actual.some((key) => typeof key !== "string"))
49
+ throw failure(`${label} shape is invalid`);
50
+ const keys = actual.sort();
51
+ const wanted = [...expected].sort();
52
+ if (keys.length !== wanted.length ||
53
+ keys.some((key, index) => key !== wanted[index]))
54
+ throw failure(`${label} shape is invalid`);
55
+ for (const key of keys) {
56
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
57
+ if (descriptor === undefined ||
58
+ !("value" in descriptor) ||
59
+ !descriptor.enumerable)
60
+ throw failure(`${label} properties are invalid`);
61
+ }
62
+ return value;
63
+ }
64
+ function integer(value) {
65
+ return Number.isSafeInteger(value) && value >= 0;
66
+ }
67
+ function requireIdentity(catalogId, issuer) {
68
+ if (typeof catalogId !== "string" ||
69
+ catalogId.length === 0 ||
70
+ catalogId.length > 128 ||
71
+ catalogId === "." ||
72
+ catalogId === ".." ||
73
+ !identityPattern.test(catalogId) ||
74
+ typeof issuer !== "string" ||
75
+ issuer.length === 0 ||
76
+ issuer.length > 256 ||
77
+ /[^\x21-\x7e]/.test(issuer))
78
+ throw failure("entry identity is invalid");
79
+ }
80
+ function decodeState(value) {
81
+ const object = exactObject(value, stateKeys, "entry state");
82
+ if (!integer(object.revision) ||
83
+ !integer(object.highestGeneration) ||
84
+ !integer(object.activeGeneration) ||
85
+ typeof object.highestManifestDigest !== "string" ||
86
+ !digestPattern.test(object.highestManifestDigest) ||
87
+ typeof object.activeManifestDigest !== "string" ||
88
+ !digestPattern.test(object.activeManifestDigest) ||
89
+ !Array.isArray(object.consumedRollbackAuthorizationIds) ||
90
+ object.consumedRollbackAuthorizationIds.some((id) => typeof id !== "string" ||
91
+ id.length === 0 ||
92
+ id.length > 128 ||
93
+ !identityPattern.test(id)) ||
94
+ new Set(object.consumedRollbackAuthorizationIds).size !==
95
+ object.consumedRollbackAuthorizationIds.length ||
96
+ object.activeGeneration > object.highestGeneration ||
97
+ (object.activeGeneration === object.highestGeneration &&
98
+ object.activeManifestDigest !== object.highestManifestDigest))
99
+ throw failure("entry state invariants are invalid");
100
+ return {
101
+ revision: object.revision,
102
+ highestGeneration: object.highestGeneration,
103
+ highestManifestDigest: object.highestManifestDigest,
104
+ activeGeneration: object.activeGeneration,
105
+ activeManifestDigest: object.activeManifestDigest,
106
+ consumedRollbackAuthorizationIds: [
107
+ ...object.consumedRollbackAuthorizationIds,
108
+ ],
109
+ };
110
+ }
111
+ function decodeFile(text) {
112
+ let value;
113
+ try {
114
+ value = parseJsonStrict(text, {
115
+ maxBytes: maximumStateBytes,
116
+ maxDepth: 8,
117
+ maxKeys: 1_000_000,
118
+ });
119
+ }
120
+ catch (error) {
121
+ throw failure("file is corrupt", error);
122
+ }
123
+ const root = exactObject(value, ["entries", "version"], "file");
124
+ if (root.version !== 1 || !Array.isArray(root.entries))
125
+ throw failure("file version or entries are invalid");
126
+ const seen = new Set();
127
+ return root.entries.map((rawEntry) => {
128
+ const entry = exactObject(rawEntry, ["catalogId", "issuer", "state"], "entry");
129
+ if (typeof entry.catalogId !== "string" || typeof entry.issuer !== "string")
130
+ throw failure("entry identity is invalid");
131
+ requireIdentity(entry.catalogId, entry.issuer);
132
+ const identity = `${entry.catalogId}\0${entry.issuer}`;
133
+ if (seen.has(identity))
134
+ throw failure("file has duplicate entries");
135
+ seen.add(identity);
136
+ return {
137
+ catalogId: entry.catalogId,
138
+ issuer: entry.issuer,
139
+ state: decodeState(entry.state),
140
+ };
141
+ });
142
+ }
143
+ function cloneState(state) {
144
+ return {
145
+ ...state,
146
+ consumedRollbackAuthorizationIds: [
147
+ ...state.consumedRollbackAuthorizationIds,
148
+ ],
149
+ };
150
+ }
151
+ function sameLedger(left, right) {
152
+ return (left.length === right.length &&
153
+ left.every((identity, index) => identity === right[index]));
154
+ }
155
+ function requireLegalTransition(current, next) {
156
+ if (current === null) {
157
+ if (next.revision !== 0 ||
158
+ next.activeGeneration !== next.highestGeneration ||
159
+ next.activeManifestDigest !== next.highestManifestDigest ||
160
+ next.consumedRollbackAuthorizationIds.length !== 0)
161
+ throw failure("transition is not a legal creation");
162
+ return;
163
+ }
164
+ if (next.revision !== current.revision + 1)
165
+ throw failure("transition revision is invalid");
166
+ const higherNormal = next.highestGeneration > current.highestGeneration &&
167
+ next.activeGeneration === next.highestGeneration &&
168
+ next.activeManifestDigest === next.highestManifestDigest &&
169
+ sameLedger(next.consumedRollbackAuthorizationIds, current.consumedRollbackAuthorizationIds);
170
+ const rollback = next.highestGeneration === current.highestGeneration &&
171
+ next.highestManifestDigest === current.highestManifestDigest &&
172
+ next.activeGeneration < next.highestGeneration &&
173
+ next.consumedRollbackAuthorizationIds.length ===
174
+ current.consumedRollbackAuthorizationIds.length + 1 &&
175
+ current.consumedRollbackAuthorizationIds.every((identity, index) => next.consumedRollbackAuthorizationIds[index] === identity);
176
+ if (!higherNormal && !rollback)
177
+ throw failure("transition is not a legal normal admission or rollback");
178
+ }
179
+ async function serialized(path, operation) {
180
+ const prior = pathQueues.get(path) ?? Promise.resolve();
181
+ let release;
182
+ const gate = new Promise((resolveGate) => {
183
+ release = resolveGate;
184
+ });
185
+ const current = prior.catch(() => undefined).then(() => gate);
186
+ pathQueues.set(path, current);
187
+ await prior.catch(() => undefined);
188
+ try {
189
+ return await operation();
190
+ }
191
+ finally {
192
+ release();
193
+ if (pathQueues.get(path) === current)
194
+ pathQueues.delete(path);
195
+ }
196
+ }
197
+ function assertPrivateRegular(metadata, label, requireSingleLink = false) {
198
+ if (!metadata.isFile() || metadata.isSymbolicLink())
199
+ throw failure(`${label} must be a regular non-symlink file`);
200
+ if (typeof process.getuid === "function" && metadata.uid !== process.getuid())
201
+ throw failure(`${label} has wrong owner`);
202
+ if ((metadata.mode & 0o077) !== 0)
203
+ throw failure(`${label} must not be group or world accessible`);
204
+ if (requireSingleLink && metadata.nlink !== 1)
205
+ throw new MutexLinkCountError(label);
206
+ }
207
+ function sameFile(left, right) {
208
+ return left.dev === right.dev && left.ino === right.ino;
209
+ }
210
+ function assertPrivateStateFile(metadata) {
211
+ if (!metadata.isFile() || metadata.isSymbolicLink())
212
+ throw failure("file must be a regular non-symlink file");
213
+ if (typeof process.getuid === "function" &&
214
+ metadata.uid !== BigInt(process.getuid()))
215
+ throw failure("file has wrong owner");
216
+ if ((metadata.mode & 63n) !== 0n)
217
+ throw failure("file must not be group or world accessible");
218
+ }
219
+ function sameStateSnapshot(left, right) {
220
+ return (left.dev === right.dev &&
221
+ left.ino === right.ino &&
222
+ left.size === right.size &&
223
+ left.mtimeNs === right.mtimeNs &&
224
+ left.ctimeNs === right.ctimeNs);
225
+ }
226
+ async function readStateBytes(handle, expectedSize) {
227
+ const bytes = Buffer.allocUnsafe(expectedSize || 1);
228
+ let total = 0;
229
+ while (total < expectedSize) {
230
+ const { bytesRead } = await handle.read(bytes, total, Math.min(64 * 1024, expectedSize - total), null);
231
+ if (bytesRead === 0)
232
+ throw failure("file changed while reading");
233
+ total += bytesRead;
234
+ }
235
+ const probe = new Uint8Array(1);
236
+ const { bytesRead: trailingBytes } = await handle.read(probe, 0, 1, null);
237
+ if (trailingBytes !== 0)
238
+ throw failure("file changed while reading");
239
+ return bytes.subarray(0, expectedSize).toString("utf8");
240
+ }
241
+ function sqliteRows(database, sql) {
242
+ return database.prepare(sql).all();
243
+ }
244
+ function validateMutexSchema(database) {
245
+ const version = database.prepare("PRAGMA user_version").get();
246
+ const tables = sqliteRows(database, "SELECT name, type FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY name");
247
+ const columns = sqliteRows(database, `PRAGMA table_info(${mutexTable})`);
248
+ const rows = sqliteRows(database, `SELECT singleton, format, marker FROM ${mutexTable}`);
249
+ const integrity = database.prepare("PRAGMA integrity_check").get();
250
+ if (version?.user_version !== 1 ||
251
+ tables.length !== 1 ||
252
+ tables[0]?.name !== mutexTable ||
253
+ tables[0]?.type !== "table" ||
254
+ columns.length !== 3 ||
255
+ columns[0]?.name !== "singleton" ||
256
+ columns[0]?.type !== "INTEGER" ||
257
+ columns[0]?.pk !== 1 ||
258
+ columns[1]?.name !== "format" ||
259
+ columns[1]?.type !== "INTEGER" ||
260
+ columns[1]?.notnull !== 1 ||
261
+ columns[2]?.name !== "marker" ||
262
+ columns[2]?.type !== "TEXT" ||
263
+ columns[2]?.notnull !== 1 ||
264
+ rows.length !== 1 ||
265
+ rows[0]?.singleton !== 1 ||
266
+ rows[0]?.format !== 1 ||
267
+ rows[0]?.marker !== mutexMarker ||
268
+ integrity?.integrity_check !== "ok")
269
+ throw mutexFailure("database schema or marker is invalid");
270
+ }
271
+ async function requireNoMutexSidecars(mutex) {
272
+ for (const suffix of ["-journal", "-wal", "-shm"]) {
273
+ try {
274
+ const metadata = await lstat(`${mutex}${suffix}`);
275
+ assertPrivateRegular(metadata, `mutex ${suffix} sidecar`, true);
276
+ throw mutexFailure(`${suffix} sidecar is unexpected`);
277
+ }
278
+ catch (error) {
279
+ if (error.code !== "ENOENT")
280
+ throw error;
281
+ }
282
+ }
283
+ }
284
+ async function validateMutexMetadata(mutex) {
285
+ let before;
286
+ try {
287
+ before = await lstat(mutex);
288
+ assertPrivateRegular(before, "mutex database", true);
289
+ if (before.size > maximumMutexBytes)
290
+ throw mutexFailure("database exceeds its byte limit");
291
+ await requireNoMutexSidecars(mutex);
292
+ }
293
+ catch (error) {
294
+ if (error.code === "ENOENT")
295
+ throw error;
296
+ if (error.message?.startsWith("Generation state"))
297
+ throw error;
298
+ throw mutexFailure("database cannot be inspected");
299
+ }
300
+ return before;
301
+ }
302
+ function regexEscape(value) {
303
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
304
+ }
305
+ async function clearMutexInitializerAlias(location) {
306
+ const final = await lstat(location.mutex);
307
+ const namePattern = new RegExp(`^\\.${regexEscape(basename(location.target))}\\.\\d+\\.` +
308
+ "[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.mutex-init$", "i");
309
+ let removed = false;
310
+ for (const name of await readdir(location.parent)) {
311
+ if (!namePattern.test(name))
312
+ continue;
313
+ const candidate = join(location.parent, name);
314
+ let metadata;
315
+ try {
316
+ metadata = await lstat(candidate);
317
+ }
318
+ catch (error) {
319
+ if (error.code === "ENOENT")
320
+ continue;
321
+ throw error;
322
+ }
323
+ assertPrivateRegular(metadata, "mutex initializer");
324
+ if (!sameFile(final, metadata))
325
+ continue;
326
+ let finalImmediatelyBefore;
327
+ let aliasImmediatelyBefore;
328
+ try {
329
+ finalImmediatelyBefore = await lstat(location.mutex);
330
+ aliasImmediatelyBefore = await lstat(candidate);
331
+ }
332
+ catch (error) {
333
+ if (error.code === "ENOENT")
334
+ continue;
335
+ throw error;
336
+ }
337
+ if (!sameFile(final, finalImmediatelyBefore) ||
338
+ !sameFile(finalImmediatelyBefore, aliasImmediatelyBefore))
339
+ continue;
340
+ try {
341
+ await unlink(candidate);
342
+ removed = true;
343
+ }
344
+ catch (error) {
345
+ if (error.code !== "ENOENT")
346
+ throw error;
347
+ }
348
+ }
349
+ if (removed) {
350
+ const parent = await open(location.parent, constants.O_RDONLY);
351
+ try {
352
+ await parent.sync();
353
+ }
354
+ finally {
355
+ await parent.close();
356
+ }
357
+ }
358
+ }
359
+ async function initializeMutex(location) {
360
+ const observationDeadline = Date.now() + mutexInitializationGraceMs;
361
+ while (true) {
362
+ try {
363
+ await validateMutexMetadata(location.mutex);
364
+ return;
365
+ }
366
+ catch (error) {
367
+ if (error instanceof MutexLinkCountError &&
368
+ Date.now() < observationDeadline) {
369
+ await clearMutexInitializerAlias(location);
370
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 5));
371
+ continue;
372
+ }
373
+ if (error.code !== "ENOENT")
374
+ throw error;
375
+ break;
376
+ }
377
+ }
378
+ const temporary = join(location.parent, `.${basename(location.target)}.${process.pid}.${crypto.randomUUID()}.mutex-init`);
379
+ let handle;
380
+ let database;
381
+ try {
382
+ handle = await open(temporary, constants.O_CREAT |
383
+ constants.O_EXCL |
384
+ constants.O_RDWR |
385
+ (constants.O_NOFOLLOW ?? 0), 0o600);
386
+ assertPrivateRegular(await handle.stat(), "mutex initializer", true);
387
+ await handle.close();
388
+ handle = undefined;
389
+ database = new DatabaseSync(temporary, { timeout: mutexTimeoutMs });
390
+ database.exec(`PRAGMA journal_mode=DELETE;
391
+ PRAGMA synchronous=FULL;
392
+ CREATE TABLE ${mutexTable} (
393
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
394
+ format INTEGER NOT NULL CHECK (format = 1),
395
+ marker TEXT NOT NULL CHECK (marker = '${mutexMarker}')
396
+ ) WITHOUT ROWID;
397
+ INSERT INTO ${mutexTable}(singleton, format, marker)
398
+ VALUES (1, 1, '${mutexMarker}');
399
+ PRAGMA user_version=1;`);
400
+ database.close();
401
+ database = undefined;
402
+ handle = await open(temporary, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
403
+ assertPrivateRegular(await handle.stat(), "mutex initializer", true);
404
+ await handle.sync();
405
+ await handle.close();
406
+ handle = undefined;
407
+ await requireNoMutexSidecars(temporary);
408
+ try {
409
+ await link(temporary, location.mutex);
410
+ try {
411
+ await unlink(temporary);
412
+ }
413
+ catch (error) {
414
+ if (error.code !== "ENOENT")
415
+ throw error;
416
+ }
417
+ const parent = await open(location.parent, constants.O_RDONLY);
418
+ try {
419
+ await parent.sync();
420
+ }
421
+ finally {
422
+ await parent.close();
423
+ }
424
+ }
425
+ catch (error) {
426
+ if (error.code !== "EEXIST")
427
+ throw error;
428
+ }
429
+ }
430
+ catch {
431
+ throw mutexFailure("database cannot be initialized");
432
+ }
433
+ finally {
434
+ try {
435
+ database?.close();
436
+ }
437
+ catch { }
438
+ try {
439
+ await handle?.close();
440
+ }
441
+ catch { }
442
+ try {
443
+ await unlink(temporary);
444
+ }
445
+ catch { }
446
+ }
447
+ const installationDeadline = Date.now() + mutexInitializationGraceMs;
448
+ while (true) {
449
+ try {
450
+ await validateMutexMetadata(location.mutex);
451
+ return;
452
+ }
453
+ catch (error) {
454
+ if (error instanceof MutexLinkCountError &&
455
+ Date.now() < installationDeadline) {
456
+ await clearMutexInitializerAlias(location);
457
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 5));
458
+ continue;
459
+ }
460
+ throw error;
461
+ }
462
+ }
463
+ }
464
+ async function openMutex(location) {
465
+ await initializeMutex(location);
466
+ const before = await validateMutexMetadata(location.mutex);
467
+ let database;
468
+ try {
469
+ // DatabaseSync has no fd constructor. A canonical 0700 parent prevents an
470
+ // untrusted path swap between these inode checks and the path-based open.
471
+ database = new DatabaseSync(location.mutex, { timeout: mutexTimeoutMs });
472
+ const after = await lstat(location.mutex);
473
+ assertPrivateRegular(after, "mutex database", true);
474
+ if (!sameFile(before, after))
475
+ throw mutexFailure("database changed while opening");
476
+ await requireNoMutexSidecars(location.mutex);
477
+ return database;
478
+ }
479
+ catch (error) {
480
+ try {
481
+ database?.close();
482
+ }
483
+ catch { }
484
+ if (isSqliteBusy(error))
485
+ throw new GenerationStoreContentionError();
486
+ if (error.message?.startsWith("Generation state"))
487
+ throw error;
488
+ throw mutexFailure("database is corrupt or inaccessible");
489
+ }
490
+ }
491
+ function isSqliteBusy(error) {
492
+ const candidate = error;
493
+ if (typeof candidate?.errcode !== "number")
494
+ return false;
495
+ const primaryCode = candidate.errcode & 0xff;
496
+ return primaryCode === 5 || primaryCode === 6;
497
+ }
498
+ function beginExclusive(database) {
499
+ for (let attempt = 0; attempt < 2; attempt += 1) {
500
+ try {
501
+ database.exec("BEGIN EXCLUSIVE");
502
+ return;
503
+ }
504
+ catch (error) {
505
+ if (isSqliteBusy(error))
506
+ throw new GenerationStoreContentionError();
507
+ // Bun/macOS may transiently surface SQLITE_IOERR_LOCK on the first lock
508
+ // syscall after a prior connection closes. Retry once; only a subsequent
509
+ // canonical BUSY/LOCKED result is classified as contention.
510
+ if (attempt === 0 && error.errcode === 3850)
511
+ continue;
512
+ throw mutexFailure("exclusive transaction cannot be acquired");
513
+ }
514
+ }
515
+ }
516
+ function requireValidOpenMutex(database) {
517
+ try {
518
+ validateMutexSchema(database);
519
+ }
520
+ catch (error) {
521
+ if (isSqliteBusy(error))
522
+ throw new GenerationStoreContentionError();
523
+ if (error.message?.startsWith("Generation state"))
524
+ throw error;
525
+ throw mutexFailure("database is corrupt or inaccessible");
526
+ }
527
+ }
528
+ /** Node-only durable, user-private compare-and-swap generation storage. */
529
+ export class FileGenerationStore {
530
+ #requestedPath;
531
+ constructor(path) {
532
+ if (typeof path !== "string" || path.length === 0)
533
+ throw failure("path is invalid");
534
+ this.#requestedPath = resolve(path);
535
+ }
536
+ async get(catalogId, issuer) {
537
+ requireIdentity(catalogId, issuer);
538
+ const location = await this.#location();
539
+ return serialized(location.target, async () => {
540
+ const database = await openMutex(location);
541
+ let transactionOpen = false;
542
+ try {
543
+ beginExclusive(database);
544
+ transactionOpen = true;
545
+ requireValidOpenMutex(database);
546
+ const entries = await this.#read(location.target);
547
+ const state = entries.find((entry) => entry.catalogId === catalogId && entry.issuer === issuer)?.state;
548
+ database.exec("COMMIT");
549
+ transactionOpen = false;
550
+ return state ? cloneState(state) : null;
551
+ }
552
+ catch (error) {
553
+ if (transactionOpen) {
554
+ try {
555
+ database.exec("ROLLBACK");
556
+ }
557
+ catch { }
558
+ }
559
+ throw error;
560
+ }
561
+ finally {
562
+ try {
563
+ database.close();
564
+ }
565
+ catch { }
566
+ }
567
+ });
568
+ }
569
+ async accept(catalogId, issuer, transition) {
570
+ requireIdentity(catalogId, issuer);
571
+ const location = await this.#location();
572
+ return serialized(location.target, async () => {
573
+ const database = await openMutex(location);
574
+ let transactionOpen = false;
575
+ try {
576
+ beginExclusive(database);
577
+ transactionOpen = true;
578
+ requireValidOpenMutex(database);
579
+ const entries = await this.#read(location.target);
580
+ const index = entries.findIndex((entry) => entry.catalogId === catalogId && entry.issuer === issuer);
581
+ const current = index < 0 ? null : entries[index].state;
582
+ if ((current?.revision ?? null) !== transition.expectedRevision) {
583
+ database.exec("COMMIT");
584
+ transactionOpen = false;
585
+ return null;
586
+ }
587
+ const next = decodeState(transition.next);
588
+ requireLegalTransition(current, next);
589
+ const replacement = { catalogId, issuer, state: next };
590
+ if (index < 0)
591
+ entries.push(replacement);
592
+ else
593
+ entries[index] = replacement;
594
+ entries.sort((left, right) => `${left.catalogId}\0${left.issuer}`.localeCompare(`${right.catalogId}\0${right.issuer}`));
595
+ await this.#persist(location, entries);
596
+ database.exec("COMMIT");
597
+ transactionOpen = false;
598
+ return cloneState(next);
599
+ }
600
+ catch (error) {
601
+ if (transactionOpen) {
602
+ try {
603
+ database.exec("ROLLBACK");
604
+ }
605
+ catch { }
606
+ }
607
+ throw error;
608
+ }
609
+ finally {
610
+ try {
611
+ database.close();
612
+ }
613
+ catch { }
614
+ }
615
+ });
616
+ }
617
+ async #location() {
618
+ const parent = await realpath(dirname(this.#requestedPath));
619
+ const metadata = await lstat(parent);
620
+ if (!metadata.isDirectory() || metadata.isSymbolicLink())
621
+ throw failure("parent must be a real directory");
622
+ if (typeof process.getuid === "function" &&
623
+ metadata.uid !== process.getuid())
624
+ throw failure("parent has wrong owner");
625
+ if ((metadata.mode & 0o077) !== 0)
626
+ throw failure("parent must be owner-only (0700)");
627
+ const target = join(parent, basename(this.#requestedPath));
628
+ return {
629
+ target,
630
+ parent,
631
+ mutex: join(parent, `.${basename(target)}.mutex.sqlite3`),
632
+ };
633
+ }
634
+ async #read(target) {
635
+ let metadata;
636
+ try {
637
+ metadata = await lstat(target, { bigint: true });
638
+ }
639
+ catch (error) {
640
+ if (error.code === "ENOENT")
641
+ return [];
642
+ throw failure("cannot be read", error);
643
+ }
644
+ assertPrivateStateFile(metadata);
645
+ if (metadata.size > BigInt(maximumStateBytes))
646
+ throw failure("file is too large");
647
+ let handle;
648
+ try {
649
+ handle = await open(target, constants.O_RDONLY |
650
+ (constants.O_NOFOLLOW ?? 0) |
651
+ (constants.O_NONBLOCK ?? 0));
652
+ const opened = await handle.stat({ bigint: true });
653
+ assertPrivateStateFile(opened);
654
+ if (!sameStateSnapshot(opened, metadata))
655
+ throw failure("file changed while opening");
656
+ const text = await readStateBytes(handle, Number(metadata.size));
657
+ const after = await handle.stat({ bigint: true });
658
+ assertPrivateStateFile(after);
659
+ if (!sameStateSnapshot(after, opened))
660
+ throw failure("file changed while reading");
661
+ const current = await lstat(target, { bigint: true });
662
+ assertPrivateStateFile(current);
663
+ if (!sameStateSnapshot(current, opened))
664
+ throw failure("file pathname changed while reading");
665
+ return decodeFile(text);
666
+ }
667
+ catch (error) {
668
+ if (error.message?.startsWith("Generation state"))
669
+ throw error;
670
+ throw failure("cannot be read", error);
671
+ }
672
+ finally {
673
+ await handle?.close();
674
+ }
675
+ }
676
+ async #persist(location, entries) {
677
+ const temporary = join(location.parent, `.${basename(location.target)}.${process.pid}.${crypto.randomUUID()}.tmp`);
678
+ let handle;
679
+ let renamed = false;
680
+ try {
681
+ const payload = canonicalJsonBounded({ version: 1, entries }, {
682
+ maxBytes: maximumStateBytes,
683
+ maxDepth: 8,
684
+ maxNodes: 1_000_000,
685
+ });
686
+ handle = await open(temporary, constants.O_CREAT |
687
+ constants.O_EXCL |
688
+ constants.O_WRONLY |
689
+ (constants.O_NOFOLLOW ?? 0), 0o600);
690
+ assertPrivateRegular(await handle.stat(), "temporary file", true);
691
+ await handle.writeFile(payload, "utf8");
692
+ await handle.sync();
693
+ await handle.close();
694
+ handle = undefined;
695
+ await rename(temporary, location.target);
696
+ renamed = true;
697
+ const parent = await open(location.parent, constants.O_RDONLY);
698
+ try {
699
+ await parent.sync();
700
+ }
701
+ finally {
702
+ await parent.close();
703
+ }
704
+ }
705
+ catch (error) {
706
+ try {
707
+ await handle?.close();
708
+ }
709
+ catch { }
710
+ if (!renamed) {
711
+ try {
712
+ await unlink(temporary);
713
+ }
714
+ catch { }
715
+ }
716
+ throw failure(renamed
717
+ ? "replacement was installed but parent sync failed; retry admission to reconcile active state"
718
+ : "cannot be persisted atomically", error);
719
+ }
720
+ }
721
+ }