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 a306df29..81578580 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 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`. | @@ -97,6 +97,43 @@ 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 three separate OAuth clients: + +| Client | Used by | +| --- | --- | +| Production | `https://extensions.fossbilling.org` | +| Development | `http://localhost:4321` | +| 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. +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 +`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 +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. + +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 preview +client; it accepts callback changes only from this workflow on `main`, via GitHub +Actions OIDC. The workflow includes the named `staging` Preview even though it is not a Git branch; +the development client is not modified. + ## License The extension directory website is licensed under the GNU Affero General Public License Version 3. See [LICENSE](./LICENSE) for details. diff --git a/package.json b/package.json index 112d2f22..435a6797 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", diff --git a/scripts/sync-preview-callbacks.ts b/scripts/sync-preview-callbacks.ts new file mode 100644 index 00000000..e3637fbf --- /dev/null +++ b/scripts/sync-preview-callbacks.ts @@ -0,0 +1,110 @@ +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 === 'staging' || + (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 Preview callbacks.`); +} 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, 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); + }); +}); diff --git a/test/preview-callbacks.test.ts b/test/preview-callbacks.test.ts new file mode 100644 index 00000000..71ee1e16 --- /dev/null +++ b/test/preview-callbacks.test.ts @@ -0,0 +1,82 @@ +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 staging and 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: 'staging', + urls: ['https://staging-extensions.fossbilling.workers.dev'], + }, + { + 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(2); + expect(callbacks).toEqual([ + 'https://feature-test-extensions.fossbilling.workers.dev/auth/callback', + 'https://staging-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); + }); +}); 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" + } } }