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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/preview-auth.yml
Original file line number Diff line number Diff line change
@@ -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 }}
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
110 changes: 110 additions & 0 deletions scripts/sync-preview-callbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { pathToFileURL } from 'node:url';

type Preview = { name: string; urls: string[] };
type Page<T> = { result: T[]; result_info?: { total_count?: number } };

async function readJson<T>(
fetcher: typeof fetch,
url: string,
token: string,
): Promise<T> {
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<T>;
}

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<string>();
for (let page = 1; ; page++) {
const rows = await readJson<Array<{ name: string }>>(
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<Page<Preview>>(
fetcher,
`${base}/workers/workers/${service.result.default_environment.script.tag}/previews?per_page=100&page=${page}`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The Preview API is being addressed with script.tag, which is an immutable deployed script tag rather than the extensions Worker name. Use the configured Worker name/ID for /workers/workers/{worker}/previews; otherwise the workflow cannot read previews and never reconciles callbacks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/sync-preview-callbacks.ts, line 48:

<comment>The Preview API is being addressed with `script.tag`, which is an immutable deployed script tag rather than the `extensions` Worker name. Use the configured Worker name/ID for `/workers/workers/{worker}/previews`; otherwise the workflow cannot read previews and never reconciles callbacks.</comment>

<file context>
@@ -0,0 +1,110 @@
+  for (let page = 1; ; page++) {
+    const data = await readJson<Page<Preview>>(
+      fetcher,
+      `${base}/workers/workers/${service.result.default_environment.script.tag}/previews?per_page=100&page=${page}`,
+      options.cloudflareToken,
+    );
</file context>

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A Preview without a routable urls array crashes reconciliation at flatMap, preventing all callback updates. Treat missing URLs as empty with preview.urls ?? [] and model the field as optional.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/sync-preview-callbacks.ts, line 66:

<comment>A Preview without a routable `urls` array crashes reconciliation at `flatMap`, preventing all callback updates. Treat missing URLs as empty with `preview.urls ?? []` and model the field as optional.</comment>

<file context>
@@ -0,0 +1,110 @@
+            preview.name === 'staging' ||
+            (preview.name !== 'main' && branches.has(preview.name)),
+        )
+        .flatMap((preview) => preview.urls)
+        .map((origin) => new URL('/auth/callback', origin).href),
+    ),
</file context>
Suggested change
.flatMap((preview) => preview.urls)
.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.`);
}
2 changes: 1 addition & 1 deletion src/platform/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions test/cloudflare-platform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getApplicationEnv } from '@/platform/cloudflare';

vi.mock('cloudflare:workers', () => ({
Comment thread
admdly marked this conversation as resolved.
Resolved
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);
});
});
82 changes: 82 additions & 0 deletions test/preview-callbacks.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading