Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions cli/commands/init/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,12 @@ describe("catalog", () => {
assertEquals(ids.includes("stripe"), false);
});

it("includes feature-gated integrations when explicitly enabled", () => {
it("includes eligible experiments but not provider-adapter-only integrations", () => {
Deno.env.set(EXPERIMENTAL_INTEGRATIONS_ENV, "salesforce,stripe");

const ids = getAllIntegrations().map((i) => i.id);

assertEquals(ids.includes("salesforce"), true);
assertEquals(ids.includes("salesforce"), false);
assertEquals(ids.includes("stripe"), true);
});
});
Expand Down Expand Up @@ -213,7 +213,7 @@ describe("catalog", () => {
});

it("adds headers for feature-gated categories when enabled", () => {
Deno.env.set(EXPERIMENTAL_INTEGRATIONS_ENV, "salesforce");
Deno.env.set(EXPERIMENTAL_INTEGRATIONS_ENV, "attio");

const headers = getIntegrationSelectOptionsWithHeaders()
.filter((o) => o.isHeader)
Expand Down
19 changes: 18 additions & 1 deletion cli/shared/project-creation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,30 @@ describe("createProject", () => {
assertEquals(result.createdPaths.includes("lib/token-store.ts"), true);
assertEquals(result.createdPaths.includes("lib/oauth.ts"), true);

const [tokenStore, oauth] = await Promise.all([
const [tokenStore, oauth, initRoute, callbackRoute, statusRoute] = await Promise.all([
Deno.readTextFile(join(projectDir, "lib/token-store.ts")),
Deno.readTextFile(join(projectDir, "lib/oauth.ts")),
Deno.readTextFile(join(projectDir, "app/api/auth/github/route.ts")),
Deno.readTextFile(join(projectDir, "app/api/auth/github/callback/route.ts")),
Deno.readTextFile(join(projectDir, "app/api/integrations/status/route.ts")),
]);
assertStringIncludes(tokenStore, "createDefaultTokenStore");
assertStringIncludes(tokenStore, "getDefaultTokenStore");
assertStringIncludes(oauth, "postTokenRequest");
assertStringIncludes(
initRoute,
'import { tokenStore } from "../../../../lib/token-store.ts";',
);
assertStringIncludes(
callbackRoute,
'import { tokenStore } from "../../../../../lib/token-store.ts";',
);
assertStringIncludes(
statusRoute,
'import { requireUserIdFromRequest } from "../../../../lib/user-id.ts";',
);
assertStringIncludes(statusRoute, "const userId = requireUserIdFromRequest(req);");
assertEquals(statusRoute.includes('"current-user"'), false);
} finally {
await remove(parentDir, { recursive: true }).catch(() => {});
}
Expand Down
8 changes: 4 additions & 4 deletions cli/shared/project-creation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,16 +147,16 @@ function generateIntegrationsStatusRoute(integrations: ResolvedIntegration[]): s
* This file is auto-generated based on the integrations you selected.
*/

import { tokenStore } from "../../../../lib/token-store";
import { tokenStore } from "../../../../lib/token-store.ts";
import { requireUserIdFromRequest } from "../../../../lib/user-id.ts";

// Integrations configured for this project
const INTEGRATIONS = [
${integrationEntries}
];

export async function GET(_req: Request) {
// Get actual user ID from session in production
const userId = "current-user";
export async function GET(req: Request): Promise<Response> {
const userId = requireUserIdFromRequest(req);

const statuses = await Promise.all(
INTEGRATIONS.map(async (integration) => {
Expand Down
127 changes: 117 additions & 10 deletions cli/templates/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ describe("cli/templates", () => {
"token-store.ts should centralize default store selection",
);
assertEquals(
tokenStore.includes("In-memory token storage is not allowed in production"),
tokenStore.includes("OAuth token storage is not configured for production"),
true,
"token-store.ts should fail closed for production memory storage",
);
Expand All @@ -417,32 +417,139 @@ describe("cli/templates", () => {
"token-store.ts should resolve the default store lazily",
);
assertEquals(
tokenStore.includes("export const tokenStore: TokenStore = inMemoryStore;"),
false,
"token-store.ts must not export the in-memory store unconditionally",
tokenStore.includes("type RefreshCapableTokenStore"),
true,
"token-store.ts should require the production refresh-capable contract",
);
assertEquals(
tokenStore.includes("export const tokenStore: TokenStore = createDefaultTokenStore();"),
false,
"token-store.ts must not throw during module import in production",
tokenStore.includes("return store.compareAndSetTokens"),
true,
"token-store.ts should delegate atomic token replacement",
);
assertEquals(
tokenStore.includes("return store.withTokenRefreshLock"),
true,
"token-store.ts should delegate refresh locking to the configured backend",
);
assertEquals(
tokenStore.includes("return store.consumeState"),
true,
"token-store.ts should keep one-shot state in the configured shared backend",
);
assertEquals(
tokenStore.includes("export const tokenStore: TokenStore = {"),
true,
"token-store.ts should expose a lazy proxy that is safe to import in production",
);
});

it("integration templates do not use a shared current-user token key", async () => {
it("OAuth route templates use the central shared token store", async () => {
const integrationTemplates = new URL("./integrations/", import.meta.url);
const offenders: string[] = [];
let routeCount = 0;

for (const file of await collectTemplateTsFiles(integrationTemplates)) {
const source = await Deno.readTextFile(file);
if (source.includes('"current-user"') || source.includes("'current-user'")) {
if (
!source.includes("createOAuthInitHandler") &&
!source.includes("createOAuthCallbackHandler")
) continue;

routeCount++;
const isCallbackRoute = source.includes("createOAuthCallbackHandler");
const expectedStoreImport = isCallbackRoute
? 'import { tokenStore } from "../../../../../lib/token-store.ts";'
: 'import { tokenStore } from "../../../../lib/token-store.ts";';
if (
!source.includes(expectedStoreImport) ||
(!isCallbackRoute &&
!source.includes(
'import { requireUserIdFromRequest } from "../../../../lib/user-id.ts";',
)) ||
source.includes("oauthMemoryTokenStore") ||
source.includes("hybridTokenStore")
) {
offenders.push(file.pathname.replace(integrationTemplates.pathname, ""));
}
}

assertEquals(routeCount, 46, "Expected every generated OAuth init and callback route");
assertEquals(
offenders,
[],
`Integration templates must require a real user id. Offenders: ${offenders.join(", ")}`,
`OAuth routes must share the central refresh-capable store. Offenders: ${
offenders.join(", ")
}`,
);
});

it("integration templates do not use hardcoded shared user ids", async () => {
const integrationTemplates = new URL("./integrations/", import.meta.url);
const offenders: string[] = [];
const forbiddenUserIds = [
'"current-user"',
"'current-user'",
'"demo-user"',
"'demo-user'",
"DEFAULT_USER_ID",
"CURRENT_USER_ID",
];

for (const file of await collectTemplateTsFiles(integrationTemplates)) {
const source = await Deno.readTextFile(file);
for (const userId of forbiddenUserIds) {
if (source.includes(userId)) {
offenders.push(
`${file.pathname.replace(integrationTemplates.pathname, "")}: ${userId}`,
);
}
}
}

assertEquals(
offenders,
[],
`Integration templates must resolve authenticated user ids from requests or tool contexts. Offenders: ${
offenders.join(", ")
}`,
);
});

it("Google workspace tools resolve authenticated users from execution context", async () => {
const integrations = [
{ name: "drive", clientFactory: "createDriveClient" },
{ name: "docs-google", clientFactory: "createDocsClient" },
{ name: "sheets", clientFactory: "createSheetsClient" },
];
const offenders: string[] = [];
let toolCount = 0;

for (const { name, clientFactory } of integrations) {
const toolsDirectory = new URL(`./integrations/${name}/files/tools/`, import.meta.url);
for (const file of await collectTemplateTsFiles(toolsDirectory)) {
const source = await Deno.readTextFile(file);
if (!source.includes(`${clientFactory}(`)) continue;

toolCount++;
if (
!source.includes(
'import { requireUserIdFromContext } from "../lib/user-id.ts";',
) ||
!source.includes("requireUserIdFromContext(context)") ||
!source.includes(`${clientFactory}(userId)`)
) {
offenders.push(`${name}/${file.pathname.split("/").at(-1)}`);
}
}
}

assertEquals(toolCount, 26, "Expected every Drive, Docs, and Sheets tool");
assertEquals(
offenders,
[],
`Google workspace tools must use authenticated tool execution context. Offenders: ${
offenders.join(", ")
}`,
);
});

Expand Down
6 changes: 3 additions & 3 deletions cli/templates/integration-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ describe("cli/templates/integration-loader feature gates", () => {
assertEquals(validateIntegrations(["persona"]).valid, false);
});

it("allows a feature-gated integration when explicitly enabled", () => {
it("allows eligible experiments but keeps provider-adapter-only integrations unavailable", () => {
Deno.env.set(EXPERIMENTAL_INTEGRATIONS_ENV, "salesforce,sap,persona");

assertEquals(getAvailableIntegrations().includes("salesforce"), true);
assertEquals(getAvailableIntegrations().includes("salesforce"), false);
assertEquals(getAvailableIntegrations().includes("sap"), true);
assertEquals(getAvailableIntegrations().includes("persona"), true);
assertEquals(validateIntegrations(["salesforce"]).valid, true);
assertEquals(validateIntegrations(["salesforce"]).valid, false);
assertEquals(validateIntegrations(["sap"]).valid, true);
assertEquals(validateIntegrations(["persona"]).valid, true);
});
Expand Down
40 changes: 33 additions & 7 deletions cli/templates/integrations/_base/files/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -640,19 +640,45 @@ Enable debug logging:
DEBUG=veryfront:oauth veryfront dev
```

### Token Storage
### Token storage

By default, tokens are stored in memory. For production:
The generated token-store proxy uses in-memory storage only in an explicit
development or test environment. For production:

1. Implement `TokenStore` interface in `lib/token-store.ts`
2. Use Redis, database, or encrypted file storage
3. Handle token refresh automatically
1. Select a storage extension that provides a durable
`RefreshCapableTokenStore`.
2. Configure it with `configureTokenStore()` before the first OAuth request.
3. Ensure the extension encrypts tokens, atomically consumes OAuth state,
implements compare-and-set, and uses a bounded, crash-recoverable
distributed lease for refresh locking.

```typescript
// lib/configure-oauth-storage.ts (import once during application startup)
import type { RefreshCapableTokenStore } from "veryfront/oauth";
import { configureTokenStore } from "./token-store.ts";
import { createApplicationOAuthTokenStore } from "./storage/oauth.ts";

const oauthStore: RefreshCapableTokenStore = createApplicationOAuthTokenStore();
configureTokenStore(oauthStore);
```

`createApplicationOAuthTokenStore` is the factory exported by your selected
storage extension. Veryfront core does not select or import the backend.

### Authenticated tool context

Generated integration tools resolve the token owner from `context.userId`.
Pass the authenticated user through `ToolExecutionContext` whenever an agent or
workflow invokes a tool. Production execution fails closed when the context has
no authenticated user id. For local development, set
`VERYFRONT_DEV_USER_ID` when you need a stable test identity.

## Production Checklist

- [ ] Update all redirect URIs to production domain
- [ ] Implement persistent token storage
- [ ] Set up token encryption
- [ ] Configure an extension-owned `RefreshCapableTokenStore`
- [ ] Verify token encryption and distributed refresh locking
- [ ] Pass authenticated user ids through every tool execution context
- [ ] Configure rate limiting
- [ ] Add error monitoring (Sentry)
- [ ] Test OAuth flows end-to-end
Expand Down

This file was deleted.

Loading