@audiofool/moondog 0.1.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 (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,670 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import {
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ rename,
8
+ rmdir,
9
+ unlink,
10
+ } from "node:fs/promises";
11
+ import { homedir } from "node:os";
12
+ import path from "node:path";
13
+ import { setTimeout as delay } from "node:timers/promises";
14
+
15
+ import { apiKeyPiProviderIds } from "./provider-registry.mjs";
16
+
17
+ const AUTH_DOCUMENT_VERSION = 1;
18
+ const MAX_AUTH_FILE_BYTES = 64 * 1024;
19
+ const LOCK_TIMEOUT_MS = 30_000;
20
+ const CORRUPT_LOCK_GRACE_MS = 5 * 60_000;
21
+ const SUPPORTED_CREDENTIAL_PROVIDERS = new Set([
22
+ "openai-codex",
23
+ "spotify",
24
+ ...apiKeyPiProviderIds,
25
+ ]);
26
+ const POSIX_PERMISSIONS_APPLY = process.platform !== "win32";
27
+
28
+ function safeError(code, message, cause) {
29
+ const error = new Error(message, cause ? { cause } : undefined);
30
+ error.name = "MoondogCredentialStoreError";
31
+ error.code = code;
32
+ return error;
33
+ }
34
+
35
+ function isMissing(error) {
36
+ return error?.code === "ENOENT";
37
+ }
38
+
39
+ function assertNotAborted(signal) {
40
+ signal?.throwIfAborted();
41
+ }
42
+
43
+ function assertPlainRecord(value, description) {
44
+ if (
45
+ !value ||
46
+ typeof value !== "object" ||
47
+ Array.isArray(value) ||
48
+ Object.getPrototypeOf(value) !== Object.prototype
49
+ ) {
50
+ throw safeError(
51
+ "credential_store_corrupt",
52
+ `Moondog credential storage contains an invalid ${description}.`,
53
+ );
54
+ }
55
+ }
56
+
57
+ function assertExactKeys(value, expected, description) {
58
+ const actual = Object.keys(value).sort();
59
+ const wanted = [...expected].sort();
60
+ if (
61
+ actual.length !== wanted.length ||
62
+ actual.some((key, index) => key !== wanted[index])
63
+ ) {
64
+ throw safeError(
65
+ "credential_store_corrupt",
66
+ `Moondog credential storage contains an unsupported ${description}.`,
67
+ );
68
+ }
69
+ }
70
+
71
+ function assertSecretString(value, field) {
72
+ if (typeof value !== "string" || value.length === 0) {
73
+ throw safeError(
74
+ "credential_store_corrupt",
75
+ `Moondog credential storage contains an invalid ${field}.`,
76
+ );
77
+ }
78
+ }
79
+
80
+ function validateOpenAICodexCredential(value) {
81
+ assertPlainRecord(value, "openai-codex credential");
82
+ assertExactKeys(
83
+ value,
84
+ ["access", "accountId", "expires", "refresh", "type"],
85
+ "openai-codex credential shape",
86
+ );
87
+ if (value.type !== "oauth") {
88
+ throw safeError(
89
+ "credential_store_corrupt",
90
+ "Moondog openai-codex credentials must use OAuth.",
91
+ );
92
+ }
93
+ assertSecretString(value.access, "OAuth access token");
94
+ assertSecretString(value.refresh, "OAuth refresh token");
95
+ assertSecretString(value.accountId, "OAuth account identifier");
96
+ if (!Number.isSafeInteger(value.expires) || value.expires <= 0) {
97
+ throw safeError(
98
+ "credential_store_corrupt",
99
+ "Moondog credential storage contains an invalid OAuth expiry.",
100
+ );
101
+ }
102
+ return structuredClone(value);
103
+ }
104
+
105
+ function validateSpotifyCredential(value) {
106
+ assertPlainRecord(value, "spotify credential");
107
+ assertExactKeys(
108
+ value,
109
+ [
110
+ "accessExpiresAt",
111
+ "accessToken",
112
+ "refreshExpiresAt",
113
+ "refreshToken",
114
+ "scope",
115
+ "tokenType",
116
+ "type",
117
+ ],
118
+ "spotify credential shape",
119
+ );
120
+ if (value.type !== "oauth") {
121
+ throw safeError(
122
+ "credential_store_corrupt",
123
+ "Moondog Spotify credentials must use OAuth.",
124
+ );
125
+ }
126
+ assertSecretString(value.accessToken, "Spotify OAuth access token");
127
+ assertSecretString(value.refreshToken, "Spotify OAuth refresh token");
128
+ if (
129
+ typeof value.tokenType !== "string" ||
130
+ value.tokenType.toLowerCase() !== "bearer"
131
+ ) {
132
+ throw safeError(
133
+ "credential_store_corrupt",
134
+ "Moondog Spotify credentials use an unsupported token type.",
135
+ );
136
+ }
137
+ if (typeof value.scope !== "string" || value.scope.trim().length === 0) {
138
+ throw safeError(
139
+ "credential_store_corrupt",
140
+ "Moondog Spotify credentials contain an invalid OAuth scope.",
141
+ );
142
+ }
143
+ for (const [field, expiry] of [
144
+ ["accessExpiresAt", value.accessExpiresAt],
145
+ ["refreshExpiresAt", value.refreshExpiresAt],
146
+ ]) {
147
+ if (!Number.isSafeInteger(expiry) || expiry <= 0) {
148
+ throw safeError(
149
+ "credential_store_corrupt",
150
+ `Moondog Spotify credentials contain an invalid ${field}.`,
151
+ );
152
+ }
153
+ }
154
+ return structuredClone(value);
155
+ }
156
+
157
+ function validateCredential(providerId, value) {
158
+ if (providerId === "openai-codex") {
159
+ return validateOpenAICodexCredential(value);
160
+ }
161
+ if (providerId === "spotify") {
162
+ return validateSpotifyCredential(value);
163
+ }
164
+ if (apiKeyPiProviderIds.includes(providerId)) {
165
+ assertPlainRecord(value, `${providerId} credential`);
166
+ assertExactKeys(value, ["type", "key"], `${providerId} credential shape`);
167
+ if (
168
+ value.type !== "api_key" ||
169
+ typeof value.key !== "string" ||
170
+ value.key.length === 0 ||
171
+ /[\u0000-\u0020\u007f]/u.test(value.key)
172
+ ) {
173
+ throw safeError(
174
+ "credential_store_corrupt",
175
+ `Moondog ${providerId} credentials must contain a nonempty API key without whitespace or control characters.`,
176
+ );
177
+ }
178
+ return structuredClone(value);
179
+ }
180
+ throw safeError(
181
+ "credential_provider_unsupported",
182
+ `Moondog does not persist credentials for provider ${providerId}.`,
183
+ );
184
+ }
185
+
186
+ function emptyDocument() {
187
+ return {
188
+ version: AUTH_DOCUMENT_VERSION,
189
+ credentials: {},
190
+ };
191
+ }
192
+
193
+ function validateDocument(value) {
194
+ assertPlainRecord(value, "document");
195
+ assertExactKeys(value, ["credentials", "version"], "document shape");
196
+ if (value.version !== AUTH_DOCUMENT_VERSION) {
197
+ throw safeError(
198
+ "credential_store_version_unsupported",
199
+ "Moondog credential storage uses an unsupported version.",
200
+ );
201
+ }
202
+ assertPlainRecord(value.credentials, "credential map");
203
+
204
+ const credentials = {};
205
+ for (const providerId of Object.keys(value.credentials).sort()) {
206
+ if (!SUPPORTED_CREDENTIAL_PROVIDERS.has(providerId)) {
207
+ throw safeError(
208
+ "credential_store_corrupt",
209
+ "Moondog credential storage contains an unsupported provider entry.",
210
+ );
211
+ }
212
+ credentials[providerId] = validateCredential(
213
+ providerId,
214
+ value.credentials[providerId],
215
+ );
216
+ }
217
+ return {
218
+ version: AUTH_DOCUMENT_VERSION,
219
+ credentials,
220
+ };
221
+ }
222
+
223
+ function assertOwned(stat, description) {
224
+ if (
225
+ POSIX_PERMISSIONS_APPLY &&
226
+ typeof process.getuid === "function" &&
227
+ stat.uid !== process.getuid()
228
+ ) {
229
+ throw safeError(
230
+ "credential_store_unsafe",
231
+ `Moondog credential ${description} is not owned by the current user.`,
232
+ );
233
+ }
234
+ }
235
+
236
+ function safeOpenFlags(base) {
237
+ return base | (fsConstants.O_NOFOLLOW ?? 0);
238
+ }
239
+
240
+ async function syncDirectory(directory) {
241
+ let handle;
242
+ try {
243
+ handle = await open(directory, fsConstants.O_RDONLY);
244
+ await handle.sync();
245
+ } finally {
246
+ await handle?.close();
247
+ }
248
+ }
249
+
250
+ export function resolveMoondogAuthFile(environment = process.env) {
251
+ const configured = environment.MOONDOG_CONFIG_HOME?.trim();
252
+ if (configured) {
253
+ if (!path.isAbsolute(configured)) {
254
+ throw safeError(
255
+ "credential_store_path_invalid",
256
+ "MOONDOG_CONFIG_HOME must be an absolute path.",
257
+ );
258
+ }
259
+ return path.join(path.resolve(configured), "auth.json");
260
+ }
261
+
262
+ const xdgConfigHome = environment.XDG_CONFIG_HOME?.trim();
263
+ if (xdgConfigHome && path.isAbsolute(xdgConfigHome)) {
264
+ return path.join(path.resolve(xdgConfigHome), "moondog", "auth.json");
265
+ }
266
+
267
+ return path.join(homedir(), ".config", "moondog", "auth.json");
268
+ }
269
+
270
+ export class PersistentCredentialStore {
271
+ constructor({ authFile = resolveMoondogAuthFile() } = {}) {
272
+ if (!path.isAbsolute(authFile)) {
273
+ throw safeError(
274
+ "credential_store_path_invalid",
275
+ "The Moondog credential file path must be absolute.",
276
+ );
277
+ }
278
+ this.authFile = path.resolve(authFile);
279
+ this.directory = path.dirname(this.authFile);
280
+ this.lockDirectory = `${this.authFile}.lock`;
281
+ this.lockOwnerFile = path.join(this.lockDirectory, "owner.json");
282
+ }
283
+
284
+ async ensureDirectory({ create }) {
285
+ if (create) {
286
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
287
+ }
288
+
289
+ let stat;
290
+ try {
291
+ stat = await lstat(this.directory);
292
+ } catch (error) {
293
+ if (!create && isMissing(error)) return false;
294
+ throw safeError(
295
+ "credential_store_unsafe",
296
+ "Moondog could not access its credential directory.",
297
+ error,
298
+ );
299
+ }
300
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
301
+ throw safeError(
302
+ "credential_store_unsafe",
303
+ "The Moondog credential directory must be a real directory, not a symlink.",
304
+ );
305
+ }
306
+ assertOwned(stat, "directory");
307
+ if (POSIX_PERMISSIONS_APPLY && (stat.mode & 0o077) !== 0) {
308
+ throw safeError(
309
+ "credential_store_unsafe",
310
+ "The Moondog credential directory permissions must be 0700.",
311
+ );
312
+ }
313
+ return true;
314
+ }
315
+
316
+ async readDocument(options = {}) {
317
+ assertNotAborted(options.signal);
318
+ if (!(await this.ensureDirectory({ create: false }))) {
319
+ return emptyDocument();
320
+ }
321
+
322
+ let handle;
323
+ try {
324
+ handle = await open(
325
+ this.authFile,
326
+ safeOpenFlags(fsConstants.O_RDONLY),
327
+ );
328
+ } catch (error) {
329
+ if (isMissing(error)) return emptyDocument();
330
+ throw safeError(
331
+ "credential_store_unsafe",
332
+ "Moondog could not safely open its credential file.",
333
+ error,
334
+ );
335
+ }
336
+
337
+ try {
338
+ const stat = await handle.stat();
339
+ if (!stat.isFile()) {
340
+ throw safeError(
341
+ "credential_store_unsafe",
342
+ "The Moondog credential path must contain a regular file.",
343
+ );
344
+ }
345
+ assertOwned(stat, "file");
346
+ if (stat.size > MAX_AUTH_FILE_BYTES) {
347
+ throw safeError(
348
+ "credential_store_corrupt",
349
+ "The Moondog credential file is unexpectedly large.",
350
+ );
351
+ }
352
+ if (POSIX_PERMISSIONS_APPLY && (stat.mode & 0o077) !== 0) {
353
+ throw safeError(
354
+ "credential_store_unsafe",
355
+ "The Moondog credential file permissions must be 0600.",
356
+ );
357
+ }
358
+ assertNotAborted(options.signal);
359
+ const serialized = await handle.readFile({ encoding: "utf8" });
360
+ if (Buffer.byteLength(serialized, "utf8") > MAX_AUTH_FILE_BYTES) {
361
+ throw safeError(
362
+ "credential_store_corrupt",
363
+ "The Moondog credential file is unexpectedly large.",
364
+ );
365
+ }
366
+ let parsed;
367
+ try {
368
+ parsed = JSON.parse(serialized);
369
+ } catch (error) {
370
+ throw safeError(
371
+ "credential_store_corrupt",
372
+ "The Moondog credential file is not valid JSON.",
373
+ error,
374
+ );
375
+ }
376
+ return validateDocument(parsed);
377
+ } finally {
378
+ await handle.close();
379
+ }
380
+ }
381
+
382
+ async writeDocument(document, options = {}) {
383
+ assertNotAborted(options.signal);
384
+ await this.ensureDirectory({ create: true });
385
+ const validated = validateDocument(document);
386
+ const serialized = `${JSON.stringify(validated, null, 2)}\n`;
387
+ if (Buffer.byteLength(serialized, "utf8") > MAX_AUTH_FILE_BYTES) {
388
+ throw safeError(
389
+ "credential_store_corrupt",
390
+ "The Moondog credential document is unexpectedly large.",
391
+ );
392
+ }
393
+
394
+ try {
395
+ const targetStat = await lstat(this.authFile);
396
+ if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
397
+ throw safeError(
398
+ "credential_store_unsafe",
399
+ "The Moondog credential path must contain a regular file.",
400
+ );
401
+ }
402
+ assertOwned(targetStat, "file");
403
+ } catch (error) {
404
+ if (!isMissing(error)) throw error;
405
+ }
406
+
407
+ const temporaryFile = path.join(
408
+ this.directory,
409
+ `.auth.${process.pid}.${randomUUID()}.tmp`,
410
+ );
411
+ let handle;
412
+ let renamed = false;
413
+ try {
414
+ handle = await open(
415
+ temporaryFile,
416
+ safeOpenFlags(
417
+ fsConstants.O_CREAT |
418
+ fsConstants.O_EXCL |
419
+ fsConstants.O_WRONLY,
420
+ ),
421
+ 0o600,
422
+ );
423
+ await handle.writeFile(serialized, { encoding: "utf8" });
424
+ await handle.sync();
425
+ await handle.chmod(0o600);
426
+ await handle.close();
427
+ handle = undefined;
428
+ assertNotAborted(options.signal);
429
+ await rename(temporaryFile, this.authFile);
430
+ renamed = true;
431
+ await syncDirectory(this.directory);
432
+ } finally {
433
+ await handle?.close();
434
+ if (!renamed) {
435
+ await unlink(temporaryFile).catch((error) => {
436
+ if (!isMissing(error)) throw error;
437
+ });
438
+ }
439
+ }
440
+ }
441
+
442
+ async readLockOwner() {
443
+ let handle;
444
+ try {
445
+ handle = await open(
446
+ this.lockOwnerFile,
447
+ safeOpenFlags(fsConstants.O_RDONLY),
448
+ );
449
+ const stat = await handle.stat();
450
+ if (!stat.isFile() || stat.size > 4096) return undefined;
451
+ assertOwned(stat, "lock owner file");
452
+ const parsed = JSON.parse(await handle.readFile({ encoding: "utf8" }));
453
+ if (
454
+ !parsed ||
455
+ typeof parsed !== "object" ||
456
+ !Number.isSafeInteger(parsed.pid) ||
457
+ parsed.pid <= 0 ||
458
+ typeof parsed.nonce !== "string" ||
459
+ parsed.nonce.length === 0 ||
460
+ !Number.isSafeInteger(parsed.createdAt) ||
461
+ parsed.createdAt <= 0
462
+ ) {
463
+ return undefined;
464
+ }
465
+ return parsed;
466
+ } catch (error) {
467
+ if (isMissing(error) || error instanceof SyntaxError) return undefined;
468
+ throw safeError(
469
+ "credential_store_unsafe",
470
+ "Moondog could not safely inspect its credential lock.",
471
+ error,
472
+ );
473
+ } finally {
474
+ await handle?.close();
475
+ }
476
+ }
477
+
478
+ processIsAlive(pid) {
479
+ try {
480
+ process.kill(pid, 0);
481
+ return true;
482
+ } catch (error) {
483
+ return error?.code !== "ESRCH";
484
+ }
485
+ }
486
+
487
+ async recoverStaleLock() {
488
+ let lockStat;
489
+ try {
490
+ lockStat = await lstat(this.lockDirectory);
491
+ } catch (error) {
492
+ if (isMissing(error)) return true;
493
+ throw safeError(
494
+ "credential_store_unsafe",
495
+ "Moondog could not inspect its credential lock.",
496
+ error,
497
+ );
498
+ }
499
+ if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
500
+ throw safeError(
501
+ "credential_store_unsafe",
502
+ "The Moondog credential lock path is unsafe.",
503
+ );
504
+ }
505
+ assertOwned(lockStat, "lock directory");
506
+
507
+ const owner = await this.readLockOwner();
508
+ if (owner && this.processIsAlive(owner.pid)) return false;
509
+ if (
510
+ !owner &&
511
+ Date.now() - lockStat.mtimeMs < CORRUPT_LOCK_GRACE_MS
512
+ ) {
513
+ return false;
514
+ }
515
+
516
+ if (owner) {
517
+ const currentOwner = await this.readLockOwner();
518
+ if (!currentOwner || currentOwner.nonce !== owner.nonce) return false;
519
+ }
520
+ try {
521
+ await unlink(this.lockOwnerFile);
522
+ } catch (error) {
523
+ if (!isMissing(error)) return false;
524
+ }
525
+ try {
526
+ await rmdir(this.lockDirectory);
527
+ return true;
528
+ } catch (error) {
529
+ return isMissing(error);
530
+ }
531
+ }
532
+
533
+ async acquireLock(options = {}) {
534
+ assertNotAborted(options.signal);
535
+ await this.ensureDirectory({ create: true });
536
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
537
+
538
+ while (true) {
539
+ assertNotAborted(options.signal);
540
+ const owner = {
541
+ pid: process.pid,
542
+ nonce: randomUUID(),
543
+ createdAt: Date.now(),
544
+ };
545
+ try {
546
+ await mkdir(this.lockDirectory, { mode: 0o700 });
547
+ let handle;
548
+ try {
549
+ handle = await open(
550
+ this.lockOwnerFile,
551
+ safeOpenFlags(
552
+ fsConstants.O_CREAT |
553
+ fsConstants.O_EXCL |
554
+ fsConstants.O_WRONLY,
555
+ ),
556
+ 0o600,
557
+ );
558
+ await handle.writeFile(`${JSON.stringify(owner)}\n`, {
559
+ encoding: "utf8",
560
+ });
561
+ await handle.sync();
562
+ } catch (error) {
563
+ await handle?.close();
564
+ await unlink(this.lockOwnerFile).catch(() => {});
565
+ await rmdir(this.lockDirectory).catch(() => {});
566
+ throw safeError(
567
+ "credential_store_unsafe",
568
+ "Moondog could not create its credential lock.",
569
+ error,
570
+ );
571
+ } finally {
572
+ await handle?.close();
573
+ }
574
+
575
+ return async () => {
576
+ const currentOwner = await this.readLockOwner();
577
+ if (!currentOwner || currentOwner.nonce !== owner.nonce) {
578
+ throw safeError(
579
+ "credential_store_lock_lost",
580
+ "Moondog lost ownership of its credential lock.",
581
+ );
582
+ }
583
+ await unlink(this.lockOwnerFile);
584
+ await rmdir(this.lockDirectory);
585
+ };
586
+ } catch (error) {
587
+ if (error?.code !== "EEXIST") throw error;
588
+ }
589
+
590
+ if (await this.recoverStaleLock()) continue;
591
+ if (Date.now() >= deadline) {
592
+ throw safeError(
593
+ "credential_store_locked",
594
+ "Moondog credential storage is busy in another process.",
595
+ );
596
+ }
597
+ await delay(75, undefined, { signal: options.signal });
598
+ }
599
+ }
600
+
601
+ async read(providerId, options = {}) {
602
+ assertNotAborted(options.signal);
603
+ if (!SUPPORTED_CREDENTIAL_PROVIDERS.has(providerId)) return undefined;
604
+ const document = await this.readDocument(options);
605
+ const credential = document.credentials[providerId];
606
+ return credential ? structuredClone(credential) : undefined;
607
+ }
608
+
609
+ async list(options = {}) {
610
+ assertNotAborted(options.signal);
611
+ const document = await this.readDocument(options);
612
+ return Object.entries(document.credentials).map(
613
+ ([providerId, credential]) => ({
614
+ providerId,
615
+ type: credential.type,
616
+ }),
617
+ );
618
+ }
619
+
620
+ async modify(providerId, fn, options = {}) {
621
+ if (!SUPPORTED_CREDENTIAL_PROVIDERS.has(providerId)) {
622
+ throw safeError(
623
+ "credential_provider_unsupported",
624
+ `Moondog does not persist credentials for provider ${providerId}.`,
625
+ );
626
+ }
627
+ const release = await this.acquireLock(options);
628
+ try {
629
+ const document = await this.readDocument(options);
630
+ const current = document.credentials[providerId];
631
+ const next = await fn(
632
+ current ? structuredClone(current) : undefined,
633
+ );
634
+ assertNotAborted(options.signal);
635
+ if (next === undefined) {
636
+ return current ? structuredClone(current) : undefined;
637
+ }
638
+ const validated = validateCredential(providerId, next);
639
+ document.credentials[providerId] = validated;
640
+ await this.writeDocument(document, options);
641
+ return structuredClone(validated);
642
+ } finally {
643
+ await release();
644
+ }
645
+ }
646
+
647
+ async delete(providerId, options = {}) {
648
+ if (!SUPPORTED_CREDENTIAL_PROVIDERS.has(providerId)) {
649
+ throw safeError(
650
+ "credential_provider_unsupported",
651
+ `Moondog does not persist credentials for provider ${providerId}.`,
652
+ );
653
+ }
654
+ const release = await this.acquireLock(options);
655
+ try {
656
+ const document = await this.readDocument(options);
657
+ if (!(providerId in document.credentials)) return;
658
+ delete document.credentials[providerId];
659
+ await this.writeDocument(document, options);
660
+ } finally {
661
+ await release();
662
+ }
663
+ }
664
+ }
665
+
666
+ export function createPersistentCredentialStore(environment = process.env) {
667
+ return new PersistentCredentialStore({
668
+ authFile: resolveMoondogAuthFile(environment),
669
+ });
670
+ }