From df53bd13b59101bef8ffead831f78cba375b0b40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 20:37:02 +0000 Subject: [PATCH 1/9] Migrate secrets to package scope --- .../contributing/architecture/data-storage.md | 9 +- .../contributing/architecture/primitives.yaml | 5 +- .../skill-patterns/cloudflare-api-v4.md | 5 +- docs/guides/account-secret-setup.md | 6 +- docs/use/secrets-and-values.md | 7 +- .../src/account-secret-route.node.test.ts | 20 +- packages/shared/src/account-secret-route.ts | 41 ++- packages/shared/src/chat.ts | 1 + .../client/routes/account-approval-shared.ts | 4 +- .../worker/client/routes/account-secrets.tsx | 342 +++++++++--------- packages/worker/client/routes/index.tsx | 6 +- .../0057-package-scoped-secrets.sql | 111 ++++++ .../worker/src/app/account-secrets-data.ts | 79 ++-- .../app/handlers/account-secrets.node.test.ts | 56 +-- .../src/app/handlers/account-secrets.ts | 71 ++-- packages/worker/src/app/loader-data.ts | 10 +- packages/worker/src/app/router.ts | 2 - packages/worker/src/app/routes.ts | 2 - packages/worker/src/jobs/service.ts | 2 + .../src/mcp/capabilities/meta/execute.ts | 1 + .../src/mcp/capabilities/secrets/jwt-sign.ts | 10 + .../mcp/capabilities/secrets/secret-delete.ts | 34 +- .../mcp/capabilities/secrets/secret-list.ts | 14 +- .../mcp/capabilities/secrets/secret-set.ts | 39 +- .../src/mcp/capabilities/secrets/shared.ts | 2 +- .../mcp/execute-modules/kody-runtime-utils.ts | 12 +- .../worker/src/mcp/fetch-gateway.node.test.ts | 72 +++- packages/worker/src/mcp/fetch-gateway.ts | 9 + packages/worker/src/mcp/run-kody-registry.ts | 14 +- .../mcp/secrets/capability-approval-url.ts | 2 +- .../worker/src/mcp/secrets/host-approval.ts | 2 +- .../mcp/secrets/package-access.node.test.ts | 49 ++- .../worker/src/mcp/secrets/package-access.ts | 89 +++-- .../src/mcp/secrets/package-approval-url.ts | 8 +- .../package-scope-migration.node.test.ts | 105 ++++++ .../worker/src/mcp/secrets/placeholders.ts | 8 +- packages/worker/src/mcp/secrets/repo.ts | 51 ++- .../worker/src/mcp/secrets/secret-bindings.ts | 28 ++ .../src/mcp/secrets/service.node.test.ts | 23 +- packages/worker/src/mcp/secrets/service.ts | 76 ++-- packages/worker/src/mcp/secrets/types.ts | 14 +- packages/worker/src/mcp/storage.ts | 1 + packages/worker/src/mcp/tools/execute.ts | 1 + .../worker/src/package-invocations/service.ts | 1 + .../src/package-registry/service.node.test.ts | 19 + .../worker/src/package-registry/service.ts | 14 + packages/worker/src/package-registry/types.ts | 2 +- .../worker/src/package-retrievers/service.ts | 1 + .../worker/src/package-runtime/package-app.ts | 3 + .../src/package-runtime/package-service.ts | 1 + .../src/package-runtime/package-workflows.ts | 32 +- .../src/package-runtime/realtime-session.ts | 1 + packages/worker/src/repo/checks.ts | 2 +- 53 files changed, 1077 insertions(+), 442 deletions(-) create mode 100644 packages/worker/migrations/0057-package-scoped-secrets.sql create mode 100644 packages/worker/src/mcp/secrets/package-scope-migration.node.test.ts create mode 100644 packages/worker/src/mcp/secrets/secret-bindings.ts diff --git a/docs/contributing/architecture/data-storage.md b/docs/contributing/architecture/data-storage.md index 7257b7ffac..de01ee9aed 100644 --- a/docs/contributing/architecture/data-storage.md +++ b/docs/contributing/architecture/data-storage.md @@ -183,6 +183,10 @@ The schema is defined by migrations in `packages/worker/migrations/`: and their latest published commit - `saved_packages`: package metadata/search projection derived from published `package.json` source +- `secret_buckets`: encrypted-secret ownership buckets scoped to `user`, + `package`, or `session`. Package buckets bind directly to `saved_packages.id`; + package runtimes may use their own package secrets, while user secrets require + an explicit `allowed_packages` grant on every package read path. App access pattern: @@ -485,7 +489,8 @@ on write unless a migration backfills existing rows. (`packages/worker/migrations/0018-jobs.sql`, `0025-jobs-repo-check-policy.sql`, `packages/worker/src/jobs/repo.ts`). `run_history_json` is capped in code; the other fields rely on parser and - normalizer compatibility. + normalizer compatibility. Package jobs persist both `storageContext.appId` for + value scope and `storageContext.packageId` for package-owned secret scope. - `saved_packages.tags_json` and `community_listings.tags_json` (`0027-saved-packages.sql`, `0045-community-listings.sql`) are `string[]` projections. @@ -514,6 +519,8 @@ on write unless a migration backfills existing rows. policy inputs (`0009-secret-allowed-hosts.sql`, `0010-secret-allowed-capabilities.sql`, `0023-secret-allowed-packages.sql`). Tightening parse-error behavior requires explicit compatibility review. + `allowed_packages` applies only to user-scoped secrets; package-scoped secrets + are owned exclusively by the package id in their bucket binding. - `package_runtime_runs.metadata_json` and `package_runtime_logs.fields_json` (`0037-package-runtime-debug.sql`) store bounded debug metadata and log fields. diff --git a/docs/contributing/architecture/primitives.yaml b/docs/contributing/architecture/primitives.yaml index fc0bc07ee8..f0ee703860 100644 --- a/docs/contributing/architecture/primitives.yaml +++ b/docs/contributing/architecture/primitives.yaml @@ -269,8 +269,9 @@ primitives: group: assistant name: Secret references summary: - Server-side secret references resolved through placeholders at the fetch - gateway; raw values never enter prompts or results. + User- or package-owned server-side secret references resolved through + package-aware mounts, fetch placeholders, and capability inputs; raw + values never enter prompts or results. code: - packages/worker/src/mcp/capabilities/secrets/ - packages/worker/src/security/ diff --git a/docs/contributing/skill-patterns/cloudflare-api-v4.md b/docs/contributing/skill-patterns/cloudflare-api-v4.md index d1bb9240d8..04a6e0417e 100644 --- a/docs/contributing/skill-patterns/cloudflare-api-v4.md +++ b/docs/contributing/skill-patterns/cloudflare-api-v4.md @@ -11,12 +11,13 @@ import it from other packages with `kody:@...`. - Base URL: `https://api.cloudflare.com` (or your account’s documented API hostname). - Send `Authorization: Bearer {{secret:yourSecretName}}` (or with `|scope=user` - / `app` / `session` as needed). + / `package` / `session` as needed). - The user must approve **`api.cloudflare.com`** for that secret (host approval in the account UI). Saving a secret does not approve hosts automatically. Capability **allowlists** on secrets apply to **capability inputs** that use -`x-kody-secret`. Fetch-based access is primarily gated by **allowed hosts**. +`x-kody-secret`. Package code also needs package approval for user-scoped +secrets; package-scoped secrets are available only to their owning package. ## Paths diff --git a/docs/guides/account-secret-setup.md b/docs/guides/account-secret-setup.md index cec7216e24..b30acad171 100644 --- a/docs/guides/account-secret-setup.md +++ b/docs/guides/account-secret-setup.md @@ -35,8 +35,8 @@ Provide the user a URL like: | `allowedHosts` | no | Comma-separated hosts to review for approval. | | `allowedCapabilities` | no | Comma-separated capability names to review. Use only real Kody capability names from `search` or `meta_list_capabilities`. | | `allowedPackages` | no | Comma-separated saved package ids to review for approval. | -| `scope` | no | `user` (default) or `app`. | -| `appId` | no | Required when `scope=app`. Use the saved package id that owns the package app secret scope. | +| `scope` | no | `user` (default) or `package`. | +| `packageId` | no | Required when `scope=package`. Use the saved package id that owns the secret. | ## Approval policy reminders @@ -49,7 +49,7 @@ Provide the user a URL like: ## Agent instructions 1. Generate the URL with the required `name` and any optional params. - - When using `scope=app`, include the saved package id in `appId`. + - When using `scope=package`, include the saved package id in `packageId`. - Only include `allowedCapabilities` when you have confirmed the capability names exist in Kody. 2. Ask the user to open the URL in their browser. diff --git a/docs/use/secrets-and-values.md b/docs/use/secrets-and-values.md index d91443c80e..75b0cb3b07 100644 --- a/docs/use/secrets-and-values.md +++ b/docs/use/secrets-and-values.md @@ -10,9 +10,14 @@ Use **search** first to discover saved secret references or integrations before switching to **execute**. During **execute**, **`await kody.secret_list({})`** (or a narrowed **`scope`** -such as **`app`**) returns **metadata only**: names, descriptions, allowed +such as **`package`**) returns **metadata only**: names, descriptions, allowed hosts, allowed capabilities — not plaintext values. +Package-scoped secrets belong to one saved package and are available only while +that package runs. User-scoped secrets require explicit package approval before +package code can list, read, update, or delete them through mounts, fetch +placeholders, or secret-aware capabilities. + **`kody.secret_set(...)`** persists a value that is already available inside execution (for example a refreshed OAuth token). It does not return secret values. diff --git a/packages/shared/src/account-secret-route.node.test.ts b/packages/shared/src/account-secret-route.node.test.ts index f2cc103938..2141c70fa6 100644 --- a/packages/shared/src/account-secret-route.node.test.ts +++ b/packages/shared/src/account-secret-route.node.test.ts @@ -12,16 +12,16 @@ test('account secret paths encode route segments and round-trip through the pars }), ).toBe('/account/secrets/user/github%20token') - const appPath = buildAccountSecretPath({ - scope: 'app', - appId: 'package/id', + const packagePath = buildAccountSecretPath({ + scope: 'package', + packageId: 'package/id', name: 'api/key', }) - expect(appPath).toBe('/account/secrets/app/package%2Fid/api%2Fkey') - expect(parseAccountSecretPath(appPath)).toEqual({ - id: 'app::package%2Fid::api%2Fkey', - scope: 'app', - appId: 'package/id', + expect(packagePath).toBe('/account/secrets/package/package%2Fid/api%2Fkey') + expect(parseAccountSecretPath(packagePath)).toEqual({ + id: 'package::package%2Fid::api%2Fkey', + scope: 'package', + packageId: 'package/id', sessionId: null, name: 'api/key', }) @@ -38,10 +38,10 @@ test('account secret paths encode route segments and round-trip through the pars test('account secret paths fail fast when scope binding ids are missing', () => { expect(() => buildAccountSecretPath({ - scope: 'app', + scope: 'package', name: 'api/key', }), - ).toThrow(/appId is required/) + ).toThrow(/packageId is required/) expect(() => buildAccountSecretPath({ scope: 'session', diff --git a/packages/shared/src/account-secret-route.ts b/packages/shared/src/account-secret-route.ts index c05922ad90..7519a3c075 100644 --- a/packages/shared/src/account-secret-route.ts +++ b/packages/shared/src/account-secret-route.ts @@ -1,22 +1,22 @@ import { createHref } from 'remix/route-pattern/href' -type AccountSecretRouteScope = 'app' | 'session' | 'user' +type AccountSecretRouteScope = 'package' | 'session' | 'user' const accountSecretsBasePath = '/account/secrets' const accountSecretUserPathPattern = `${accountSecretsBasePath}/user/:secretName` -const accountSecretAppPathPattern = `${accountSecretsBasePath}/app/:appId/:secretName` +const accountSecretPackagePathPattern = `${accountSecretsBasePath}/package/:packageId/:secretName` const accountSecretSessionPathPattern = `${accountSecretsBasePath}/session/:sessionId/:secretName` export type AccountSecretRouteIdInput = { name: string scope: AccountSecretRouteScope - appId?: string | null + packageId?: string | null sessionId?: string | null } export type ParsedAccountSecretRouteId = { name: string scope: AccountSecretRouteScope - appId: string | null + packageId: string | null sessionId: string | null } @@ -26,8 +26,8 @@ export type ParsedAccountSecretRoutePath = ParsedAccountSecretRouteId & { export function buildAccountSecretId(input: AccountSecretRouteIdInput) { const bindingId = - input.scope === 'app' - ? (input.appId ?? '') + input.scope === 'package' + ? (input.packageId ?? '') : input.scope === 'session' ? (input.sessionId ?? '') : '' @@ -41,7 +41,8 @@ export function parseAccountSecretId( ): ParsedAccountSecretRouteId | null { const [scope, encodedBindingId, encodedName, ...rest] = secretId.split('::') if (rest.length > 0) return null - if (scope !== 'app' && scope !== 'session' && scope !== 'user') return null + if (scope !== 'package' && scope !== 'session' && scope !== 'user') + return null try { const name = decodeURIComponent(encodedName ?? '') @@ -51,7 +52,7 @@ export function parseAccountSecretId( return { name, scope, - appId: scope === 'app' ? bindingId || null : null, + packageId: scope === 'package' ? bindingId || null : null, sessionId: scope === 'session' ? bindingId || null : null, } } catch { @@ -63,12 +64,14 @@ export function buildAccountSecretPath(input: AccountSecretRouteIdInput) { if (input.scope === 'user') { return createHref(accountSecretUserPathPattern, { secretName: input.name }) } - if (input.scope === 'app') { - if (!input.appId) { - throw new Error('appId is required for app-scoped account secret paths') + if (input.scope === 'package') { + if (!input.packageId) { + throw new Error( + 'packageId is required for package-scoped account secret paths', + ) } - return createHref(accountSecretAppPathPattern, { - appId: input.appId, + return createHref(accountSecretPackagePathPattern, { + packageId: input.packageId, secretName: input.name, }) } @@ -99,21 +102,21 @@ export function parseAccountSecretPath( const parsed = { name: decodeURIComponent(rest[1] ?? ''), scope: 'user' as const, - appId: null, + packageId: null, sessionId: null, } if (!parsed.name.trim()) return null const id = buildAccountSecretId(parsed) return { ...parsed, id } } - if (rest.length === 3 && rest[0] === 'app') { + if (rest.length === 3 && rest[0] === 'package') { const parsed = { name: decodeURIComponent(rest[2] ?? ''), - scope: 'app' as const, - appId: decodeURIComponent(rest[1] ?? '') || null, + scope: 'package' as const, + packageId: decodeURIComponent(rest[1] ?? '') || null, sessionId: null, } - if (!parsed.name.trim() || !parsed.appId) return null + if (!parsed.name.trim() || !parsed.packageId) return null const id = buildAccountSecretId(parsed) return { ...parsed, id } } @@ -121,7 +124,7 @@ export function parseAccountSecretPath( const parsed = { name: decodeURIComponent(rest[2] ?? ''), scope: 'session' as const, - appId: null, + packageId: null, sessionId: decodeURIComponent(rest[1] ?? '') || null, } if (!parsed.name.trim() || !parsed.sessionId) return null diff --git a/packages/shared/src/chat.ts b/packages/shared/src/chat.ts index e52bd130dc..869af3b2e6 100644 --- a/packages/shared/src/chat.ts +++ b/packages/shared/src/chat.ts @@ -32,6 +32,7 @@ export const mcpUserContextSchema = object({ export const mcpStorageContextSchema = object({ sessionId: optional(nullable(string())), appId: optional(nullable(string())), + packageId: optional(nullable(string())), storageId: optional(nullable(string())), }) diff --git a/packages/worker/client/routes/account-approval-shared.ts b/packages/worker/client/routes/account-approval-shared.ts index 88c3d81c65..fb8d9f5d70 100644 --- a/packages/worker/client/routes/account-approval-shared.ts +++ b/packages/worker/client/routes/account-approval-shared.ts @@ -1,6 +1,6 @@ export type AccountStatus = 'loading' | 'ready' | 'error' export type ApprovalAction = 'approve' | 'reject' -export type ApprovalScope = 'session' | 'app' | 'user' +export type ApprovalScope = 'session' | 'package' | 'user' export type ApprovalView = { name: string @@ -16,7 +16,7 @@ export const accountSecretsApiPath = '/account/secrets.json' export const accountProfileApiPath = '/account/profile.json' export function getScopeLabel(scope: ApprovalScope) { - if (scope === 'app') return 'App' + if (scope === 'package') return 'Package' if (scope === 'session') return 'Session' return 'User' } diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx index ab89e80b61..c54f2ef077 100644 --- a/packages/worker/client/routes/account-secrets.tsx +++ b/packages/worker/client/routes/account-secrets.tsx @@ -63,9 +63,9 @@ import { MetadataGrid, } from './account-management-components.tsx' -type SecretScope = 'app' | 'user' +type SecretScope = 'package' | 'user' -type PackageAppOption = { +type PackageOption = { id: string title: string updatedAt: string @@ -76,8 +76,8 @@ type SecretListItem = { name: string scope: SecretScope description: string - appId: string | null - appTitle: string | null + packageId: string | null + packageTitle: string | null allowedHosts: Array allowedCapabilities: Array allowedPackages: Array @@ -93,7 +93,7 @@ type SecretDetail = SecretListItem & { type AccountSecretsPayload = { ok: true email: string - apps: Array + packageOptions: Array packages: Array<{ id: string kodyId: string @@ -109,7 +109,7 @@ type EditorState = { currentId: string | null name: string scope: SecretScope - appId: string + packageId: string description: string value: string allowedHosts: Array @@ -122,12 +122,12 @@ type SelectionState = { isCreating: boolean } -type SecretFilterScope = 'all' | 'user' | 'app' +type SecretFilterScope = 'all' | 'user' | 'package' type SecretFilterState = { search: string scope: SecretFilterScope - appId: string + packageId: string } const secretsBasePath = '/account/secrets' @@ -156,12 +156,14 @@ function formatRelativeTtl(ttlMs: number | null) { return `Expires in ${totalDays} day${totalDays === 1 ? '' : 's'}` } -function createEmptyEditorState(apps: Array): EditorState { +function createEmptyEditorState( + packageOptions: Array, +): EditorState { return { currentId: null, name: '', scope: 'user', - appId: apps[0]?.id ?? '', + packageId: packageOptions[0]?.id ?? '', description: '', value: '', allowedHosts: [''], @@ -171,29 +173,25 @@ function createEmptyEditorState(apps: Array): EditorState { } function readNewSecretScope(value: string | null): SecretScope | null { - return value === 'app' || value === 'user' ? value : null + return value === 'package' || value === 'user' ? value : null } function createEditorStateFromNewSecretQuery( - apps: Array, + packageOptions: Array, href: string, ): EditorState { const params = new URL(href, 'http://localhost').searchParams - const state = createEmptyEditorState(apps) + const state = createEmptyEditorState(packageOptions) const requestedScope = readNewSecretScope(readTrimmedParam(params, 'scope')) - const requestedAppId = readTrimmedParam(params, 'appId') - const appId = - apps.find((app) => app.id === requestedAppId)?.id ?? apps[0]?.id ?? '' + const requestedPackageId = readTrimmedParam(params, 'packageId') + const packageId = + packageOptions.find( + (packageOption) => packageOption.id === requestedPackageId, + )?.id ?? + packageOptions[0]?.id ?? + '' const scope: SecretScope = - requestedScope === 'app' - ? appId - ? 'app' - : 'user' - : requestedScope === 'user' - ? 'user' - : requestedAppId && appId - ? 'app' - : 'user' + requestedScope ?? (requestedPackageId ? 'package' : 'user') const allowedHosts = normalizeAllowedHosts( readCommaListParams(params, ['allowedHosts', 'allowed-host']), ) @@ -208,7 +206,7 @@ function createEditorStateFromNewSecretQuery( ...state, name: readTrimmedParam(params, 'name') ?? state.name, scope, - appId: scope === 'app' ? appId : '', + packageId: scope === 'package' ? packageId : '', description: readTrimmedParam(params, 'description') ?? state.description, allowedHosts: allowedHosts.length > 0 ? allowedHosts : state.allowedHosts, allowedCapabilities: @@ -216,7 +214,7 @@ function createEditorStateFromNewSecretQuery( ? allowedCapabilities : state.allowedCapabilities, allowedPackages: - allowedPackages.length > 0 + scope === 'user' && allowedPackages.length > 0 ? allowedPackages.map((value) => createAllowedPackageRow(value)) : state.allowedPackages, } @@ -229,7 +227,7 @@ function getNewSecretQueryKey(href: string) { 'name', 'description', 'scope', - 'appId', + 'packageId', 'allowedHosts', 'allowed-host', 'allowedCapabilities', @@ -270,7 +268,7 @@ function createEditorStateFromSecret(secret: SecretDetail): EditorState { currentId: secret.id, name: secret.name, scope: secret.scope, - appId: secret.appId ?? '', + packageId: secret.packageId ?? '', description: secret.description, value: secret.value, allowedHosts: allowedHosts.length > 0 ? allowedHosts : [''], @@ -436,7 +434,7 @@ function buildSecretHref( secret: { name: string scope: SecretScope - appId: string | null + packageId: string | null }, search: string, ) { @@ -444,7 +442,7 @@ function buildSecretHref( buildAccountSecretPath({ name: secret.name, scope: secret.scope, - appId: secret.appId, + packageId: secret.packageId, }), search, ) @@ -513,24 +511,26 @@ export async function accountSecretsRouteLoader( function readFilterState( href: string, - apps: Array, + packageOptions: Array, ): SecretFilterState { const url = new URL(href, 'http://localhost') const search = url.searchParams.get('q')?.trim() ?? '' const rawScope = url.searchParams.get('scope') const scope = - rawScope === 'user' || rawScope === 'app' ? rawScope : ('all' as const) - const rawAppId = url.searchParams.get('app')?.trim() ?? '' - const appId = + rawScope === 'user' || rawScope === 'package' ? rawScope : ('all' as const) + const rawPackageId = url.searchParams.get('package')?.trim() ?? '' + const packageId = scope === 'user' ? '' - : apps.some((app) => app.id === rawAppId) - ? rawAppId + : packageOptions.some( + (packageOption) => packageOption.id === rawPackageId, + ) + ? rawPackageId : '' return { search, scope, - appId, + packageId, } } @@ -543,8 +543,8 @@ function filterSecrets( if (filters.scope !== 'all' && secret.scope !== filters.scope) return false if ( filters.scope !== 'user' && - filters.appId && - secret.appId !== filters.appId + filters.packageId && + secret.packageId !== filters.packageId ) return false if (!search) return true @@ -552,7 +552,7 @@ function filterSecrets( const haystack = [ secret.name, secret.description, - secret.appTitle ?? '', + secret.packageTitle ?? '', secret.scope, ...secret.allowedHosts, ...secret.allowedCapabilities, @@ -565,7 +565,7 @@ function filterSecrets( }) } -function buildAppOptionDescription(updatedAt: string) { +function buildPackageOptionDescription(updatedAt: string) { return `Updated ${new Date(updatedAt).toLocaleDateString()}` } @@ -579,7 +579,7 @@ const truncatedTextCss = { export function AccountSecretsRoute(handle: Handle) { let status: AccountStatus = 'loading' let email = '' - let apps: Array = [] + let packageOptions: Array = [] let packagesById = new Map() let secrets: Array = [] let selectedSecret: SecretDetail | null = null @@ -595,8 +595,8 @@ export function AccountSecretsRoute(handle: Handle) { let retryTimeout: ReturnType | null = null let showSecretValue = false const deleteSecretCheck = createDoubleCheck(handle) - const filterAppCombobox = TypeaheadCombobox(handle) - const editorAppCombobox = TypeaheadCombobox(handle) + const filterPackageCombobox = TypeaheadCombobox(handle) + const editorPackageCombobox = TypeaheadCombobox(handle) function getCurrentHref() { return readCurrentRouterHref(handle) @@ -619,7 +619,7 @@ export function AccountSecretsRoute(handle: Handle) { ) { const currentUrl = new URL(getCurrentHref(), 'http://localhost') const filters = { - ...readFilterState(currentUrl.toString(), apps), + ...readFilterState(currentUrl.toString(), packageOptions), ...nextFilters, } const nextUrl = new URL(currentUrl.toString()) @@ -630,8 +630,9 @@ export function AccountSecretsRoute(handle: Handle) { else nextUrl.searchParams.delete('q') if (filters.scope === 'all') nextUrl.searchParams.delete('scope') else nextUrl.searchParams.set('scope', filters.scope) - if (filters.appId) nextUrl.searchParams.set('app', filters.appId) - else nextUrl.searchParams.delete('app') + if (filters.packageId) + nextUrl.searchParams.set('package', filters.packageId) + else nextUrl.searchParams.delete('package') return `${nextUrl.pathname}${nextUrl.search}` } @@ -641,7 +642,7 @@ export function AccountSecretsRoute(handle: Handle) { const capabilityPrefill = readCapabilityPrefill(getCurrentHref()) if (selection.isCreating) { editorState = applyCapabilityPrefill( - createEditorStateFromNewSecretQuery(apps, getCurrentHref()), + createEditorStateFromNewSecretQuery(packageOptions, getCurrentHref()), capabilityPrefill, ) return @@ -654,7 +655,7 @@ export function AccountSecretsRoute(handle: Handle) { return } editorState = applyCapabilityPrefill( - createEmptyEditorState(apps), + createEmptyEditorState(packageOptions), capabilityPrefill, ) } @@ -665,7 +666,7 @@ export function AccountSecretsRoute(handle: Handle) { nextMessage: string | null, ) { email = payload.email - apps = payload.apps + packageOptions = payload.packageOptions packagesById = new Map( payload.packages.map((pkg) => [ pkg.id, @@ -804,7 +805,7 @@ export function AccountSecretsRoute(handle: Handle) { pathname: buildAccountSecretPath({ name: selectedSecret.name, scope: selectedSecret.scope, - appId: selectedSecret.appId, + packageId: selectedSecret.packageId, }), }, ) @@ -847,13 +848,16 @@ export function AccountSecretsRoute(handle: Handle) { const allowedCapabilities = normalizeAllowedCapabilities( collectRepeatedTextRows(form, 'allowed-capabilities'), ) - const allowedPackages = Array.from( - new Set( - submittedEditorState.allowedPackages - .map((entry) => entry.value.trim()) - .filter((value) => value.length > 0), - ), - ).sort((left, right) => left.localeCompare(right)) + const allowedPackages = + submittedEditorState.scope === 'user' + ? Array.from( + new Set( + submittedEditorState.allowedPackages + .map((entry) => entry.value.trim()) + .filter((value) => value.length > 0), + ), + ).sort((left, right) => left.localeCompare(right)) + : [] const response = await fetch(accountSecretsApiPath, { method: 'POST', headers: { @@ -866,9 +870,9 @@ export function AccountSecretsRoute(handle: Handle) { currentId: submittedEditorState.currentId, name: submittedEditorState.name, scope: submittedEditorState.scope, - appId: - submittedEditorState.scope === 'app' - ? submittedEditorState.appId + packageId: + submittedEditorState.scope === 'package' + ? submittedEditorState.packageId : null, description: submittedEditorState.description, value: submittedEditorState.value, @@ -1097,26 +1101,26 @@ export function AccountSecretsRoute(handle: Handle) { } const selection = getSelectionState(currentHref) - const filters = readFilterState(currentHref, apps) + const filters = readFilterState(currentHref, packageOptions) const filteredSecrets = filterSecrets(secrets, filters) - const appOptions = apps.map((app) => ({ - id: app.id, - label: app.title, - description: buildAppOptionDescription(app.updatedAt), + const packageSelectOptions = packageOptions.map((packageOption) => ({ + id: packageOption.id, + label: packageOption.title, + description: buildPackageOptionDescription(packageOption.updatedAt), })) - const filterAppOptions = [ + const filterPackageOptions = [ { id: '', - label: 'All apps', - description: 'Show secrets across every app', + label: 'All packages', + description: 'Show secrets across every package', }, - ...appOptions, + ...packageSelectOptions, ] const activeSecretId = selection.selectedSecretId ?? selectedSecret?.id ?? null const isMutating = saveState !== 'idle' || submittingApprovalAction != null - const canCreateAppSecrets = apps.length > 0 + const canCreatePackageSecrets = packageOptions.length > 0 const showEditor = selection.isCreating || selectedSecret != null const alreadyAddedNotice = getAlreadyAddedNotice({ href: currentHref, @@ -1135,7 +1139,7 @@ export function AccountSecretsRoute(handle: Handle) { - + - {apps.length > 0 - ? filterAppCombobox({ - id: 'secret-app-filter', - label: 'App filter', - placeholder: 'Filter by app', - value: filters.scope === 'user' ? '' : filters.appId, + {packageOptions.length > 0 + ? filterPackageCombobox({ + id: 'secret-package-filter', + label: 'Package filter', + placeholder: 'Filter by package', + value: filters.scope === 'user' ? '' : filters.packageId, disabled: filters.scope === 'user', - options: filterAppOptions, - onChange: (appId) => { + options: filterPackageOptions, + onChange: (packageId) => { replaceSecretsLocation( buildHrefWithUpdatedFilters({ - appId, + packageId, }), ) }, @@ -1455,7 +1460,9 @@ export function AccountSecretsRoute(handle: Handle) { })} > {getScopeLabel(secret.scope)} - {secret.appTitle ? ` - ${secret.appTitle}` : ''} + {secret.packageTitle + ? ` - ${secret.packageTitle}` + : ''} {secret.description ? (

{selection.isCreating - ? 'Create a new user or app secret.' + ? 'Create a new user or package secret.' : 'Update the secret value and metadata for this entry.'}

@@ -1562,9 +1569,11 @@ export function AccountSecretsRoute(handle: Handle) { editorState = { ...editorState, scope, - appId: - scope === 'app' - ? editorState.appId || apps[0]?.id || '' + packageId: + scope === 'package' + ? editorState.packageId || + packageOptions[0]?.id || + '' : '', } handle.update() @@ -1575,24 +1584,24 @@ export function AccountSecretsRoute(handle: Handle) { ]} > - {canCreateAppSecrets ? ( - + {canCreatePackageSecrets ? ( + ) : null} - {editorState.scope === 'app' - ? editorAppCombobox({ - id: 'secret-editor-app', - label: 'App', - placeholder: 'Choose an app', - value: editorState.appId, - options: appOptions, - onChange: (appId) => { + {editorState.scope === 'package' + ? editorPackageCombobox({ + id: 'secret-editor-package', + label: 'Package', + placeholder: 'Choose a package', + value: editorState.packageId, + options: packageSelectOptions, + onChange: (packageId) => { editorState = { ...editorState, - appId, + packageId, } handle.update() }, @@ -1636,73 +1645,75 @@ export function AccountSecretsRoute(handle: Handle) { allowedCapabilitiesListName="allowed-capabilities" /> -
-
- Allowed packages -

- Only listed package ids may read this secret via package - secret mounts. -

-
+ {editorState.scope === 'user' ? (
- {editorState.allowedPackages.map((packageEntry) => ( -
- { - updateAllowedPackage( - packageEntry.id, - event.currentTarget.value, - ) - }, - ), - - css(inputCss), - ]} - /> - - -
- ))} -
-
- + { + updateAllowedPackage( + packageEntry.id, + event.currentTarget.value, + ) + }, + ), + + css(inputCss), + ]} + /> + + +
+ ))} +
+
+ +
- + ) : null} {selectedSecret ? ( diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx index b366f41dcd..4ca92a1760 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -75,9 +75,8 @@ export const clientRouteLoaders: Record = { '/account/secrets': accountSecretsRouteLoader, '/account/secrets/new': accountSecretsRouteLoader, '/account/secrets/approve': accountSecretsRouteLoader, - '/account/secrets/:secretId': accountSecretsRouteLoader, '/account/secrets/user/:secretName': accountSecretsRouteLoader, - '/account/secrets/app/:appId/:secretName': accountSecretsRouteLoader, + '/account/secrets/package/:packageId/:secretName': accountSecretsRouteLoader, '/account/secrets/session/:sessionId/:secretName': accountSecretsRouteLoader, '/account/two-factor': accountTwoFactorRouteLoader, '/admin': adminUsersRouteLoader, @@ -112,9 +111,8 @@ export const clientRoutes = { '/account/secrets': , '/account/secrets/new': , '/account/secrets/approve': , - '/account/secrets/:secretId': , '/account/secrets/user/:secretName': , - '/account/secrets/app/:appId/:secretName': , + '/account/secrets/package/:packageId/:secretName': , '/account/secrets/session/:sessionId/:secretName': , '/account/two-factor': , '/admin': , diff --git a/packages/worker/migrations/0057-package-scoped-secrets.sql b/packages/worker/migrations/0057-package-scoped-secrets.sql new file mode 100644 index 0000000000..5672e6aad8 --- /dev/null +++ b/packages/worker/migrations/0057-package-scoped-secrets.sql @@ -0,0 +1,111 @@ +CREATE TABLE secret_buckets_new ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('session', 'package', 'user')), + binding_key TEXT NOT NULL, + expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + UNIQUE(user_id, scope, binding_key) +); + +INSERT INTO secret_buckets_new ( + id, + user_id, + scope, + binding_key, + expires_at, + created_at, + updated_at +) +SELECT + id, + user_id, + CASE scope WHEN 'app' THEN 'package' ELSE scope END, + binding_key, + expires_at, + created_at, + updated_at +FROM secret_buckets; + +CREATE TABLE secret_entries_new ( + bucket_id TEXT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + encrypted_value TEXT NOT NULL, + allowed_hosts TEXT NOT NULL DEFAULT '[]', + allowed_capabilities TEXT NOT NULL DEFAULT '[]', + lookup_hash TEXT, + allowed_packages TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + PRIMARY KEY (bucket_id, name), + FOREIGN KEY (bucket_id) REFERENCES secret_buckets_new(id) ON DELETE CASCADE +); + +INSERT INTO secret_entries_new ( + bucket_id, + name, + description, + encrypted_value, + allowed_hosts, + allowed_capabilities, + lookup_hash, + allowed_packages, + created_at, + updated_at +) +SELECT + bucket_id, + name, + description, + encrypted_value, + allowed_hosts, + allowed_capabilities, + lookup_hash, + CASE + WHEN bucket_id IN ( + SELECT id FROM secret_buckets WHERE scope = 'user' + ) + THEN allowed_packages + ELSE '[]' + END, + created_at, + updated_at +FROM secret_entries; + +DROP TABLE secret_entries; +DROP TABLE secret_buckets; + +ALTER TABLE secret_buckets_new RENAME TO secret_buckets; +ALTER TABLE secret_entries_new RENAME TO secret_entries; + +CREATE INDEX idx_secret_buckets_user_scope_binding + ON secret_buckets(user_id, scope, binding_key); +CREATE INDEX idx_secret_entries_bucket_id + ON secret_entries(bucket_id); +CREATE INDEX idx_secret_entries_name + ON secret_entries(name); +CREATE INDEX idx_secret_entries_lookup_hash + ON secret_entries(lookup_hash); + +UPDATE jobs +SET caller_context_json = json_set( + caller_context_json, + '$.storageContext.packageId', + ( + SELECT p.id + FROM saved_packages AS p + WHERE p.user_id = jobs.user_id + AND p.source_id = jobs.source_id + LIMIT 1 + ) +) +WHERE source_id IS NOT NULL + AND json_valid(caller_context_json) + AND EXISTS ( + SELECT 1 + FROM saved_packages AS p + WHERE p.user_id = jobs.user_id + AND p.source_id = jobs.source_id + ); diff --git a/packages/worker/src/app/account-secrets-data.ts b/packages/worker/src/app/account-secrets-data.ts index 380ef9405d..86fdadb0e8 100644 --- a/packages/worker/src/app/account-secrets-data.ts +++ b/packages/worker/src/app/account-secrets-data.ts @@ -7,7 +7,7 @@ import { type AccountSecretsLoaderData } from '#app/loader-data.ts' import { type readAuthenticatedAppUser } from '#app/authenticated-user.ts' import { type StorageContext } from '#mcp/storage.ts' import { - listAppSecretsByAppIds, + listPackageSecretsByPackageIds, listSecrets, resolveSecret, } from '#mcp/secrets/service.ts' @@ -19,9 +19,9 @@ type AuthenticatedUser = NonNullable< Awaited> > -type AccountEditableSecretScope = Extract +type AccountEditableSecretScope = Extract -type SavedPackageAppOption = { +type SavedPackageOption = { id: string title: string updatedAt: string @@ -31,7 +31,6 @@ type SavedPackageSummary = { id: string kodyId: string name: string - hasApp: boolean updatedAt: string } @@ -40,8 +39,8 @@ type AccountSecretListItem = { name: string scope: AccountEditableSecretScope description: string - appId: string | null - appTitle: string | null + packageId: string | null + packageTitle: string | null allowedHosts: Array allowedCapabilities: Array allowedPackages: Array @@ -79,11 +78,6 @@ function readSelectedSecretIdFromPath(pathname: string) { if (pathname === `${secretsBasePath}/approve`) return null const parsedPath = parseAccountSecretPath(pathname) if (parsedPath) return parsedPath.id - if (pathname.startsWith(`${secretsBasePath}/`)) { - const legacySecretId = pathname.slice(`${secretsBasePath}/`.length) - const parsedLegacyId = parseAccountSecretId(legacySecretId) - return parsedLegacyId ? legacySecretId : legacySecretId || null - } return null } @@ -92,7 +86,7 @@ export async function loadAccountSecretsData(input: { env: Env user: AuthenticatedUser selectedSecretId?: string | null - packageApps?: Array + packageOptions?: Array savedPackages?: Array }): Promise { const selectedSecretId = @@ -110,7 +104,7 @@ async function buildAccountSecretsPayload(input: { env: Env user: AuthenticatedUser selectedSecretId?: string | null - packageApps?: Array + packageOptions?: Array savedPackages?: Array }): Promise { const url = new URL(input.request.url) @@ -122,12 +116,12 @@ async function buildAccountSecretsPayload(input: { (await listSavedPackagesByUserId(input.env.APP_DB, { userId: input.user.mcpUser.userId, })) - const packageApps = input.packageApps ?? toPackageAppOptions(savedPackages) + const packageOptions = input.packageOptions ?? toPackageOptions(savedPackages) const packageLookup = toAllowedPackageLookup(savedPackages) const secrets = await listAccountSecrets({ env: input.env, user: input.user, - packageApps, + packageOptions, }) const selectedSecret = input.selectedSecretId ? await resolveAccountSecretDetail({ @@ -161,7 +155,7 @@ async function buildAccountSecretsPayload(input: { return { ok: true, email: input.user.email, - apps: packageApps, + packageOptions, packages: Array.from(packageLookup.values()).map((packageEntry) => ({ id: packageEntry.packageId, kodyId: packageEntry.kodyId, @@ -177,32 +171,39 @@ async function buildAccountSecretsPayload(input: { async function listAccountSecrets(input: { env: Env user: AuthenticatedUser - packageApps: Array + packageOptions: Array }) { - const appTitles = new Map(input.packageApps.map((app) => [app.id, app.title])) - const [userSecrets, appSecrets] = await Promise.all([ + const packageTitles = new Map( + input.packageOptions.map((packageOption) => [ + packageOption.id, + packageOption.title, + ]), + ) + const [userSecrets, packageSecrets] = await Promise.all([ listSecrets({ env: input.env, userId: input.user.mcpUser.userId, scope: 'user', }), - listAppSecretsByAppIds({ + listPackageSecretsByPackageIds({ env: input.env, userId: input.user.mcpUser.userId, - appIds: input.packageApps.map((app) => app.id), + packageIds: input.packageOptions.map((packageOption) => packageOption.id), }), ]) return [ - ...userSecrets.map((secret) => toAccountSecretListItem(secret, appTitles)), - ...Array.from(appSecrets.values()) + ...userSecrets.map((secret) => + toAccountSecretListItem(secret, packageTitles), + ), + ...Array.from(packageSecrets.values()) .flat() - .map((secret) => toAccountSecretListItem(secret, appTitles)), + .map((secret) => toAccountSecretListItem(secret, packageTitles)), ].sort((left, right) => { return ( left.name.localeCompare(right.name) || left.scope.localeCompare(right.scope) || - (left.appTitle ?? '').localeCompare(right.appTitle ?? '') + (left.packageTitle ?? '').localeCompare(right.packageTitle ?? '') ) }) } @@ -237,6 +238,9 @@ function resolveApprovalRequest(input: { } const storageContext = getSecretContextForAccountSecret(parsed) if (input.requestedPackageId) { + if (parsed.scope !== 'user') { + throw new Error('Only user secrets support package approvals.') + } return { kind: 'package', name: parsed.name, @@ -322,7 +326,7 @@ function toAccountSecretListItem( name: string scope: SecretScope description: string - appId: string | null + packageId: string | null allowedHosts: Array allowedCapabilities: Array allowedPackages: Array @@ -330,24 +334,26 @@ function toAccountSecretListItem( updatedAt: string ttlMs: number | null }, - appTitles: Map, + packageTitles: Map, ) { if (secret.scope === 'session') { throw new Error('Session secrets are not editable from the account page.') } - const scope = secret.scope === 'app' ? 'app' : 'user' + const scope = secret.scope === 'package' ? 'package' : 'user' return { id: buildAccountSecretId({ name: secret.name, scope, - appId: secret.appId, + packageId: secret.packageId, }), name: secret.name, scope, description: secret.description, - appId: secret.appId, - appTitle: secret.appId ? (appTitles.get(secret.appId) ?? null) : null, + packageId: secret.packageId, + packageTitle: secret.packageId + ? (packageTitles.get(secret.packageId) ?? null) + : null, allowedHosts: secret.allowedHosts, allowedCapabilities: secret.allowedCapabilities, allowedPackages: secret.allowedPackages, @@ -357,16 +363,14 @@ function toAccountSecretListItem( } satisfies AccountSecretListItem } -function toPackageAppOptions( +function toPackageOptions( savedPackages: Array<{ id: string name: string - hasApp: boolean updatedAt: string }>, ) { return savedPackages - .filter((savedPackage) => savedPackage.hasApp) .map((savedPackage) => ({ id: savedPackage.id, title: savedPackage.name, @@ -401,12 +405,13 @@ function toAllowedPackageLookup( export function getSecretContextForAccountSecret(input: { scope: SecretScope - appId: string | null + packageId: string | null sessionId?: string | null }): StorageContext { return { sessionId: input.scope === 'session' ? (input.sessionId ?? null) : null, - appId: input.scope === 'app' ? input.appId : null, + appId: null, + packageId: input.scope === 'package' ? input.packageId : null, } } @@ -425,6 +430,6 @@ function readRequestedCapability(url: URL) { return value?.trim() ? value.trim() : null } -export { resolveApprovalRequest, toPackageAppOptions, listAccountSecrets } +export { resolveApprovalRequest, toPackageOptions, listAccountSecrets } export { buildAccountSecretsPayload } diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts index 22aef69eaa..23b2cb2106 100644 --- a/packages/worker/src/app/handlers/account-secrets.node.test.ts +++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts @@ -19,7 +19,7 @@ const mockModule = vi.hoisted(() => ({ name: 'githubAccessToken', scope: 'user', description: '', - appId: null, + packageId: null, allowedHosts: [], allowedCapabilities: [], createdAt: new Date(0).toISOString(), @@ -34,7 +34,7 @@ const mockModule = vi.hoisted(() => ({ ), listSavedPackagesByUserId: vi.fn(async () => []), listSecrets: vi.fn(async () => []), - listAppSecretsByAppIds: vi.fn(async () => []), + listPackageSecretsByPackageIds: vi.fn(async () => []), resolveSecret: vi.fn(async () => ({ found: false, value: null })), deleteSecret: vi.fn(async () => false), setSecretAllowedCapabilities: vi.fn(async () => undefined), @@ -85,8 +85,8 @@ vi.mock('#mcp/secrets/service.ts', () => ({ setSecretAllowedHosts: (...args: Array) => mockModule.setSecretAllowedHosts(...args), listSecrets: (...args: Array) => mockModule.listSecrets(...args), - listAppSecretsByAppIds: (...args: Array) => - mockModule.listAppSecretsByAppIds(...args), + listPackageSecretsByPackageIds: (...args: Array) => + mockModule.listPackageSecretsByPackageIds(...args), resolveSecret: (...args: Array) => mockModule.resolveSecret(...args), deleteSecret: (...args: Array) => mockModule.deleteSecret(...args), setSecretAllowedCapabilities: (...args: Array) => @@ -255,7 +255,7 @@ test('connect oauth saves tokens, integration metadata, and host approval links' name: 'teslaAccessToken', scope: 'user', description: '', - appId: null, + packageId: null, allowedHosts: [ 'auth.tesla.com', 'fleet-api.prd.na.vn.cloud.tesla.com', @@ -270,7 +270,7 @@ test('connect oauth saves tokens, integration metadata, and host approval links' name: 'teslaRefreshToken', scope: 'user', description: '', - appId: null, + packageId: null, allowedHosts: [ 'auth.tesla.com', 'fleet-api.prd.na.vn.cloud.tesla.com', @@ -366,7 +366,7 @@ test('host approval view and approve persist normalized hosts for the selected s name: 'cloudflareToken', scope: 'user', description: 'Cloudflare token', - appId: null, + packageId: null, allowedHosts: [], allowedCapabilities: [], allowedPackages: [], @@ -380,7 +380,7 @@ test('host approval view and approve persist normalized hosts for the selected s name: 'cloudflareToken', scope: 'user', description: 'Cloudflare token', - appId: null, + packageId: null, allowedHosts: [], allowedCapabilities: [], allowedPackages: [], @@ -416,7 +416,7 @@ test('host approval view and approve persist normalized hosts for the selected s name: 'cloudflareToken', scope: 'user', description: 'Cloudflare token', - appId: null, + packageId: null, allowedHosts: ['api.github.com'], allowedCapabilities: [], allowedPackages: [], @@ -427,7 +427,7 @@ test('host approval view and approve persist normalized hosts for the selected s ]) mockModule.listSecrets.mockResolvedValueOnce([]) mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([]) - mockModule.listAppSecretsByAppIds.mockResolvedValueOnce(new Map()) + mockModule.listPackageSecretsByPackageIds.mockResolvedValueOnce(new Map()) const approveResponse = await handler.handler({ request: new Request( @@ -448,7 +448,7 @@ test('host approval view and approve persist normalized hosts for the selected s name: 'cloudflareToken', scope: 'user', allowedHosts: ['api.github.com', 'api.cloudflare.com'], - storageContext: { appId: null, sessionId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }), ) }) @@ -476,10 +476,10 @@ test('approval request rejects ambiguous host and package targets', async () => expect(mockModule.setSecretAllowedPackages).not.toHaveBeenCalled() }) -test('account secrets payload preserves app titles and allowed packages', async () => { +test('account secrets payload includes all packages and package titles and allowed packages', async () => { mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([ { - id: 'app-123', + id: 'package-123', userId: 'stable-user-1', name: '@kentcdodds/discord-gateway', kodyId: 'discord-gateway', @@ -510,7 +510,7 @@ test('account secrets payload preserves app titles and allowed packages', async name: 'discordBotToken', scope: 'user', description: 'Discord bot token', - appId: null, + packageId: null, allowedHosts: [], allowedCapabilities: [], allowedPackages: ['pkg-allowed'], @@ -519,16 +519,16 @@ test('account secrets payload preserves app titles and allowed packages', async ttlMs: null, }, ]) - mockModule.listAppSecretsByAppIds.mockResolvedValueOnce( + mockModule.listPackageSecretsByPackageIds.mockResolvedValueOnce( new Map([ [ - 'app-123', + 'package-123', [ { name: 'gatewaySigningSecret', - scope: 'app', + scope: 'package', description: 'Gateway signing secret', - appId: 'app-123', + packageId: 'package-123', allowedHosts: [], allowedCapabilities: [], allowedPackages: [], @@ -560,8 +560,8 @@ test('account secrets payload preserves app titles and allowed packages', async }), expect.objectContaining({ name: 'gatewaySigningSecret', - scope: 'app', - appTitle: '@kentcdodds/discord-gateway', + scope: 'package', + packageTitle: '@kentcdodds/discord-gateway', }), ]), }) @@ -572,7 +572,7 @@ test('package approval reject and approve handle missing secrets and deduplicate mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([]) mockModule.listSecrets.mockResolvedValueOnce([]) - mockModule.listAppSecretsByAppIds.mockResolvedValueOnce(new Map()) + mockModule.listPackageSecretsByPackageIds.mockResolvedValueOnce(new Map()) const rejectResponse = await handler.handler({ request: new Request( @@ -599,7 +599,7 @@ test('package approval reject and approve handle missing secrets and deduplicate name: 'discordBotToken', scope: 'user', description: 'Discord bot token', - appId: null, + packageId: null, allowedHosts: [], allowedCapabilities: [], allowedPackages: ['pkg-allowed', 'pkg-allowed'], @@ -610,7 +610,7 @@ test('package approval reject and approve handle missing secrets and deduplicate ]) mockModule.listSecrets.mockResolvedValueOnce([]) mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([]) - mockModule.listAppSecretsByAppIds.mockResolvedValueOnce(new Map()) + mockModule.listPackageSecretsByPackageIds.mockResolvedValueOnce(new Map()) const approveResponse = await handler.handler({ request: new Request( @@ -641,7 +641,7 @@ test('account secrets API loads selected secret values and deletes the selected name: 'myApiKey', scope: 'user', description: 'API key', - appId: null, + packageId: null, allowedHosts: [], allowedCapabilities: [], allowedPackages: [], @@ -651,7 +651,7 @@ test('account secrets API loads selected secret values and deletes the selected }, ]) mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([]) - mockModule.listAppSecretsByAppIds.mockResolvedValueOnce(new Map()) + mockModule.listPackageSecretsByPackageIds.mockResolvedValueOnce(new Map()) mockModule.resolveSecret.mockResolvedValueOnce({ found: true, value: 'seeded-secret-value', @@ -678,14 +678,14 @@ test('account secrets API loads selected secret values and deletes the selected expect.objectContaining({ name: 'myApiKey', scope: 'user', - storageContext: { appId: null, sessionId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }), ) mockModule.deleteSecret.mockResolvedValueOnce(true) mockModule.listSavedPackagesByUserId.mockResolvedValueOnce([]) mockModule.listSecrets.mockResolvedValueOnce([]) - mockModule.listAppSecretsByAppIds.mockResolvedValueOnce(new Map()) + mockModule.listPackageSecretsByPackageIds.mockResolvedValueOnce(new Map()) const deleteResponse = await handler.handler({ request: new Request('https://example.com/account/secrets.json', { @@ -711,7 +711,7 @@ test('account secrets API loads selected secret values and deletes the selected userId: 'stable-user-1', name: 'myApiKey', scope: 'user', - storageContext: { appId: null, sessionId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }), ) }) diff --git a/packages/worker/src/app/handlers/account-secrets.ts b/packages/worker/src/app/handlers/account-secrets.ts index 91abcbcfd0..179eb320ba 100644 --- a/packages/worker/src/app/handlers/account-secrets.ts +++ b/packages/worker/src/app/handlers/account-secrets.ts @@ -14,7 +14,7 @@ import { loadAccountSecretsData, readAccountSecretsSelectedSecretId, resolveApprovalRequest, - toPackageAppOptions, + toPackageOptions, } from '#app/account-secrets-data.ts' import { getAppBaseUrl } from '#app/app-base-url.ts' import { readAuthSessionResult } from '#app/auth-session.ts' @@ -46,9 +46,9 @@ import { parseIntegrationConfig, } from '#mcp/capabilities/integrations/integration-shared.ts' -type AccountEditableSecretScope = Extract +type AccountEditableSecretScope = Extract -type SavedPackageAppOption = { +type SavedPackageOption = { id: string title: string updatedAt: string @@ -95,9 +95,7 @@ export function createAccountSecretsHandler(env: Env) { | typeof routes.accountSecrets | typeof routes.accountSecretNew | typeof routes.accountSecretsApprove - | typeof routes.accountSecretDetail | typeof routes.accountSecretUserDetail - | typeof routes.accountSecretAppDetail | typeof routes.accountSecretSessionDetail | typeof routes.accountSecretPackageDetail > @@ -196,7 +194,7 @@ async function handleValueGetAction(input: { userId: input.user.mcpUser.userId, name, scope: 'user', - storageContext: { sessionId: null, appId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }) return jsonResponse({ ok: true, @@ -226,7 +224,7 @@ async function handleValueSetAction(input: { value, scope: 'user', description, - storageContext: { sessionId: null, appId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }) return jsonResponse({ ok: true, value: { value: saved.value } }) } @@ -328,7 +326,7 @@ async function handleConnectOauthAction(input: { value: accessToken, scope: 'user', description: `${provider} OAuth access token`, - storageContext: { sessionId: null, appId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }) let refreshSaved = false @@ -341,7 +339,7 @@ async function handleConnectOauthAction(input: { value: refreshToken, scope: 'user', description: `${provider} OAuth refresh token`, - storageContext: { sessionId: null, appId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }) refreshSaved = true } @@ -512,7 +510,7 @@ async function handleOAuthExchangeAction(input: { userId: input.user.mcpUser.userId, name: clientSecretSecretName, scope: 'user', - storageContext: { sessionId: null, appId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }) if (!resolved.found || !resolved.value) { return jsonResponse({ ok: false, error: 'Client secret not found.' }, 400) @@ -598,7 +596,7 @@ async function saveIntegrationConfig(input: { value: JSON.stringify(integration), scope: 'user', description: `OAuth integration config for ${integration.name}`, - storageContext: { sessionId: null, appId: null }, + storageContext: { sessionId: null, appId: null, packageId: null }, }) return integration.name } @@ -744,6 +742,15 @@ async function handleSaveAction(input: { if (!scope) { return jsonResponse({ ok: false, error: 'Secret scope is required.' }, 400) } + if (scope === 'package' && allowedPackages.length > 0) { + return jsonResponse( + { + ok: false, + error: 'Package secrets are accessible only to their owning package.', + }, + 400, + ) + } if (!value && currentId) { const parsed = parseAccountSecretId(currentId) @@ -768,15 +775,15 @@ async function handleSaveAction(input: { const savedPackages = await listSavedPackagesByUserId(input.env.APP_DB, { userId: input.user.mcpUser.userId, }) - const packageApps = toPackageAppOptions(savedPackages) - const appId = readAppIdForScope({ + const packageOptions = toPackageOptions(savedPackages) + const packageId = readPackageIdForScope({ body: input.body, scope, - packageApps, + packageOptions, }) - if (scope === 'app' && !appId) { + if (scope === 'package' && !packageId) { return jsonResponse( - { ok: false, error: 'Choose an app for app secrets.' }, + { ok: false, error: 'Choose a package for package secrets.' }, 400, ) } @@ -784,7 +791,7 @@ async function handleSaveAction(input: { const secrets = await listAccountSecrets({ env: input.env, user: input.user, - packageApps, + packageOptions, }) const secretById = new Map(secrets.map((secret) => [secret.id, secret])) const currentSecret = currentId ? (secretById.get(currentId) ?? null) : null @@ -795,7 +802,7 @@ async function handleSaveAction(input: { const nextId = buildAccountSecretId({ name, scope, - appId, + packageId, }) if (currentId !== nextId && secretById.has(nextId)) { return jsonResponse( @@ -818,7 +825,7 @@ async function handleSaveAction(input: { description, storageContext: getSecretContextForAccountSecret({ scope, - appId, + packageId, }), }) await setSecretAllowedHosts({ @@ -829,7 +836,7 @@ async function handleSaveAction(input: { allowedHosts, storageContext: getSecretContextForAccountSecret({ scope, - appId, + packageId, }), }) await setSecretAllowedCapabilities({ @@ -840,7 +847,7 @@ async function handleSaveAction(input: { allowedCapabilities, storageContext: getSecretContextForAccountSecret({ scope, - appId, + packageId, }), }) await setSecretAllowedPackages({ @@ -851,7 +858,7 @@ async function handleSaveAction(input: { allowedPackages, storageContext: getSecretContextForAccountSecret({ scope, - appId, + packageId, }), }) @@ -869,7 +876,7 @@ async function handleSaveAction(input: { request: input.request, env: input.env, user: input.user, - packageApps, + packageOptions, savedPackages, selectedSecretId: nextId, }) @@ -962,16 +969,20 @@ function readAccountSecretScope( body: object, ): AccountEditableSecretScope | null { const raw = readString(body, 'scope') - return raw === 'app' || raw === 'user' ? raw : null + return raw === 'package' || raw === 'user' ? raw : null } -function readAppIdForScope(input: { +function readPackageIdForScope(input: { body: object scope: AccountEditableSecretScope - packageApps: Array + packageOptions: Array }) { - if (input.scope !== 'app') return null - const appId = readString(input.body, 'appId') - if (!appId) return null - return input.packageApps.some((app) => app.id === appId) ? appId : null + if (input.scope !== 'package') return null + const packageId = readString(input.body, 'packageId') + if (!packageId) return null + return input.packageOptions.some( + (packageOption) => packageOption.id === packageId, + ) + ? packageId + : null } diff --git a/packages/worker/src/app/loader-data.ts b/packages/worker/src/app/loader-data.ts index c85b2490d8..55fc7cdf16 100644 --- a/packages/worker/src/app/loader-data.ts +++ b/packages/worker/src/app/loader-data.ts @@ -386,10 +386,10 @@ export type AccountPackageInvocationTokensLoaderData = { export type AccountSecretListItem = { id: string name: string - scope: 'app' | 'user' + scope: 'package' | 'user' description: string - appId: string | null - appTitle: string | null + packageId: string | null + packageTitle: string | null allowedHosts: Array allowedCapabilities: Array allowedPackages: Array @@ -405,7 +405,7 @@ export type AccountSecretDetail = AccountSecretListItem & { export type AccountSecretsLoaderData = { ok: true email: string - apps: Array<{ + packageOptions: Array<{ id: string title: string updatedAt: string @@ -419,7 +419,7 @@ export type AccountSecretsLoaderData = { selectedSecret: AccountSecretDetail | null approval: { name: string - scope: 'app' | 'session' | 'user' + scope: 'package' | 'session' | 'user' requestedHost: string requestedCapability: string | null requestedPackageId: string | null diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index d74bb3c8bd..ccd004bbf4 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -172,9 +172,7 @@ export function createAppRouter(env: Env) { accountSecrets: createAccountSecretsHandler(env), accountSecretNew: createAccountSecretsHandler(env), accountSecretsApprove: createAccountSecretsHandler(env), - accountSecretDetail: createAccountSecretsHandler(env), accountSecretUserDetail: createAccountSecretsHandler(env), - accountSecretAppDetail: createAccountSecretsHandler(env), accountSecretSessionDetail: createAccountSecretsHandler(env), accountSecretPackageDetail: createAccountSecretsHandler(env), accountSecretsApi: createAccountSecretsApiHandler(env), diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index 26356c14bc..97820a2afe 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -22,9 +22,7 @@ export const routes = route({ accountRemoteConnectorsApiPost: post('/account/remote-connectors.json'), accountSecrets: '/account/secrets', accountSecretNew: '/account/secrets/new', - accountSecretDetail: '/account/secrets/:secretId', accountSecretUserDetail: '/account/secrets/user/:secretName', - accountSecretAppDetail: '/account/secrets/app/:appId/:secretName', accountSecretSessionDetail: '/account/secrets/session/:sessionId/:secretName', accountSecretPackageDetail: '/account/secrets/package/:packageId/:secretName', accountSecretsApprove: '/account/secrets/approve', diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 1cf122c4ae..28c4b09ba8 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -750,6 +750,7 @@ function createPackageJobCallerContext(input: { storageContext: { sessionId: null, appId: input.packageId, + packageId: input.packageId, storageId: null, }, repoContext: null, @@ -1233,6 +1234,7 @@ export async function executeJobOnce(input: { storageContext: { sessionId: input.callerContext.storageContext?.sessionId ?? null, appId: input.callerContext.storageContext?.appId ?? null, + packageId: input.callerContext.storageContext?.packageId ?? null, storageId: input.job.storageId, }, repoContext: input.callerContext.repoContext ?? null, diff --git a/packages/worker/src/mcp/capabilities/meta/execute.ts b/packages/worker/src/mcp/capabilities/meta/execute.ts index 4691589603..56d6906f6f 100644 --- a/packages/worker/src/mcp/capabilities/meta/execute.ts +++ b/packages/worker/src/mcp/capabilities/meta/execute.ts @@ -99,6 +99,7 @@ export const executeCapability = defineDomainCapability( storageContext: { sessionId: ctx.callerContext.storageContext?.sessionId ?? null, appId: ctx.callerContext.storageContext?.appId ?? null, + packageId: ctx.callerContext.storageContext?.packageId ?? null, storageId: resolvedStorageId, }, } diff --git a/packages/worker/src/mcp/capabilities/secrets/jwt-sign.ts b/packages/worker/src/mcp/capabilities/secrets/jwt-sign.ts index f702a87203..edf7a3452f 100644 --- a/packages/worker/src/mcp/capabilities/secrets/jwt-sign.ts +++ b/packages/worker/src/mcp/capabilities/secrets/jwt-sign.ts @@ -8,6 +8,7 @@ import { createCapabilitySecretAccessDeniedMessage, createMissingSecretMessage, } from '#mcp/secrets/errors.ts' +import { assertPackageCanAccessResolvedSecret } from '#mcp/secrets/package-access.ts' import { resolveSecret } from '#mcp/secrets/service.ts' import { secretScopeValues } from '#mcp/secrets/types.ts' import { @@ -73,6 +74,7 @@ export const jwtSignCapability = defineDomainCapability( const storageContext = { sessionId: ctx.callerContext.storageContext?.sessionId ?? null, appId: ctx.callerContext.storageContext?.appId ?? null, + packageId: ctx.callerContext.storageContext?.packageId ?? null, storageId: ctx.callerContext.storageContext?.storageId ?? null, } const resolved = await resolveSecret({ @@ -87,6 +89,14 @@ export const jwtSignCapability = defineDomainCapability( createMissingSecretMessage(args.private_key_secret_name), ) } + await assertPackageCanAccessResolvedSecret({ + env: ctx.env, + baseUrl: ctx.callerContext.baseUrl, + userId: user.userId, + storageContext, + secretName: args.private_key_secret_name, + resolved, + }) if (!resolved.allowedCapabilities.includes('secret_jwt_sign')) { const approvalUrl = buildSecretCapabilityApprovalUrl({ baseUrl: ctx.callerContext.baseUrl, diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-delete.ts b/packages/worker/src/mcp/capabilities/secrets/secret-delete.ts index 5fdb538e8d..d4e35b29fa 100644 --- a/packages/worker/src/mcp/capabilities/secrets/secret-delete.ts +++ b/packages/worker/src/mcp/capabilities/secrets/secret-delete.ts @@ -3,7 +3,8 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' -import { deleteSecret } from '#mcp/secrets/service.ts' +import { assertPackageCanAccessResolvedSecret } from '#mcp/secrets/package-access.ts' +import { deleteSecret, resolveSecret } from '#mcp/secrets/service.ts' import { secretScopeValues } from '#mcp/secrets/types.ts' export const secretDeleteCapability = defineDomainCapability( @@ -27,17 +28,38 @@ export const secretDeleteCapability = defineDomainCapability( }), async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) + const storageContext = { + sessionId: ctx.callerContext.storageContext?.sessionId ?? null, + appId: ctx.callerContext.storageContext?.appId ?? null, + packageId: ctx.callerContext.storageContext?.packageId ?? null, + storageId: ctx.callerContext.storageContext?.storageId ?? null, + } + if (args.scope === 'user' && storageContext.packageId) { + const existing = await resolveSecret({ + env: ctx.env, + userId: user.userId, + name: args.name, + scope: 'user', + storageContext, + }) + if (existing.found) { + await assertPackageCanAccessResolvedSecret({ + env: ctx.env, + baseUrl: ctx.callerContext.baseUrl, + userId: user.userId, + storageContext, + secretName: args.name, + resolved: existing, + }) + } + } return { deleted: await deleteSecret({ env: ctx.env, userId: user.userId, name: args.name, scope: args.scope, - storageContext: { - sessionId: ctx.callerContext.storageContext?.sessionId ?? null, - appId: ctx.callerContext.storageContext?.appId ?? null, - storageId: ctx.callerContext.storageContext?.storageId ?? null, - }, + storageContext, }), } }, diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts index 9ac222e08d..2194a629ca 100644 --- a/packages/worker/src/mcp/capabilities/secrets/secret-list.ts +++ b/packages/worker/src/mcp/capabilities/secrets/secret-list.ts @@ -30,6 +30,8 @@ export const secretListCapability = defineDomainCapability( }), async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) + const packageId = + ctx.callerContext.storageContext?.packageId?.trim() || null const secrets = await listSecrets({ env: ctx.env, userId: user.userId, @@ -37,15 +39,23 @@ export const secretListCapability = defineDomainCapability( storageContext: { sessionId: ctx.callerContext.storageContext?.sessionId ?? null, appId: ctx.callerContext.storageContext?.appId ?? null, + packageId: ctx.callerContext.storageContext?.packageId ?? null, storageId: ctx.callerContext.storageContext?.storageId ?? null, }, }) + const accessibleSecrets = packageId + ? secrets.filter( + (secret) => + secret.scope !== 'user' || + secret.allowedPackages.includes(packageId), + ) + : secrets return { - secrets: secrets.map((secret) => ({ + secrets: accessibleSecrets.map((secret) => ({ name: secret.name, scope: secret.scope, description: secret.description, - app_id: secret.appId, + package_id: secret.packageId, allowed_hosts: secret.allowedHosts, allowed_capabilities: secret.allowedCapabilities, allowed_packages: secret.allowedPackages, diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-set.ts b/packages/worker/src/mcp/capabilities/secrets/secret-set.ts index e8d15d62bc..d71b17474b 100644 --- a/packages/worker/src/mcp/capabilities/secrets/secret-set.ts +++ b/packages/worker/src/mcp/capabilities/secrets/secret-set.ts @@ -4,7 +4,8 @@ import { defineDomainCapability } from '#mcp/capabilities/define-domain-capabili import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { saveSecret } from '#mcp/secrets/service.ts' +import { assertPackageCanAccessResolvedSecret } from '#mcp/secrets/package-access.ts' +import { resolveSecret, saveSecret } from '#mcp/secrets/service.ts' import { secretScopeValues } from '#mcp/secrets/types.ts' import { secretMetadataSchema } from './shared.ts' @@ -45,6 +46,34 @@ export const secretSetCapability = defineDomainCapability( async handler(args, ctx: CapabilityContext) { const parsed = secretSetInputSchema.parse(args) const user = requireMcpUser(ctx.callerContext) + const storageContext = { + sessionId: ctx.callerContext.storageContext?.sessionId ?? null, + appId: ctx.callerContext.storageContext?.appId ?? null, + packageId: ctx.callerContext.storageContext?.packageId ?? null, + storageId: ctx.callerContext.storageContext?.storageId ?? null, + } + if (parsed.scope === 'user' && storageContext.packageId) { + const existing = await resolveSecret({ + env: ctx.env, + userId: user.userId, + name: parsed.name, + scope: 'user', + storageContext, + }) + if (!existing.found) { + throw new Error( + 'Package runtimes cannot create user-scoped secrets. Create the secret from the account page and approve the package first.', + ) + } + await assertPackageCanAccessResolvedSecret({ + env: ctx.env, + baseUrl: ctx.callerContext.baseUrl, + userId: user.userId, + storageContext, + secretName: parsed.name, + resolved: existing, + }) + } const saved = await saveSecret({ env: ctx.env, userId: user.userId, @@ -53,17 +82,13 @@ export const secretSetCapability = defineDomainCapability( name: parsed.name, value: parsed.value, description: parsed.description ?? '', - storageContext: { - sessionId: ctx.callerContext.storageContext?.sessionId ?? null, - appId: ctx.callerContext.storageContext?.appId ?? null, - storageId: ctx.callerContext.storageContext?.storageId ?? null, - }, + storageContext, }) return { name: saved.name, scope: saved.scope, description: saved.description, - app_id: saved.appId, + package_id: saved.packageId, allowed_hosts: saved.allowedHosts, allowed_capabilities: saved.allowedCapabilities, allowed_packages: saved.allowedPackages, diff --git a/packages/worker/src/mcp/capabilities/secrets/shared.ts b/packages/worker/src/mcp/capabilities/secrets/shared.ts index aeda3fcd7b..d35a439c27 100644 --- a/packages/worker/src/mcp/capabilities/secrets/shared.ts +++ b/packages/worker/src/mcp/capabilities/secrets/shared.ts @@ -5,7 +5,7 @@ export const secretMetadataSchema = z.object({ name: z.string(), scope: z.enum(secretScopeValues), description: z.string(), - app_id: z.string().nullable(), + package_id: z.string().nullable(), allowed_hosts: z.array(z.string()), allowed_capabilities: z.array(z.string()), allowed_packages: z.array(z.string()), diff --git a/packages/worker/src/mcp/execute-modules/kody-runtime-utils.ts b/packages/worker/src/mcp/execute-modules/kody-runtime-utils.ts index 77e1342d53..6a03a1d2f6 100644 --- a/packages/worker/src/mcp/execute-modules/kody-runtime-utils.ts +++ b/packages/worker/src/mcp/execute-modules/kody-runtime-utils.ts @@ -6,7 +6,7 @@ import { export { IntegrationHostNotAllowedError } type CapabilityResult = unknown -type SecretScope = 'session' | 'app' | 'user' +type SecretScope = 'session' | 'package' | 'user' export type CapabilityArgs = Record @@ -336,10 +336,7 @@ async function refreshAccessTokenWithIntegration( return payload.access_token } -function buildSecretPlaceholder( - name: string, - scope: 'user' | 'app' | 'session', -) { +function buildSecretPlaceholder(name: string, scope: SecretScope) { return `{{secret:${name}|scope=${scope}}}` } @@ -396,7 +393,8 @@ function normalizeSecretName(value: string, fieldName: string) { function normalizeOptionalSecretScope(scope: SecretScope | null | undefined) { if (scope == null) return null - if (scope === 'app' || scope === 'session' || scope === 'user') return scope + if (scope === 'package' || scope === 'session' || scope === 'user') + return scope throw new Error(`Unsupported secret scope "${scope}".`) } @@ -562,7 +560,7 @@ const __kodyNormalizeSecretName = (value, fieldName) => { }; const __kodyNormalizeOptionalSecretScope = (scope) => { if (scope == null) return null; - if (scope === 'app' || scope === 'session' || scope === 'user') return scope; + if (scope === 'package' || scope === 'session' || scope === 'user') return scope; throw new Error(\`Unsupported secret scope "\${scope}".\`); }; const __kodyBuildBasicAuthSecretPlaceholder = (input) => { diff --git a/packages/worker/src/mcp/fetch-gateway.node.test.ts b/packages/worker/src/mcp/fetch-gateway.node.test.ts index fd6c15afe6..197e22c8b3 100644 --- a/packages/worker/src/mcp/fetch-gateway.node.test.ts +++ b/packages/worker/src/mcp/fetch-gateway.node.test.ts @@ -5,12 +5,16 @@ import { expandSecretPlaceholders, secretResolutionHeaderName, } from '#mcp/fetch-gateway.ts' -import { parseHostApprovalRequiredBatchMessage } from '#mcp/secrets/errors.ts' +import { + parseHostApprovalRequiredBatchMessage, + parsePackageAccessRequiredMessage, +} from '#mcp/secrets/errors.ts' import { buildBasicAuthSecretPlaceholder, parseBasicAuthSecretPlaceholders, } from '#mcp/secrets/placeholders.ts' import * as secretService from '#mcp/secrets/service.ts' +import * as packageRepo from '#worker/package-registry/repo.ts' const env = { APP_DB: {} as D1Database, @@ -89,6 +93,72 @@ test('fetch gateway blocks or expands secret placeholders based on host approval } }) +test('fetch gateway requires package approval before resolving user secrets', async () => { + const request = () => + new Request('https://example.com/api', { + headers: { + Authorization: 'Bearer {{secret:userToken|scope=user}}', + }, + }) + const packageProps = { + ...props, + storageContext: { + sessionId: null, + appId: 'pkg-1', + packageId: 'pkg-1', + storageId: 'pkg-1', + }, + } + const packageSpy = vi + .spyOn(packageRepo, 'getSavedPackageById') + .mockResolvedValue({ + id: 'pkg-1', + userId: 'user-123', + kodyId: 'example-package', + name: '@user/example-package', + description: '', + tags: [], + searchText: null, + hasApp: false, + sourceId: 'source-1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + const resolveSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValueOnce({ + found: true, + value: 'secret-value', + scope: 'user', + allowedHosts: ['example.com'], + allowedCapabilities: [], + allowedPackages: [], + }) + .mockResolvedValueOnce({ + found: true, + value: 'secret-value', + scope: 'user', + allowedHosts: ['example.com'], + allowedCapabilities: [], + allowedPackages: ['pkg-1'], + }) + + await expect( + expandSecretPlaceholders({ request: request(), props: packageProps, env }), + ).rejects.toSatisfy((error: unknown) => { + const parsed = parsePackageAccessRequiredMessage(getErrorMessage(error)) + return parsed?.packageName === 'example-package' + }) + const transformed = await expandSecretPlaceholders({ + request: request(), + props: packageProps, + env, + }) + expect(transformed.headers.get('Authorization')).toBe('Bearer secret-value') + expect(packageSpy).toHaveBeenCalledTimes(1) + expect(resolveSpy).toHaveBeenCalledTimes(2) +}) + test('opt-out header sends placeholders literally, strips itself, and never resolves secrets', async () => { const resolveSpy = vi.spyOn(secretService, 'resolveSecret') const request = new Request('https://discord.com/api/channels/1/messages', { diff --git a/packages/worker/src/mcp/fetch-gateway.ts b/packages/worker/src/mcp/fetch-gateway.ts index f914a7a333..e862b16288 100644 --- a/packages/worker/src/mcp/fetch-gateway.ts +++ b/packages/worker/src/mcp/fetch-gateway.ts @@ -20,6 +20,7 @@ import { } from '#mcp/secrets/errors.ts' import { normalizeHost } from '#mcp/secrets/allowed-hosts.ts' import { resolveSecret, type ResolvedSecret } from '#mcp/secrets/service.ts' +import { assertPackageCanAccessResolvedSecret } from '#mcp/secrets/package-access.ts' import { type StorageContext } from '#mcp/storage.ts' import { recordUsage, type UsageEnv } from '#worker/usage/record-usage.ts' @@ -214,6 +215,14 @@ export async function expandSecretPlaceholders(input: { if (!resolved.found || typeof resolved.value !== 'string') { throw new Error(createMissingSecretMessage(referenced.name)) } + await assertPackageCanAccessResolvedSecret({ + env: input.env, + baseUrl: input.props.baseUrl, + userId: input.props.userId!, + storageContext: input.props.storageContext, + secretName: referenced.name, + resolved, + }) return { referenced, resolved, value: resolved.value } }), ) diff --git a/packages/worker/src/mcp/run-kody-registry.ts b/packages/worker/src/mcp/run-kody-registry.ts index edd1191c82..2dc9356f92 100644 --- a/packages/worker/src/mcp/run-kody-registry.ts +++ b/packages/worker/src/mcp/run-kody-registry.ts @@ -25,7 +25,10 @@ import { createCapabilitySecretAccessDeniedBatchMessage, createMissingSecretMessage, } from '#mcp/secrets/errors.ts' -import { resolvePackageMountedSecret } from '#mcp/secrets/package-access.ts' +import { + assertPackageCanAccessResolvedSecret, + resolvePackageMountedSecret, +} from '#mcp/secrets/package-access.ts' import { buildSecretCapabilityApprovalUrl } from '#mcp/secrets/capability-approval-url.ts' import { resolveSecret } from '#mcp/secrets/service.ts' import { type ReferencedSecret } from '#mcp/secrets/placeholders.ts' @@ -869,6 +872,14 @@ function createCapabilityInputSecretResolver( if (!resolved.found || typeof resolved.value !== 'string') { throw new Error(createMissingSecretMessage(secret.name)) } + await assertPackageCanAccessResolvedSecret({ + env, + baseUrl: callerContext.baseUrl, + userId, + storageContext: callerContext.storageContext, + secretName: secret.name, + resolved, + }) if (!resolved.allowedCapabilities.includes(capabilityName)) { const approvalUrl = buildSecretCapabilityApprovalUrl({ baseUrl: callerContext.baseUrl, @@ -1552,6 +1563,7 @@ function normalizeStorageContext( return { sessionId: storageContext.sessionId ?? null, appId: storageContext.appId ?? null, + packageId: storageContext.packageId ?? null, storageId: storageContext.storageId ?? null, } } diff --git a/packages/worker/src/mcp/secrets/capability-approval-url.ts b/packages/worker/src/mcp/secrets/capability-approval-url.ts index 986c8143a0..c10f1e006b 100644 --- a/packages/worker/src/mcp/secrets/capability-approval-url.ts +++ b/packages/worker/src/mcp/secrets/capability-approval-url.ts @@ -12,7 +12,7 @@ export function buildSecretCapabilityApprovalUrl(input: { const secretPath = buildAccountSecretPath({ name: input.name, scope: input.scope, - appId: input.storageContext?.appId ?? null, + packageId: input.storageContext?.packageId ?? null, sessionId: input.storageContext?.sessionId ?? null, }) const url = new URL(secretPath, input.baseUrl) diff --git a/packages/worker/src/mcp/secrets/host-approval.ts b/packages/worker/src/mcp/secrets/host-approval.ts index 2302108c83..469f569017 100644 --- a/packages/worker/src/mcp/secrets/host-approval.ts +++ b/packages/worker/src/mcp/secrets/host-approval.ts @@ -13,7 +13,7 @@ export function buildSecretHostApprovalUrl(input: { const secretPath = buildAccountSecretPath({ name: input.name, scope: input.scope, - appId: input.storageContext?.appId ?? null, + packageId: input.storageContext?.packageId ?? null, sessionId: input.storageContext?.sessionId ?? null, }) const url = new URL(secretPath, input.baseUrl) diff --git a/packages/worker/src/mcp/secrets/package-access.node.test.ts b/packages/worker/src/mcp/secrets/package-access.node.test.ts index 5ff0aae87a..fe2734cbd7 100644 --- a/packages/worker/src/mcp/secrets/package-access.node.test.ts +++ b/packages/worker/src/mcp/secrets/package-access.node.test.ts @@ -21,12 +21,39 @@ vi.mock('./service.ts', () => ({ resolveSecret: (...args: Array) => mockModule.resolveSecret(...args), })) -const { buildPackageApprovalErrorForMounts, resolvePackageMountedSecret } = - await import('./package-access.ts') +const { + assertPackageCanAccessResolvedSecret, + buildPackageApprovalErrorForMounts, + resolvePackageMountedSecret, +} = await import('./package-access.ts') -test('resolvePackageMountedSecret rejects package runtime calls without a matching appId', async () => { +test('package-owned secrets do not require an allowed-packages grant', async () => { + await expect( + assertPackageCanAccessResolvedSecret({ + env: { APP_DB: {} as D1Database }, + baseUrl: 'https://example.com', + userId: 'user-1', + storageContext: { + sessionId: null, + packageId: 'pkg-1', + }, + secretName: 'packageToken', + resolved: { + found: true, + value: 'secret', + scope: 'package', + allowedHosts: [], + allowedCapabilities: [], + allowedPackages: [], + }, + }), + ).resolves.toBeUndefined() + expect(mockModule.getSavedPackageById).not.toHaveBeenCalled() +}) + +test('resolvePackageMountedSecret rejects package runtime calls without a matching packageId', async () => { const runtimeError = - 'Package secret access is only available inside server-side package runtime contexts.' + 'Package secret access requires a matching server-side package runtime context.' const baseInput = { env: {} as Env, packageId: 'pkg-1', @@ -50,7 +77,7 @@ test('resolvePackageMountedSecret rejects package runtime calls without a matchi ...baseInput.callerContext, storageContext: { sessionId: null, - appId: null, + packageId: null, storageId: 'pkg-1', }, }, @@ -64,7 +91,7 @@ test('resolvePackageMountedSecret rejects package runtime calls without a matchi ...baseInput.callerContext, storageContext: { sessionId: null, - appId: 'pkg-2', + packageId: 'pkg-2', storageId: 'pkg-1', }, }, @@ -72,7 +99,7 @@ test('resolvePackageMountedSecret rejects package runtime calls without a matchi ).rejects.toThrow(runtimeError) }) -test('resolvePackageMountedSecret resolves mounted secret when package appId matches', async () => { +test('resolvePackageMountedSecret resolves approved user secret when package id matches', async () => { mockModule.getSavedPackageById.mockResolvedValueOnce({ id: 'pkg-1', kodyId: 'discord-gateway', @@ -120,7 +147,7 @@ test('resolvePackageMountedSecret resolves mounted secret when package appId mat repoContext: null, storageContext: { sessionId: null, - appId: 'pkg-1', + packageId: 'pkg-1', storageId: 'pkg-1', }, }, @@ -141,7 +168,8 @@ test('resolvePackageMountedSecret resolves mounted secret when package appId mat scope: 'user', storageContext: { sessionId: null, - appId: 'pkg-1', + appId: null, + packageId: 'pkg-1', storageId: 'pkg-1', }, }), @@ -194,7 +222,8 @@ test('findMissingPackageApprovals reads saved package metadata without loading m }, storageContext: { sessionId: null, - appId: 'pkg-1', + appId: null, + packageId: 'pkg-1', storageId: 'pkg-1', }, }) diff --git a/packages/worker/src/mcp/secrets/package-access.ts b/packages/worker/src/mcp/secrets/package-access.ts index a604329032..b012d0a631 100644 --- a/packages/worker/src/mcp/secrets/package-access.ts +++ b/packages/worker/src/mcp/secrets/package-access.ts @@ -5,7 +5,7 @@ import { createPackageSecretAccessDeniedBatchMessage, createPackageSecretAccessDeniedMessage, } from './errors.ts' -import { resolveSecret } from './service.ts' +import { resolveSecret, type ResolvedSecret } from './service.ts' import { type SecretScope } from './types.ts' import { loadPackageManifestBySourceId, @@ -30,6 +30,57 @@ export function isPackageSecretAccessUnavailableError(error: unknown) { ) } +export async function assertPackageCanAccessResolvedSecret(input: { + env: Pick + baseUrl: string + userId: string + storageContext: + | { + sessionId?: string | null + appId?: string | null + packageId?: string | null + storageId?: string | null + } + | null + | undefined + secretName: string + resolved: ResolvedSecret +}) { + const packageId = input.storageContext?.packageId?.trim() + if (!packageId || input.resolved.scope !== 'user') return + if (input.resolved.allowedPackages.includes(packageId)) return + + const savedPackage = await getSavedPackageById(input.env.APP_DB, { + userId: input.userId, + packageId, + }) + if (!savedPackage) { + throw new PackageSecretAccessDeniedError( + `Package "${packageId}" was not found for secret access.`, + ) + } + const approvalUrl = buildSecretPackageApprovalUrl({ + baseUrl: input.baseUrl, + name: input.secretName, + scope: 'user', + packageId: savedPackage.id, + kodyId: savedPackage.kodyId, + storageContext: { + sessionId: input.storageContext?.sessionId ?? null, + appId: input.storageContext?.appId ?? null, + packageId: input.storageContext?.packageId ?? null, + storageId: input.storageContext?.storageId ?? null, + }, + }) + throw new PackageSecretAccessDeniedError( + createPackageSecretAccessDeniedMessage({ + secretName: input.secretName, + packageName: savedPackage.kodyId, + approvalUrl, + }), + ) +} + export async function loadPackageSecretMounts(input: { env: Env baseUrl: string @@ -76,10 +127,10 @@ export async function resolvePackageMountedSecret(input: { packageId: string alias: string }) { - const packageContext = input.callerContext.storageContext?.appId + const packageContext = input.callerContext.storageContext?.packageId if (!packageContext || packageContext !== input.packageId) { throw new Error( - 'Package secret access is only available inside server-side package runtime contexts.', + 'Package secret access requires a matching server-side package runtime context.', ) } const userId = input.callerContext.user?.userId @@ -108,33 +159,21 @@ export async function resolvePackageMountedSecret(input: { storageContext: { sessionId: input.callerContext.storageContext?.sessionId ?? null, appId: input.callerContext.storageContext?.appId ?? null, + packageId: input.callerContext.storageContext?.packageId ?? null, storageId: input.callerContext.storageContext?.storageId ?? null, }, }) if (!resolved.found || typeof resolved.value !== 'string') { throw new PackageSecretMissingError(createMissingSecretMessage(mount.name)) } - if (!resolved.allowedPackages.includes(packageInfo.savedPackage.id)) { - const approvalUrl = buildSecretPackageApprovalUrl({ - baseUrl: input.callerContext.baseUrl, - name: mount.name, - scope: resolved.scope ?? mount.scope ?? 'user', - packageId: packageInfo.savedPackage.id, - kodyId: packageInfo.savedPackage.kodyId, - storageContext: { - sessionId: input.callerContext.storageContext?.sessionId ?? null, - appId: input.callerContext.storageContext?.appId ?? null, - storageId: input.callerContext.storageContext?.storageId ?? null, - }, - }) - throw new PackageSecretAccessDeniedError( - createPackageSecretAccessDeniedMessage({ - secretName: mount.name, - packageName: packageInfo.savedPackage.kodyId, - approvalUrl, - }), - ) - } + await assertPackageCanAccessResolvedSecret({ + env: input.env, + baseUrl: input.callerContext.baseUrl, + userId, + storageContext: input.callerContext.storageContext, + secretName: mount.name, + resolved, + }) return { alias: input.alias, name: mount.name, @@ -163,6 +202,7 @@ export async function findMissingPackageApprovals(input: { const storageContext = { sessionId: input.storageContext?.sessionId ?? null, appId: input.storageContext?.appId ?? null, + packageId: input.storageContext?.packageId ?? null, storageId: input.storageContext?.storageId ?? null, } const entries = await Promise.all( @@ -175,6 +215,7 @@ export async function findMissingPackageApprovals(input: { storageContext, }) if (!resolved.found) return null + if (resolved.scope !== 'user') return null if (resolved.allowedPackages.includes(savedPackage.id)) { return null } diff --git a/packages/worker/src/mcp/secrets/package-approval-url.ts b/packages/worker/src/mcp/secrets/package-approval-url.ts index 9d1529b1d4..c41c143c7a 100644 --- a/packages/worker/src/mcp/secrets/package-approval-url.ts +++ b/packages/worker/src/mcp/secrets/package-approval-url.ts @@ -10,8 +10,10 @@ export function buildSecretPackageApprovalUrl(input: { kodyId: string | null storageContext: StorageContext | null }) { - if (input.scope === 'app' && !input.storageContext?.appId) { - throw new Error('storageContext.appId is required for app-scope approvals.') + if (input.scope === 'package' && !input.storageContext?.packageId) { + throw new Error( + 'storageContext.packageId is required for package-scope approvals.', + ) } if (input.scope === 'session' && !input.storageContext?.sessionId) { throw new Error( @@ -21,7 +23,7 @@ export function buildSecretPackageApprovalUrl(input: { const secretPath = buildAccountSecretPath({ name: input.name, scope: input.scope, - appId: input.storageContext?.appId ?? null, + packageId: input.storageContext?.packageId ?? null, sessionId: input.storageContext?.sessionId ?? null, }) const url = new URL(secretPath, input.baseUrl) diff --git a/packages/worker/src/mcp/secrets/package-scope-migration.node.test.ts b/packages/worker/src/mcp/secrets/package-scope-migration.node.test.ts new file mode 100644 index 0000000000..ee057f42d9 --- /dev/null +++ b/packages/worker/src/mcp/secrets/package-scope-migration.node.test.ts @@ -0,0 +1,105 @@ +import { readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from 'vitest' + +const migrationsDirectory = new URL('../../../migrations/', import.meta.url) + +function applyMigration(db: DatabaseSync, fileName: string) { + db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) +} + +test('package scope migration converts app buckets without retaining package grants', () => { + const db = new DatabaseSync(':memory:') + for (const fileName of [ + '0008-secret-buckets.sql', + '0009-secret-allowed-hosts.sql', + '0010-secret-allowed-capabilities.sql', + '0022-secret-entry-lookup-hash.sql', + '0023-secret-allowed-packages.sql', + ]) { + applyMigration(db, fileName) + } + db.exec(` + CREATE TABLE jobs ( + user_id TEXT NOT NULL, + source_id TEXT, + caller_context_json TEXT NOT NULL + ); + CREATE TABLE saved_packages ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + source_id TEXT NOT NULL + ); + INSERT INTO saved_packages (id, user_id, source_id) + VALUES ('package-1', 'user-1', 'source-1'); + INSERT INTO jobs (user_id, source_id, caller_context_json) + VALUES ( + 'user-1', + 'source-1', + '{"storageContext":{"appId":"package-1"}}' + ); + INSERT INTO secret_buckets (id, user_id, scope, binding_key) + VALUES + ('package-bucket', 'user-1', 'app', 'package-1'), + ('user-bucket', 'user-1', 'user', ''); + INSERT INTO secret_entries ( + bucket_id, + name, + encrypted_value, + allowed_packages + ) + VALUES + ('package-bucket', 'package-token', 'encrypted-package', '["package-2"]'), + ('user-bucket', 'shared-token', 'encrypted-user', '["package-2"]'); + `) + + applyMigration(db, '0057-package-scoped-secrets.sql') + + expect( + db + .prepare( + `SELECT scope, binding_key + FROM secret_buckets + WHERE id = 'package-bucket'`, + ) + .get(), + ).toEqual({ scope: 'package', binding_key: 'package-1' }) + expect( + db + .prepare( + `SELECT encrypted_value, allowed_packages + FROM secret_entries + WHERE bucket_id = 'package-bucket'`, + ) + .get(), + ).toEqual({ + encrypted_value: 'encrypted-package', + allowed_packages: '[]', + }) + expect( + db + .prepare( + `SELECT allowed_packages + FROM secret_entries + WHERE bucket_id = 'user-bucket'`, + ) + .get(), + ).toEqual({ allowed_packages: '["package-2"]' }) + expect( + db + .prepare( + `SELECT json_extract( + caller_context_json, + '$.storageContext.packageId' + ) AS package_id + FROM jobs`, + ) + .get(), + ).toEqual({ package_id: 'package-1' }) + expect(() => + db.exec( + `INSERT INTO secret_buckets (id, user_id, scope, binding_key) + VALUES ('legacy-app-bucket', 'user-1', 'app', 'package-2')`, + ), + ).toThrow() +}) diff --git a/packages/worker/src/mcp/secrets/placeholders.ts b/packages/worker/src/mcp/secrets/placeholders.ts index 5a3cdb7528..835e913c38 100644 --- a/packages/worker/src/mcp/secrets/placeholders.ts +++ b/packages/worker/src/mcp/secrets/placeholders.ts @@ -1,9 +1,9 @@ import { type SecretScope } from '#mcp/secrets/types.ts' const secretPlaceholderRegex = - /\{\{secret:([a-zA-Z0-9._-]+)(?:\|scope=(session|app|user))?\}\}/g + /\{\{secret:([a-zA-Z0-9._-]+)(?:\|scope=(session|package|user))?\}\}/g const basicAuthSecretPlaceholderRegex = - /\{\{secret-basic:username=([a-zA-Z0-9._-]+),password=([a-zA-Z0-9._-]+)(?:\|scope=(session|app|user))?\}\}/g + /\{\{secret-basic:username=([a-zA-Z0-9._-]+),password=([a-zA-Z0-9._-]+)(?:\|scope=(session|package|user))?\}\}/g export type ReferencedSecret = { name: string @@ -25,7 +25,7 @@ export function parseSecretPlaceholders(value: string) { secrets.push({ name, scope: - scope === 'app' || scope === 'session' || scope === 'user' + scope === 'package' || scope === 'session' || scope === 'user' ? scope : null, }) @@ -131,7 +131,7 @@ export function containsSecretPlaceholder(value: string) { } function parseSecretScope(scope: string | undefined) { - return scope === 'app' || scope === 'session' || scope === 'user' + return scope === 'package' || scope === 'session' || scope === 'user' ? scope : null } diff --git a/packages/worker/src/mcp/secrets/repo.ts b/packages/worker/src/mcp/secrets/repo.ts index 7f14f796eb..ecfe43082b 100644 --- a/packages/worker/src/mcp/secrets/repo.ts +++ b/packages/worker/src/mcp/secrets/repo.ts @@ -174,41 +174,72 @@ export async function listUserScopeSecretMetadata(input: { return (results ?? []).map(mapSecretMetadataRow) } -export async function listAppScopeSecretMetadata(input: { +export async function listPackageScopeSecretMetadata(input: { db: D1Database userId: string - appIds: Array + packageIds: Array now?: string }): Promise> { - if (input.appIds.length === 0) return [] + if (input.packageIds.length === 0) return [] const now = input.now ?? new Date().toISOString() - const placeholders = input.appIds.map(() => '?').join(', ') + const placeholders = input.packageIds.map(() => '?').join(', ') const { results } = await input.db .prepare( `SELECT b.scope, b.binding_key, e.name, e.description, e.allowed_hosts, e.allowed_capabilities, e.allowed_packages, e.created_at, e.updated_at, b.expires_at FROM secret_buckets b JOIN secret_entries e ON e.bucket_id = b.id - WHERE b.user_id = ? AND b.scope = 'app' + WHERE b.user_id = ? AND b.scope = 'package' AND b.binding_key IN (${placeholders}) AND (b.expires_at IS NULL OR b.expires_at > ?) ORDER BY e.name ASC`, ) - .bind(input.userId, ...input.appIds, now) + .bind(input.userId, ...input.packageIds, now) .all>() return (results ?? []).map(mapSecretMetadataRow) } -export async function deleteAppScopeSecretBuckets(input: { +export async function deletePackageScopeSecretBuckets(input: { db: D1Database userId: string - appId: string + packageId: string }) { const result = await input.db .prepare( `DELETE FROM secret_buckets - WHERE user_id = ? AND scope = 'app' AND binding_key = ?`, + WHERE user_id = ? AND scope = 'package' AND binding_key = ?`, ) - .bind(input.userId, input.appId) + .bind(input.userId, input.packageId) + .run() + return result.meta.changes ?? 0 +} + +export async function removePackageFromSecretApprovals(input: { + db: D1Database + userId: string + packageId: string +}) { + const result = await input.db + .prepare( + `UPDATE secret_entries AS e + SET allowed_packages = ( + SELECT json_group_array(value) + FROM json_each(e.allowed_packages) + WHERE value <> ? + ), + updated_at = CURRENT_TIMESTAMP + WHERE e.bucket_id IN ( + SELECT id + FROM secret_buckets + WHERE user_id = ? AND scope = 'user' + ) + AND json_valid(e.allowed_packages) + AND EXISTS ( + SELECT 1 + FROM json_each(e.allowed_packages) + WHERE value = ? + )`, + ) + .bind(input.packageId, input.userId, input.packageId) .run() return result.meta.changes ?? 0 } diff --git a/packages/worker/src/mcp/secrets/secret-bindings.ts b/packages/worker/src/mcp/secrets/secret-bindings.ts new file mode 100644 index 0000000000..0185f87d09 --- /dev/null +++ b/packages/worker/src/mcp/secrets/secret-bindings.ts @@ -0,0 +1,28 @@ +import { secretScopeValues, type SecretScope } from './types.ts' +import { type StorageContext } from '#mcp/storage.ts' + +export const defaultSecretLookupOrder = [ + ...secretScopeValues, +] as Array + +export function resolveSecretScopeOrder(storageContext: StorageContext | null) { + return defaultSecretLookupOrder.filter( + (scope) => getSecretBindingKey(scope, storageContext) != null, + ) +} + +export function getSecretBindingKey( + scope: SecretScope, + storageContext: StorageContext | null, +) { + if (scope === 'user') return '' + if (scope === 'package') { + const packageId = storageContext?.packageId?.trim() + return packageId || null + } + if (scope === 'session') { + const sessionId = storageContext?.sessionId?.trim() + return sessionId || null + } + return null +} diff --git a/packages/worker/src/mcp/secrets/service.node.test.ts b/packages/worker/src/mcp/secrets/service.node.test.ts index 0d749249b4..92d70db720 100644 --- a/packages/worker/src/mcp/secrets/service.node.test.ts +++ b/packages/worker/src/mcp/secrets/service.node.test.ts @@ -7,7 +7,7 @@ import { listSecrets, resolveSecret, saveSecret } from './service.ts' type SecretBucketRow = { id: string user_id: string - scope: 'session' | 'app' | 'user' + scope: 'session' | 'package' | 'user' binding_key: string expires_at: string | null created_at: string @@ -21,6 +21,7 @@ type SecretEntryRow = { encrypted_value: string allowed_hosts: string allowed_capabilities: string + allowed_packages: string created_at: string updated_at: string } @@ -112,7 +113,7 @@ function createSecretTestDb( async all() { if ( normalizedQuery.startsWith( - 'select ? as scope, ? as binding_key, name, description, allowed_hosts, allowed_capabilities, created_at, updated_at, ? as expires_at from secret_entries', + 'select ? as scope, ? as binding_key, name, description, allowed_hosts, allowed_capabilities, allowed_packages, created_at, updated_at, ? as expires_at from secret_entries', ) ) { const [scope, bindingKey, expiresAt, bucketId] = @@ -127,6 +128,7 @@ function createSecretTestDb( description: entry.description, allowed_hosts: entry.allowed_hosts, allowed_capabilities: entry.allowed_capabilities, + allowed_packages: entry.allowed_packages, created_at: entry.created_at, updated_at: entry.updated_at, expires_at: expiresAt, @@ -171,6 +173,7 @@ function createSecretTestDb( encryptedValue, allowedHosts, allowedCapabilities, + allowedPackages, createdAt, updatedAt, ] = params as Array @@ -183,6 +186,7 @@ function createSecretTestDb( encrypted_value: encryptedValue, allowed_hosts: allowedHosts, allowed_capabilities: allowedCapabilities, + allowed_packages: allowedPackages, created_at: existing?.created_at ?? createdAt, updated_at: updatedAt, }) @@ -218,6 +222,7 @@ function createSecretTestDb( encrypted_value: 'ciphertext', allowed_hosts: '[]', allowed_capabilities: '[]', + allowed_packages: '[]', created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z', }) @@ -273,8 +278,9 @@ test('resolveSecret returns the first scope hit in precedence order', async () = const secretName = 'shared-secret' const storageContext = { sessionId: 'session-abc', - appId: 'app-xyz', - storageId: 'app-xyz', + appId: null, + packageId: 'package-xyz', + storageId: 'package-xyz', } await saveSecret({ @@ -287,9 +293,9 @@ test('resolveSecret returns the first scope hit in precedence order', async () = await saveSecret({ env, userId, - scope: 'app', + scope: 'package', name: secretName, - value: 'app-value', + value: 'package-value', storageContext, }) await saveSecret({ @@ -330,8 +336,9 @@ test('resolveSecret ignores a corrupted lower-precedence entry when a higher sco const secretName = 'shared-secret' const storageContext = { sessionId: 'session-abc', - appId: 'app-xyz', - storageId: 'app-xyz', + appId: null, + packageId: 'package-xyz', + storageId: 'package-xyz', } await saveSecret({ diff --git a/packages/worker/src/mcp/secrets/service.ts b/packages/worker/src/mcp/secrets/service.ts index fea3e15add..ba8149fbb3 100644 --- a/packages/worker/src/mcp/secrets/service.ts +++ b/packages/worker/src/mcp/secrets/service.ts @@ -17,18 +17,19 @@ import { import { decryptSecretValue, encryptSecretValue } from './crypto.ts' import { assertSecretNameAllowed, isReservedSecretName } from './name-guards.ts' import { - getStorageBindingKey, - resolveStorageScopeOrder, -} from '#mcp/storage-bindings.ts' + getSecretBindingKey, + resolveSecretScopeOrder, +} from './secret-bindings.ts' import { type StorageContext } from '#mcp/storage.ts' import { - deleteAppScopeSecretBuckets, + deletePackageScopeSecretBuckets, deleteSecretEntry, getSecretBucket, getSecretEntry, - listAppScopeSecretMetadata, + listPackageScopeSecretMetadata, listSecretMetadataForBucket, listUserScopeSecretMetadata, + removePackageFromSecretApprovals, upsertSecretBucket, upsertSecretEntry, } from './repo.ts' @@ -170,7 +171,7 @@ export async function saveSecret( name, scope: input.scope, description, - appId: input.scope === 'app' ? bucket.binding_key : null, + packageId: input.scope === 'package' ? bucket.binding_key : null, allowedHosts: existingEntry ? parseAllowedHosts(existingEntry.allowed_hosts) : [], @@ -211,7 +212,7 @@ export async function listSecrets( name: row.name, scope: row.scope, description: row.description, - appId: row.scope === 'app' ? row.binding_key : null, + packageId: row.scope === 'package' ? row.binding_key : null, allowedHosts: parseAllowedHosts(row.allowed_hosts), allowedCapabilities: parseAllowedCapabilities(row.allowed_capabilities), allowedPackages: parseAllowedPackages(row.allowed_packages), @@ -228,7 +229,7 @@ export async function resolveSecret( assertSecretNameAllowed(input.name) const scopes = input.scope ? [input.scope] - : resolveStorageScopeOrder(input.storageContext ?? null) + : resolveSecretScopeOrder(input.storageContext ?? null) const scopeResults = await Promise.allSettled( scopes.map(async (scope) => { const bucket = await getExistingBucketForScope({ @@ -293,15 +294,27 @@ export async function deleteSecret(input: DeleteSecretInput) { }) } -export async function deleteAllAppScopedSecrets(input: { +export async function deleteAllPackageScopedSecrets(input: { env: Pick userId: string - appId: string + packageId: string }) { - return await deleteAppScopeSecretBuckets({ + return await deletePackageScopeSecretBuckets({ db: input.env.APP_DB, userId: input.userId, - appId: input.appId, + packageId: input.packageId, + }) +} + +export async function removeAllSecretApprovalsForPackage(input: { + env: Pick + userId: string + packageId: string +}) { + return await removePackageFromSecretApprovals({ + db: input.env.APP_DB, + userId: input.userId, + packageId: input.packageId, }) } @@ -363,7 +376,7 @@ export async function updateSecret( name, scope: input.scope, description: nextDescription, - appId: input.scope === 'app' ? bucket.binding_key : null, + packageId: input.scope === 'package' ? bucket.binding_key : null, allowedHosts: parseAllowedHosts(existingEntry.allowed_hosts), allowedCapabilities: parseAllowedCapabilities( existingEntry.allowed_capabilities, @@ -389,32 +402,32 @@ export async function listUserSecretsForSearch(input: { name: row.name, scope: row.scope, description: row.description, - appId: null, + packageId: null, updatedAt: row.updated_at, })) } -export async function listAppSecretsByAppIds(input: { +export async function listPackageSecretsByPackageIds(input: { env: Pick userId: string - appIds: Array + packageIds: Array }) { - const rows = await listAppScopeSecretMetadata({ + const rows = await listPackageScopeSecretMetadata({ db: input.env.APP_DB, userId: input.userId, - appIds: input.appIds, + packageIds: input.packageIds, }) const grouped = new Map>() for (const row of rows) { if (isReservedSecretName(row.name)) continue - const appId = row.binding_key - const current = grouped.get(appId) ?? [] + const packageId = row.binding_key + const current = grouped.get(packageId) ?? [] current.push( toSecretMetadata({ name: row.name, scope: row.scope, description: row.description, - appId, + packageId, allowedHosts: parseAllowedHosts(row.allowed_hosts), allowedCapabilities: parseAllowedCapabilities(row.allowed_capabilities), allowedPackages: parseAllowedPackages(row.allowed_packages), @@ -423,7 +436,7 @@ export async function listAppSecretsByAppIds(input: { expiresAt: row.expires_at, }), ) - grouped.set(appId, current) + grouped.set(packageId, current) } return grouped } @@ -436,7 +449,7 @@ async function getAccessibleBuckets(input: { }) { const scopes = input.scope ? [input.scope] - : resolveStorageScopeOrder(input.storageContext) + : resolveSecretScopeOrder(input.storageContext) const buckets = await Promise.all( scopes.map((scope) => getExistingBucketForScope({ @@ -458,7 +471,7 @@ async function getExistingBucketForScope(input: { scope: SecretScope storageContext: StorageContext | null }) { - const bindingKey = getStorageBindingKey(input.scope, input.storageContext) + const bindingKey = getSecretBindingKey(input.scope, input.storageContext) if (bindingKey == null) return null return getSecretBucket({ db: input.db, @@ -475,7 +488,7 @@ async function getOrCreateSecretBucket(input: { storageContext: StorageContext | null sessionExpiresAt: string | null }) { - const bindingKey = getStorageBindingKey(input.scope, input.storageContext) + const bindingKey = getSecretBindingKey(input.scope, input.storageContext) if (bindingKey == null) { throw new Error( `Secret scope "${input.scope}" is unavailable in this context.`, @@ -530,7 +543,7 @@ function toSecretMetadata(input: { name: string scope: SecretScope description: string - appId: string | null + packageId: string | null allowedHosts: Array allowedCapabilities: Array allowedPackages: Array @@ -542,7 +555,7 @@ function toSecretMetadata(input: { name: input.name, scope: input.scope, description: input.description, - appId: input.appId, + packageId: input.packageId, allowedHosts: normalizeAllowedHosts(input.allowedHosts), allowedCapabilities: normalizeAllowedCapabilities( input.allowedCapabilities, @@ -600,7 +613,7 @@ export async function setSecretAllowedHosts(input: { name, scope: input.scope, description: existingEntry.description, - appId: input.scope === 'app' ? bucket.binding_key : null, + packageId: input.scope === 'package' ? bucket.binding_key : null, allowedHosts: input.allowedHosts, allowedCapabilities: parseAllowedCapabilities( existingEntry.allowed_capabilities, @@ -657,7 +670,7 @@ export async function setSecretAllowedCapabilities(input: { name, scope: input.scope, description: existingEntry.description, - appId: input.scope === 'app' ? bucket.binding_key : null, + packageId: input.scope === 'package' ? bucket.binding_key : null, allowedHosts: parseAllowedHosts(existingEntry.allowed_hosts), allowedCapabilities: input.allowedCapabilities, allowedPackages: parseAllowedPackages(existingEntry.allowed_packages), @@ -675,6 +688,9 @@ export async function setSecretAllowedPackages(input: { allowedPackages: Array storageContext?: StorageContext | null }) { + if (input.scope !== 'user' && input.allowedPackages.length > 0) { + throw new Error('Package approvals are only supported for user secrets.') + } const name = input.name.trim() if (!name) { throw new Error('Secret name is required.') @@ -710,7 +726,7 @@ export async function setSecretAllowedPackages(input: { name, scope: input.scope, description: existingEntry.description, - appId: input.scope === 'app' ? bucket.binding_key : null, + packageId: input.scope === 'package' ? bucket.binding_key : null, allowedHosts: parseAllowedHosts(existingEntry.allowed_hosts), allowedCapabilities: parseAllowedCapabilities( existingEntry.allowed_capabilities, diff --git a/packages/worker/src/mcp/secrets/types.ts b/packages/worker/src/mcp/secrets/types.ts index 513c1f7c9e..8d31784949 100644 --- a/packages/worker/src/mcp/secrets/types.ts +++ b/packages/worker/src/mcp/secrets/types.ts @@ -1,12 +1,8 @@ -import { - storageScopeValues, - type StorageContext, - type StorageScope, -} from '#mcp/storage.ts' +import { type StorageContext } from '#mcp/storage.ts' -export const secretScopeValues = storageScopeValues +export const secretScopeValues = ['session', 'package', 'user'] as const -export type SecretScope = StorageScope +export type SecretScope = (typeof secretScopeValues)[number] export type SecretBucketRow = { id: string @@ -34,7 +30,7 @@ export type SecretMetadata = { name: string scope: SecretScope description: string - appId: string | null + packageId: string | null allowedHosts: Array allowedCapabilities: Array allowedPackages: Array @@ -45,7 +41,7 @@ export type SecretMetadata = { export type SecretSearchRow = Pick< SecretMetadata, - 'name' | 'scope' | 'description' | 'appId' | 'updatedAt' + 'name' | 'scope' | 'description' | 'packageId' | 'updatedAt' > export type { StorageContext } diff --git a/packages/worker/src/mcp/storage.ts b/packages/worker/src/mcp/storage.ts index 7f6cc06d50..0dc14aec66 100644 --- a/packages/worker/src/mcp/storage.ts +++ b/packages/worker/src/mcp/storage.ts @@ -5,5 +5,6 @@ export type StorageScope = (typeof storageScopeValues)[number] export type StorageContext = { sessionId: string | null appId: string | null + packageId?: string | null storageId?: string | null } diff --git a/packages/worker/src/mcp/tools/execute.ts b/packages/worker/src/mcp/tools/execute.ts index 300859e48a..9c79629ff2 100644 --- a/packages/worker/src/mcp/tools/execute.ts +++ b/packages/worker/src/mcp/tools/execute.ts @@ -167,6 +167,7 @@ export async function registerExecuteTool(agent: McpRegistrationAgent) { storageContext: { sessionId: baseCallerContext.storageContext?.sessionId ?? null, appId: baseCallerContext.storageContext?.appId ?? null, + packageId: baseCallerContext.storageContext?.packageId ?? null, storageId: resolvedStorageId ?? baseCallerContext.storageContext?.storageId ?? diff --git a/packages/worker/src/package-invocations/service.ts b/packages/worker/src/package-invocations/service.ts index c9cce76f55..a8ab7c078b 100644 --- a/packages/worker/src/package-invocations/service.ts +++ b/packages/worker/src/package-invocations/service.ts @@ -1046,6 +1046,7 @@ async function invokeSavedPackageModule(input: { storageContext: { sessionId: null, appId: input.savedPackage.id, + packageId: input.savedPackage.id, storageId: buildPackageInvocationStorageId(input.savedPackage.id), }, remoteConnectors: input.actor.remoteConnectors ?? null, diff --git a/packages/worker/src/package-registry/service.node.test.ts b/packages/worker/src/package-registry/service.node.test.ts index f27aa998f5..511877f4ab 100644 --- a/packages/worker/src/package-registry/service.node.test.ts +++ b/packages/worker/src/package-registry/service.node.test.ts @@ -39,6 +39,8 @@ const mockModule = vi.hoisted(() => ({ updateSavedPackage: vi.fn(), upsertSavedPackageVector: vi.fn(), cleanupArtifactReposForPackage: vi.fn(), + deleteAllPackageScopedSecrets: vi.fn(), + removeAllSecretApprovalsForPackage: vi.fn(), })) vi.mock('./manifest.ts', () => ({ @@ -111,6 +113,13 @@ vi.mock('#worker/jobs/manager-client.ts', () => ({ mockModule.syncJobManagerAlarm(...args), })) +vi.mock('#mcp/secrets/service.ts', () => ({ + deleteAllPackageScopedSecrets: (...args: Array) => + mockModule.deleteAllPackageScopedSecrets(...args), + removeAllSecretApprovalsForPackage: (...args: Array) => + mockModule.removeAllSecretApprovalsForPackage(...args), +})) + vi.mock('#worker/jobs/service.ts', () => ({ syncPackageJobsForPackage: (...args: Array) => mockModule.syncPackageJobsForPackage(...args), @@ -489,6 +498,16 @@ test('deleteSavedPackageProjection resyncs the job manager after removing packag }) expect(mockModule.deleteJobRow).toHaveBeenCalledTimes(1) expect(mockModule.deleteJobRow).toHaveBeenCalledWith({}, 'user-1', 'job-1') + expect(mockModule.deleteAllPackageScopedSecrets).toHaveBeenCalledWith({ + env, + userId: 'user-1', + packageId: 'package-1', + }) + expect(mockModule.removeAllSecretApprovalsForPackage).toHaveBeenCalledWith({ + env, + userId: 'user-1', + packageId: 'package-1', + }) expect(mockModule.deleteSavedPackage).toHaveBeenCalledWith( {}, { diff --git a/packages/worker/src/package-registry/service.ts b/packages/worker/src/package-registry/service.ts index 6ea4313894..0de89486da 100644 --- a/packages/worker/src/package-registry/service.ts +++ b/packages/worker/src/package-registry/service.ts @@ -40,6 +40,10 @@ import { assertWithinStorageBytesEntitlement, estimateEntitlementStorageEntryByteDelta, } from '#worker/entitlements/service.ts' +import { + deleteAllPackageScopedSecrets, + removeAllSecretApprovalsForPackage, +} from '#mcp/secrets/service.ts' function logPackageRetrieverProjectionError(input: { action: 'refresh' | 'delete' @@ -374,6 +378,16 @@ export async function deleteSavedPackageProjection(input: { for (const row of packageRows) { await deleteJobRow(input.env.APP_DB, input.userId, row.id) } + await deleteAllPackageScopedSecrets({ + env: input.env, + userId: input.userId, + packageId: input.packageId, + }) + await removeAllSecretApprovalsForPackage({ + env: input.env, + userId: input.userId, + packageId: input.packageId, + }) } await deleteSavedPackage(input.env.APP_DB, { userId: input.userId, diff --git a/packages/worker/src/package-registry/types.ts b/packages/worker/src/package-registry/types.ts index b521e0fcd2..0532046f19 100644 --- a/packages/worker/src/package-registry/types.ts +++ b/packages/worker/src/package-registry/types.ts @@ -37,7 +37,7 @@ export type PackageAppDefinition = z.infer export const packageSecretMountDefinitionSchema = z.object({ name: z.string().min(1), - scope: z.enum(['user', 'app', 'session']).optional(), + scope: z.enum(['user', 'package', 'session']).optional(), }) export type PackageSecretMountDefinition = z.infer< diff --git a/packages/worker/src/package-retrievers/service.ts b/packages/worker/src/package-retrievers/service.ts index 32ce837b83..34ec1eb518 100644 --- a/packages/worker/src/package-retrievers/service.ts +++ b/packages/worker/src/package-retrievers/service.ts @@ -132,6 +132,7 @@ async function invokeRetriever(input: { storageContext: { sessionId: null, appId: input.entry.packageId, + packageId: input.entry.packageId, storageId: buildPackageRetrieverStorageId(input.entry.packageId), }, repoContext: createRepoContext(source), diff --git a/packages/worker/src/package-runtime/package-app.ts b/packages/worker/src/package-runtime/package-app.ts index f9114a4a68..7eeb25c5c0 100644 --- a/packages/worker/src/package-runtime/package-app.ts +++ b/packages/worker/src/package-runtime/package-app.ts @@ -704,6 +704,7 @@ export class PackageAppRuntimeBridge extends WorkerEntrypoint< storageContext: { sessionId: null, appId: this.ctx.props.packageId, + packageId: this.ctx.props.packageId, storageId, }, }) @@ -1495,6 +1496,7 @@ async function buildPackageAppWorkerOptionsUncached(input: { storageContext: { sessionId: null, appId: input.savedPackage.id, + packageId: input.savedPackage.id, storageId: input.savedPackage.id, }, }, @@ -1586,6 +1588,7 @@ export async function createPackageAppCallerContext(input: { storageContext: { sessionId: null, appId: input.packageId, + packageId: input.packageId, storageId: input.packageId, }, }) diff --git a/packages/worker/src/package-runtime/package-service.ts b/packages/worker/src/package-runtime/package-service.ts index 13428fdc0d..d875624040 100644 --- a/packages/worker/src/package-runtime/package-service.ts +++ b/packages/worker/src/package-runtime/package-service.ts @@ -637,6 +637,7 @@ class PackageServiceInstanceBase extends DurableObject { storageContext: { sessionId: null, appId: binding.packageId, + packageId: binding.packageId, storageId: runtime.storageId, }, }) diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index ce3196521b..d556c78505 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -83,9 +83,14 @@ export type DynamicCallableWorkflowPayload = params?: PackageWorkflowParams } | { - version: 2 + version: 3 sourceType: 'inline' userId: string + packageContext: { + packageId: string + kodyId: string + sourceId?: string | null + } | null workflowName: string code: string idempotencyKey: string @@ -269,6 +274,11 @@ function normalizeWorkflowIdempotencyKey(idempotencyKey: string | undefined) { function createInlineWorkflowPayload(input: { userId: string + packageContext?: { + packageId: string + kodyId: string + sourceId?: string | null + } | null workflowName?: string code: string idempotencyKey?: string @@ -280,9 +290,10 @@ function createInlineWorkflowPayload(input: { const idempotencyKey = normalizeWorkflowIdempotencyKey(input.idempotencyKey) const params = normalizePackageWorkflowParams(input.params) return { - version: 2, + version: 3, sourceType: 'inline', userId: normalizeNonEmptyString(input.userId, 'userId'), + packageContext: input.packageContext ?? null, workflowName: normalizeOptionalWorkflowName( input.workflowName, 'inline-code', @@ -661,6 +672,7 @@ async function resolveWorkflowPayload(input: { if (code) { return createInlineWorkflowPayload({ userId: input.userId, + packageContext: input.packageContext, workflowName: input.body.workflowName, code, idempotencyKey: input.body.idempotencyKey, @@ -1080,6 +1092,11 @@ export class DynamicCallableWorkflowBase extends WorkflowEntrypoint< private async invokeInlineWorkflowCode( payload: Extract, ): Promise { + if (payload.packageContext === undefined) { + throw new Error( + 'Inline workflow payload is missing required package security context.', + ) + } const [{ runModuleWithRegistry }, { createMcpCallerContext }] = await Promise.all([ import('#mcp/run-kody-registry.ts'), @@ -1101,10 +1118,21 @@ export class DynamicCallableWorkflowBase extends WorkflowEntrypoint< username: undefined, displayName: `workflow:${payload.workflowName}`, }, + storageContext: payload.packageContext + ? { + sessionId: null, + appId: payload.packageContext.packageId, + packageId: payload.packageContext.packageId, + storageId: null, + } + : null, remoteConnectors, }), payload.code, payload.params, + { + packageContext: payload.packageContext, + }, ) if (result.error) { throw new Error(result.error) diff --git a/packages/worker/src/package-runtime/realtime-session.ts b/packages/worker/src/package-runtime/realtime-session.ts index 0b0d6890c4..6508136fa3 100644 --- a/packages/worker/src/package-runtime/realtime-session.ts +++ b/packages/worker/src/package-runtime/realtime-session.ts @@ -320,6 +320,7 @@ async function resolvePackageAppWorker(input: { storageContext: { sessionId: null, appId: input.binding.packageId, + packageId: input.binding.packageId, storageId: input.binding.packageId, }, repoContext: null, diff --git a/packages/worker/src/repo/checks.ts b/packages/worker/src/repo/checks.ts index aac7b87ebd..6bcec6e67b 100644 --- a/packages/worker/src/repo/checks.ts +++ b/packages/worker/src/repo/checks.ts @@ -310,7 +310,7 @@ type KodyStorageRuntime = { delete(key: string): Promise; clear(): Promise; }; -type KodySecretScope = 'user' | 'app' | 'session'; +type KodySecretScope = 'user' | 'package' | 'session'; type KodySecretHeadersRuntime = { basic(input: { usernameSecret: string; From 6311e32c21b8a42f1ad3074156a6efa993081b41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 20:41:07 +0000 Subject: [PATCH 2/9] Align package context test contracts --- packages/worker/src/jobs/service.node.test.ts | 2 +- .../src/mcp/capabilities/meta/search-and-execute.node.test.ts | 1 + packages/worker/src/mcp/tools/execute.node.test.ts | 1 + packages/worker/src/package-invocations/service.node.test.ts | 1 + .../worker/src/package-runtime/package-workflows.node.test.ts | 4 ++++ 5 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/worker/src/jobs/service.node.test.ts b/packages/worker/src/jobs/service.node.test.ts index 2377d0ee18..ef15e64178 100644 --- a/packages/worker/src/jobs/service.node.test.ts +++ b/packages/worker/src/jobs/service.node.test.ts @@ -2099,7 +2099,7 @@ test('executeJobOnce preserves kody secret and value semantics', async () => { await saveSecret({ env, userId: callerContext.user.userId, - scope: 'app', + scope: 'user', name: 'apiToken', value: 'very-secret-token', storageContext: callerContext.storageContext, diff --git a/packages/worker/src/mcp/capabilities/meta/search-and-execute.node.test.ts b/packages/worker/src/mcp/capabilities/meta/search-and-execute.node.test.ts index 48e1af6a36..5ec0df1d6d 100644 --- a/packages/worker/src/mcp/capabilities/meta/search-and-execute.node.test.ts +++ b/packages/worker/src/mcp/capabilities/meta/search-and-execute.node.test.ts @@ -52,6 +52,7 @@ test('execute capability runs modules through the shared execute runtime', async storageContext: { sessionId: null, appId: null, + packageId: null, storageId: 'package:agent-turns', }, }), diff --git a/packages/worker/src/mcp/tools/execute.node.test.ts b/packages/worker/src/mcp/tools/execute.node.test.ts index 7b37423602..00b88229f4 100644 --- a/packages/worker/src/mcp/tools/execute.node.test.ts +++ b/packages/worker/src/mcp/tools/execute.node.test.ts @@ -210,6 +210,7 @@ test('execute tool serializes successes and errors, binds storage, passes packag storageContext: { sessionId: null, appId: null, + packageId: null, storageId: 'job:lights-off', }, }), diff --git a/packages/worker/src/package-invocations/service.node.test.ts b/packages/worker/src/package-invocations/service.node.test.ts index 6d646d023b..d6a6c9ae47 100644 --- a/packages/worker/src/package-invocations/service.node.test.ts +++ b/packages/worker/src/package-invocations/service.node.test.ts @@ -1895,6 +1895,7 @@ test('invokePackageSubscription uses the normal capability registry with package storageContext: { sessionId: null, appId: 'pkg-1', + packageId: 'pkg-1', storageId: 'package:pkg-1', }, repoContext: expect.objectContaining({ diff --git a/packages/worker/src/package-runtime/package-workflows.node.test.ts b/packages/worker/src/package-runtime/package-workflows.node.test.ts index 5b1159a51d..45c117cfb9 100644 --- a/packages/worker/src/package-runtime/package-workflows.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows.node.test.ts @@ -263,8 +263,10 @@ test('createDynamicCallableWorkflow queues inline code without package context a expect(binding.create).toHaveBeenCalledWith({ id: created.id, params: expect.objectContaining({ + version: 3, sourceType: 'inline', userId: 'user-1', + packageContext: null, code: 'export default async function main(p) { return { ok: true, p } }', params: { greeting: 'hello' }, }), @@ -356,6 +358,7 @@ test('DynamicCallableWorkflowBase executes queued inline code and records comple }), 'export default async function main(p){ return { ok: true, p }; }', { greeting: 'hello' }, + { packageContext: null }, ) expect(db.workflowRuns.get(created.id)).toMatchObject({ status: 'complete', @@ -419,6 +422,7 @@ test('DynamicCallableWorkflowBase restores attached remote connectors for inline expect.objectContaining({ remoteConnectors }), expect.any(String), undefined, + { packageContext: null }, ) const packageBinding = createStatefulWorkflowBinding() From d4cf66e0b5867e5383379318d3e0ad6862422bb0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:16:10 +0000 Subject: [PATCH 3/9] Clarify package secret access copy --- packages/worker/client/routes/account-secrets.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx index c54f2ef077..a4e864a313 100644 --- a/packages/worker/client/routes/account-secrets.tsx +++ b/packages/worker/client/routes/account-secrets.tsx @@ -1650,8 +1650,8 @@ export function AccountSecretsRoute(handle: Handle) {
Allowed packages

- Only listed package ids may read this secret via package - secret mounts. + Only listed package ids may access this user secret from + package runtimes.

From e989bc16ec1fa03a129644d2cbdc26c0c79c3e41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:30:35 +0000 Subject: [PATCH 4/9] Preserve package context in inline workflows --- .../package-workflows.node.test.ts | 72 +++++++++++++++++++ .../src/package-runtime/package-workflows.ts | 39 ++++++++++ 2 files changed, 111 insertions(+) diff --git a/packages/worker/src/package-runtime/package-workflows.node.test.ts b/packages/worker/src/package-runtime/package-workflows.node.test.ts index 45c117cfb9..99c7316791 100644 --- a/packages/worker/src/package-runtime/package-workflows.node.test.ts +++ b/packages/worker/src/package-runtime/package-workflows.node.test.ts @@ -369,6 +369,78 @@ test('DynamicCallableWorkflowBase executes queued inline code and records comple } }) +test('package-created inline workflows retain package secret authorization context', async () => { + const binding = createStatefulWorkflowBinding() + const env = { + APP_DB: createWorkflowRunsDatabase(), + DYNAMIC_CALLABLE_WORKFLOWS: binding.workflow, + APP_BASE_URL: 'https://app.example.com', + } as Env + const packageContext = { + packageId: 'package-1', + kodyId: 'example-package', + sourceId: 'source-1', + } + const created = await createDynamicCallableWorkflow({ + env, + userId: 'user-1', + packageContext, + body: { + code: 'export default async function main(){ return { ok: true }; }', + idempotencyKey: 'package-inline-security-context', + }, + }) + const queued = binding.instances.get(created.id) + if (!queued?.params) throw new Error('Expected queued workflow payload.') + invocationMocks.runModuleWithRegistry.mockReset() + invocationMocks.runModuleWithRegistry.mockResolvedValueOnce({ + result: { ok: true }, + logs: [], + }) + const stepDo = vi.fn( + async (_name: string, _config: unknown, callback: () => unknown) => + await callback(), + ) + const legacyPayload = { + ...(queued.params as Record), + } + delete legacyPayload['packageContext'] + await expect( + new DynamicCallableWorkflowBase({} as ExecutionContext, env).run( + { + payload: legacyPayload as never, + timestamp: new Date(), + instanceId: 'legacy-inline-workflow', + }, + { sleepUntil: vi.fn(), do: stepDo } as unknown as WorkflowStep, + ), + ).rejects.toThrow('packageContext must be an object or null') + + await new DynamicCallableWorkflowBase({} as ExecutionContext, env).run( + { + payload: queued.params as never, + timestamp: new Date(), + instanceId: created.id, + }, + { sleepUntil: vi.fn(), do: stepDo } as unknown as WorkflowStep, + ) + + expect(invocationMocks.runModuleWithRegistry).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + storageContext: { + sessionId: null, + appId: 'package-1', + packageId: 'package-1', + storageId: null, + }, + }), + expect.any(String), + undefined, + { packageContext }, + ) +}) + test('DynamicCallableWorkflowBase restores attached remote connectors for inline code and package exports', async () => { const remoteConnectors = [{ instanceId: 'home' }] const stepDo = vi.fn( diff --git a/packages/worker/src/package-runtime/package-workflows.ts b/packages/worker/src/package-runtime/package-workflows.ts index d556c78505..2fc5a26ba1 100644 --- a/packages/worker/src/package-runtime/package-workflows.ts +++ b/packages/worker/src/package-runtime/package-workflows.ts @@ -353,8 +353,47 @@ function validateDynamicCallableWorkflowPayload( record['params'] as PackageWorkflowParams | null | undefined, ) if (sourceType === 'inline') { + if (record['version'] !== 3) { + throw new Error('Inline workflow payload version must be 3.') + } + const rawPackageContext = record['packageContext'] + if ( + rawPackageContext !== null && + (!rawPackageContext || + typeof rawPackageContext !== 'object' || + Array.isArray(rawPackageContext)) + ) { + throw new Error( + 'Inline workflow payload packageContext must be an object or null.', + ) + } + const packageContext = rawPackageContext + ? { + packageId: normalizeNonEmptyString( + String( + (rawPackageContext as Record)['packageId'] ?? '', + ), + 'packageContext.packageId', + ), + kodyId: normalizeNonEmptyString( + String( + (rawPackageContext as Record)['kodyId'] ?? '', + ), + 'packageContext.kodyId', + ), + sourceId: + typeof (rawPackageContext as Record)[ + 'sourceId' + ] === 'string' + ? String( + (rawPackageContext as Record)['sourceId'], + ) + : null, + } + : null return createInlineWorkflowPayload({ userId: String(record['userId'] ?? ''), + packageContext, workflowName: typeof record['workflowName'] === 'string' ? record['workflowName'] From a5b868958f7af6fa10e19eb7d79a029b07a6ffbc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:37:39 +0000 Subject: [PATCH 5/9] Preserve package identity in OpenAPI calls --- .../capabilities/openapi-provider/index.ts | 6 ++ .../operation-request.node.test.ts | 69 +++++++++++++++++++ .../openapi-provider/operation-request.ts | 61 ++++++++++++++-- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/index.ts b/packages/worker/src/mcp/capabilities/openapi-provider/index.ts index 7fbaedfa67..c044de6c1d 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/index.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/index.ts @@ -201,6 +201,12 @@ function createCapabilityFromOperation(input: { return executeOpenApiOperationRequest({ env: ctx.env, userId, + storageContext: { + sessionId: ctx.callerContext.storageContext?.sessionId ?? null, + appId: ctx.callerContext.storageContext?.appId ?? null, + packageId: ctx.callerContext.storageContext?.packageId ?? null, + storageId: ctx.callerContext.storageContext?.storageId ?? null, + }, binding, operation, args: (args ?? {}) as OpenApiOperationRequestArgs, diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts index 9efd7491af..93a70ae420 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts @@ -1,5 +1,6 @@ import { expect, test, vi } from 'vitest' import * as secretService from '#mcp/secrets/service.ts' +import * as packageRepo from '#worker/package-registry/repo.ts' import * as usageModule from '#worker/usage/record-usage.ts' import { type OpenApiBinding } from '#worker/openapi/binding-shared.ts' import { executeOpenApiOperationRequest } from './operation-request.ts' @@ -143,6 +144,7 @@ test('builds URL with path params and query serialization', async () => { const result = await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + storageContext: null, binding: bindingBase, operation: getWidget, args: { @@ -177,6 +179,7 @@ test('preserves apiBaseUrl path segments when building operation URLs', async () const result = await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + storageContext: null, binding: { ...bindingBase, apiBaseUrl: 'https://api.widgets.example/v1', @@ -198,6 +201,7 @@ test('rejects absolute and protocol-relative operation paths', async () => { executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + storageContext: null, binding: { ...bindingBase, apiBaseUrl: 'https://api.widgets.example', @@ -240,6 +244,7 @@ test('injects auth headers and overrides caller attempts', async () => { await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + storageContext: null, binding: { ...bindingBase, auth: { kind: 'bearerSecret', secretName: 'apiToken' }, @@ -260,6 +265,7 @@ test('injects auth headers and overrides caller attempts', async () => { await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + storageContext: null, binding: { ...bindingBase, auth: { @@ -280,6 +286,7 @@ test('injects auth headers and overrides caller attempts', async () => { await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + storageContext: null, binding: { ...bindingBase, auth: { @@ -300,6 +307,65 @@ test('injects auth headers and overrides caller attempts', async () => { } }) +test('requires package approval before OpenAPI resolves a user secret', async () => { + const usageSpy = vi + .spyOn(usageModule, 'recordUsage') + .mockResolvedValue(undefined) + const resolveSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValue({ + found: true, + value: 'resolved-secret', + scope: 'user', + allowedHosts: ['api.widgets.example'], + allowedCapabilities: [], + allowedPackages: [], + }) + const packageSpy = vi + .spyOn(packageRepo, 'getSavedPackageById') + .mockResolvedValue({ + id: 'package-1', + userId: 'user-1', + name: '@user/example-package', + kodyId: 'example-package', + description: '', + tags: [], + searchText: null, + sourceId: 'source-1', + hasApp: false, + createdAt: '2026-07-09T00:00:00.000Z', + updatedAt: '2026-07-09T00:00:00.000Z', + }) + const fetchStub = vi.fn() + + try { + await expect( + executeOpenApiOperationRequest({ + env: createEnv(), + userId: 'user-1', + storageContext: { + sessionId: null, + appId: 'package-1', + packageId: 'package-1', + storageId: 'package-1', + }, + binding: { + ...bindingBase, + auth: { kind: 'bearerSecret', secretName: 'apiToken' }, + }, + operation: getWidget, + args: { params: { widgetId: '1' } }, + globalFetch: fetchStub as unknown as typeof fetch, + }), + ).rejects.toThrow('Secrets require package approval') + expect(fetchStub).not.toHaveBeenCalled() + } finally { + packageSpy.mockRestore() + resolveSpy.mockRestore() + usageSpy.mockRestore() + } +}) + test('returns truncated flag for oversized non-json bodies', async () => { const usageSpy = vi .spyOn(usageModule, 'recordUsage') @@ -309,6 +375,7 @@ test('returns truncated flag for oversized non-json bodies', async () => { const result = await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + storageContext: null, binding: bindingBase, operation: getWidget, args: { params: { widgetId: '1' } }, @@ -407,6 +474,7 @@ test('integration auth 401 triggers host-side refresh retry', async () => { const result = await executeOpenApiOperationRequest({ env: createEnv(entries), userId: 'user-1', + storageContext: null, binding: { ...bindingBase, apiBaseUrl: 'https://api.spotify.com/v1', @@ -454,6 +522,7 @@ test('passes application/json-seq request bodies through as strings', async () = const result = await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + storageContext: null, binding: bindingBase, operation: { ...createWidget, diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts index 08defd9ec7..752f56664b 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts @@ -10,7 +10,9 @@ import { buildBasicAuthSecretPlaceholder, buildSecretPlaceholder, } from '#mcp/secrets/placeholders.ts' -import { saveSecret } from '#mcp/secrets/service.ts' +import { assertPackageCanAccessResolvedSecret } from '#mcp/secrets/package-access.ts' +import { resolveSecret, saveSecret } from '#mcp/secrets/service.ts' +import { type StorageContext } from '#mcp/storage.ts' import { getValue } from '#mcp/values/service.ts' import { normalizeApiBaseUrl, @@ -38,6 +40,7 @@ export type OpenApiOperationRequestResult = { export async function executeOpenApiOperationRequest(input: { env: Env userId: string + storageContext: StorageContext | null binding: OpenApiBinding operation: OpenApiBindingOperation args: OpenApiOperationRequestArgs @@ -88,7 +91,7 @@ export async function executeOpenApiOperationRequest(input: { props: { baseUrl: input.binding.apiBaseUrl, userId: input.userId, - storageContext: null, + storageContext: input.storageContext, }, request, globalFetch: input.globalFetch, @@ -104,6 +107,7 @@ export async function executeOpenApiOperationRequest(input: { userId: input.userId, provider: input.binding.auth.provider, integration, + storageContext: input.storageContext, globalFetch: input.globalFetch, }) if (refreshed.ok) { @@ -113,7 +117,7 @@ export async function executeOpenApiOperationRequest(input: { props: { baseUrl: input.binding.apiBaseUrl, userId: input.userId, - storageContext: null, + storageContext: input.storageContext, }, request: new Request(url.toString(), requestInit), globalFetch: input.globalFetch, @@ -348,6 +352,7 @@ async function tryRefreshIntegrationAccessToken(input: { userId: string provider: string integration: IntegrationConfig + storageContext: StorageContext | null globalFetch?: typeof fetch }): Promise<{ ok: boolean; guidance?: string }> { const refreshTokenSecretName = @@ -406,7 +411,7 @@ async function tryRefreshIntegrationAccessToken(input: { props: { baseUrl: input.integration.tokenUrl, userId: input.userId, - storageContext: null, + storageContext: input.storageContext, }, request: new Request(input.integration.tokenUrl, { method: 'POST', @@ -443,6 +448,13 @@ async function tryRefreshIntegrationAccessToken(input: { } } + await assertPackageCanUpdateUserSecret({ + env: input.env, + userId: input.userId, + baseUrl: input.integration.tokenUrl, + storageContext: input.storageContext, + secretName: input.integration.accessTokenSecretName, + }) await saveSecret({ env: input.env, userId: input.userId, @@ -450,12 +462,19 @@ async function tryRefreshIntegrationAccessToken(input: { value: payload.access_token, scope: 'user', description: `Access token for integration ${input.provider}`, - storageContext: null, + storageContext: input.storageContext, }) if ( typeof payload.refresh_token === 'string' && payload.refresh_token.length > 0 ) { + await assertPackageCanUpdateUserSecret({ + env: input.env, + userId: input.userId, + baseUrl: input.integration.tokenUrl, + storageContext: input.storageContext, + secretName: refreshTokenSecretName, + }) await saveSecret({ env: input.env, userId: input.userId, @@ -463,12 +482,42 @@ async function tryRefreshIntegrationAccessToken(input: { value: payload.refresh_token, scope: 'user', description: `Refresh token for integration ${input.provider}`, - storageContext: null, + storageContext: input.storageContext, }) } return { ok: true } } +async function assertPackageCanUpdateUserSecret(input: { + env: Env + userId: string + baseUrl: string + storageContext: StorageContext | null + secretName: string +}) { + if (!input.storageContext?.packageId) return + const resolved = await resolveSecret({ + env: input.env, + userId: input.userId, + name: input.secretName, + scope: 'user', + storageContext: input.storageContext, + }) + if (!resolved.found) { + throw new Error( + `Package runtime cannot create missing user secret "${input.secretName}" during OpenAPI token refresh.`, + ) + } + await assertPackageCanAccessResolvedSecret({ + env: input.env, + baseUrl: input.baseUrl, + userId: input.userId, + storageContext: input.storageContext, + secretName: input.secretName, + resolved, + }) +} + async function readClientIdValue(input: { env: Pick userId: string From 9ac1e9880e8d5809e110e558767735dd2ca1f192 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:40:11 +0000 Subject: [PATCH 6/9] Match OpenAPI package denial contract --- .../openapi-provider/operation-request.node.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts index 93a70ae420..d345ec8e75 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts @@ -357,7 +357,7 @@ test('requires package approval before OpenAPI resolves a user secret', async () args: { params: { widgetId: '1' } }, globalFetch: fetchStub as unknown as typeof fetch, }), - ).rejects.toThrow('Secrets require package approval') + ).rejects.toThrow('is not allowed for package "example-package"') expect(fetchStub).not.toHaveBeenCalled() } finally { packageSpy.mockRestore() From 53ea3f95122c3a0a30a80194a8d6bb3d211da582 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 21:48:07 +0000 Subject: [PATCH 7/9] Backfill package identity for ad hoc jobs --- .../migrations/0057-package-scoped-secrets.sql | 13 +++++++++---- .../secrets/package-scope-migration.node.test.ts | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/worker/migrations/0057-package-scoped-secrets.sql b/packages/worker/migrations/0057-package-scoped-secrets.sql index 5672e6aad8..95659e4422 100644 --- a/packages/worker/migrations/0057-package-scoped-secrets.sql +++ b/packages/worker/migrations/0057-package-scoped-secrets.sql @@ -97,15 +97,20 @@ SET caller_context_json = json_set( SELECT p.id FROM saved_packages AS p WHERE p.user_id = jobs.user_id - AND p.source_id = jobs.source_id + AND p.id = json_extract( + jobs.caller_context_json, + '$.storageContext.appId' + ) LIMIT 1 ) ) -WHERE source_id IS NOT NULL - AND json_valid(caller_context_json) +WHERE json_valid(caller_context_json) AND EXISTS ( SELECT 1 FROM saved_packages AS p WHERE p.user_id = jobs.user_id - AND p.source_id = jobs.source_id + AND p.id = json_extract( + jobs.caller_context_json, + '$.storageContext.appId' + ) ); diff --git a/packages/worker/src/mcp/secrets/package-scope-migration.node.test.ts b/packages/worker/src/mcp/secrets/package-scope-migration.node.test.ts index ee057f42d9..6fbe63ea74 100644 --- a/packages/worker/src/mcp/secrets/package-scope-migration.node.test.ts +++ b/packages/worker/src/mcp/secrets/package-scope-migration.node.test.ts @@ -31,11 +31,11 @@ test('package scope migration converts app buckets without retaining package gra source_id TEXT NOT NULL ); INSERT INTO saved_packages (id, user_id, source_id) - VALUES ('package-1', 'user-1', 'source-1'); + VALUES ('package-1', 'user-1', 'package-source-1'); INSERT INTO jobs (user_id, source_id, caller_context_json) VALUES ( 'user-1', - 'source-1', + 'ad-hoc-job-source-1', '{"storageContext":{"appId":"package-1"}}' ); INSERT INTO secret_buckets (id, user_id, scope, binding_key) From 313051341da5c6f187331ffc0500ca6d2012b276 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 22:14:52 +0000 Subject: [PATCH 8/9] Address package secret review findings --- .../worker/client/routes/account-secrets.tsx | 11 -- .../capabilities/openapi-provider/index.ts | 1 + .../operation-request.node.test.ts | 61 ++++--- .../openapi-provider/operation-request.ts | 75 +++++--- .../mcp/capabilities/secrets/secret-set.ts | 37 ++-- packages/worker/src/mcp/secrets/repo.ts | 39 ++++ .../src/mcp/secrets/service.node.test.ts | 167 +++++++++++++++++- packages/worker/src/mcp/secrets/service.ts | 92 ++++++++++ .../src/package-registry/service.node.test.ts | 23 +++ .../worker/src/package-registry/service.ts | 20 +-- 10 files changed, 451 insertions(+), 75 deletions(-) diff --git a/packages/worker/client/routes/account-secrets.tsx b/packages/worker/client/routes/account-secrets.tsx index a4e864a313..ddc5666c32 100644 --- a/packages/worker/client/routes/account-secrets.tsx +++ b/packages/worker/client/routes/account-secrets.tsx @@ -4,7 +4,6 @@ import { type Handle, css } from 'remix/ui' import { on } from '#client/event-mixin.ts' import { buildAccountSecretPath, - parseAccountSecretId, parseAccountSecretPath, } from '@kody-internal/shared/account-secret-route.ts' import { @@ -302,16 +301,6 @@ function getSelectionState(href: string): SelectionState { isCreating: false, } } - if (url.pathname.startsWith(`${secretsBasePath}/`)) { - const legacySecretId = url.pathname.slice(`${secretsBasePath}/`.length) - const parsedLegacyId = parseAccountSecretId(legacySecretId) - return { - selectedSecretId: parsedLegacyId - ? legacySecretId - : url.pathname.slice(`${secretsBasePath}/`.length), - isCreating: false, - } - } return { selectedSecretId: null, isCreating: false, diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/index.ts b/packages/worker/src/mcp/capabilities/openapi-provider/index.ts index c044de6c1d..130fd4d43d 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/index.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/index.ts @@ -201,6 +201,7 @@ function createCapabilityFromOperation(input: { return executeOpenApiOperationRequest({ env: ctx.env, userId, + baseUrl: ctx.callerContext.baseUrl, storageContext: { sessionId: ctx.callerContext.storageContext?.sessionId ?? null, appId: ctx.callerContext.storageContext?.appId ?? null, diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts index d345ec8e75..21f6084415 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts @@ -144,6 +144,7 @@ test('builds URL with path params and query serialization', async () => { const result = await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: bindingBase, operation: getWidget, @@ -179,6 +180,7 @@ test('preserves apiBaseUrl path segments when building operation URLs', async () const result = await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: { ...bindingBase, @@ -201,6 +203,7 @@ test('rejects absolute and protocol-relative operation paths', async () => { executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: { ...bindingBase, @@ -244,6 +247,7 @@ test('injects auth headers and overrides caller attempts', async () => { await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: { ...bindingBase, @@ -265,6 +269,7 @@ test('injects auth headers and overrides caller attempts', async () => { await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: { ...bindingBase, @@ -286,6 +291,7 @@ test('injects auth headers and overrides caller attempts', async () => { await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: { ...bindingBase, @@ -339,25 +345,37 @@ test('requires package approval before OpenAPI resolves a user secret', async () const fetchStub = vi.fn() try { - await expect( - executeOpenApiOperationRequest({ - env: createEnv(), - userId: 'user-1', - storageContext: { - sessionId: null, - appId: 'package-1', - packageId: 'package-1', - storageId: 'package-1', - }, - binding: { - ...bindingBase, - auth: { kind: 'bearerSecret', secretName: 'apiToken' }, - }, - operation: getWidget, - args: { params: { widgetId: '1' } }, - globalFetch: fetchStub as unknown as typeof fetch, - }), - ).rejects.toThrow('is not allowed for package "example-package"') + const error = await executeOpenApiOperationRequest({ + env: createEnv(), + userId: 'user-1', + baseUrl: 'https://app.example.com', + storageContext: { + sessionId: null, + appId: 'package-1', + packageId: 'package-1', + storageId: 'package-1', + }, + binding: { + ...bindingBase, + auth: { kind: 'bearerSecret', secretName: 'apiToken' }, + }, + operation: getWidget, + args: { params: { widgetId: '1' } }, + globalFetch: fetchStub as unknown as typeof fetch, + }).then( + () => null, + (thrown: unknown) => thrown, + ) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toContain( + 'is not allowed for package "example-package"', + ) + expect((error as Error).message).toContain( + 'https://app.example.com/account/secrets/user/apiToken', + ) + expect((error as Error).message).not.toContain( + 'https://api.widgets.example/account/secrets', + ) expect(fetchStub).not.toHaveBeenCalled() } finally { packageSpy.mockRestore() @@ -375,6 +393,7 @@ test('returns truncated flag for oversized non-json bodies', async () => { const result = await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: bindingBase, operation: getWidget, @@ -414,7 +433,7 @@ test('integration auth 401 triggers host-side refresh retry', async () => { name: 'spotifyAccessToken', scope: 'user', description: '', - appId: null, + packageId: null, createdAt: '2026-03-29T00:00:00.000Z', updatedAt: '2026-03-29T00:00:00.000Z', ttlMs: null, @@ -474,6 +493,7 @@ test('integration auth 401 triggers host-side refresh retry', async () => { const result = await executeOpenApiOperationRequest({ env: createEnv(entries), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: { ...bindingBase, @@ -522,6 +542,7 @@ test('passes application/json-seq request bodies through as strings', async () = const result = await executeOpenApiOperationRequest({ env: createEnv(), userId: 'user-1', + baseUrl: 'https://app.example.com', storageContext: null, binding: bindingBase, operation: { diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts index 752f56664b..8f1d126fcb 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.ts @@ -11,7 +11,11 @@ import { buildSecretPlaceholder, } from '#mcp/secrets/placeholders.ts' import { assertPackageCanAccessResolvedSecret } from '#mcp/secrets/package-access.ts' -import { resolveSecret, saveSecret } from '#mcp/secrets/service.ts' +import { + resolveSecret, + saveSecret, + updateUserSecretForPackage, +} from '#mcp/secrets/service.ts' import { type StorageContext } from '#mcp/storage.ts' import { getValue } from '#mcp/values/service.ts' import { @@ -40,6 +44,7 @@ export type OpenApiOperationRequestResult = { export async function executeOpenApiOperationRequest(input: { env: Env userId: string + baseUrl: string storageContext: StorageContext | null binding: OpenApiBinding operation: OpenApiBindingOperation @@ -89,7 +94,7 @@ export async function executeOpenApiOperationRequest(input: { let response = await executeGatewayFetch({ env: input.env, props: { - baseUrl: input.binding.apiBaseUrl, + baseUrl: input.baseUrl, userId: input.userId, storageContext: input.storageContext, }, @@ -105,6 +110,7 @@ export async function executeOpenApiOperationRequest(input: { const refreshed = await tryRefreshIntegrationAccessToken({ env: input.env, userId: input.userId, + baseUrl: input.baseUrl, provider: input.binding.auth.provider, integration, storageContext: input.storageContext, @@ -115,7 +121,7 @@ export async function executeOpenApiOperationRequest(input: { response = await executeGatewayFetch({ env: input.env, props: { - baseUrl: input.binding.apiBaseUrl, + baseUrl: input.baseUrl, userId: input.userId, storageContext: input.storageContext, }, @@ -350,6 +356,7 @@ function isJsonContentType(contentType: string): boolean { async function tryRefreshIntegrationAccessToken(input: { env: Env userId: string + baseUrl: string provider: string integration: IntegrationConfig storageContext: StorageContext | null @@ -409,7 +416,7 @@ async function tryRefreshIntegrationAccessToken(input: { refreshResponse = await executeGatewayFetch({ env: input.env, props: { - baseUrl: input.integration.tokenUrl, + baseUrl: input.baseUrl, userId: input.userId, storageContext: input.storageContext, }, @@ -448,46 +455,70 @@ async function tryRefreshIntegrationAccessToken(input: { } } - await assertPackageCanUpdateUserSecret({ + await saveUserSecretFromOpenApiRefresh({ env: input.env, userId: input.userId, - baseUrl: input.integration.tokenUrl, + baseUrl: input.baseUrl, storageContext: input.storageContext, - secretName: input.integration.accessTokenSecretName, - }) - await saveSecret({ - env: input.env, - userId: input.userId, name: input.integration.accessTokenSecretName, value: payload.access_token, - scope: 'user', description: `Access token for integration ${input.provider}`, - storageContext: input.storageContext, }) if ( typeof payload.refresh_token === 'string' && payload.refresh_token.length > 0 ) { - await assertPackageCanUpdateUserSecret({ + await saveUserSecretFromOpenApiRefresh({ env: input.env, userId: input.userId, - baseUrl: input.integration.tokenUrl, + baseUrl: input.baseUrl, storageContext: input.storageContext, - secretName: refreshTokenSecretName, - }) - await saveSecret({ - env: input.env, - userId: input.userId, name: refreshTokenSecretName, value: payload.refresh_token, - scope: 'user', description: `Refresh token for integration ${input.provider}`, - storageContext: input.storageContext, }) } return { ok: true } } +async function saveUserSecretFromOpenApiRefresh(input: { + env: Env + userId: string + baseUrl: string + storageContext: StorageContext | null + name: string + value: string + description: string +}) { + const packageId = input.storageContext?.packageId?.trim() ?? '' + if (packageId) { + await assertPackageCanUpdateUserSecret({ + env: input.env, + userId: input.userId, + baseUrl: input.baseUrl, + storageContext: input.storageContext, + secretName: input.name, + }) + return updateUserSecretForPackage({ + env: input.env, + userId: input.userId, + packageId, + name: input.name, + value: input.value, + description: input.description, + }) + } + return saveSecret({ + env: input.env, + userId: input.userId, + name: input.name, + value: input.value, + scope: 'user', + description: input.description, + storageContext: input.storageContext, + }) +} + async function assertPackageCanUpdateUserSecret(input: { env: Env userId: string diff --git a/packages/worker/src/mcp/capabilities/secrets/secret-set.ts b/packages/worker/src/mcp/capabilities/secrets/secret-set.ts index d71b17474b..c39f8c292d 100644 --- a/packages/worker/src/mcp/capabilities/secrets/secret-set.ts +++ b/packages/worker/src/mcp/capabilities/secrets/secret-set.ts @@ -5,7 +5,11 @@ import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' import { assertPackageCanAccessResolvedSecret } from '#mcp/secrets/package-access.ts' -import { resolveSecret, saveSecret } from '#mcp/secrets/service.ts' +import { + resolveSecret, + saveSecret, + updateUserSecretForPackage, +} from '#mcp/secrets/service.ts' import { secretScopeValues } from '#mcp/secrets/types.ts' import { secretMetadataSchema } from './shared.ts' @@ -52,6 +56,7 @@ export const secretSetCapability = defineDomainCapability( packageId: ctx.callerContext.storageContext?.packageId ?? null, storageId: ctx.callerContext.storageContext?.storageId ?? null, } + let saved if (parsed.scope === 'user' && storageContext.packageId) { const existing = await resolveSecret({ env: ctx.env, @@ -73,17 +78,27 @@ export const secretSetCapability = defineDomainCapability( secretName: parsed.name, resolved: existing, }) + saved = await updateUserSecretForPackage({ + env: ctx.env, + userId: user.userId, + userEmail: user.email, + packageId: storageContext.packageId, + name: parsed.name, + value: parsed.value, + description: parsed.description, + }) + } else { + saved = await saveSecret({ + env: ctx.env, + userId: user.userId, + userEmail: user.email, + scope: parsed.scope, + name: parsed.name, + value: parsed.value, + description: parsed.description ?? '', + storageContext, + }) } - const saved = await saveSecret({ - env: ctx.env, - userId: user.userId, - userEmail: user.email, - scope: parsed.scope, - name: parsed.name, - value: parsed.value, - description: parsed.description ?? '', - storageContext, - }) return { name: saved.name, scope: saved.scope, diff --git a/packages/worker/src/mcp/secrets/repo.ts b/packages/worker/src/mcp/secrets/repo.ts index ecfe43082b..e29d16e848 100644 --- a/packages/worker/src/mcp/secrets/repo.ts +++ b/packages/worker/src/mcp/secrets/repo.ts @@ -121,6 +121,45 @@ export async function upsertSecretEntry(input: { .run() } +export async function updateApprovedUserSecretEntryForPackage(input: { + db: D1Database + userId: string + packageId: string + name: string + description: string + encryptedValue: string + updatedAt: string +}): Promise { + const result = await input.db + .prepare( + `UPDATE secret_entries + SET description = ?, encrypted_value = ?, updated_at = ? + WHERE name = ? + AND bucket_id = ( + SELECT id + FROM secret_buckets + WHERE user_id = ? AND scope = 'user' AND binding_key = '' + LIMIT 1 + ) + AND json_valid(allowed_packages) + AND EXISTS ( + SELECT 1 + FROM json_each(allowed_packages) + WHERE value = ? + )`, + ) + .bind( + input.description, + input.encryptedValue, + input.updatedAt, + input.name, + input.userId, + input.packageId, + ) + .run() + return (result.meta.changes ?? 0) > 0 +} + export async function deleteSecretEntry(input: { db: D1Database bucketId: string diff --git a/packages/worker/src/mcp/secrets/service.node.test.ts b/packages/worker/src/mcp/secrets/service.node.test.ts index 92d70db720..cfaa1c4311 100644 --- a/packages/worker/src/mcp/secrets/service.node.test.ts +++ b/packages/worker/src/mcp/secrets/service.node.test.ts @@ -2,7 +2,14 @@ import { expect, test } from 'vitest' import { isEntitlementLimitError } from '#worker/entitlements/errors.ts' import { planLimits } from '#worker/entitlements/plans.ts' import { createStableUserIdFromEmail } from '#worker/user-id.ts' -import { listSecrets, resolveSecret, saveSecret } from './service.ts' +import { + listPackageSecretsByPackageIds, + listSecrets, + resolveSecret, + saveSecret, + setSecretAllowedPackages, + updateUserSecretForPackage, +} from './service.ts' type SecretBucketRow = { id: string @@ -111,6 +118,43 @@ function createSecretTestDb( return null }, async all() { + if ( + normalizedQuery.includes('from secret_buckets b') && + normalizedQuery.includes("b.scope = 'package'") + ) { + const [userId, ...packageIdsAndNow] = params as Array + const packageIds = new Set(packageIdsAndNow.slice(0, -1)) + const results = Array.from(entries.values()) + .flatMap((entry) => { + const bucket = Array.from(buckets.values()).find( + (candidate) => candidate.id === entry.bucket_id, + ) + if ( + !bucket || + bucket.user_id !== userId || + bucket.scope !== 'package' || + !packageIds.has(bucket.binding_key) + ) { + return [] + } + return [ + { + scope: bucket.scope, + binding_key: bucket.binding_key, + name: entry.name, + description: entry.description, + allowed_hosts: entry.allowed_hosts, + allowed_capabilities: entry.allowed_capabilities, + allowed_packages: entry.allowed_packages, + created_at: entry.created_at, + updated_at: entry.updated_at, + expires_at: bucket.expires_at, + }, + ] + }) + .sort((left, right) => left.name.localeCompare(right.name)) + return { results: results as Array, meta: { changes: 0 } } + } if ( normalizedQuery.startsWith( 'select ? as scope, ? as binding_key, name, description, allowed_hosts, allowed_capabilities, allowed_packages, created_at, updated_at, ? as expires_at from secret_entries', @@ -138,6 +182,34 @@ function createSecretTestDb( return { results: [] as Array, meta: { changes: 0 } } }, async run() { + if ( + normalizedQuery.startsWith( + 'update secret_entries set description = ?', + ) + ) { + const [ + description, + encryptedValue, + updatedAt, + name, + userId, + packageId, + ] = params as Array + const bucket = buckets.get(getBucketKey(userId, 'user', '')) + const entry = bucket + ? entries.get(getEntryKey(bucket.id, name)) + : null + const allowedPackages = entry + ? (JSON.parse(entry.allowed_packages) as Array) + : [] + if (!entry || !allowedPackages.includes(packageId)) { + return { meta: { changes: 0 } } + } + entry.description = description + entry.encrypted_value = encryptedValue + entry.updated_at = updatedAt + return { meta: { changes: 1 } } + } if (normalizedQuery.startsWith('insert into secret_buckets')) { const [ id, @@ -325,6 +397,99 @@ test('resolveSecret returns the first scope hit in precedence order', async () = }) }) +test('listPackageSecretsByPackageIds groups package-owned secrets', async () => { + const testDb = createSecretTestDb() + const env = { + APP_DB: testDb.db, + COOKIE_SECRET: 'test-cookie-secret', + SECRET_STORE_KEY: 'test-secret-store-key-32-chars-minimum', + } + const storageContext = { + sessionId: null, + appId: null, + packageId: 'package-1', + storageId: null, + } + await saveSecret({ + env, + userId: 'user-123', + scope: 'package', + name: 'package-token', + value: 'package-value', + storageContext, + }) + + const grouped = await listPackageSecretsByPackageIds({ + env, + userId: 'user-123', + packageIds: ['package-1', 'package-2'], + }) + + expect(grouped.get('package-1')).toEqual([ + expect.objectContaining({ + name: 'package-token', + scope: 'package', + packageId: 'package-1', + }), + ]) + expect(grouped.has('package-2')).toBe(false) +}) + +test('updateUserSecretForPackage atomically requires an existing package approval', async () => { + const testDb = createSecretTestDb() + const env = { + APP_DB: testDb.db, + COOKIE_SECRET: 'test-cookie-secret', + SECRET_STORE_KEY: 'test-secret-store-key-32-chars-minimum', + } + await saveSecret({ + env, + userId: 'user-123', + scope: 'user', + name: 'shared-token', + value: 'old-value', + }) + await setSecretAllowedPackages({ + env, + userId: 'user-123', + scope: 'user', + name: 'shared-token', + allowedPackages: ['package-1'], + }) + + await expect( + updateUserSecretForPackage({ + env, + userId: 'user-123', + packageId: 'package-1', + name: 'shared-token', + value: 'new-value', + description: 'Updated by package', + }), + ).resolves.toMatchObject({ + name: 'shared-token', + description: 'Updated by package', + allowedPackages: ['package-1'], + }) + await expect( + resolveSecret({ + env, + userId: 'user-123', + scope: 'user', + name: 'shared-token', + }), + ).resolves.toMatchObject({ found: true, value: 'new-value' }) + await expect( + updateUserSecretForPackage({ + env, + userId: 'user-123', + packageId: 'package-2', + name: 'shared-token', + value: 'unauthorized-value', + }), + ).rejects.toThrow('is not approved for package "package-2"') +}) + test('resolveSecret ignores a corrupted lower-precedence entry when a higher scope resolves', async () => { const testDb = createSecretTestDb() const env = { diff --git a/packages/worker/src/mcp/secrets/service.ts b/packages/worker/src/mcp/secrets/service.ts index ba8149fbb3..c8b2957bbf 100644 --- a/packages/worker/src/mcp/secrets/service.ts +++ b/packages/worker/src/mcp/secrets/service.ts @@ -30,6 +30,7 @@ import { listSecretMetadataForBucket, listUserScopeSecretMetadata, removePackageFromSecretApprovals, + updateApprovedUserSecretEntryForPackage, upsertSecretBucket, upsertSecretEntry, } from './repo.ts' @@ -187,6 +188,97 @@ export async function saveSecret( }) } +export async function updateUserSecretForPackage(input: { + env: Pick + userId: string + userEmail?: string | null + packageId: string + name: string + value: string + description?: string | null +}) { + const name = input.name.trim() + if (!name) throw new Error('Secret name is required.') + assertSecretNameAllowed(name) + const value = input.value.trim() + if (!value) throw new Error('Secret value is required.') + const packageId = input.packageId.trim() + if (!packageId) throw new Error('Package id is required.') + + const bucket = await getExistingBucketForScope({ + db: input.env.APP_DB, + userId: input.userId, + scope: 'user', + storageContext: null, + }) + if (!bucket) throw new Error('User secret not found.') + const existingEntry = await getSecretEntry({ + db: input.env.APP_DB, + bucketId: bucket.id, + name, + }) + if (!existingEntry) throw new Error('User secret not found.') + + const description = input.description?.trim() ?? existingEntry.description + const encryptedValue = await encryptSecretValue(input.env, value) + await assertWithinStorageBytesEntitlement({ + db: input.env.APP_DB, + userId: input.userId, + email: input.userEmail, + requested: estimateEntitlementStorageEntryByteDelta({ + next: { + key: name, + value: { + description, + encryptedValue, + allowedHosts: existingEntry.allowed_hosts, + allowedCapabilities: existingEntry.allowed_capabilities, + allowedPackages: existingEntry.allowed_packages, + }, + }, + existing: { + key: existingEntry.name, + value: { + description: existingEntry.description, + encryptedValue: existingEntry.encrypted_value, + allowedHosts: existingEntry.allowed_hosts, + allowedCapabilities: existingEntry.allowed_capabilities, + allowedPackages: existingEntry.allowed_packages, + }, + }, + }), + }) + const now = new Date().toISOString() + const updated = await updateApprovedUserSecretEntryForPackage({ + db: input.env.APP_DB, + userId: input.userId, + packageId, + name, + description, + encryptedValue, + updatedAt: now, + }) + if (!updated) { + throw new Error( + `User secret "${name}" no longer exists or is not approved for package "${packageId}".`, + ) + } + return toSecretMetadata({ + name, + scope: 'user', + description, + packageId: null, + allowedHosts: parseAllowedHosts(existingEntry.allowed_hosts), + allowedCapabilities: parseAllowedCapabilities( + existingEntry.allowed_capabilities, + ), + allowedPackages: parseAllowedPackages(existingEntry.allowed_packages), + createdAt: existingEntry.created_at, + updatedAt: now, + expiresAt: bucket.expires_at, + }) +} + export async function listSecrets( input: ListSecretsInput, ): Promise> { diff --git a/packages/worker/src/package-registry/service.node.test.ts b/packages/worker/src/package-registry/service.node.test.ts index 511877f4ab..3779ffea20 100644 --- a/packages/worker/src/package-registry/service.node.test.ts +++ b/packages/worker/src/package-registry/service.node.test.ts @@ -635,6 +635,29 @@ test('deleteSavedPackageProjection continues best-effort cleanup when dependent expect(mockModule.syncJobManagerAlarm).not.toHaveBeenCalled() }) +test('deleteSavedPackageProjection cleans secrets when package projection is missing', async () => { + setupDefaultMocks() + const env = createEnv() + mockModule.getSavedPackageById.mockResolvedValue(null) + + await deleteSavedPackageProjection({ + env, + userId: 'user-1', + packageId: 'missing-package', + }) + + expect(mockModule.deleteAllPackageScopedSecrets).toHaveBeenCalledWith({ + env, + userId: 'user-1', + packageId: 'missing-package', + }) + expect(mockModule.removeAllSecretApprovalsForPackage).toHaveBeenCalledWith({ + env, + userId: 'user-1', + packageId: 'missing-package', + }) +}) + function createEntitlementsDatabase(input: { users?: Array<{ email: string; plan: string | null }> savedPackageCount?: number diff --git a/packages/worker/src/package-registry/service.ts b/packages/worker/src/package-registry/service.ts index 0de89486da..f68d1879c4 100644 --- a/packages/worker/src/package-registry/service.ts +++ b/packages/worker/src/package-registry/service.ts @@ -378,17 +378,17 @@ export async function deleteSavedPackageProjection(input: { for (const row of packageRows) { await deleteJobRow(input.env.APP_DB, input.userId, row.id) } - await deleteAllPackageScopedSecrets({ - env: input.env, - userId: input.userId, - packageId: input.packageId, - }) - await removeAllSecretApprovalsForPackage({ - env: input.env, - userId: input.userId, - packageId: input.packageId, - }) } + await deleteAllPackageScopedSecrets({ + env: input.env, + userId: input.userId, + packageId: input.packageId, + }) + await removeAllSecretApprovalsForPackage({ + env: input.env, + userId: input.userId, + packageId: input.packageId, + }) await deleteSavedPackage(input.env.APP_DB, { userId: input.userId, packageId: input.packageId, From c88f14808e73aff57e8b55ecae669f7fd95fcfdd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 9 Jul 2026 22:25:45 +0000 Subject: [PATCH 9/9] Refresh package job caller contexts --- packages/worker/src/jobs/service.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/worker/src/jobs/service.ts b/packages/worker/src/jobs/service.ts index 28c4b09ba8..1c91afa0ec 100644 --- a/packages/worker/src/jobs/service.ts +++ b/packages/worker/src/jobs/service.ts @@ -799,15 +799,13 @@ export async function syncPackageJobsForPackage(input: { db: input.env.APP_DB, userId: input.userId, job: updated, - callerContextJson: - existing.callerContextJson || - JSON.stringify( - createPackageJobCallerContext({ - baseUrl: input.baseUrl, - userId: input.userId, - packageId: input.packageId, - }), - ), + callerContextJson: serializeCallerContext( + createPackageJobCallerContext({ + baseUrl: input.baseUrl, + userId: input.userId, + packageId: input.packageId, + }), + ), }) continue }