From 058b546b56b665cd95c2e1a872a263cd74faf59c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:43:01 -0300 Subject: [PATCH 1/8] fix(studio): send API keys on the apikey header in the edge function tester (#49650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Kalleby Santos** · [Slack thread](https://supabase.slack.com/archives/C0AQ3UHCCKW/p1787840441551609?thread_ts=1787840441.551609&cid=C0AQ3UHCCKW)_ **Before:** you deploy the editor's default template ("Deploy a new function" → "Via Editor"), which wraps its handler in `withSupabase({ auth: ["publishable", "secret"] })`. You click **Test** and get `401 {"message":"Invalid credentials","code":"INVALID_CREDENTIALS"}` — from the function's own middleware, with an empty Headers section. Studio was quietly setting `Authorization` to a legacy `service_role` JWT (and, before that, to your dashboard session token), routed through a private `x-test-authorization` header that the proxy route renamed to `Authorization`. A legacy JWT is neither a publishable nor a secret key, so the middleware rejected it. Pasting your own `Authorization` row did not help: the route overwrote it unconditionally. On a project with legacy keys disabled there was no `service_role` key at all and the literal string `Bearer undefined` went out. **After:** the tester sends your publishable key on the `apikey` header, where new-format keys belong, and never generates an `Authorization` header. `Authorization` only ever comes from your own header rows — typed by hand, or prefilled for you by the role selector. The editor's default template works on the first click, a header you paste is actually sent, and an **Add secret key** action in the "Add header" dropdown gives you one-click access to a secret key, the same affordance the database webhooks and cron job screens already have. **How:** header construction moves into `buildEdgeFunctionTestHeaders` (`EdgeFunctionTesterSheet.utils.ts`), which sets `Content-Type` and `apikey` and then applies the user's rows last. The `x-test-authorization` hop is gone from both the component and `pages/api/edge-functions/test.ts`; the route now forwards the supplied headers as given. Both sides merge on the lowercased header name, so a row typed `authorization` or `apikey` replaces the generated one instead of sitting beside it and being comma-joined by `fetch`. The Headers and Query Parameters sections now use the shared `KeyValueFieldArray`, which is what makes `buildEdgeFunctionHeaderAddActions` reusable here. ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix. ## What is the current behavior? Fixes #42755. - `EdgeFunctionTesterSheet.tsx` sent the legacy `service_role` JWT (or a role-impersonation JWT) as the value of `x-test-authorization` on every request, plus the dashboard session access token as `Authorization`. - `pages/api/edge-functions/test.ts` then overwrote `Authorization` with `x-test-authorization` whenever that header was present, discarding any `Authorization` the user had entered. - No `apikey` header was ever sent, so `withSupabase` in `publishable` or `secret` auth mode — the modes used by the editor's own templates — could never succeed. - Header merging was case-sensitive on both sides of the proxy, so a row typed in the conventional lowercase form produced two entries that `fetch` comma-joined into one malformed value. - The API keys query did not pass `reveal: true`, unlike the webhooks and cron job UIs. ## What is the new behavior? - `apikey` carries the publishable key, falling back to the legacy `anon` key. This mirrors the example snippets on the function details page, which already prefer `publishableKey ?? anonKey`. Defaulting to the least-privileged key means a secret key is only ever sent when the user explicitly adds it. - `Authorization` is never generated. The `useSessionAccessTokenQuery` call is removed from this component entirely — the dashboard user's own session token has no business being forwarded to a project's function. - `x-test-authorization` is removed from both files. The proxy route stays, because it is what reads the raw upstream response for the response panel (`redirect: 'manual'`, full status/header/body capture), keeps the request off the browser's CORS path, and holds the `isValidEdgeFunctionURL` guard and the local-dev URL rewrite. Only the header rewriting is gone. - Role impersonation keeps working, but as a visible, editable `Authorization` row rather than a hidden injected header, so what is sent is always what is displayed. Two details worth reviewing: the selector tracks the value it last wrote, so clearing the role removes only that row and leaves an `Authorization` row you typed by hand alone; and an incrementing request id discards a JWT that resolves after a newer role has already been picked. - Headers merge case-insensitively, user rows winning. - `reveal: true` is passed on the API keys query, matching `Database/Hooks/HTTPHeaders.tsx`. ## Additional context **Relationship to #47159.** #47159 identified the same root cause independently and got the important part right: the key belongs on `apikey`, and neither the legacy service-role JWT nor the dashboard session token should be forwarded. Its extraction of a testable header builder is a good shape, and this PR keeps it — including the spirit of its test suite. The differences are in scope rather than direction. This PR also removes the `x-test-authorization` hop and the route's unconditional `Authorization` overwrite (#47159 leaves the route untouched); drops the remaining legacy service-role fallback rather than keeping it for projects without a publishable key; adds `reveal: true`, secret-key support and the shared "Add secret key" affordance; and normalizes header casing for every header rather than only `x-test-authorization`. Whether to land that PR first and layer this on top, or take this one, is the maintainers' call — either way the credit for spotting it belongs there too. **Overlap with #48143.** That open PR fixes the same case-sensitivity defect for `Content-Type` in these two files. It is not addressed separately here, but the case-insensitive merge in this PR covers `Content-Type` as a side effect, so the two will conflict textually. Happy to rebase on whichever lands first. **A note on `verify_jwt`.** The gateway creates a temporary token when `apikey` is present, so `verify_jwt` does not affect this path and a request with `apikey` and no `Authorization` reaches the function normally. No deploy defaults are changed here. **Compatibility.** One behaviour gets worse and is worth an explicit decision: a function that expects a legacy JWT on `Authorization` used to "just work" in the tester because Studio injected the service-role key. It now needs an `Authorization` row, which the **Add secret key** action produces in one click — the shared helper already emits an `Authorization: Bearer` row for legacy-format keys. Projects with legacy keys disabled strictly improve: they used to receive `Bearer undefined`. Functions using `auth: "user"` are unchanged — the tester never had a real end-user JWT, only the impersonation token. ## Testing `apps/studio` dependencies could not be installed in the environment this was written in (`pnpm install` fails on a 403 from `npm.jsr.io`), so `vitest`, `tsc --noEmit` and `eslint` were not run. What was run instead: - Prettier with the repo's config, including `@ianvs/prettier-plugin-sort-imports`: clean on all five files. - `tsc` parse of the changed files: no syntax or type errors beyond pre-existing unresolved-module noise. - Both new test suites transpiled and executed as plain Node assertions: 7/7 for `buildEdgeFunctionTestHeaders`, 4/4 driving the API route handler with a stubbed `fetch`. Please run the real suites in CI. `pnpm --filter studio exec vitest --run tests/components/Functions/EdgeFunctionTesterSheet.utils.test.ts tests/pages/api/edge-functions/test.test.ts` covers the added tests. A component-level test of the impersonation prefill is not included and would be a reasonable follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Co-authored-by: Kalleby Santos <105971119+kallebysantos@users.noreply.github.com> --- .../EdgeFunctionTesterSheet.tsx | 225 +++++------------- .../EdgeFunctionTesterSheet.utils.ts | 46 ++++ .../Functions/httpHeaderAddActions.test.ts | 61 +++++ .../Functions/httpHeaderAddActions.ts | 42 +++- apps/studio/pages/api/edge-functions/test.ts | 37 ++- .../EdgeFunctionTesterSheet.utils.test.ts | 97 ++++++++ .../pages/api/edge-functions/test.test.ts | 48 +++- 7 files changed, 363 insertions(+), 193 deletions(-) create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.utils.ts create mode 100644 apps/studio/tests/components/Functions/EdgeFunctionTesterSheet.utils.test.ts diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index 44280555658ad..cd5666b0596f7 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -1,16 +1,15 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { BookOpen, Loader2, Plus, Send, X } from 'lucide-react' +import { BookOpen, Loader2, Send } from 'lucide-react' import { useState } from 'react' -import { useFieldArray, useForm, useWatch } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { Badge, Button, Form, FormControl, FormField, - Input, Label, ResizableHandle, ResizablePanel, @@ -33,27 +32,21 @@ import { } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray' import * as z from 'zod' import { HTTP_METHODS } from './EdgeFunctionDetails.constants' import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types' import { getEdgeFunctionErrorDocs } from './EdgeFunctionDetails.utils' -import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' +import { buildEdgeFunctionTestHeaders } from './EdgeFunctionTesterSheet.utils' +import { buildEdgeFunctionHeaderAddActions } from '@/components/interfaces/Functions/httpHeaderAddActions' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useAPIKeys } from '@/data/api-keys/api-keys-query' -import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query' -import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useEdgeFunctionTestMutation } from '@/data/edge-functions/edge-function-test-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { IS_PLATFORM } from '@/lib/constants' import { prettifyJSON } from '@/lib/helpers' -import { getRoleImpersonationJWT } from '@/lib/role-impersonation' import { useTrack } from '@/lib/telemetry/track' -import { - RoleImpersonationStateContextProvider, - useGetImpersonatedRoleState, -} from '@/state/role-impersonation-state' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' @@ -84,31 +77,33 @@ const FormSchema = z.object({ type FormValues = z.infer -export const EdgeFunctionTesterSheet = (props: EdgeFunctionTesterSheetProps) => { - const { ref: projectRef } = useParams() - - // [Alaister]: We're using a fresh context here as edge functions don't allow impersonating users. - return ( - - - - ) -} - -const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { +export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { const { ref: projectRef, functionSlug } = useParams() - const getImpersonatedRoleState = useGetImpersonatedRoleState() const [response, setResponse] = useState(null) const [error, setError] = useState(null) const errorDocs = response ? getEdgeFunctionErrorDocs(response.headers) : undefined const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') - const { data: apiKeysData } = useAPIKeys({ projectRef }, { enabled: canReadAPIKeys }) - const { serviceKey } = apiKeysData ?? {} - const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: apiKeysData } = useAPIKeys( + { projectRef, reveal: true }, + { enabled: canReadAPIKeys } + ) + const { anonKey, publishableKey, secretKey, serviceKey } = apiKeysData ?? {} const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) + + // Sent on the `apikey` header. Defaults to the least privileged key available, matching what the + // function details page shows in its example snippets. + const clientApiKey = publishableKey?.api_key ?? anonKey?.api_key + const secretApiKey = secretKey?.api_key ?? serviceKey?.api_key + + // Both keys are offered so the user can swap the request's credential without looking one up. + // The webhook specific action the helper also builds is not relevant here. + const headerAddActions = buildEdgeFunctionHeaderAddActions({ + apiKey: secretApiKey ?? '[YOUR API KEY]', + publishableKey: clientApiKey, + createRow: (key: string, value: string) => ({ key, value }), + }).filter(({ key }) => key !== 'add-source-header') const track = useTrack() const { mutate: testEdgeFunction, isPending } = useEdgeFunctionTestMutation({ @@ -141,40 +136,6 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester }) const method = useWatch({ control: form.control, name: 'method' }) - const { - fields: headerFields, - append: appendHeader, - remove: removeHeader, - } = useFieldArray({ - control: form.control, - name: 'headers', - }) - - const { - fields: queryParamFields, - append: appendQueryParam, - remove: removeQueryParam, - } = useFieldArray({ - control: form.control, - name: 'queryParams', - }) - - const addKeyValuePair = (type: 'headers' | 'queryParams') => { - if (type === 'headers') { - appendHeader({ key: '', value: '' }) - } else { - appendQueryParam({ key: '', value: '' }) - } - } - - const removeKeyValuePair = (index: number, type: 'headers' | 'queryParams') => { - if (type === 'headers') { - removeHeader(index) - } else { - removeQueryParam(index) - } - } - useShortcut( SHORTCUT_IDS.FUNCTION_DETAIL_SUBMIT_TEST, () => { @@ -195,34 +156,6 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester return } - let testAuthorization: string | undefined - const role = getImpersonatedRoleState().role - - if ( - projectRef !== undefined && - config?.jwt_secret !== undefined && - role !== undefined && - role.type === 'postgrest' - ) { - try { - const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role) - testAuthorization = 'Bearer ' + token - } catch (err: any) { - console.error('Failed to generate JWT:', { - error: err.message, - roleDetails: role, - }) - } - } - - // Construct custom headers - const customHeaders: Record = {} - values.headers.forEach(({ key, value }) => { - if (key && value) { - customHeaders[key] = value - } - }) - // Construct query parameters const queryString = values.queryParams .filter(({ key, value }) => key && value) @@ -235,80 +168,13 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester url: finalUrl, method: values.method, body: values.body, - headers: { - ...(accessToken && { - Authorization: `Bearer ${accessToken}`, - }), - 'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`, - 'Content-Type': 'application/json', - ...customHeaders, - }, + headers: buildEdgeFunctionTestHeaders({ + apiKey: clientApiKey, + customHeaders: values.headers, + }), }) } - const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => ( -
-
- - -
-
- {(type === 'headers' ? headerFields : queryParamFields).map((field, index) => ( -
- ( - - - - )} - /> - ( - - - - )} - /> -
- {(type === 'headers' ? headerFields : queryParamFields).length > 1 && ( -
-
- ))} -
-
- ) - return ( )} - {renderKeyValuePairs('headers', 'Headers')} - {renderKeyValuePairs('queryParams', 'Query Parameters')} +
+ + ({ key: '', value: '' })} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + addLabel="Add header" + addActions={headerAddActions} + disabled={isPending} + /> +
+
+ + ({ key: '', value: '' })} + keyPlaceholder="Parameter name" + valuePlaceholder="Parameter value" + addLabel="Add parameter" + disabled={isPending} + /> +
@@ -484,10 +377,6 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester
- +
- - - - - )} + )} + { const hasChannel = realtimeConfig.channelName.length > 0 const isListening = realtimeConfig.enabled + // Once a channel is set, MessagesTable renders its own empty states (including + // the "Broadcast a message" entry point), so sending doesn't depend on a + // message having arrived first. EmptyRealtime is only the pre-channel onboarding. + const showMessagesTable = hasChannel || (logData ?? []).length > 0 + const handleJoinChannel = useCallback(() => { if (!hasChannel) { setChannelPopoverOpen(true) @@ -98,7 +103,7 @@ export const RealtimeInspector = () => { />
- {(logData ?? []).length > 0 ? ( + {showMessagesTable ? ( Date: Mon, 31 Aug 2026 21:17:39 +0200 Subject: [PATCH 7/8] feat(studio): restore workers secrets page FE-4280 (#49762) ## Problem Workers Secrets was merged in #49589 into the stacked jordi/workers-detail branch. The parent Workers PR reached master without that child merge, leaving the page absent from staging. ## Fix Cherry-pick the missing Workers Secrets route, menu item, shared-secret copy, and generated route tree onto current master. The page uses the existing workers flag and permission gates. ## How to test - Enable the workers flag for a project with Workers access. - Open Workers, then select Secrets. - Expected result: the shared project secrets page renders at /project/:ref/workers/secrets and is not treated as a worker named secrets. - Add, edit, or delete a secret, then confirm the same value appears under Edge Functions, Secrets. ## Summary by CodeRabbit * **New Features** * Added a **Secrets** page to the Workers section. * Added navigation to Worker secrets from the Workers menu. * Displayed default secrets and deployment-specific guidance where applicable. * Clarified that platform secrets are shared between Edge Functions and Workers. * Updated deletion warnings to reflect shared secret usage. --- apps/studio/TANSTACK_MIGRATION.md | 1 + .../EdgeFunctionSecrets.tsx | 19 +++- .../layouts/WorkersLayout/WorkersLayout.tsx | 6 + .../pages/project/[ref]/workers/secrets.tsx | 106 ++++++++++++++++++ apps/studio/routeTree.gen.ts | 22 ++++ .../routes/project/$ref/workers/secrets.tsx | 18 +++ 6 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 apps/studio/pages/project/[ref]/workers/secrets.tsx create mode 100644 apps/studio/routes/project/$ref/workers/secrets.tsx diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 13644b1c7eacd..e41b2b124dde4 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -232,6 +232,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] A `routes/project/$ref/workers/index.tsx` ← `pages/project/[ref]/workers/index.tsx` - [x] A `routes/project/$ref/workers/$name.tsx` ← `pages/project/[ref]/workers/[name].tsx` +- [x] A `routes/project/$ref/workers/secrets.tsx` ← `pages/project/[ref]/workers/secrets.tsx` ### Project shell — `/functions/*` diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index 589f111693bc4..b7f60af7cd81b 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import { Search } from 'lucide-react' import { parseAsString, useQueryState } from 'nuqs' import { useEffect, useMemo, useState } from 'react' @@ -27,6 +27,7 @@ import { DOCS_URL } from '@/lib/constants' export const EdgeFunctionSecrets = () => { const { ref: projectRef } = useParams() + const workersEnabled = useFlag('workers') const [searchString, setSearchString] = useState('') const { can: canReadSecrets, isLoading: isLoadingSecretsPermissions } = useAsyncCheckPermissions( @@ -231,10 +232,18 @@ export const EdgeFunctionSecrets = () => { } }} > -

- Ensure none of your edge functions are actively using this secret before deleting it. This - action cannot be undone. -

+ {workersEnabled ? ( +

+ Ensure none of your edge functions or{' '} + workers are actively using this secret before + deleting it. This action cannot be undone. +

+ ) : ( +

+ Ensure none of your edge functions are actively using this secret before deleting it. + This action cannot be undone. +

+ )} ) diff --git a/apps/studio/components/layouts/WorkersLayout/WorkersLayout.tsx b/apps/studio/components/layouts/WorkersLayout/WorkersLayout.tsx index 3931a524d21a4..928c72d573344 100644 --- a/apps/studio/components/layouts/WorkersLayout/WorkersLayout.tsx +++ b/apps/studio/components/layouts/WorkersLayout/WorkersLayout.tsx @@ -26,6 +26,12 @@ const useGenerateWorkersMenu = (): ProductMenuGroup[] => { url: `/project/${projectRef}/workers`, items: [], }, + { + name: 'Secrets', + key: 'secrets', + url: `/project/${projectRef}/workers/secrets`, + items: [], + }, ], }, ], diff --git a/apps/studio/pages/project/[ref]/workers/secrets.tsx b/apps/studio/pages/project/[ref]/workers/secrets.tsx new file mode 100644 index 0000000000000..f4fe171b6ae5f --- /dev/null +++ b/apps/studio/pages/project/[ref]/workers/secrets.tsx @@ -0,0 +1,106 @@ +import type { PropsWithChildren } from 'react' +import { Admonition } from 'ui-patterns/Admonition' +import { PageContainer } from 'ui-patterns/PageContainer' +import { + PageHeader, + PageHeaderDescription, + PageHeaderMeta, + PageHeaderSummary, + PageHeaderTitle, +} from 'ui-patterns/PageHeader' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' + +import { DefaultEdgeFunctionSecrets } from '@/components/interfaces/Functions/EdgeFunctionSecrets/DefaultEdgeFunctionSecrets' +import { DEFAULT_EDGE_FUNCTION_SECRETS } from '@/components/interfaces/Functions/EdgeFunctionSecrets/DefaultEdgeFunctionSecrets.utils' +import { EdgeFunctionSecrets } from '@/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets' +import { DefaultLayout } from '@/components/layouts/DefaultLayout' +import { WorkersLayout } from '@/components/layouts/WorkersLayout/WorkersLayout' +import { DocsButton } from '@/components/ui/DocsButton' +import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode' +import { DOCS_URL, IS_PLATFORM } from '@/lib/constants' +import type { NextPageWithLayout } from '@/types' + +const WorkerSecretsPage: NextPageWithLayout = () => { + const { isCli, isSelfHosted } = useDeploymentMode() + + if (!IS_PLATFORM) { + return ( + + + + {isCli && ( + Add custom secrets from the Supabase CLI.

} + /> + )} + {isSelfHosted && ( + Set custom secrets via environment variables.

} + /> + )} +
+
+
+

Default secrets

+

+ Reserved secrets available in every project +

+
+ +
+ !secret.isRuntime)} + /> +
+
+
+
+ ) + } + + return ( + + + + + + + + + ) +} + +// Hoisted out of `getLayout` so the TanStack route can import it directly. +export const WorkerSecretsPageWrapper = ({ children }: PropsWithChildren) => ( +
+ + + + Secrets + + Environment variables loaded into every worker at start-up + + + + + + {children} +
+) + +WorkerSecretsPage.getLayout = (page) => ( + + + {page} + + +) + +export default WorkerSecretsPage diff --git a/apps/studio/routeTree.gen.ts b/apps/studio/routeTree.gen.ts index 7be3871d460d3..8c15cc0547c0e 100644 --- a/apps/studio/routeTree.gen.ts +++ b/apps/studio/routeTree.gen.ts @@ -100,6 +100,7 @@ import { Route as ApiPlatformProfileIndexRouteImport } from './routes/api/platfo import { Route as ApiPlatformOrganizationsIndexRouteImport } from './routes/api/platform/organizations/index' import { Route as AppOrgSlugIndexRouteImport } from './routes/_app/org/$slug/index' import { Route as AppAccountTokensIndexRouteImport } from './routes/_app/account/tokens/index' +import { Route as ProjectRefWorkersSecretsRouteImport } from './routes/project/$ref/workers/secrets' import { Route as ProjectRefWorkersNameRouteImport } from './routes/project/$ref/workers/$name' import { Route as ProjectRefStorageS3RouteImport } from './routes/project/$ref/storage/s3' import { Route as ProjectRefSqlTemplatesRouteImport } from './routes/project/$ref/sql/templates' @@ -794,6 +795,12 @@ const AppAccountTokensIndexRoute = AppAccountTokensIndexRouteImport.update({ path: '/tokens/', getParentRoute: () => AppAccountRoute, } as any) +const ProjectRefWorkersSecretsRoute = + ProjectRefWorkersSecretsRouteImport.update({ + id: '/secrets', + path: '/secrets', + getParentRoute: () => ProjectRefWorkersRoute, + } as any) const ProjectRefWorkersNameRoute = ProjectRefWorkersNameRouteImport.update({ id: '/$name', path: '/$name', @@ -2277,6 +2284,7 @@ export interface FileRoutesByFullPath { '/project/$ref/sql/templates': typeof ProjectRefSqlTemplatesRoute '/project/$ref/storage/s3': typeof ProjectRefStorageS3Route '/project/$ref/workers/$name': typeof ProjectRefWorkersNameRoute + '/project/$ref/workers/secrets': typeof ProjectRefWorkersSecretsRoute '/account/tokens/': typeof AppAccountTokensIndexRoute '/org/$slug/': typeof AppOrgSlugIndexRoute '/api/platform/organizations/': typeof ApiPlatformOrganizationsIndexRoute @@ -2580,6 +2588,7 @@ export interface FileRoutesByTo { '/project/$ref/sql/templates': typeof ProjectRefSqlTemplatesRoute '/project/$ref/storage/s3': typeof ProjectRefStorageS3Route '/project/$ref/workers/$name': typeof ProjectRefWorkersNameRoute + '/project/$ref/workers/secrets': typeof ProjectRefWorkersSecretsRoute '/account/tokens': typeof AppAccountTokensIndexRoute '/org/$slug': typeof AppOrgSlugIndexRoute '/api/platform/organizations': typeof ApiPlatformOrganizationsIndexRoute @@ -2900,6 +2909,7 @@ export interface FileRoutesById { '/project/$ref/sql/templates': typeof ProjectRefSqlTemplatesRoute '/project/$ref/storage/s3': typeof ProjectRefStorageS3Route '/project/$ref/workers/$name': typeof ProjectRefWorkersNameRoute + '/project/$ref/workers/secrets': typeof ProjectRefWorkersSecretsRoute '/_app/account/tokens/': typeof AppAccountTokensIndexRoute '/_app/org/$slug/': typeof AppOrgSlugIndexRoute '/api/platform/organizations/': typeof ApiPlatformOrganizationsIndexRoute @@ -3219,6 +3229,7 @@ export interface FileRouteTypes { | '/project/$ref/sql/templates' | '/project/$ref/storage/s3' | '/project/$ref/workers/$name' + | '/project/$ref/workers/secrets' | '/account/tokens/' | '/org/$slug/' | '/api/platform/organizations/' @@ -3522,6 +3533,7 @@ export interface FileRouteTypes { | '/project/$ref/sql/templates' | '/project/$ref/storage/s3' | '/project/$ref/workers/$name' + | '/project/$ref/workers/secrets' | '/account/tokens' | '/org/$slug' | '/api/platform/organizations' @@ -3841,6 +3853,7 @@ export interface FileRouteTypes { | '/project/$ref/sql/templates' | '/project/$ref/storage/s3' | '/project/$ref/workers/$name' + | '/project/$ref/workers/secrets' | '/_app/account/tokens/' | '/_app/org/$slug/' | '/api/platform/organizations/' @@ -4744,6 +4757,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppAccountTokensIndexRouteImport parentRoute: typeof AppAccountRoute } + '/project/$ref/workers/secrets': { + id: '/project/$ref/workers/secrets' + path: '/secrets' + fullPath: '/project/$ref/workers/secrets' + preLoaderRoute: typeof ProjectRefWorkersSecretsRouteImport + parentRoute: typeof ProjectRefWorkersRoute + } '/project/$ref/workers/$name': { id: '/project/$ref/workers/$name' path: '/$name' @@ -6948,11 +6968,13 @@ const ProjectRefStorageRouteWithChildren = interface ProjectRefWorkersRouteChildren { ProjectRefWorkersNameRoute: typeof ProjectRefWorkersNameRoute + ProjectRefWorkersSecretsRoute: typeof ProjectRefWorkersSecretsRoute ProjectRefWorkersIndexRoute: typeof ProjectRefWorkersIndexRoute } const ProjectRefWorkersRouteChildren: ProjectRefWorkersRouteChildren = { ProjectRefWorkersNameRoute: ProjectRefWorkersNameRoute, + ProjectRefWorkersSecretsRoute: ProjectRefWorkersSecretsRoute, ProjectRefWorkersIndexRoute: ProjectRefWorkersIndexRoute, } diff --git a/apps/studio/routes/project/$ref/workers/secrets.tsx b/apps/studio/routes/project/$ref/workers/secrets.tsx new file mode 100644 index 0000000000000..05d44c5e7789d --- /dev/null +++ b/apps/studio/routes/project/$ref/workers/secrets.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from '@tanstack/react-router' + +import WorkerSecretsPage, { WorkerSecretsPageWrapper } from '@/pages/project/[ref]/workers/secrets' + +export const Route = createFileRoute('/project/$ref/workers/secrets')({ + component: WorkerSecretsRoute, + staticData: { + workersLayoutTitle: 'Secrets', + }, +}) + +function WorkerSecretsRoute() { + return ( + + + + ) +} From eea39cc3163b34627cfec31a6756b80280874f41 Mon Sep 17 00:00:00 2001 From: Douglas J Hunley Date: Mon, 31 Aug 2026 15:28:37 -0400 Subject: [PATCH 8/8] fix(studio): register pitr_archiving_stale in the advisor lintInfoMap (#48044) ## Summary Studio's Advisor UI reads lint metadata from a fixed `lintInfoMap`, not from the API response. A lint name missing from that map shows a blank title, no icon, no filter checkbox, and no remediation link. This PR adds a `pitr_archiving_stale` entry to `lintInfoMap`, copied from the existing `pitr_not_enabled` entry, so the new lint renders correctly in the Advisor UI. ## Dependencies > [!WARNING] > [supabase/platform#35862](https://github.com/supabase/platform/pull/35862) defines the `pitr_archiving_stale` lint. Until it merges, the API never sends this lint name, so the Advisor grid and the public `/v1/projects/{ref}/advisors/security` response never show the new row -- but the Security Rules page (`/project//advisors/rules/security`) renders one row per `lintInfoMap` entry regardless of the API, so this PR's new row appears there immediately, before the backend lint exists. See Details for what that means in the gap between merges. ---
Details - A lint name missing from `lintInfoMap` has these effects: - The grid row shows a blank title and no icon. There is no fallback to the API's own `title`. - The row has no filter checkbox. Filter options come from `lintInfoMap`, not from the API. - The row has no lint-specific remediation link. The "Learn more" link falls back to the generic database-linter page. - The row does not appear in the Advisor Rules enable/disable list. - The new `pitr_archiving_stale` entry copies the existing `pitr_not_enabled` entry's `link`, `docsLink`, and `category`, and uses a new `title` matching [supabase/platform#35862](https://github.com/supabase/platform/pull/35862)'s lint definition verbatim. Its `name` also matches that lint definition exactly. - **Known gap, until the backend PR merges:** `AdvisorRules` (`components/interfaces/Advisors/AdvisorRules.tsx`) filters `lintInfoMap` by `category` alone, with no dependency on the API returning the lint -- so this entry makes a "PITR archiving may be broken" row appear on the Security Rules page for every project right away, ahead of the backend lint actually existing. From that row, a user can open `CreateRuleSheet` and submit a disable rule, which `POST`s `lint_name: 'pitr_archiving_stale'` to the notification-exceptions endpoint. That name is not yet in the generated `CreateNotificationExceptionsBody` enum (`packages/api-types/types/platform.d.ts`), so the request either errors or stores an exception keyed to a lint name nothing will ever match, until api-types regenerates after the backend PR ships. This window closes on its own once [supabase/platform#35862](https://github.com/supabase/platform/pull/35862) merges; accepted as a short-lived tradeoff rather than gating this PR on merge order or adding code to hide the row until then. - The docs anchor (`#point-in-time-recovery`) explains what PITR and WAL-G archiving are. It does not explain how to fix a stale or broken archive. That content does not exist yet in either pull request. INDATA-1149 tracks this as a follow-up. - `packages/api-types/types/platform.d.ts` is a generated file. This repo's own CLAUDE.md says never to hand-edit it. The file does not list `pitr_archiving_stale` yet, because it regenerates only after the backend lint ships and `pnpm api:codegen` runs. Until then, `LintInfo['name']` stays a plain `string`. If someone misspells the new entry's `name`, the code still compiles and the tests still pass. At runtime, the icon and docs link fall back silently instead of failing a build. Once [supabase/platform#35862](https://github.com/supabase/platform/pull/35862) merges and api-types regenerates, `LintInfo['name']` must tighten to the generated `LINT_TYPES` union. This closes the gap for every lint entry, not only this one.
---
Testing - `pnpm --filter=studio test Linter.utils.test.tsx` (17 passed, including a test that asserts the `pitr_archiving_stale` entry's shape) - `pnpm typecheck --filter=studio` (clean) - `pnpm exec eslint` on the touched files (clean; the `pnpm lint --filter=studio` turbo wrapper itself errors on this machine with an unrelated JSON-parse failure -- a tool-invocation issue, not a lint finding) - `prettier --check` on the touched files - The `docsLink` assertion (`toContain('/guides/platform/backups#point-in-time-recovery')`) is domain-agnostic by construction, so it holds regardless of which `NEXT_PUBLIC_DOCS_URL` value is set -- no test in this file overrides that variable, this is a property of the assertion's own shape, not a scenario the suite exercises
---
Misc - Part of INDATA-979 - Changelog: [supabase/changelog#192](https://github.com/supabase/changelog/pull/192)
--- .../interfaces/Linter/Linter.utils.test.tsx | 17 +++++++++++++++++ .../interfaces/Linter/Linter.utils.tsx | 10 ++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.test.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.test.tsx index b60ef3e36cc2b..054c0d01c66d3 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.test.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.test.tsx @@ -1,3 +1,5 @@ +import { Ruler } from 'lucide-react' +import { isValidElement } from 'react' import { describe, expect, it } from 'vitest' import { lintInfoMap } from './Linter.utils' @@ -66,3 +68,18 @@ describe('Linter.utils lintInfoMap link encoding', () => { expect(url).toBe('/project/abc/storage/files/buckets/a%2Fb%20c') }) }) + +describe('Linter.utils lintInfoMap pitr_archiving_stale entry', () => { + it('registers a security entry linking to the PITR settings page', () => { + const info = lintInfoMap.find((entry) => entry.name === 'pitr_archiving_stale') + expect(info, 'expected pitr_archiving_stale in lintInfoMap').toBeDefined() + + expect(info!.title).toBe('PITR archiving may be broken') + expect(isValidElement(info!.icon) && info!.icon.type).toBe(Ruler) + expect(info!.category).toBe('security') + expect(info!.linkText).toBe('View settings') + // metadata is unused by this entry's link(), and every field on Lint['metadata'] is optional, so {} needs no cast + expect(info!.link({ projectRef, metadata: {} })).toBe('/project/abc/database/backups/pitr') + expect(info!.docsLink).toContain('/guides/platform/backups#point-in-time-recovery') + }) +}) diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx index 2e8a7b0c11c4d..bc373167d904a 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx @@ -257,6 +257,16 @@ export const lintInfoMap: LintInfo[] = [ docsLink: `${DOCS_URL}/guides/platform/backups#point-in-time-recovery`, category: 'security', }, + { + name: 'pitr_archiving_stale', + title: 'PITR archiving may be broken', + icon: , + link: ({ projectRef }) => `/project/${projectRef}/database/backups/pitr`, + linkText: 'View settings', + // anchor explains what PITR is, not how to fix a stale archive; PITR-archiving-specific docs tracked in TODO: INDATA-1149 + docsLink: `${DOCS_URL}/guides/platform/backups#point-in-time-recovery`, + category: 'security', + }, { name: 'auth_leaked_password_protection', title: 'Leaked Password Protection Disabled',