@bojanrajkovic/mcp-paprika 1.2.0-beta.3 → 1.3.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 (57) hide show
  1. package/dist/auth/build.d.ts +2 -2
  2. package/dist/auth/cleanup.d.ts +2 -2
  3. package/dist/auth/cleanup.js +3 -3
  4. package/dist/auth/client-registration.d.ts +2 -2
  5. package/dist/auth/client-registration.js +6 -6
  6. package/dist/auth/routes.d.ts +2 -2
  7. package/dist/auth/routes.js +1 -1
  8. package/dist/auth/token-store.d.ts +2 -2
  9. package/dist/auth/token-store.js +13 -13
  10. package/dist/auth/types.d.ts +24 -24
  11. package/dist/cache/disk/base.d.ts +56 -0
  12. package/dist/cache/disk/base.js +191 -0
  13. package/dist/cache/disk/index.d.ts +5 -0
  14. package/dist/cache/disk/index.js +4 -0
  15. package/dist/cache/disk/oauth-clients.d.ts +30 -0
  16. package/dist/cache/disk/oauth-clients.js +39 -0
  17. package/dist/cache/disk/recipes.d.ts +26 -0
  18. package/dist/cache/disk/recipes.js +110 -0
  19. package/dist/cache/disk/root.d.ts +46 -0
  20. package/dist/cache/disk/root.js +133 -0
  21. package/dist/cache/pantry-store.d.ts +2 -16
  22. package/dist/cache/pantry-store.js +8 -61
  23. package/dist/cache/recipe-store.d.ts +2 -14
  24. package/dist/cache/recipe-store.js +8 -61
  25. package/dist/entity/index.d.ts +1 -0
  26. package/dist/entity/index.js +1 -0
  27. package/dist/entity/store.d.ts +31 -0
  28. package/dist/entity/store.js +81 -0
  29. package/dist/features/discover-feature.d.ts +3 -3
  30. package/dist/features/discover-feature.js +5 -3
  31. package/dist/paprika/sync.d.ts +2 -2
  32. package/dist/paprika/sync.js +25 -25
  33. package/dist/paprika/types.d.ts +11 -3
  34. package/dist/server/app-context.d.ts +2 -2
  35. package/dist/server/build.js +16 -4
  36. package/dist/tools/categories.js +2 -0
  37. package/dist/tools/create.js +1 -0
  38. package/dist/tools/delete.js +1 -0
  39. package/dist/tools/discover.js +2 -0
  40. package/dist/tools/filter.js +4 -0
  41. package/dist/tools/helpers.js +16 -2
  42. package/dist/tools/list.js +13 -1
  43. package/dist/tools/pantry-add.js +1 -0
  44. package/dist/tools/pantry-delete.js +1 -0
  45. package/dist/tools/pantry-get.js +2 -0
  46. package/dist/tools/pantry-helpers.js +2 -2
  47. package/dist/tools/pantry-list.js +2 -0
  48. package/dist/tools/pantry-update.js +1 -0
  49. package/dist/tools/read.js +2 -0
  50. package/dist/tools/search.js +11 -0
  51. package/dist/tools/update.js +6 -1
  52. package/dist/transport/http.d.ts +0 -10
  53. package/dist/transport/http.js +5 -0
  54. package/dist/utils/config.d.ts +10 -10
  55. package/package.json +1 -1
  56. package/dist/cache/disk-cache.d.ts +0 -66
  57. package/dist/cache/disk-cache.js +0 -433
@@ -10,7 +10,7 @@
10
10
  * after cache.init() completes.
11
11
  */
12
12
  import type { Logger } from "pino";
13
- import type { DiskCache } from "../cache/disk-cache.js";
13
+ import type { DiskCacheRoot } from "../cache/disk/index.js";
14
14
  import type { PaprikaConfig } from "../utils/config.js";
15
15
  import type { AuthContext } from "./types.js";
16
- export declare function buildAuthContext(config: PaprikaConfig, cache: DiskCache, parentLog: Logger): Promise<AuthContext | null>;
16
+ export declare function buildAuthContext(config: PaprikaConfig, cache: DiskCacheRoot, parentLog: Logger): Promise<AuthContext | null>;
@@ -16,7 +16,7 @@
16
16
  * Public `sweepOnce()` is exposed for direct testing and for startup use.
17
17
  */
18
18
  import type { Logger } from "pino";
19
- import type { DiskCache } from "../cache/disk-cache.js";
19
+ import type { DiskCacheRoot } from "../cache/disk/index.js";
20
20
  import type { AuthRequestStore } from "./auth-request-store.js";
21
21
  import type { AuthCodeStore } from "./auth-code-store.js";
22
22
  import type { DiskClientRegistrationStore } from "./client-registration.js";
@@ -31,7 +31,7 @@ export declare class AuthCleanup {
31
31
  private readonly _now;
32
32
  private readonly _intervalMs;
33
33
  private _ac;
34
- constructor(_clientStore: DiskClientRegistrationStore, _tokenStore: TokenStore, _cache: DiskCache, _authRequests: AuthRequestStore, _authCodes: AuthCodeStore, log: Logger, _now?: () => number, _intervalMs?: number);
34
+ constructor(_clientStore: DiskClientRegistrationStore, _tokenStore: TokenStore, _cache: DiskCacheRoot, _authRequests: AuthRequestStore, _authCodes: AuthCodeStore, log: Logger, _now?: () => number, _intervalMs?: number);
35
35
  /** Start the background cleanup loop. Idempotent — second call is a no-op. */
36
36
  start(): void;
37
37
  /** Stop the background cleanup loop. Idempotent — second call is a no-op. */
@@ -67,12 +67,12 @@ export class AuthCleanup {
67
67
  const now = this._now();
68
68
  // (1) Stale DCR clients: lastTokenActivityAt older than DCR_CLIENT_STALE_DAYS (90d)
69
69
  const cutoff = now - DCR_CLIENT_STALE_DAYS * 86400;
70
- const allClients = await this._cache.getAllOAuthClients();
70
+ const allClients = await this._cache.oauthClients.getAll();
71
71
  const stale = allClients.filter((c) => c.lastTokenActivityAt < cutoff);
72
72
  // Fetch tokens once. Precompute per-client counts for the cascade loop and
73
73
  // collect expired tokens for the orphan sweep in (3). One pass over all
74
74
  // tokens, then we partition by stale-client cascade vs. expired-orphan.
75
- const allTokens = await this._cache.getAllOAuthTokens();
75
+ const allTokens = await this._cache.oauthTokens.getAll();
76
76
  const tokensByClient = new Map();
77
77
  for (const t of allTokens) {
78
78
  tokensByClient.set(t.clientId, (tokensByClient.get(t.clientId) ?? 0) + 1);
@@ -95,7 +95,7 @@ export class AuthCleanup {
95
95
  let expiredTokensRemoved = 0;
96
96
  const expiredOrphans = allTokens.filter((t) => t.expiresAt < now && !staleClientIds.has(t.clientId));
97
97
  if (expiredOrphans.length > 0) {
98
- await Promise.all(expiredOrphans.map((t) => this._cache.removeOAuthToken(t.tokenHash)));
98
+ await Promise.all(expiredOrphans.map((t) => this._cache.oauthTokens.remove(t.tokenHash)));
99
99
  await this._cache.flush();
100
100
  expiredTokensRemoved = expiredOrphans.length;
101
101
  }
@@ -10,7 +10,7 @@
10
10
  * - verifyRegistrationAccessToken: used by route handlers to gate PUT/DELETE access
11
11
  */
12
12
  import type { Logger } from "pino";
13
- import { DiskCache } from "../cache/disk-cache.js";
13
+ import type { DiskCacheRoot } from "../cache/disk/index.js";
14
14
  /**
15
15
  * Wire format for client information (RFC 7591 response format).
16
16
  * Snake_case to match RFC 7591.
@@ -41,7 +41,7 @@ export declare class DiskClientRegistrationStore {
41
41
  * middleware's fast-path 429 limit).
42
42
  */
43
43
  private readonly _maxClients;
44
- constructor(_cache: DiskCache, _publicUrl: string, log: Logger,
44
+ constructor(_cache: DiskCacheRoot, _publicUrl: string, log: Logger,
45
45
  /**
46
46
  * Hard cap on the number of registered clients. Enforced atomically
47
47
  * inside `registerClient` (via `DiskCache.tryPutOAuthClient`) so concurrent
@@ -63,7 +63,7 @@ export class DiskClientRegistrationStore {
63
63
  * Returns undefined if not found.
64
64
  */
65
65
  async getClient(clientId) {
66
- const client = await this._cache.getOAuthClient(clientId);
66
+ const client = await this._cache.oauthClients.get(clientId);
67
67
  if (client === null)
68
68
  return undefined;
69
69
  return storedToWire(client);
@@ -101,7 +101,7 @@ export class DiskClientRegistrationStore {
101
101
  // the authoritative race-safe enforcement. On overflow we throw an OAuth
102
102
  // `InvalidRequestError` so @hono/mcp's DCR handler returns 400 with the
103
103
  // standard `invalid_request` error code (rather than a 500).
104
- const result = await this._cache.tryPutOAuthClient(stored, this._maxClients);
104
+ const result = await this._cache.oauthClients.tryPut(stored, this._maxClients);
105
105
  if (!result.ok) {
106
106
  throw new InvalidRequestError(`client registration cap reached (${result.currentCount.toString()} clients)`);
107
107
  }
@@ -120,7 +120,7 @@ export class DiskClientRegistrationStore {
120
120
  * Throws OAuthMetadataValidationError on invalid metadata.
121
121
  */
122
122
  async updateClient(clientId, metaIn) {
123
- const existing = await this._cache.getOAuthClient(clientId);
123
+ const existing = await this._cache.oauthClients.get(clientId);
124
124
  if (existing === null)
125
125
  throw OAuthClientNotFoundError.forId(clientId);
126
126
  // Validate patch via dcr-validator; pass logger for URL-parse debug diagnosability
@@ -139,7 +139,7 @@ export class DiskClientRegistrationStore {
139
139
  tokenEndpointAuthMethod: "none",
140
140
  updatedAt: now,
141
141
  };
142
- await this._cache.putOAuthClient(updated);
142
+ await this._cache.oauthClients.put(updated);
143
143
  await this._cache.flush();
144
144
  return storedToWire(updated, {
145
145
  registrationClientUri: `${this._publicUrl}/register/${clientId}`,
@@ -150,7 +150,7 @@ export class DiskClientRegistrationStore {
150
150
  * Removes from cache and disk. No cascade — the DELETE /register/:id route composes with TokenStore.removeAllForClient.
151
151
  */
152
152
  async deleteClient(clientId) {
153
- await this._cache.removeOAuthClient(clientId);
153
+ await this._cache.oauthClients.remove(clientId);
154
154
  await this._cache.flush();
155
155
  }
156
156
  /**
@@ -158,7 +158,7 @@ export class DiskClientRegistrationStore {
158
158
  * Returns true if the hashes match, false otherwise (including client not found).
159
159
  */
160
160
  async verifyRegistrationAccessToken(clientId, presentedToken) {
161
- const client = await this._cache.getOAuthClient(clientId);
161
+ const client = await this._cache.oauthClients.get(clientId);
162
162
  if (client === null)
163
163
  return false;
164
164
  const presentedHash = hashTokenForStorage(presentedToken);
@@ -7,7 +7,7 @@ import type { AuthCodeStore } from "./auth-code-store.js";
7
7
  import type { ResolvedOAuthConfig } from "./types.js";
8
8
  import type { DiscoveryDoc } from "./oidc-client.js";
9
9
  import type { JWTVerifyGetKey } from "jose";
10
- import type { DiskCache } from "../cache/disk-cache.js";
10
+ import type { DiskCacheRoot } from "../cache/disk/index.js";
11
11
  export interface AuthRoutesDeps {
12
12
  readonly clientStore: DiskClientRegistrationStore;
13
13
  readonly tokenStore: TokenStore;
@@ -86,4 +86,4 @@ export declare function buildDcrRateLimit(options: {
86
86
  * case where the cap is obviously hit; the atomic store check closes the
87
87
  * race window for the rest.
88
88
  */
89
- export declare function buildClientCap(cache: DiskCache, max: number): MiddlewareHandler;
89
+ export declare function buildClientCap(cache: DiskCacheRoot, max: number): MiddlewareHandler;
@@ -331,7 +331,7 @@ export function buildClientCap(cache, max) {
331
331
  return async (c, next) => {
332
332
  if (c.req.path !== "/register" || c.req.method !== "POST")
333
333
  return next();
334
- const clients = await cache.getAllOAuthClients();
334
+ const clients = await cache.oauthClients.getAll();
335
335
  if (clients.length >= max) {
336
336
  return c.json({ error: "invalid_request", error_description: "client registration cap reached" }, 429);
337
337
  }
@@ -11,7 +11,7 @@
11
11
  import { type Result } from "neverthrow";
12
12
  import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
13
13
  import type { OAuthError } from "@modelcontextprotocol/sdk/server/auth/errors.js";
14
- import type { DiskCache } from "../cache/disk-cache.js";
14
+ import type { DiskCacheRoot } from "../cache/disk/index.js";
15
15
  import type { OAuthToken } from "./types.js";
16
16
  import type { VerifiedIdentity } from "./allowlist.js";
17
17
  export interface IssuedPair {
@@ -36,7 +36,7 @@ export declare class TokenStore {
36
36
  private readonly _cache;
37
37
  private readonly _now;
38
38
  private readonly _rotateLock;
39
- constructor(_cache: DiskCache, _now?: () => number);
39
+ constructor(_cache: DiskCacheRoot, _now?: () => number);
40
40
  /**
41
41
  * Issues a new access + refresh token pair for a client.
42
42
  *
@@ -62,8 +62,8 @@ export class TokenStore {
62
62
  expiresAt: refreshExpiresAt,
63
63
  createdAt: now,
64
64
  };
65
- await this._cache.putOAuthToken(access);
66
- await this._cache.putOAuthToken(refresh);
65
+ await this._cache.oauthTokens.put(access);
66
+ await this._cache.oauthTokens.put(refresh);
67
67
  await this._bumpLastActivity(input.clientId, now);
68
68
  await this._cache.flush();
69
69
  return {
@@ -84,7 +84,7 @@ export class TokenStore {
84
84
  */
85
85
  async lookupAccessToken(plaintext) {
86
86
  const hash = hashTokenForStorage(plaintext);
87
- const record = await this._cache.getOAuthToken(hash);
87
+ const record = await this._cache.oauthTokens.get(hash);
88
88
  if (record === null || record.kind !== "access")
89
89
  return null;
90
90
  if (record.expiresAt < this._now())
@@ -115,7 +115,7 @@ export class TokenStore {
115
115
  */
116
116
  async lookupRefreshToken(plaintext) {
117
117
  const hash = hashTokenForStorage(plaintext);
118
- const record = await this._cache.getOAuthToken(hash);
118
+ const record = await this._cache.oauthTokens.get(hash);
119
119
  if (record === null || record.kind !== "refresh")
120
120
  return null;
121
121
  if (record.expiresAt < this._now())
@@ -166,7 +166,7 @@ export class TokenStore {
166
166
  newScope = requestedScopes.join(" ");
167
167
  }
168
168
  // Invalidate the old refresh token IMMEDIATELY (AC7.7)
169
- await this._cache.removeOAuthToken(existing.tokenHash);
169
+ await this._cache.oauthTokens.remove(existing.tokenHash);
170
170
  await this._cache.flush();
171
171
  // Mint the new pair with rotation linkage
172
172
  const accessPlain = generateOpaqueToken("mcp_at_");
@@ -174,7 +174,7 @@ export class TokenStore {
174
174
  const now = this._now();
175
175
  const accessExpiresAt = now + ACCESS_TOKEN_TTL_SECONDS;
176
176
  const refreshExpiresAt = now + REFRESH_TOKEN_TTL_SECONDS;
177
- await this._cache.putOAuthToken({
177
+ await this._cache.oauthTokens.put({
178
178
  tokenHash: hashTokenForStorage(accessPlain),
179
179
  kind: "access",
180
180
  clientId: existing.clientId,
@@ -184,7 +184,7 @@ export class TokenStore {
184
184
  expiresAt: accessExpiresAt,
185
185
  createdAt: now,
186
186
  });
187
- await this._cache.putOAuthToken({
187
+ await this._cache.oauthTokens.put({
188
188
  tokenHash: hashTokenForStorage(refreshPlain),
189
189
  kind: "refresh",
190
190
  clientId: existing.clientId,
@@ -215,7 +215,7 @@ export class TokenStore {
215
215
  */
216
216
  async getTokenRecord(plaintext) {
217
217
  const hash = hashTokenForStorage(plaintext);
218
- return this._cache.getOAuthToken(hash);
218
+ return this._cache.oauthTokens.get(hash);
219
219
  }
220
220
  /**
221
221
  * Revokes a token by removing its hash from the cache.
@@ -235,7 +235,7 @@ export class TokenStore {
235
235
  async revoke(plaintext) {
236
236
  await this._rotateLock.runExclusive(async () => {
237
237
  const hash = hashTokenForStorage(plaintext);
238
- await this._cache.removeOAuthToken(hash);
238
+ await this._cache.oauthTokens.remove(hash);
239
239
  await this._cache.flush();
240
240
  });
241
241
  }
@@ -254,9 +254,9 @@ export class TokenStore {
254
254
  */
255
255
  async removeAllForClient(clientId) {
256
256
  await this._rotateLock.runExclusive(async () => {
257
- const all = await this._cache.getAllOAuthTokens();
257
+ const all = await this._cache.oauthTokens.getAll();
258
258
  const matching = all.filter((t) => t.clientId === clientId);
259
- await Promise.all(matching.map((t) => this._cache.removeOAuthToken(t.tokenHash)));
259
+ await Promise.all(matching.map((t) => this._cache.oauthTokens.remove(t.tokenHash)));
260
260
  await this._cache.flush();
261
261
  });
262
262
  }
@@ -267,9 +267,9 @@ export class TokenStore {
267
267
  * (race with deletion). Does not flush — caller is responsible.
268
268
  */
269
269
  async _bumpLastActivity(clientId, now) {
270
- const client = await this._cache.getOAuthClient(clientId);
270
+ const client = await this._cache.oauthClients.get(clientId);
271
271
  if (client === null)
272
272
  return; // race with deletion
273
- await this._cache.putOAuthClient({ ...client, lastTokenActivityAt: now });
273
+ await this._cache.oauthClients.put({ ...client, lastTokenActivityAt: now });
274
274
  }
275
275
  }
@@ -240,13 +240,13 @@ export declare const IdentitySchema: z.ZodObject<{
240
240
  sub: z.ZodString;
241
241
  source: z.ZodEnum<["email", "sub"]>;
242
242
  }, "strip", z.ZodTypeAny, {
243
- source: "sub" | "email";
244
- sub: string;
245
243
  email: string | null;
246
- }, {
247
- source: "sub" | "email";
248
244
  sub: string;
245
+ source: "email" | "sub";
246
+ }, {
249
247
  email: string | null;
248
+ sub: string;
249
+ source: "email" | "sub";
250
250
  }>;
251
251
  /**
252
252
  * RFC 8707 resource indicator — must be a fully-qualified URL or an explicit empty
@@ -264,13 +264,13 @@ export declare const OAuthTokenSchema: z.ZodObject<{
264
264
  sub: z.ZodString;
265
265
  source: z.ZodEnum<["email", "sub"]>;
266
266
  }, "strip", z.ZodTypeAny, {
267
- source: "sub" | "email";
268
- sub: string;
269
267
  email: string | null;
270
- }, {
271
- source: "sub" | "email";
272
268
  sub: string;
269
+ source: "email" | "sub";
270
+ }, {
273
271
  email: string | null;
272
+ sub: string;
273
+ source: "email" | "sub";
274
274
  }>;
275
275
  resource: z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>;
276
276
  expiresAt: z.ZodNumber;
@@ -282,9 +282,9 @@ export declare const OAuthTokenSchema: z.ZodObject<{
282
282
  scope: string;
283
283
  createdAt: number;
284
284
  identity: {
285
- source: "sub" | "email";
286
- sub: string;
287
285
  email: string | null;
286
+ sub: string;
287
+ source: "email" | "sub";
288
288
  };
289
289
  tokenHash: string;
290
290
  kind: "access" | "refresh";
@@ -296,9 +296,9 @@ export declare const OAuthTokenSchema: z.ZodObject<{
296
296
  scope: string;
297
297
  createdAt: number;
298
298
  identity: {
299
- source: "sub" | "email";
300
- sub: string;
301
299
  email: string | null;
300
+ sub: string;
301
+ source: "email" | "sub";
302
302
  };
303
303
  tokenHash: string;
304
304
  kind: "access" | "refresh";
@@ -353,13 +353,13 @@ export declare const AuthCodeStateSchema: z.ZodObject<{
353
353
  sub: z.ZodString;
354
354
  source: z.ZodEnum<["email", "sub"]>;
355
355
  }, "strip", z.ZodTypeAny, {
356
- source: "sub" | "email";
357
- sub: string;
358
356
  email: string | null;
359
- }, {
360
- source: "sub" | "email";
361
357
  sub: string;
358
+ source: "email" | "sub";
359
+ }, {
362
360
  email: string | null;
361
+ sub: string;
362
+ source: "email" | "sub";
363
363
  }>;
364
364
  }, "strip", z.ZodTypeAny, {
365
365
  clientId: string;
@@ -370,9 +370,9 @@ export declare const AuthCodeStateSchema: z.ZodObject<{
370
370
  scope: string;
371
371
  createdAt: number;
372
372
  identity: {
373
- source: "sub" | "email";
374
- sub: string;
375
373
  email: string | null;
374
+ sub: string;
375
+ source: "email" | "sub";
376
376
  };
377
377
  }, {
378
378
  clientId: string;
@@ -383,9 +383,9 @@ export declare const AuthCodeStateSchema: z.ZodObject<{
383
383
  scope: string;
384
384
  createdAt: number;
385
385
  identity: {
386
- source: "sub" | "email";
387
- sub: string;
388
386
  email: string | null;
387
+ sub: string;
388
+ source: "email" | "sub";
389
389
  };
390
390
  }>;
391
391
  export type AuthCodeState = z.infer<typeof AuthCodeStateSchema>;
@@ -423,13 +423,13 @@ export declare const AuthInfoExtraSchema: z.ZodObject<{
423
423
  sub: z.ZodString;
424
424
  source: z.ZodEnum<["email", "sub"]>;
425
425
  }, "strip", z.ZodTypeAny, {
426
- source: "sub" | "email";
427
- sub: string;
428
426
  email: string | null;
429
- }, {
430
- source: "sub" | "email";
431
427
  sub: string;
428
+ source: "email" | "sub";
429
+ }, {
432
430
  email: string | null;
431
+ sub: string;
432
+ source: "email" | "sub";
433
433
  }>;
434
434
  export type AuthInfoExtra = z.infer<typeof AuthInfoExtraSchema>;
435
435
  export interface AuthContext {
@@ -0,0 +1,56 @@
1
+ import { Mutex } from "async-mutex";
2
+ import type { Logger } from "pino";
3
+ /** Open + write + fsync + close — one atomic durable write per call. */
4
+ export declare function writeFileAtomic(path: string, contents: string): Promise<void>;
5
+ export interface DiskCacheOptions<T> {
6
+ readonly subdir: string;
7
+ /**
8
+ * Validates a raw JSON value read from disk and returns a typed `T`.
9
+ * Wraps the Zod schema's `parse` to sidestep Zod's branded-type input
10
+ * variance (the schema input is `string`, the output is `string & BRAND`,
11
+ * which can't be expressed through a single `ZodType<T>` parameter).
12
+ */
13
+ readonly parse: (raw: unknown) => T;
14
+ readonly getKey: (item: T) => string;
15
+ readonly log?: Logger;
16
+ }
17
+ export declare class DiskCache<T> {
18
+ protected readonly _subdir: string;
19
+ protected readonly _parse: (raw: unknown) => T;
20
+ protected readonly _getKey: (item: T) => string;
21
+ protected readonly _pending: Map<string, T>;
22
+ protected readonly _knownKeys: Set<string>;
23
+ protected readonly _mutex: Mutex;
24
+ protected readonly log: Logger;
25
+ protected _initialized: boolean;
26
+ constructor(opts: DiskCacheOptions<T>);
27
+ init(): Promise<void>;
28
+ flush(): Promise<void>;
29
+ get(key: string): Promise<T | null>;
30
+ getAll(): Promise<Array<T>>;
31
+ put(item: T): Promise<void>;
32
+ remove(key: string): Promise<void>;
33
+ /**
34
+ * Mutex-free buffer write. Subclasses call this from their own `put`
35
+ * overrides (which already hold the mutex) to extend the put with extra
36
+ * bookkeeping like a hash map without recursively locking.
37
+ */
38
+ protected _putInner(item: T): void;
39
+ /**
40
+ * Mutex-free remove. Subclasses call this from their own `remove`
41
+ * overrides. ENOENT on unlink is idempotent — the file already being
42
+ * gone is the desired end state.
43
+ */
44
+ protected _removeInner(key: string): Promise<void>;
45
+ has(key: string): boolean;
46
+ get size(): number;
47
+ /**
48
+ * Template-method hook: write pending entries to disk inside the mutex.
49
+ * Subclasses override to add post-write work (e.g. recipes writes its
50
+ * hash index after the data files are durable). Must NOT re-acquire the
51
+ * mutex — the caller (`flush()`) already holds it.
52
+ */
53
+ protected _writePending(): Promise<void>;
54
+ protected _writeFileAtomic(path: string, contents: string): Promise<void>;
55
+ protected _assertInitialized(method: string): void;
56
+ }
@@ -0,0 +1,191 @@
1
+ import { mkdir, open, readdir, readFile, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { Mutex } from "async-mutex";
4
+ import { isNodeError } from "../../utils/errors.js";
5
+ import { SILENT_LOG } from "../../utils/log.js";
6
+ // I/O error handling convention throughout this module:
7
+ // We use try/catch and check error.code rather than existsSync()-then-read.
8
+ // existsSync() is synchronous (blocks the event loop) and introduces a TOCTOU
9
+ // race; try/catch handles the file's actual state at I/O time with no race
10
+ // window. Non-ENOENT codes (EISDIR, EACCES, …) are rethrown so unexpected
11
+ // errors are never silently swallowed.
12
+ /** Open + write + fsync + close — one atomic durable write per call. */
13
+ export async function writeFileAtomic(path, contents) {
14
+ const fh = await open(path, "w");
15
+ try {
16
+ await fh.writeFile(contents);
17
+ await fh.sync();
18
+ }
19
+ finally {
20
+ await fh.close();
21
+ }
22
+ }
23
+ export class DiskCache {
24
+ _subdir;
25
+ _parse;
26
+ _getKey;
27
+ _pending = new Map();
28
+ // In-memory mirror of "what keys have a .json file on disk for this entity."
29
+ // Populated from readdir at init() and maintained by put()/remove(). For
30
+ // non-hashed entities this is the complete index — the recipes subclass
31
+ // additionally tracks a UID → hash map for diffing.
32
+ _knownKeys = new Set();
33
+ _mutex = new Mutex();
34
+ log;
35
+ _initialized = false;
36
+ constructor(opts) {
37
+ this._subdir = opts.subdir;
38
+ this._parse = opts.parse;
39
+ this._getKey = opts.getKey;
40
+ this.log = opts.log ?? SILENT_LOG;
41
+ }
42
+ async init() {
43
+ await mkdir(this._subdir, { recursive: true });
44
+ let files;
45
+ try {
46
+ files = await readdir(this._subdir);
47
+ }
48
+ catch (error) {
49
+ if (isNodeError(error) && error.code === "ENOENT") {
50
+ this._initialized = true;
51
+ return;
52
+ }
53
+ throw error;
54
+ }
55
+ for (const f of files) {
56
+ // Skip the per-entity index file (only RecipeDiskCache writes one). All
57
+ // other data files are <key>.json; the recipes index is the lone
58
+ // exception, kept inside the entity's subdir to keep migration simple.
59
+ if (f === "index.json")
60
+ continue;
61
+ if (f.endsWith(".json")) {
62
+ this._knownKeys.add(f.slice(0, -5));
63
+ }
64
+ }
65
+ this._initialized = true;
66
+ }
67
+ async flush() {
68
+ return this._mutex.runExclusive(() => {
69
+ this._assertInitialized("flush");
70
+ return this._writePending();
71
+ });
72
+ }
73
+ async get(key) {
74
+ this._assertInitialized("get");
75
+ const hit = this._pending.get(key);
76
+ if (hit !== undefined)
77
+ return hit;
78
+ const filePath = join(this._subdir, `${key}.json`);
79
+ let raw;
80
+ try {
81
+ raw = await readFile(filePath, "utf-8");
82
+ }
83
+ catch (error) {
84
+ if (isNodeError(error) && error.code === "ENOENT") {
85
+ // Cold-start cache miss; silent by design.
86
+ return null;
87
+ }
88
+ throw error;
89
+ }
90
+ return this._parse(JSON.parse(raw));
91
+ }
92
+ async getAll() {
93
+ this._assertInitialized("getAll");
94
+ // Pending entries shadow disk; seed result with the buffer.
95
+ const result = new Map(this._pending);
96
+ // Read the directory live, not from `_knownKeys`. A second cache
97
+ // instance pointing at the same dir can have written new files we
98
+ // haven't observed yet; tests and operator-side seeding both rely on
99
+ // this. The DCR cap middleware in particular reads `oauthClients.getAll()`
100
+ // on every POST /register and must see externally-seeded files.
101
+ let files;
102
+ try {
103
+ files = await readdir(this._subdir);
104
+ }
105
+ catch (error) {
106
+ if (isNodeError(error) && error.code === "ENOENT") {
107
+ return [...result.values()];
108
+ }
109
+ throw error;
110
+ }
111
+ await Promise.all(files.map(async (filename) => {
112
+ if (!filename.endsWith(".json") || filename === "index.json")
113
+ return;
114
+ const key = filename.slice(0, -5);
115
+ if (result.has(key))
116
+ return;
117
+ const raw = await readFile(join(this._subdir, filename), "utf-8");
118
+ result.set(key, this._parse(JSON.parse(raw)));
119
+ }));
120
+ return [...result.values()];
121
+ }
122
+ async put(item) {
123
+ return this._mutex.runExclusive(() => {
124
+ this._assertInitialized("put");
125
+ this._putInner(item);
126
+ });
127
+ }
128
+ async remove(key) {
129
+ return this._mutex.runExclusive(async () => {
130
+ this._assertInitialized("remove");
131
+ await this._removeInner(key);
132
+ });
133
+ }
134
+ /**
135
+ * Mutex-free buffer write. Subclasses call this from their own `put`
136
+ * overrides (which already hold the mutex) to extend the put with extra
137
+ * bookkeeping like a hash map without recursively locking.
138
+ */
139
+ _putInner(item) {
140
+ const key = this._getKey(item);
141
+ this._pending.set(key, item);
142
+ this._knownKeys.add(key);
143
+ }
144
+ /**
145
+ * Mutex-free remove. Subclasses call this from their own `remove`
146
+ * overrides. ENOENT on unlink is idempotent — the file already being
147
+ * gone is the desired end state.
148
+ */
149
+ async _removeInner(key) {
150
+ const filePath = join(this._subdir, `${key}.json`);
151
+ try {
152
+ await unlink(filePath);
153
+ }
154
+ catch (error) {
155
+ if (!isNodeError(error) || error.code !== "ENOENT") {
156
+ throw error;
157
+ }
158
+ }
159
+ this._pending.delete(key);
160
+ this._knownKeys.delete(key);
161
+ }
162
+ has(key) {
163
+ return this._knownKeys.has(key);
164
+ }
165
+ get size() {
166
+ return this._knownKeys.size;
167
+ }
168
+ /**
169
+ * Template-method hook: write pending entries to disk inside the mutex.
170
+ * Subclasses override to add post-write work (e.g. recipes writes its
171
+ * hash index after the data files are durable). Must NOT re-acquire the
172
+ * mutex — the caller (`flush()`) already holds it.
173
+ */
174
+ async _writePending() {
175
+ if (this._pending.size === 0)
176
+ return;
177
+ const entries = [...this._pending.entries()];
178
+ await Promise.all(entries.map(async ([key, item]) => {
179
+ await this._writeFileAtomic(join(this._subdir, `${key}.json`), JSON.stringify(item, null, 2));
180
+ }));
181
+ this._pending.clear();
182
+ }
183
+ async _writeFileAtomic(path, contents) {
184
+ return writeFileAtomic(path, contents);
185
+ }
186
+ _assertInitialized(method) {
187
+ if (!this._initialized) {
188
+ throw new Error(`DiskCache: ${method}() called before init()`);
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,5 @@
1
+ export { DiskCache } from "./base.js";
2
+ export type { DiskCacheOptions } from "./base.js";
3
+ export { RecipeDiskCache } from "./recipes.js";
4
+ export { OAuthClientDiskCache } from "./oauth-clients.js";
5
+ export { DiskCacheRoot } from "./root.js";
@@ -0,0 +1,4 @@
1
+ export { DiskCache } from "./base.js";
2
+ export { RecipeDiskCache } from "./recipes.js";
3
+ export { OAuthClientDiskCache } from "./oauth-clients.js";
4
+ export { DiskCacheRoot } from "./root.js";