@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.
- package/dist/auth/build.d.ts +2 -2
- package/dist/auth/cleanup.d.ts +2 -2
- package/dist/auth/cleanup.js +3 -3
- package/dist/auth/client-registration.d.ts +2 -2
- package/dist/auth/client-registration.js +6 -6
- package/dist/auth/routes.d.ts +2 -2
- package/dist/auth/routes.js +1 -1
- package/dist/auth/token-store.d.ts +2 -2
- package/dist/auth/token-store.js +13 -13
- package/dist/auth/types.d.ts +24 -24
- package/dist/cache/disk/base.d.ts +56 -0
- package/dist/cache/disk/base.js +191 -0
- package/dist/cache/disk/index.d.ts +5 -0
- package/dist/cache/disk/index.js +4 -0
- package/dist/cache/disk/oauth-clients.d.ts +30 -0
- package/dist/cache/disk/oauth-clients.js +39 -0
- package/dist/cache/disk/recipes.d.ts +26 -0
- package/dist/cache/disk/recipes.js +110 -0
- package/dist/cache/disk/root.d.ts +46 -0
- package/dist/cache/disk/root.js +133 -0
- package/dist/cache/pantry-store.d.ts +2 -16
- package/dist/cache/pantry-store.js +8 -61
- package/dist/cache/recipe-store.d.ts +2 -14
- package/dist/cache/recipe-store.js +8 -61
- package/dist/entity/index.d.ts +1 -0
- package/dist/entity/index.js +1 -0
- package/dist/entity/store.d.ts +31 -0
- package/dist/entity/store.js +81 -0
- package/dist/features/discover-feature.d.ts +3 -3
- package/dist/features/discover-feature.js +5 -3
- package/dist/paprika/sync.d.ts +2 -2
- package/dist/paprika/sync.js +25 -25
- package/dist/paprika/types.d.ts +11 -3
- package/dist/server/app-context.d.ts +2 -2
- package/dist/server/build.js +16 -4
- package/dist/tools/categories.js +2 -0
- package/dist/tools/create.js +1 -0
- package/dist/tools/delete.js +1 -0
- package/dist/tools/discover.js +2 -0
- package/dist/tools/filter.js +4 -0
- package/dist/tools/helpers.js +16 -2
- package/dist/tools/list.js +13 -1
- package/dist/tools/pantry-add.js +1 -0
- package/dist/tools/pantry-delete.js +1 -0
- package/dist/tools/pantry-get.js +2 -0
- package/dist/tools/pantry-helpers.js +2 -2
- package/dist/tools/pantry-list.js +2 -0
- package/dist/tools/pantry-update.js +1 -0
- package/dist/tools/read.js +2 -0
- package/dist/tools/search.js +11 -0
- package/dist/tools/update.js +6 -1
- package/dist/transport/http.d.ts +0 -10
- package/dist/transport/http.js +5 -0
- package/dist/utils/config.d.ts +10 -10
- package/package.json +1 -1
- package/dist/cache/disk-cache.d.ts +0 -66
- package/dist/cache/disk-cache.js +0 -433
package/dist/auth/build.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* after cache.init() completes.
|
|
11
11
|
*/
|
|
12
12
|
import type { Logger } from "pino";
|
|
13
|
-
import type {
|
|
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:
|
|
16
|
+
export declare function buildAuthContext(config: PaprikaConfig, cache: DiskCacheRoot, parentLog: Logger): Promise<AuthContext | null>;
|
package/dist/auth/cleanup.d.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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. */
|
package/dist/auth/cleanup.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
161
|
+
const client = await this._cache.oauthClients.get(clientId);
|
|
162
162
|
if (client === null)
|
|
163
163
|
return false;
|
|
164
164
|
const presentedHash = hashTokenForStorage(presentedToken);
|
package/dist/auth/routes.d.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
89
|
+
export declare function buildClientCap(cache: DiskCacheRoot, max: number): MiddlewareHandler;
|
package/dist/auth/routes.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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:
|
|
39
|
+
constructor(_cache: DiskCacheRoot, _now?: () => number);
|
|
40
40
|
/**
|
|
41
41
|
* Issues a new access + refresh token pair for a client.
|
|
42
42
|
*
|
package/dist/auth/token-store.js
CHANGED
|
@@ -62,8 +62,8 @@ export class TokenStore {
|
|
|
62
62
|
expiresAt: refreshExpiresAt,
|
|
63
63
|
createdAt: now,
|
|
64
64
|
};
|
|
65
|
-
await this._cache.
|
|
66
|
-
await this._cache.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
270
|
+
const client = await this._cache.oauthClients.get(clientId);
|
|
271
271
|
if (client === null)
|
|
272
272
|
return; // race with deletion
|
|
273
|
-
await this._cache.
|
|
273
|
+
await this._cache.oauthClients.put({ ...client, lastTokenActivityAt: now });
|
|
274
274
|
}
|
|
275
275
|
}
|
package/dist/auth/types.d.ts
CHANGED
|
@@ -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
|
+
}
|