From 754995b97ee49707b826710a0b393c84763fa495 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 10 Nov 2026 16:01:05 +0000 Subject: [PATCH 1/7] Preserve native fetch binding for HTTP API requests --- src/platform/cloudflare.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/cloudflare.ts b/src/platform/cloudflare.ts index d63ea786..4e8edc8b 100644 --- a/src/platform/cloudflare.ts +++ b/src/platform/cloudflare.ts @@ -18,7 +18,7 @@ export function getApplicationEnv(): ApplicationEnv { fetch: transport === 'binding' ? (input, init) => env.EXTENSIONS_API.fetch(input, init) - : globalThis.fetch, + : (input, init) => globalThis.fetch(input, init), }, authClientId: env.AUTH_CLIENT_ID, authClientSecret: env.AUTH_CLIENT_SECRET, From 1e2df6c472ae7bbdefc0bd8f557d436dfc11b97e Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 10 Nov 2026 16:07:51 +0000 Subject: [PATCH 2/7] Use the staging API for directory branch previews --- README.md | 6 +++++ worker-configuration.d.ts | 49 +++++++++++++++++++++------------------ wrangler.jsonc | 12 +++------- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index a306df29..62fd6ca1 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,12 @@ npx wrangler secret put ASSERTION_SIGNING_SECRET npx wrangler secret put EXTENSIONS_REVALIDATE_SECRET ``` +Branch Previews use HTTP transport to the shared staging API and display synthetic +data. Use the fixed staging directory or localhost for authenticated testing; the +development OAuth client registers those exact callbacks, rather than every branch +hostname. Its assertion-signing and revalidation secrets must match the API's +Previews Base settings. + ## License The extension directory website is licensed under the GNU Affero General Public License Version 3. See [LICENSE](./LICENSE) for details. diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index e9c9cfa0..afcbb77b 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,16 +1,16 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: fbad63b3d62be095c15c2f040127647a) -// Runtime types generated with workerd@1.20261012.1 2026-10-01 global_fetch_strictly_public +// Generated by Wrangler by running `wrangler types` (hash: e08f648e3c78128ed464bfb61b3d3300) +// Runtime types generated with workerd@1.20261104.1 2026-10-01 global_fetch_strictly_public interface __BaseEnv_Env { ASSETS: Fetcher; - ENVIRONMENT: "production"; AUTH_CLIENT_ID: string; AUTH_CLIENT_SECRET: string; SESSION_SECRET: string; ASSERTION_SIGNING_SECRET: string; - EXTENSIONS_API_BASE_URL: string; - EXTENSIONS_API_TRANSPORT: string; EXTENSIONS_REVALIDATE_SECRET: string; + EXTENSIONS_API_TRANSPORT: string; + EXTENSIONS_API_BASE_URL: string; + ENVIRONMENT: string; EXTENSIONS_API: Fetcher /* api */; } declare namespace Cloudflare { @@ -21,7 +21,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } declare module "*.txt" { const value: string; @@ -473,6 +473,7 @@ interface ExecutionContext { readonly props: Props; cache?: CacheContext; readonly access?: CloudflareAccessContext; + mapVirtualHost(fetcher: Fetcher, port: number): string; tracing: Tracing; abort(reason?: any): void; } @@ -573,9 +574,14 @@ type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; routingMode?: DurableObjectRoutingMode; + retry?: DurableObjectRetryOptions; } interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> { } +interface DurableObjectRetryOptions { + maxRetries?: number; + timeoutMs?: number; +} interface DurableObjectState { waitUntil(promise: Promise): void; readonly exports: Cloudflare.Exports; @@ -3490,9 +3496,9 @@ interface ContainerSnapshotOptions { } type ContainerStartupOptions = { entrypoint?: string[]; - enableInternet: boolean; + enableInternet?: boolean; env?: Record; - instance?: "lite" | "standard-1" | "standard-2" | "standard-3" | "standard-4" | ContainerStartResources; + instance?: "lite" | "basic" | "standard-1" | "standard-2" | "standard-3" | "standard-4" | ContainerStartResources; labels?: Record; directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; } & ({ @@ -11568,20 +11574,16 @@ interface BrowserRunBaseOptions { */ cacheTTL?: number; } -/** - * Backend selection, mixed into the options of the quick actions that support it. - * Deliberately not part of `BrowserRunBaseOptions`: `scrape`, `links` and `snapshot` - * reject an alternate backend, so they must not accept the field. - */ +/** Backend selection for quick actions. */ interface BrowserRunAlternateBackendOptions { /** Render with an alternate browser backend instead of the default one. */ browser?: 'kitesurf'; } /** Common options shared by all quick actions. Exactly one of `url` or `html` must be provided.*/ -type BrowserRunCommonOptions = (BrowserRunBaseOptions & { +type BrowserRunCommonOptions = BrowserRunBaseOptions & BrowserRunAlternateBackendOptions & ({ /** URL to navigate to, e.g. `"https://example.com"`. */ url: string; -}) | (BrowserRunBaseOptions & { +} | { /** Set the HTML content of the page directly. */ html: string; }); @@ -11611,7 +11613,7 @@ type BrowserRunScreenshotOptions = BrowserRunCommonOptions & { scrollPage?: boolean; /** @see https://pptr.dev/api/puppeteer.screenshotoptions */ screenshotOptions?: BrowserRunPuppeteerScreenshotOptions; -} & BrowserRunAlternateBackendOptions; +}; type BrowserRunPDFOptions = BrowserRunCommonOptions & { /** @see https://pptr.dev/api/puppeteer.pdfoptions */ pdfOptions?: { @@ -11647,7 +11649,7 @@ type BrowserRunPDFOptions = BrowserRunCommonOptions & { /** @default 30000 */ timeout?: number; }; -} & BrowserRunAlternateBackendOptions; +}; type BrowserRunScrapeOptions = BrowserRunCommonOptions & { /** CSS selectors to scrape. At least one element is required. */ elements: Array<{ @@ -11682,7 +11684,7 @@ type BrowserRunAccessibilityTreeOptions = BrowserRunCommonOptions & { * HTTP 200; a malformed selector is an error. */ root?: string; -} & BrowserRunAlternateBackendOptions; +}; interface BrowserRunJsonBaseOptions { /** Custom AI models to try in order. Max 3. Falls back to next on error. */ custom_ai?: Array<{ @@ -11696,7 +11698,7 @@ interface BrowserRunJsonBaseOptions { * Options for the `json` quick action. * At least one of `prompt` or `response_format` must be provided. */ -type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions & BrowserRunJsonBaseOptions & ({ +type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunJsonBaseOptions & ({ /** Natural-language prompt describing what data to extract. */ prompt: string; /** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */ @@ -11707,8 +11709,8 @@ type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunAlternateBacken /** Structured output schema for the AI model. @see https://developers.cloudflare.com/workers-ai/json-mode/ */ response_format: AiTextGenerationResponseFormat; }); -type BrowserRunContentOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions; -type BrowserRunMarkdownOptions = BrowserRunCommonOptions & BrowserRunAlternateBackendOptions; +type BrowserRunContentOptions = BrowserRunCommonOptions; +type BrowserRunMarkdownOptions = BrowserRunCommonOptions; type BrowserRunRedirectHop = { /** URL that returned the redirect. */ url: string; @@ -15415,7 +15417,7 @@ declare namespace TailStream { } type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime" | "aborted"; interface ScriptVersion { - readonly id: string; + readonly id?: string; readonly tag?: string; readonly message?: string; } @@ -15426,7 +15428,7 @@ declare namespace TailStream { } interface Onset { readonly type: "onset"; - readonly attributes: Attribute[]; + readonly attributes?: Attribute[]; // id for the span being opened by this Onset event. readonly spanId: string; readonly dispatchNamespace?: string; @@ -15565,6 +15567,7 @@ declare namespace TailStream { diagnosticChannel?: TailEventHandler; exception?: TailEventHandler; log?: TailEventHandler; + streamDiagEvent?: TailEventHandler; return?: TailEventHandler; attributes?: TailEventHandler; }; diff --git a/wrangler.jsonc b/wrangler.jsonc index 46edbdd0..a53883b1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -49,14 +49,8 @@ "previews": { "vars": { "ENVIRONMENT": "preview", - "EXTENSIONS_API_BASE_URL": "https://api.fossbilling.net", - "EXTENSIONS_API_TRANSPORT": "binding" - }, - "services": [ - { - "binding": "EXTENSIONS_API", - "service": "api" - } - ] + "EXTENSIONS_API_BASE_URL": "https://staging-api.fossbilling.workers.dev", + "EXTENSIONS_API_TRANSPORT": "http" + } } } From 7cc4f9544dea8cd038a940fa7471cf63d5844bd5 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 14 Nov 2026 10:46:28 +0000 Subject: [PATCH 3/7] Cover native HTTP fetch binding and clarify preview OAuth setup --- README.md | 11 ++++++----- test/cloudflare-platform.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 test/cloudflare-platform.test.ts diff --git a/README.md b/README.md index 62fd6ca1..d27fc8d2 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ cp .dev.vars.example .dev.vars | Variable | Notes | | --- | --- | -| `AUTH_CLIENT_ID` / `AUTH_CLIENT_SECRET` | Issued by an admin of [`FOSSBilling/auth`](https://github.com/FOSSBilling/auth). Request `https://extensions.fossbilling.org/auth/callback` and `http://localhost:4321/auth/callback` as redirect URIs. | +| `AUTH_CLIENT_ID` / `AUTH_CLIENT_SECRET` | Issued by an admin of [`FOSSBilling/auth`](https://github.com/FOSSBilling/auth). The production client uses `https://extensions.fossbilling.org/auth/callback`; use the development client for localhost and staging (see below). | | `SESSION_SECRET` | Any random string, e.g. `openssl rand -base64 32`. | | `ASSERTION_SIGNING_SECRET` | Must match the `api` repo's value exactly. | | `EXTENSIONS_REVALIDATE_SECRET` | Shared with the `api` repo for `POST /api/revalidate`. | @@ -98,10 +98,11 @@ npx wrangler secret put EXTENSIONS_REVALIDATE_SECRET ``` Branch Previews use HTTP transport to the shared staging API and display synthetic -data. Use the fixed staging directory or localhost for authenticated testing; the -development OAuth client registers those exact callbacks, rather than every branch -hostname. Its assertion-signing and revalidation secrets must match the API's -Previews Base settings. +data. For authenticated testing, one development OAuth client registers +`http://localhost:4321/auth/callback` and +`https://extensions-staging.fossbilling.workers.dev/auth/callback`; branch hostnames +are not registered. The staging directory's assertion-signing and revalidation +secrets must match the API's Previews Base settings. ## License diff --git a/test/cloudflare-platform.test.ts b/test/cloudflare-platform.test.ts new file mode 100644 index 00000000..3423983e --- /dev/null +++ b/test/cloudflare-platform.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getApplicationEnv } from '@/platform/cloudflare'; + +vi.mock('cloudflare:workers', () => ({ + env: { + EXTENSIONS_API_TRANSPORT: 'http', + EXTENSIONS_API_BASE_URL: 'https://staging-api.example.test', + }, +})); + +afterEach(() => vi.unstubAllGlobals()); + +describe('Cloudflare HTTP transport', () => { + it('calls native fetch with its global receiver', async () => { + const request = new Request( + 'https://staging-api.example.test/extensions/v2', + ); + const response = new Response('{}'); + vi.stubGlobal('fetch', function (this: unknown, input: RequestInfo | URL) { + expect(this).toBe(globalThis); + expect(input).toBe(request); + return Promise.resolve(response); + }); + + const env = getApplicationEnv(); + await expect(env.extensionsApi.fetch(request)).resolves.toBe(response); + }); +}); From 95825854befe64ac7a33fa84001904eddb0ba225 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 14 Nov 2026 11:29:39 +0000 Subject: [PATCH 4/7] Synchronize branch Preview OAuth callbacks --- .github/workflows/preview-auth.yml | 43 ++++++++++++ README.md | 17 +++-- scripts/sync-preview-callbacks.ts | 108 +++++++++++++++++++++++++++++ test/preview-callbacks.test.ts | 77 ++++++++++++++++++++ 4 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/preview-auth.yml create mode 100644 scripts/sync-preview-callbacks.ts create mode 100644 test/preview-callbacks.test.ts diff --git a/.github/workflows/preview-auth.yml b/.github/workflows/preview-auth.yml new file mode 100644 index 00000000..b729fa45 --- /dev/null +++ b/.github/workflows/preview-auth.yml @@ -0,0 +1,43 @@ +name: Preview Authentication + +on: + check_run: + types: [completed] + delete: + workflow_dispatch: + schedule: + - cron: '17 * * * *' + +permissions: + contents: read + id-token: write + +concurrency: + group: preview-auth + cancel-in-progress: false + +jobs: + callbacks: + if: >- + github.event_name != 'check_run' || + (github.event.check_run.app.slug == 'cloudflare-workers-and-pages' && + github.event.check_run.name == 'Workers Builds: extensions' && + github.event.check_run.conclusion == 'success') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout trusted workflow code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: main + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: '26' + - name: Reconcile Preview callbacks + run: node scripts/sync-preview-callbacks.ts + env: + GITHUB_TOKEN: ${{ github.token }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_PREVIEW_READ_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_READ_TOKEN }} diff --git a/README.md b/README.md index d27fc8d2..31e75201 100644 --- a/README.md +++ b/README.md @@ -98,11 +98,18 @@ npx wrangler secret put EXTENSIONS_REVALIDATE_SECRET ``` Branch Previews use HTTP transport to the shared staging API and display synthetic -data. For authenticated testing, one development OAuth client registers -`http://localhost:4321/auth/callback` and -`https://extensions-staging.fossbilling.workers.dev/auth/callback`; branch hostnames -are not registered. The staging directory's assertion-signing and revalidation -secrets must match the API's Previews Base settings. +data. The development OAuth client is shared by localhost, fixed staging and +same-repository branch Previews. Set its credentials, a preview-only `SESSION_SECRET`, and the staging API's +assertion-signing/revalidation secrets in Cloudflare's **Previews Base**. Existing +Previews need these secrets set individually. + +The **Preview Authentication** workflow registers exact deployed branch callbacks +when Cloudflare builds finish, removes callbacks for deleted branches, and reconciles +hourly to clean up deleted Previews. Set the repository's `CLOUDFLARE_ACCOUNT_ID` +variable and `CLOUDFLARE_PREVIEW_READ_TOKEN` secret (Workers Content Read-Only). +The auth service must configure `EXTENSIONS_PREVIEW_CLIENT_ID` to this development +client; it accepts callback changes only from this workflow on `main`, via GitHub +Actions OIDC. The localhost and fixed-staging callbacks remain registered. ## License diff --git a/scripts/sync-preview-callbacks.ts b/scripts/sync-preview-callbacks.ts new file mode 100644 index 00000000..cbf44472 --- /dev/null +++ b/scripts/sync-preview-callbacks.ts @@ -0,0 +1,108 @@ +import { pathToFileURL } from 'node:url'; + +type Preview = { name: string; urls: string[] }; +type Page = { result: T[]; result_info?: { total_count?: number } }; + +async function readJson( + fetcher: typeof fetch, + url: string, + token: string, +): Promise { + const response = await fetcher(url, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) + throw new Error(`Metadata request failed: HTTP ${response.status}`); + return response.json() as Promise; +} + +export async function syncPreviewCallbacks( + options: { + githubToken: string; + cloudflareToken: string; + cloudflareAccount: string; + oidcRequestUrl: string; + oidcRequestToken: string; + }, + fetcher: typeof fetch = (...args) => globalThis.fetch(...args), +) { + const branches = new Set(); + for (let page = 1; ; page++) { + const rows = await readJson>( + fetcher, + `https://api.github.com/repos/FOSSBilling/extensions/branches?per_page=100&page=${page}`, + options.githubToken, + ); + rows.forEach((row) => branches.add(row.name)); + if (rows.length < 100) break; + } + const base = `https://api.cloudflare.com/client/v4/accounts/${options.cloudflareAccount}`; + const service = await readJson<{ + result: { default_environment: { script: { tag: string } } }; + }>(fetcher, `${base}/workers/services/extensions`, options.cloudflareToken); + const previews: Preview[] = []; + for (let page = 1; ; page++) { + const data = await readJson>( + fetcher, + `${base}/workers/workers/${service.result.default_environment.script.tag}/previews?per_page=100&page=${page}`, + options.cloudflareToken, + ); + previews.push(...data.result); + if ( + data.result.length < 100 || + previews.length >= (data.result_info?.total_count ?? Infinity) + ) + break; + } + const callbacks = [ + ...new Set( + previews + .filter( + (preview) => preview.name !== 'main' && branches.has(preview.name), + ) + .flatMap((preview) => preview.urls) + .map((origin) => new URL('/auth/callback', origin).href), + ), + ].sort(); + const endpoint = + 'https://auth.fossbilling.net/integrations/extensions-preview-callbacks'; + const oidcUrl = new URL(options.oidcRequestUrl); + oidcUrl.searchParams.set('audience', endpoint); + const { value } = await readJson<{ value: string }>( + fetcher, + oidcUrl.href, + options.oidcRequestToken, + ); + const response = await fetcher(endpoint, { + method: 'PUT', + headers: { + Authorization: `Bearer ${value}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(callbacks), + signal: AbortSignal.timeout(30_000), + }); + if (response.status !== 204) + throw new Error(`Callback registration failed: HTTP ${response.status}`); + return callbacks.length; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + function required(name: string) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; + } + const count = await syncPreviewCallbacks({ + githubToken: required('GITHUB_TOKEN'), + cloudflareToken: required('CLOUDFLARE_PREVIEW_READ_TOKEN'), + cloudflareAccount: required('CLOUDFLARE_ACCOUNT_ID'), + oidcRequestUrl: required('ACTIONS_ID_TOKEN_REQUEST_URL'), + oidcRequestToken: required('ACTIONS_ID_TOKEN_REQUEST_TOKEN'), + }); + console.log(`Registered ${count} active branch Preview callbacks.`); +} diff --git a/test/preview-callbacks.test.ts b/test/preview-callbacks.test.ts new file mode 100644 index 00000000..44c78d3b --- /dev/null +++ b/test/preview-callbacks.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { syncPreviewCallbacks } from '../scripts/sync-preview-callbacks'; + +const options = { + githubToken: 'github-token', + cloudflareToken: 'read-only-cloudflare-token', + cloudflareAccount: 'account', + oidcRequestUrl: 'https://oidc.example.test/token?request=1', + oidcRequestToken: 'oidc-request-token', +}; + +describe('Preview callback reconciliation', () => { + it('registers live branch Preview URLs and removes orphaned callbacks', async () => { + let callbacks: unknown; + const fetcher = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = String(input); + if (url.startsWith('https://api.github.com/')) { + return Response.json([{ name: 'feature/test' }, { name: 'main' }]); + } + if (url.endsWith('/workers/services/extensions')) { + return Response.json({ + result: { default_environment: { script: { tag: 'worker' } } }, + }); + } + if (url.includes('/previews?')) { + return Response.json({ + result: [ + { + name: 'feature/test', + urls: ['https://feature-test-extensions.fossbilling.workers.dev'], + }, + { + name: 'deleted-branch', + urls: [ + 'https://deleted-branch-extensions.fossbilling.workers.dev', + ], + }, + ], + }); + } + if (url.startsWith('https://oidc.example.test/')) { + expect(new URL(url).searchParams.get('audience')).toBe( + 'https://auth.fossbilling.net/integrations/extensions-preview-callbacks', + ); + return Response.json({ value: 'short-lived-oidc-token' }); + } + expect(url).toBe( + 'https://auth.fossbilling.net/integrations/extensions-preview-callbacks', + ); + expect(init?.method).toBe('PUT'); + expect(new Headers(init?.headers).get('Authorization')).toBe( + 'Bearer short-lived-oidc-token', + ); + callbacks = JSON.parse(String(init?.body)); + return new Response(null, { status: 204 }); + }) as typeof fetch; + expect(await syncPreviewCallbacks(options, fetcher)).toBe(1); + expect(callbacks).toEqual([ + 'https://feature-test-extensions.fossbilling.workers.dev/auth/callback', + ]); + }); + + it('does not clear callbacks when metadata cannot be read', async () => { + const calls: string[] = []; + const fetcher = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response(null, { status: 403 }); + }) as typeof fetch; + await expect(syncPreviewCallbacks(options, fetcher)).rejects.toThrow( + 'HTTP 403', + ); + expect(calls).toHaveLength(1); + }); +}); From fef26cd29cdc0a3a22b84a30e6a4f555afc524c8 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 14 Nov 2026 14:26:40 +0000 Subject: [PATCH 5/7] Document the dedicated Preview OAuth client --- README.md | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 31e75201..f473b359 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ cp .dev.vars.example .dev.vars | Variable | Notes | | --- | --- | -| `AUTH_CLIENT_ID` / `AUTH_CLIENT_SECRET` | Issued by an admin of [`FOSSBilling/auth`](https://github.com/FOSSBilling/auth). The production client uses `https://extensions.fossbilling.org/auth/callback`; use the development client for localhost and staging (see below). | +| `AUTH_CLIENT_ID` / `AUTH_CLIENT_SECRET` | Issued by an admin of [`FOSSBilling/auth`](https://github.com/FOSSBilling/auth). The production client uses `https://extensions.fossbilling.org/auth/callback`; use separate development and preview clients for localhost and staging (see below). | | `SESSION_SECRET` | Any random string, e.g. `openssl rand -base64 32`. | | `ASSERTION_SIGNING_SECRET` | Must match the `api` repo's value exactly. | | `EXTENSIONS_REVALIDATE_SECRET` | Shared with the `api` repo for `POST /api/revalidate`. | @@ -98,18 +98,31 @@ npx wrangler secret put EXTENSIONS_REVALIDATE_SECRET ``` Branch Previews use HTTP transport to the shared staging API and display synthetic -data. The development OAuth client is shared by localhost, fixed staging and -same-repository branch Previews. Set its credentials, a preview-only `SESSION_SECRET`, and the staging API's -assertion-signing/revalidation secrets in Cloudflare's **Previews Base**. Existing -Previews need these secrets set individually. +data. Use three separate OAuth clients: + +| Client | Used by | +| --- | --- | +| Production | `https://extensions.fossbilling.org` | +| Development | `http://localhost:4321` | +| Previews | `extensions-staging` and same-repository branch Previews | + +Set the preview client's `AUTH_CLIENT_ID` and `AUTH_CLIENT_SECRET`, a preview-only +`SESSION_SECRET`, and the staging API's assertion-signing/revalidation secrets in +Cloudflare's **Previews Base**. Existing Previews need these secrets set individually. +Configure the fixed `extensions-staging` Worker with the same preview OAuth client; +keep the development client in local `.dev.vars`. + +Register `https://extensions-staging.fossbilling.workers.dev/auth/callback` on the +preview client. The workflow below manages branch callbacks; localhost belongs to +the separate development client. The **Preview Authentication** workflow registers exact deployed branch callbacks when Cloudflare builds finish, removes callbacks for deleted branches, and reconciles hourly to clean up deleted Previews. Set the repository's `CLOUDFLARE_ACCOUNT_ID` variable and `CLOUDFLARE_PREVIEW_READ_TOKEN` secret (Workers Content Read-Only). -The auth service must configure `EXTENSIONS_PREVIEW_CLIENT_ID` to this development +The auth service must configure `EXTENSIONS_PREVIEW_CLIENT_ID` to this preview client; it accepts callback changes only from this workflow on `main`, via GitHub -Actions OIDC. The localhost and fixed-staging callbacks remain registered. +Actions OIDC. The fixed-staging callback is preserved; the development client is not modified. ## License From 9459caabe559351746d7830cf45f24759359f3ac Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 14 Nov 2026 15:06:14 +0000 Subject: [PATCH 6/7] Use a named staging Preview for the directory --- README.md | 22 ++++++++++++++++------ scripts/sync-preview-callbacks.ts | 6 ++++-- test/preview-callbacks.test.ts | 9 +++++++-- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f473b359..c5961870 100644 --- a/README.md +++ b/README.md @@ -104,15 +104,24 @@ data. Use three separate OAuth clients: | --- | --- | | Production | `https://extensions.fossbilling.org` | | Development | `http://localhost:4321` | -| Previews | `extensions-staging` and same-repository branch Previews | +| Previews | Named `staging` Preview and same-repository branch Previews | Set the preview client's `AUTH_CLIENT_ID` and `AUTH_CLIENT_SECRET`, a preview-only `SESSION_SECRET`, and the staging API's assertion-signing/revalidation secrets in Cloudflare's **Previews Base**. Existing Previews need these secrets set individually. -Configure the fixed `extensions-staging` Worker with the same preview OAuth client; -keep the development client in local `.dev.vars`. - -Register `https://extensions-staging.fossbilling.workers.dev/auth/callback` on the +The named `staging` Preview uses these same Base settings; keep the development +client in local `.dev.vars`. + +Cloudflare Workers Builds builds `main` once with `npm run build`, then runs +`npx wrangler deploy && npx wrangler preview --name staging` to update production +and the named `staging` Preview. Staging serves +`https://staging-extensions.fossbilling.workers.dev` and uses the fixed +`https://staging-api.fossbilling.workers.dev` API. Both are Previews under their +existing Workers, without a separate staging Worker. API-side cache invalidation +calls the named directory Preview over HTTP; other branch Previews retain their +normal cache expiry and invalidate their own cache after directory edits. + +Register `https://staging-extensions.fossbilling.workers.dev/auth/callback` on the preview client. The workflow below manages branch callbacks; localhost belongs to the separate development client. @@ -122,7 +131,8 @@ hourly to clean up deleted Previews. Set the repository's `CLOUDFLARE_ACCOUNT_ID variable and `CLOUDFLARE_PREVIEW_READ_TOKEN` secret (Workers Content Read-Only). The auth service must configure `EXTENSIONS_PREVIEW_CLIENT_ID` to this preview client; it accepts callback changes only from this workflow on `main`, via GitHub -Actions OIDC. The fixed-staging callback is preserved; the development client is not modified. +Actions OIDC. The workflow includes the named `staging` Preview even though it is not a Git branch; +the development client is not modified. ## License diff --git a/scripts/sync-preview-callbacks.ts b/scripts/sync-preview-callbacks.ts index cbf44472..e3637fbf 100644 --- a/scripts/sync-preview-callbacks.ts +++ b/scripts/sync-preview-callbacks.ts @@ -59,7 +59,9 @@ export async function syncPreviewCallbacks( ...new Set( previews .filter( - (preview) => preview.name !== 'main' && branches.has(preview.name), + (preview) => + preview.name === 'staging' || + (preview.name !== 'main' && branches.has(preview.name)), ) .flatMap((preview) => preview.urls) .map((origin) => new URL('/auth/callback', origin).href), @@ -104,5 +106,5 @@ if ( oidcRequestUrl: required('ACTIONS_ID_TOKEN_REQUEST_URL'), oidcRequestToken: required('ACTIONS_ID_TOKEN_REQUEST_TOKEN'), }); - console.log(`Registered ${count} active branch Preview callbacks.`); + console.log(`Registered ${count} active Preview callbacks.`); } diff --git a/test/preview-callbacks.test.ts b/test/preview-callbacks.test.ts index 44c78d3b..71ee1e16 100644 --- a/test/preview-callbacks.test.ts +++ b/test/preview-callbacks.test.ts @@ -10,7 +10,7 @@ const options = { }; describe('Preview callback reconciliation', () => { - it('registers live branch Preview URLs and removes orphaned callbacks', async () => { + it('registers staging and live branch Preview URLs and removes orphaned callbacks', async () => { let callbacks: unknown; const fetcher = (async ( input: string | URL | Request, @@ -28,6 +28,10 @@ describe('Preview callback reconciliation', () => { if (url.includes('/previews?')) { return Response.json({ result: [ + { + name: 'staging', + urls: ['https://staging-extensions.fossbilling.workers.dev'], + }, { name: 'feature/test', urls: ['https://feature-test-extensions.fossbilling.workers.dev'], @@ -57,9 +61,10 @@ describe('Preview callback reconciliation', () => { callbacks = JSON.parse(String(init?.body)); return new Response(null, { status: 204 }); }) as typeof fetch; - expect(await syncPreviewCallbacks(options, fetcher)).toBe(1); + expect(await syncPreviewCallbacks(options, fetcher)).toBe(2); expect(callbacks).toEqual([ 'https://feature-test-extensions.fossbilling.workers.dev/auth/callback', + 'https://staging-extensions.fossbilling.workers.dev/auth/callback', ]); }); From dee45cef8a95a126d48e3b6d2dc09cc1f8cf35a2 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 14 Nov 2026 15:08:25 +0000 Subject: [PATCH 7/7] Deploy the named staging Preview with the main build --- README.md | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c5961870..81578580 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,8 @@ The named `staging` Preview uses these same Base settings; keep the development client in local `.dev.vars`. Cloudflare Workers Builds builds `main` once with `npm run build`, then runs -`npx wrangler deploy && npx wrangler preview --name staging` to update production -and the named `staging` Preview. Staging serves +`npm run cf-deploy` to deploy production and then the named `staging` Preview +(`wrangler deploy && wrangler preview --name staging`). Staging serves `https://staging-extensions.fossbilling.workers.dev` and uses the fixed `https://staging-api.fossbilling.workers.dev` API. Both are Previews under their existing Workers, without a separate staging Worker. API-side cache invalidation diff --git a/package.json b/package.json index 813d868c..e2b57cb6 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "api:update": "node scripts/update-extensions-openapi.ts && prettier --write openapi/extensions-v2.json && npm run api:generate", "api:check": "npm run api:generate && git diff --exit-code -- src/lib/api/generated/extensions-v2 && test -z \"$(git ls-files --others --exclude-standard -- src/lib/api/generated/extensions-v2)\"", "cf-typegen": "wrangler types", - "cf-deploy": "wrangler deploy", + "cf-deploy": "wrangler deploy && wrangler preview --name staging", "dev": "astro dev", "build": "astro build", "preview": "astro preview",