veryfront 0.1.241 → 0.1.243

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 (56) hide show
  1. package/esm/cli/templates/manifest.js +77 -77
  2. package/esm/deno.js +1 -1
  3. package/esm/src/agent/conversation-root-run-context.d.ts.map +1 -1
  4. package/esm/src/agent/conversation-root-run-context.js +2 -0
  5. package/esm/src/agent/conversation-run-context.d.ts +2 -0
  6. package/esm/src/agent/conversation-run-context.d.ts.map +1 -1
  7. package/esm/src/agent/durable.d.ts +23 -0
  8. package/esm/src/agent/durable.d.ts.map +1 -1
  9. package/esm/src/agent/durable.js +39 -0
  10. package/esm/src/agent/index.d.ts +1 -1
  11. package/esm/src/agent/index.d.ts.map +1 -1
  12. package/esm/src/agent/index.js +1 -1
  13. package/esm/src/oauth/handlers/callback-handler.d.ts +2 -2
  14. package/esm/src/oauth/handlers/callback-handler.d.ts.map +1 -1
  15. package/esm/src/oauth/handlers/callback-handler.js +17 -5
  16. package/esm/src/oauth/handlers/init-handler.d.ts +24 -4
  17. package/esm/src/oauth/handlers/init-handler.d.ts.map +1 -1
  18. package/esm/src/oauth/handlers/init-handler.js +47 -10
  19. package/esm/src/oauth/providers/base.d.ts +9 -2
  20. package/esm/src/oauth/providers/base.d.ts.map +1 -1
  21. package/esm/src/oauth/providers/base.js +12 -5
  22. package/esm/src/oauth/token-store/index.d.ts +1 -1
  23. package/esm/src/oauth/token-store/index.d.ts.map +1 -1
  24. package/esm/src/oauth/token-store/memory.d.ts +21 -9
  25. package/esm/src/oauth/token-store/memory.d.ts.map +1 -1
  26. package/esm/src/oauth/token-store/memory.js +42 -28
  27. package/esm/src/oauth/types.d.ts +33 -7
  28. package/esm/src/oauth/types.d.ts.map +1 -1
  29. package/esm/src/platform/compat/framework-source-resolver.d.ts.map +1 -1
  30. package/esm/src/platform/compat/framework-source-resolver.js +34 -0
  31. package/esm/src/routing/api/module-loader/loader.d.ts +11 -0
  32. package/esm/src/routing/api/module-loader/loader.d.ts.map +1 -1
  33. package/esm/src/routing/api/module-loader/loader.js +18 -2
  34. package/esm/src/server/handlers/dev/dashboard/api.d.ts.map +1 -1
  35. package/esm/src/server/handlers/dev/dashboard/api.js +34 -13
  36. package/esm/src/server/handlers/dev/files/esbuild-plugins.d.ts.map +1 -1
  37. package/esm/src/server/handlers/dev/files/esbuild-plugins.js +45 -4
  38. package/esm/src/utils/version-constant.d.ts +1 -1
  39. package/esm/src/utils/version-constant.js +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/templates/manifest.js +77 -77
  42. package/src/deno.js +1 -1
  43. package/src/src/agent/conversation-root-run-context.ts +2 -0
  44. package/src/src/agent/durable.ts +60 -0
  45. package/src/src/agent/index.ts +3 -0
  46. package/src/src/oauth/handlers/callback-handler.ts +25 -8
  47. package/src/src/oauth/handlers/init-handler.ts +83 -15
  48. package/src/src/oauth/providers/base.ts +12 -5
  49. package/src/src/oauth/token-store/index.ts +1 -1
  50. package/src/src/oauth/token-store/memory.ts +48 -35
  51. package/src/src/oauth/types.ts +34 -7
  52. package/src/src/platform/compat/framework-source-resolver.ts +32 -0
  53. package/src/src/routing/api/module-loader/loader.ts +18 -2
  54. package/src/src/server/handlers/dev/dashboard/api.ts +32 -10
  55. package/src/src/server/handlers/dev/files/esbuild-plugins.ts +54 -5
  56. package/src/src/utils/version-constant.ts +1 -1
package/src/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "veryfront",
3
- "version": "0.1.241",
3
+ "version": "0.1.243",
4
4
  "license": "Apache-2.0",
5
5
  "nodeModulesDir": "auto",
6
6
  "workspace": [
@@ -24,6 +24,8 @@ function normalizeProvidedRun(input: {
24
24
  messageId: input.providedRun.messageId,
25
25
  latestEventId: input.providedRun.latestEventId ?? 0,
26
26
  latestExternalEventSequence: input.providedRun.latestExternalEventSequence ?? 0,
27
+ waitingToolCallId: null,
28
+ waitingToolName: null,
27
29
  status: "running",
28
30
  };
29
31
  }
@@ -86,6 +86,10 @@ export const ConversationRunProjectionSchema = z
86
86
  latest_event_id: z.number().int().nonnegative().optional(),
87
87
  latestExternalEventSequence: z.number().int().nonnegative().optional(),
88
88
  latest_external_event_sequence: z.number().int().nonnegative().optional(),
89
+ waitingToolCallId: z.string().min(1).nullable().optional(),
90
+ waiting_tool_call_id: z.string().min(1).nullable().optional(),
91
+ waitingToolName: z.string().nullable().optional(),
92
+ waiting_tool_name: z.string().nullable().optional(),
89
93
  status: ConversationRunStatusSchema,
90
94
  })
91
95
  .passthrough()
@@ -111,6 +115,8 @@ export const ConversationRunProjectionSchema = z
111
115
  messageId,
112
116
  latestEventId,
113
117
  latestExternalEventSequence,
118
+ waitingToolCallId: data.waitingToolCallId ?? data.waiting_tool_call_id ?? null,
119
+ waitingToolName: data.waitingToolName ?? data.waiting_tool_name ?? null,
114
120
  status: data.status,
115
121
  };
116
122
  });
@@ -124,6 +130,10 @@ export type TerminalConversationRunStatus = Extract<
124
130
  ConversationRunProjection["status"],
125
131
  "completed" | "failed" | "cancelled"
126
132
  >;
133
+ export type ConversationRunAppendCursorResyncResult =
134
+ | "advanced"
135
+ | "non_appendable"
136
+ | "unchanged";
127
137
 
128
138
  export const CreateConversationRunAcceptedSchema = z
129
139
  .object({
@@ -322,6 +332,56 @@ export function isActiveConversationRunStatus(
322
332
  return status === "pending" || status === "running" || status === "waiting_for_tool";
323
333
  }
324
334
 
335
+ export function isAppendableConversationRunProjection(run: ConversationRunProjection): boolean {
336
+ return (
337
+ run.status !== "completed" &&
338
+ run.status !== "failed" &&
339
+ run.status !== "cancelled" &&
340
+ run.status !== "waiting_for_tool" &&
341
+ run.waitingToolCallId === null &&
342
+ run.waitingToolName === null
343
+ );
344
+ }
345
+
346
+ export async function resyncConversationRunAppendCursor(input: {
347
+ authToken: string;
348
+ apiUrl: string;
349
+ conversationId: string;
350
+ runId: string;
351
+ previousLatestExternalEventSequence: number;
352
+ abortSignal?: AbortSignal;
353
+ }): Promise<{
354
+ result: ConversationRunAppendCursorResyncResult;
355
+ run: ConversationRunProjection;
356
+ }> {
357
+ const run = await getConversationRun({
358
+ authToken: input.authToken,
359
+ apiUrl: input.apiUrl,
360
+ conversationId: input.conversationId,
361
+ runId: input.runId,
362
+ abortSignal: input.abortSignal,
363
+ });
364
+
365
+ if (run.latestExternalEventSequence > input.previousLatestExternalEventSequence) {
366
+ return {
367
+ result: "advanced",
368
+ run,
369
+ };
370
+ }
371
+
372
+ if (!isAppendableConversationRunProjection(run)) {
373
+ return {
374
+ result: "non_appendable",
375
+ run,
376
+ };
377
+ }
378
+
379
+ return {
380
+ result: "unchanged",
381
+ run,
382
+ };
383
+ }
384
+
325
385
  async function waitForConversationRunPoll(
326
386
  ms: number,
327
387
  abortSignal?: AbortSignal,
@@ -219,6 +219,7 @@ export {
219
219
  AppendConversationRunEventsResponseSchema,
220
220
  CompleteConversationRunResponseSchema,
221
221
  type ConversationAgentRunUsage,
222
+ type ConversationRunAppendCursorResyncResult,
222
223
  type ConversationRunProjection,
223
224
  ConversationRunProjectionSchema,
224
225
  ConversationRunStatusSchema,
@@ -229,11 +230,13 @@ export {
229
230
  finalizeConversationAgentRun,
230
231
  getConversationRun,
231
232
  isActiveConversationRunStatus,
233
+ isAppendableConversationRunProjection,
232
234
  isCursorMismatchConversationRunAppendError,
233
235
  isIgnorableConversationRunAppendError,
234
236
  monitorConversationRunStatus,
235
237
  parseAppendConversationRunEventsErrorBody,
236
238
  resolveConversationRunTargets,
239
+ resyncConversationRunAppendCursor,
237
240
  type TerminalConversationRunStatus,
238
241
  } from "./durable.js";
239
242
  export {
@@ -6,7 +6,7 @@ import {
6
6
  } from "../../config/environment-config.js";
7
7
  import { type EnvReader, OAuthService } from "../providers/base.js";
8
8
  import { memoryTokenStore } from "../token-store/memory.js";
9
- import type { OAuthServiceConfig, OAuthState, TokenStore } from "../types.js";
9
+ import type { OAuthServiceConfig, StoredOAuthState, TokenStore } from "../types.js";
10
10
 
11
11
  const logger = baseLogger.component("o-auth");
12
12
 
@@ -23,8 +23,8 @@ export interface OAuthCallbackHandlerOptions {
23
23
  /** Error redirect path */
24
24
  errorRedirect?: string;
25
25
 
26
- /** Custom success callback */
27
- onSuccess?: (serviceId: string, tokens: unknown) => void | Promise<void>;
26
+ /** Custom success callback (called with the user id the tokens were stored under) */
27
+ onSuccess?: (serviceId: string, tokens: unknown, userId: string) => void | Promise<void>;
28
28
 
29
29
  /** Custom error callback */
30
30
  onError?: (serviceId: string, error: string) => void | Promise<void>;
@@ -102,7 +102,7 @@ export function createOAuthCallbackHandler(
102
102
 
103
103
  if (!code) return handleError(appUrl, "no_code");
104
104
 
105
- let storedState: OAuthState | null = null;
105
+ let storedState: StoredOAuthState | null = null;
106
106
 
107
107
  if (!skipStateValidation && !state) {
108
108
  return handleError(appUrl, "invalid_state", "Missing state parameter", {
@@ -111,12 +111,20 @@ export function createOAuthCallbackHandler(
111
111
  }
112
112
 
113
113
  if (state) {
114
- storedState = await tokenStore.getState(state);
114
+ // Atomic read+delete. Unknown/expired/forged state all return null.
115
+ storedState = await tokenStore.consumeState(state);
115
116
  if (!skipStateValidation && !storedState) {
116
117
  return handleError(appUrl, "invalid_state", "Invalid or expired state", {
117
118
  serviceId: config.serviceId,
118
119
  });
119
120
  }
121
+ // A state record from a different service must never authorize this one.
122
+ if (storedState && storedState.serviceId !== config.serviceId) {
123
+ return handleError(appUrl, "invalid_state", "State serviceId mismatch", {
124
+ serviceId: config.serviceId,
125
+ stateServiceId: storedState.serviceId,
126
+ });
127
+ }
120
128
  }
121
129
 
122
130
  const service = new OAuthService(config, tokenStore, envReader);
@@ -138,11 +146,20 @@ export function createOAuthCallbackHandler(
138
146
  );
139
147
  }
140
148
 
141
- await tokenStore.setTokens(config.serviceId, result.tokens);
149
+ // Without state (skipStateValidation) we have no userId — refuse to
150
+ // store tokens under a shared slot. Callers who need this path must
151
+ // provide a store that handles it themselves (e.g. cookie-scoped).
152
+ if (!storedState) {
153
+ return handleError(
154
+ appUrl,
155
+ "invalid_state",
156
+ `Cannot store tokens for ${config.serviceId}: no state (and thus no userId) available`,
157
+ );
158
+ }
142
159
 
143
- if (state) await tokenStore.clearState(state);
160
+ await tokenStore.setTokens(config.serviceId, storedState.userId, result.tokens);
144
161
 
145
- await onSuccess?.(config.serviceId, result.tokens);
162
+ await onSuccess?.(config.serviceId, result.tokens, storedState.userId);
146
163
 
147
164
  const successUrl = new URL(successRedirect, appUrl);
148
165
  successUrl.searchParams.set("connected", config.serviceId);
@@ -22,6 +22,24 @@ async function isRequestUnauthorized(
22
22
  return isAuthenticated ? !(await isAuthenticated(req)) : false;
23
23
  }
24
24
 
25
+ /**
26
+ * Resolve the userId for a request, returning null when anonymous.
27
+ *
28
+ * `getUserId` is required at compile time (see handler option types). We
29
+ * still tolerate `undefined` at runtime (e.g. a JS caller) and treat it as
30
+ * unauthenticated — NEVER fall back to "anonymous": that preserves
31
+ * VULN-AUTH-2 where unrelated users share a single token slot.
32
+ */
33
+ async function resolveUserId(
34
+ req: Request,
35
+ getUserId: GetUserIdFn | undefined,
36
+ ): Promise<string | null> {
37
+ if (!getUserId) return null;
38
+ const result = await getUserId(req);
39
+ if (!result) return null; // null, undefined, or empty string all fail.
40
+ return result;
41
+ }
42
+
25
43
  function resolveAppUrl(baseUrl: string | undefined, env: EnvironmentConfig): string {
26
44
  return baseUrl ?? env.appUrl ?? DEFAULT_APP_URL;
27
45
  }
@@ -46,6 +64,9 @@ function createInitErrorResponse(error: unknown): Response {
46
64
  );
47
65
  }
48
66
 
67
+ /** Signature for resolving the authenticated user's id from a request. */
68
+ export type GetUserIdFn = (req: Request) => string | null | Promise<string | null>;
69
+
49
70
  export interface OAuthInitHandlerOptions {
50
71
  /** Token store to use (defaults to memory store) */
51
72
  tokenStore?: TokenStore;
@@ -61,21 +82,45 @@ export interface OAuthInitHandlerOptions {
61
82
 
62
83
  /** EnvReader for dynamic env vars (defaults to getEnv) */
63
84
  envReader?: EnvReader;
85
+
86
+ /**
87
+ * Optional authentication check. If supplied and returns false the request
88
+ * is rejected with 401. Independent from `getUserId` which always runs.
89
+ */
90
+ isAuthenticated?: (req: Request) => boolean | Promise<boolean>;
91
+
92
+ /**
93
+ * REQUIRED. Resolve the authenticated user's id. The returned id is
94
+ * persisted with the OAuth `state` so the callback stores tokens in that
95
+ * user's slot. Return `null` (or an empty string) to reject unauthenticated
96
+ * requests with 401. NEVER return a shared constant like "anonymous" —
97
+ * that re-introduces VULN-AUTH-2.
98
+ */
99
+ getUserId: GetUserIdFn;
64
100
  }
65
101
 
66
102
  export function createOAuthInitHandler(
67
103
  config: OAuthServiceConfig,
68
- options: OAuthInitHandlerOptions = {},
69
- ): () => Promise<Response> {
104
+ options: OAuthInitHandlerOptions,
105
+ ): (req: Request) => Promise<Response> {
70
106
  const {
71
107
  tokenStore = memoryTokenStore,
72
108
  baseUrl,
73
109
  authOptions = {},
74
110
  env = getEnvironmentConfig(),
75
111
  envReader = getEnv,
76
- } = options;
112
+ isAuthenticated,
113
+ getUserId,
114
+ } = options ?? ({} as OAuthInitHandlerOptions);
115
+
116
+ return async function handler(req: Request): Promise<Response> {
117
+ if (await isRequestUnauthorized(req, isAuthenticated)) {
118
+ return createUnauthorizedResponse();
119
+ }
120
+
121
+ const userId = await resolveUserId(req, getUserId);
122
+ if (!userId) return createUnauthorizedResponse();
77
123
 
78
- return async function handler(): Promise<Response> {
79
124
  const service = new OAuthService(config, tokenStore, envReader);
80
125
 
81
126
  if (!service.isConfigured()) {
@@ -87,7 +132,15 @@ export function createOAuthInitHandler(
87
132
 
88
133
  try {
89
134
  const { url, state } = await service.createAuthorizationUrl({ ...authOptions, redirectUri });
90
- await tokenStore.setState(state);
135
+ await tokenStore.setState(state.state, {
136
+ userId,
137
+ serviceId: config.serviceId,
138
+ codeVerifier: state.codeVerifier,
139
+ redirectUri: state.redirectUri,
140
+ scopes: state.scopes,
141
+ createdAt: state.createdAt,
142
+ metadata: state.metadata,
143
+ });
91
144
  return Response.redirect(url);
92
145
  } catch (error) {
93
146
  logger.error("Init error", { serviceId: config.serviceId }, error);
@@ -105,24 +158,31 @@ export interface OAuthStatusHandlerOptions {
105
158
 
106
159
  /** Optional authentication check — return true if the request is authenticated */
107
160
  isAuthenticated?: (req: Request) => boolean | Promise<boolean>;
161
+
162
+ /** REQUIRED. Resolve the authenticated user's id (see OAuthInitHandlerOptions). */
163
+ getUserId: GetUserIdFn;
108
164
  }
109
165
 
110
166
  export function createOAuthStatusHandler(
111
167
  config: OAuthServiceConfig,
112
- options: OAuthStatusHandlerOptions = {},
168
+ options: OAuthStatusHandlerOptions,
113
169
  ): (req: Request) => Promise<Response> {
114
170
  const {
115
171
  tokenStore = memoryTokenStore,
116
172
  envReader = getEnv,
117
173
  isAuthenticated,
118
- } = options;
174
+ getUserId,
175
+ } = options ?? ({} as OAuthStatusHandlerOptions);
119
176
 
120
177
  return async function handler(req: Request): Promise<Response> {
121
178
  if (await isRequestUnauthorized(req, isAuthenticated)) {
122
179
  return createUnauthorizedResponse();
123
180
  }
124
181
 
125
- const tokens = await tokenStore.getTokens(config.serviceId);
182
+ const userId = await resolveUserId(req, getUserId);
183
+ if (!userId) return createUnauthorizedResponse();
184
+
185
+ const tokens = await tokenStore.getTokens(config.serviceId, userId);
126
186
 
127
187
  const isConnected = !!tokens?.accessToken;
128
188
  const isExpired = tokens?.expiresAt ? Date.now() > tokens.expiresAt : false;
@@ -139,22 +199,30 @@ export function createOAuthStatusHandler(
139
199
  };
140
200
  }
141
201
 
202
+ export interface OAuthDisconnectHandlerOptions {
203
+ tokenStore?: TokenStore;
204
+ /** Optional authentication check — return true if the request is authenticated */
205
+ isAuthenticated?: (req: Request) => boolean | Promise<boolean>;
206
+ /** REQUIRED. Resolve the authenticated user's id (see OAuthInitHandlerOptions). */
207
+ getUserId: GetUserIdFn;
208
+ }
209
+
142
210
  export function createOAuthDisconnectHandler(
143
211
  config: OAuthServiceConfig,
144
- options: {
145
- tokenStore?: TokenStore;
146
- /** Optional authentication check — return true if the request is authenticated */
147
- isAuthenticated?: (req: Request) => boolean | Promise<boolean>;
148
- } = {},
212
+ options: OAuthDisconnectHandlerOptions,
149
213
  ): (req: Request) => Promise<Response> {
150
- const { tokenStore = memoryTokenStore, isAuthenticated } = options;
214
+ const { tokenStore = memoryTokenStore, isAuthenticated, getUserId } = options ??
215
+ ({} as OAuthDisconnectHandlerOptions);
151
216
 
152
217
  return async function handler(req: Request): Promise<Response> {
153
218
  if (await isRequestUnauthorized(req, isAuthenticated)) {
154
219
  return createUnauthorizedResponse();
155
220
  }
156
221
 
157
- await tokenStore.clearTokens(config.serviceId);
222
+ const userId = await resolveUserId(req, getUserId);
223
+ if (!userId) return createUnauthorizedResponse();
224
+
225
+ await tokenStore.clearTokens(config.serviceId, userId);
158
226
 
159
227
  return Response.json({
160
228
  success: true,
@@ -315,8 +315,15 @@ export class OAuthService extends OAuthProvider {
315
315
  });
316
316
  }
317
317
 
318
- async getAccessToken(): Promise<string | null> {
319
- const tokens = await this.tokenStore?.getTokens(this.serviceId);
318
+ /**
319
+ * Get a valid access token for the given user, refreshing if needed.
320
+ *
321
+ * `userId` is required — this store is keyed by `(serviceId, userId)` to
322
+ * prevent one user's OAuth completion from overwriting another user's
323
+ * tokens. See VULN-AUTH-2.
324
+ */
325
+ async getAccessToken(userId: string): Promise<string | null> {
326
+ const tokens = await this.tokenStore?.getTokens(this.serviceId, userId);
320
327
  if (!tokens) return null;
321
328
 
322
329
  const isExpired = tokens.expiresAt && Date.now() > tokens.expiresAt - TOKEN_REFRESH_BUFFER_MS;
@@ -330,12 +337,12 @@ export class OAuthService extends OAuthProvider {
330
337
  if (!this.tokenStore) {
331
338
  throw new Error("TokenStore not configured");
332
339
  }
333
- await this.tokenStore.setTokens(this.serviceId, result.tokens);
340
+ await this.tokenStore.setTokens(this.serviceId, userId, result.tokens);
334
341
  return result.tokens.accessToken;
335
342
  }
336
343
 
337
- async fetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
338
- const token = await this.getAccessToken();
344
+ async fetch<T>(userId: string, endpoint: string, options: RequestInit = {}): Promise<T> {
345
+ const token = await this.getAccessToken(userId);
339
346
  if (!token) {
340
347
  throw TOKEN_STORAGE_ERROR.create({
341
348
  detail: `Not authenticated with ${this.serviceConfig.displayName}`,
@@ -5,4 +5,4 @@
5
5
  */
6
6
 
7
7
  export { MemoryTokenStore, memoryTokenStore } from "./memory.js";
8
- export type { OAuthState, OAuthTokens, TokenStore } from "../types.js";
8
+ export type { OAuthState, OAuthTokens, StoredOAuthState, TokenStore } from "../types.js";
@@ -1,69 +1,82 @@
1
- import type { OAuthState, OAuthTokens, TokenStore } from "../types.js";
2
-
3
- /** How long an OAuth state nonce remains valid (10 minutes). */
4
- const STATE_EXPIRATION_MS = 10 * 60 * 1_000;
5
-
1
+ import type { OAuthTokens, StoredOAuthState, TokenStore } from "../types.js";
2
+
3
+ /** State expiry window: reject any state older than this (10 minutes). */
4
+ const STATE_EXPIRY_MS = 10 * 60 * 1_000;
5
+
6
+ /**
7
+ * In-memory TokenStore keyed by `(serviceId, userId)`.
8
+ *
9
+ * Suitable for development and tests. For production use a persistent store
10
+ * (Redis, Postgres, ...) keyed the same way. Never share a single slot per
11
+ * service across users — see VULN-AUTH-2.
12
+ */
6
13
  export class MemoryTokenStore implements TokenStore {
7
14
  private tokens = new Map<string, OAuthTokens>();
8
- private states = new Map<string, OAuthState>();
15
+ private states = new Map<string, StoredOAuthState>();
9
16
  private projectId: string;
10
17
 
11
18
  constructor(projectId = "default") {
12
19
  this.projectId = projectId;
13
20
  }
14
21
 
15
- private scopedKey(serviceId: string): string {
16
- return `${this.projectId}:${serviceId}`;
17
- }
18
-
19
- async getTokens(serviceId: string): Promise<OAuthTokens | null> {
20
- return this.tokens.get(this.scopedKey(serviceId)) ?? null;
22
+ private scopedKey(serviceId: string, userId: string): string {
23
+ return `${this.projectId}:${serviceId}:${userId}`;
21
24
  }
22
25
 
23
- async setTokens(serviceId: string, tokens: OAuthTokens): Promise<void> {
24
- this.tokens.set(this.scopedKey(serviceId), tokens);
26
+ getTokens(serviceId: string, userId: string): Promise<OAuthTokens | null> {
27
+ return Promise.resolve(this.tokens.get(this.scopedKey(serviceId, userId)) ?? null);
25
28
  }
26
29
 
27
- async clearTokens(serviceId: string): Promise<void> {
28
- this.tokens.delete(this.scopedKey(serviceId));
30
+ setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise<void> {
31
+ this.tokens.set(this.scopedKey(serviceId, userId), tokens);
32
+ return Promise.resolve();
29
33
  }
30
34
 
31
- async getState(state: string): Promise<OAuthState | null> {
32
- const storedState = this.states.get(state);
33
- if (!storedState) return null;
34
-
35
- if (Date.now() - storedState.createdAt > STATE_EXPIRATION_MS) {
36
- this.states.delete(state);
37
- return null;
38
- }
39
-
40
- return storedState;
35
+ clearTokens(serviceId: string, userId: string): Promise<void> {
36
+ this.tokens.delete(this.scopedKey(serviceId, userId));
37
+ return Promise.resolve();
41
38
  }
42
39
 
43
- async setState(storedState: OAuthState): Promise<void> {
44
- this.states.set(storedState.state, storedState);
40
+ setState(state: string, meta: StoredOAuthState): Promise<void> {
41
+ this.states.set(state, meta);
45
42
  this.cleanupExpiredStates();
43
+ return Promise.resolve();
46
44
  }
47
45
 
48
- async clearState(state: string): Promise<void> {
46
+ /**
47
+ * Atomically read and delete state (one-shot). Returns null for unknown or
48
+ * expired entries. Expired entries are removed on read.
49
+ */
50
+ consumeState(state: string): Promise<StoredOAuthState | null> {
51
+ const meta = this.states.get(state);
52
+ if (!meta) return Promise.resolve(null);
49
53
  this.states.delete(state);
54
+ if (Date.now() - meta.createdAt > STATE_EXPIRY_MS) {
55
+ return Promise.resolve(null);
56
+ }
57
+ return Promise.resolve(meta);
50
58
  }
51
59
 
52
60
  private cleanupExpiredStates(): void {
53
61
  const now = Date.now();
54
- for (const [state, storedState] of this.states) {
55
- if (now - storedState.createdAt > STATE_EXPIRATION_MS) {
62
+ for (const [state, meta] of this.states) {
63
+ if (now - meta.createdAt > STATE_EXPIRY_MS) {
56
64
  this.states.delete(state);
57
65
  }
58
66
  }
59
67
  }
60
68
 
69
+ /** List connected slots as `${serviceId}:${userId}` strings (test/debug aid). */
61
70
  getConnectedServices(): string[] {
62
- return [...this.tokens.keys()];
71
+ const prefix = `${this.projectId}:`;
72
+ return [...this.tokens.keys()].map((key) =>
73
+ key.startsWith(prefix) ? key.slice(prefix.length) : key
74
+ );
63
75
  }
64
76
 
65
- isConnected(serviceId: string): boolean {
66
- const tokens = this.tokens.get(this.scopedKey(serviceId));
77
+ /** Whether a given user has usable tokens for a service. */
78
+ isConnected(serviceId: string, userId: string): boolean {
79
+ const tokens = this.tokens.get(this.scopedKey(serviceId, userId));
67
80
  if (!tokens) return false;
68
81
 
69
82
  const isExpired = tokens.expiresAt != null && Date.now() > tokens.expiresAt;
@@ -76,4 +89,4 @@ export class MemoryTokenStore implements TokenStore {
76
89
  }
77
90
  }
78
91
 
79
- export const memoryTokenStore = new MemoryTokenStore();
92
+ export const memoryTokenStore: TokenStore = new MemoryTokenStore();
@@ -10,13 +10,40 @@ export type {
10
10
  } from "./schemas/index.js";
11
11
 
12
12
  // Import types used locally in this file
13
- import type { OAuthState, OAuthTokens } from "./schemas/index.js";
13
+ import type { OAuthTokens } from "./schemas/index.js";
14
14
 
15
+ /**
16
+ * Persisted OAuth state row. Created when init handler starts a flow and
17
+ * consumed exactly once by the callback handler.
18
+ *
19
+ * `userId` binds the flow to the authenticated user who initiated it so the
20
+ * resulting tokens are stored in that user's slot (not a shared one).
21
+ */
22
+ export interface StoredOAuthState {
23
+ userId: string;
24
+ serviceId: string;
25
+ codeVerifier?: string;
26
+ redirectUri?: string;
27
+ scopes?: string[];
28
+ createdAt: number;
29
+ metadata?: Record<string, unknown>;
30
+ }
31
+
32
+ /**
33
+ * TokenStore is keyed by `(serviceId, userId)` — tokens are per-user.
34
+ *
35
+ * Using a single-slot-per-service store is a vulnerability: the last OAuth
36
+ * completion overwrites all others, so an attacker who starts and finishes
37
+ * an OAuth flow with their own account can cause server-side code to act
38
+ * on the attacker's account. Callers MUST pass `userId` from authenticated
39
+ * session context.
40
+ */
15
41
  export interface TokenStore {
16
- getTokens(serviceId: string): Promise<OAuthTokens | null>;
17
- setTokens(serviceId: string, tokens: OAuthTokens): Promise<void>;
18
- clearTokens(serviceId: string): Promise<void>;
19
- getState(state: string): Promise<OAuthState | null>;
20
- setState(state: OAuthState): Promise<void>;
21
- clearState(state: string): Promise<void>;
42
+ getTokens(serviceId: string, userId: string): Promise<OAuthTokens | null>;
43
+ setTokens(serviceId: string, userId: string, tokens: OAuthTokens): Promise<void>;
44
+ clearTokens(serviceId: string, userId: string): Promise<void>;
45
+ /** Persist a new OAuth state row for the initiating user. */
46
+ setState(state: string, meta: StoredOAuthState): Promise<void>;
47
+ /** Atomically read and delete state. Returns null if unknown/expired. */
48
+ consumeState(state: string): Promise<StoredOAuthState | null>;
22
49
  }
@@ -1,8 +1,31 @@
1
1
  import { join } from "./path/index.js";
2
2
  import type { FileInfo } from "../adapters/base.js";
3
+ import { isWithinDirectory } from "../../security/path-validation.js";
3
4
  import { createFileSystem } from "./fs.js";
4
5
  import { getFrameworkRoot, getFrameworkRootFromMeta } from "./vfs-paths.js";
5
6
 
7
+ /**
8
+ * Reject candidate paths that contain traversal indicators — plain `..`,
9
+ * NUL, or any percent-encoded variant (including multiply-encoded forms such
10
+ * as `%252e` or `%25252e`). The public `/_vf_modules/...` route reaches this
11
+ * resolver, so a malicious basePath like
12
+ * `_veryfront/%2e%2e%2fsecret.ts` would otherwise be joined with the
13
+ * framework lookupDir and escape it.
14
+ */
15
+ function hasDangerousSegments(candidate: string): boolean {
16
+ if (candidate.includes("\0")) return true;
17
+ // Plain-text traversal (post URL-decode).
18
+ if (/(^|[/\\])\.\.([/\\]|$)/.test(candidate)) return true;
19
+ // Any occurrence of a percent sign is treated as suspicious: this resolver
20
+ // is called with inputs taken from URL path segments which have already
21
+ // been decoded once upstream. A lingering `%` means the attacker
22
+ // double-encoded the input, or that decoding missed a sequence — either
23
+ // way, refuse to probe the filesystem. Framework source paths never
24
+ // legitimately contain `%`.
25
+ if (candidate.includes("%")) return true;
26
+ return false;
27
+ }
28
+
6
29
  export const FRAMEWORK_ROOT = getFrameworkRootFromMeta(import.meta.url);
7
30
  export const FRAMEWORK_SRC_DIR = join(FRAMEWORK_ROOT, "src");
8
31
  export const FRAMEWORK_EMBEDDED_SRC_DIR = join(FRAMEWORK_ROOT, "dist", "framework-src");
@@ -105,6 +128,11 @@ export async function resolveFrameworkSourcePath(
105
128
  relativePathWithoutExt: string,
106
129
  options: ResolveFrameworkSourcePathOptions = {},
107
130
  ): Promise<FrameworkSourceLookupResult | null> {
131
+ // VULN-FS-3: Reject any candidate containing traversal indicators
132
+ // (plain or percent-encoded) before joining with the framework lookup dir.
133
+ // The public /_vf_modules/... route reaches this function with user input.
134
+ if (hasDangerousSegments(relativePathWithoutExt)) return null;
135
+
108
136
  const fs = options.fileSystem ?? createFileSystem();
109
137
  const lookupDirs = getFrameworkSourceLookupDirs(options.extraLookupDirs);
110
138
  const extensions = options.extensions ?? DEFAULT_FRAMEWORK_SOURCE_EXTENSIONS;
@@ -119,6 +147,10 @@ export async function resolveFrameworkSourcePath(
119
147
  for (const ext of extensions) {
120
148
  const candidatePath = join(lookupDir, candidate + ext);
121
149
 
150
+ // Defence in depth: even if the candidate passed the textual gate
151
+ // above, confirm the joined path is physically within the lookup dir.
152
+ if (!isWithinDirectory(lookupDir, candidatePath)) continue;
153
+
122
154
  try {
123
155
  const stat = await fs.stat(candidatePath);
124
156
  if (stat.isFile) {