Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/publish-token-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@azphalt/storefront-worker": patch
---

`POST /packages` gets its GitHub token from the central gateway Worker over a service binding (`GITHUB_TOKENS` → `RepositoryTokens.azphaltPublishToken()`): a short-lived App installation token narrowed to HereLiesAz/azphalt, fetched only after a package verifies. The Worker stores no GitHub secret; a fixed `GITHUB_PUBLISH_TOKEN` remains as an override for deployments without the gateway, and a failed mint answers `503`.
13 changes: 9 additions & 4 deletions apps/storefront-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,20 @@ cd apps/storefront-worker
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
npx wrangler secret put ADMIN_TOKEN
npx wrangler secret put GITHUB_PUBLISH_TOKEN
~~~

`STRIPE_WEBHOOK_SECRET` is required for subscriptions and renewal/cancellation events. One-time
purchases can still fulfil from the Stripe Checkout session on the success page if the webhook is
delayed. `ADMIN_TOKEN` protects paid-package uploads and the moderation queue.
`GITHUB_PUBLISH_TOKEN` is a fine-grained token with **Contents** and **Pull requests** read/write on
`PUBLISH_REPOSITORY` (a `wrangler.jsonc` var, `HereLiesAz/azphalt`); without it `POST /packages`
answers `501`.

There is **no GitHub secret**. `POST /packages` asks the central gateway Worker (`workflows`, from
HereLiesAz/workflows) for a token over the `GITHUB_TOKENS` service binding: its `RepositoryTokens`
entrypoint mints a GitHub App installation token narrowed to `HereLiesAz/azphalt` with contents and
pull-requests write, valid for an hour. A service binding is private to the Cloudflare account, so
the minter has no public URL. The App must hold those two permissions, or minting fails and publishes
answer `503`. A deployment without the gateway can set a fixed `GITHUB_PUBLISH_TOKEN` secret instead
(fine-grained, Contents and Pull requests read/write on `PUBLISH_REPOSITORY`); with neither,
`POST /packages` answers `501`.

## Publishing

Expand Down
6 changes: 4 additions & 2 deletions apps/storefront-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MAX_PUBLISH_BYTES, publish } from "./publish";
import { MAX_PUBLISH_BYTES, publish, type RepositoryTokens } from "./publish";

type Fetcher = { fetch(input: Request): Promise<Response> };
type DurableObjectNamespaceLike = {
Expand Down Expand Up @@ -36,7 +36,9 @@ interface Env {
WRITE_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
/** The same, much tighter, for `POST /packages` — each accepted publish opens a pull request. */
PUBLISH_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
/** Fine-grained GitHub token for publish pull requests (`src/publish.ts`). Unset → publish answers 501. */
/** The gateway's token minter, over a service binding (`src/publish.ts` § RepositoryTokens). */
GITHUB_TOKENS?: RepositoryTokens;
/** A fixed GitHub token instead of the binding (self-hosted). Neither set → publish answers 501. */
GITHUB_PUBLISH_TOKEN?: string;
/** `owner/repo` publish pull requests are opened against. */
PUBLISH_REPOSITORY?: string;
Expand Down
28 changes: 25 additions & 3 deletions apps/storefront-worker/src/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,22 @@ const PUBLISHERS_PATH = "apps/storefront/registry/publishers.json";
const REVERSE_DNS = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+$/;
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;

/**
* The `RepositoryTokens` entrypoint of HereLiesAz/workflows' gateway Worker, reached over a Cloudflare
* service binding. It mints a short-lived GitHub App installation token narrowed to HereLiesAz/azphalt
* with contents and pull-requests write, so this Worker keeps no GitHub secret.
*/
export interface RepositoryTokens {
azphaltPublishToken(): Promise<{ token: string; expiresAt: string }>;
}

export interface PublishEnv {
/** Fine-grained token: Contents and Pull requests read/write on {@link PublishEnv.PUBLISH_REPOSITORY}. */
/** How the flagship store gets its GitHub token (`wrangler.jsonc` § services). */
GITHUB_TOKENS?: RepositoryTokens;
/**
* A fixed token instead, for a deployment without the gateway: Contents and Pull requests
* read/write on {@link PublishEnv.PUBLISH_REPOSITORY}. Wins over {@link PublishEnv.GITHUB_TOKENS}.
*/
GITHUB_PUBLISH_TOKEN?: string;
/** `owner/repo` the pull requests are opened against. */
PUBLISH_REPOSITORY?: string;
Expand Down Expand Up @@ -225,7 +239,7 @@ interface Publishers {
}

export async function publish(bytes: Uint8Array, env: PublishEnv, ctx: PublishContext): Promise<PublishAccepted | PublishError> {
if (!env.GITHUB_PUBLISH_TOKEN || !env.PUBLISH_REPOSITORY) {
if (!(env.GITHUB_PUBLISH_TOKEN || env.GITHUB_TOKENS) || !env.PUBLISH_REPOSITORY) {
return { status: 501, code: "not_implemented", message: "publishing is not configured on this repository" };
}
if (bytes.byteLength === 0) return { status: 400, code: "bad_request", message: "empty body: POST the signed .azp bytes" };
Expand All @@ -246,7 +260,15 @@ export async function publish(bytes: Uint8Array, env: PublishEnv, ctx: PublishCo
return { status: 409, code: "conflict", message: `${id} is a paid listing; paid bytes are uploaded to protected storage, not published` };
}

const gh = new GitHub(env.GITHUB_PUBLISH_TOKEN, env.PUBLISH_REPOSITORY);
// Only now, once the package has earned it, is a token fetched.
let token: string;
try {
token = env.GITHUB_PUBLISH_TOKEN ?? (await env.GITHUB_TOKENS!.azphaltPublishToken()).token;
} catch (error) {
console.error("publish token unavailable", error);
return { status: 503, code: "unavailable", message: "the repository's GitHub token is unavailable; try again shortly" };
}
const gh = new GitHub(token, env.PUBLISH_REPOSITORY);
try {
const head = await gh.ok<{ object: { sha: string } }>("GET", "/git/ref/heads/main");
const commit = await gh.ok<{ tree: { sha: string } }>("GET", "/git/commits/" + head.object.sha);
Expand Down
33 changes: 29 additions & 4 deletions apps/storefront-worker/test/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ function build(id: string, version: string, key?: { privateKey: string }, payloa

/** A GitHub REST stand-in holding one repository: a main branch, its trees, refs and pull requests. */
function fakeGitHub(opts: { publishers?: Record<string, { publicKey: string; pinnedAt: string }>; pinnedFiles?: string[] } = {}) {
const calls: Array<{ method: string; path: string; body?: any }> = [];
const calls: Array<{ method: string; path: string; body?: any; auth?: string }> = [];
const refs = new Set<string>();
const handler = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(String(input));
const path = url.pathname.replace("/repos/HereLiesAz/azphalt", "") + url.search;
const method = init?.method ?? "GET";
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
calls.push({ method, path, body });
calls.push({ method, path, body, auth: new Headers(init?.headers).get("authorization") ?? undefined });
const reply = (status: number, data: unknown) => new Response(JSON.stringify(data), { status });

if (method === "GET" && path === "/git/ref/heads/main") return reply(200, { object: { sha: "c0" } });
Expand Down Expand Up @@ -68,7 +68,8 @@ function makeEnv(extra: Record<string, unknown> = {}) {
"/listings.json": JSON.stringify(listings),
}),
STATE: durableState(),
GITHUB_PUBLISH_TOKEN: "ghp_test",
// The gateway's RepositoryTokens entrypoint, as the service binding exposes it.
GITHUB_TOKENS: { azphaltPublishToken: async () => ({ token: "ghs_minted", expiresAt: "2026-09-27T07:00:00Z" }) },
PUBLISH_REPOSITORY: "HereLiesAz/azphalt",
...extra,
} as never;
Expand Down Expand Up @@ -197,7 +198,7 @@ describe("POST /packages", () => {

it("answers 501 when publishing is not configured, and 413 over the size cap", async () => {
const key = generateSigningKey();
const off = await worker.fetch(post(build("com.example.fresh", "1.0.0", key)), makeEnv({ GITHUB_PUBLISH_TOKEN: undefined }));
const off = await worker.fetch(post(build("com.example.fresh", "1.0.0", key)), makeEnv({ GITHUB_TOKENS: undefined }));
expect(off.status).toBe(501);
const big = new Request("https://azphalt.store/packages", {
method: "POST",
Expand All @@ -207,6 +208,30 @@ describe("POST /packages", () => {
expect((await worker.fetch(big, makeEnv())).status).toBe(413);
});

it("authenticates to GitHub with the token the gateway mints, and only after the package verifies", async () => {
let minted = 0;
const env = makeEnv({
GITHUB_TOKENS: { azphaltPublishToken: async () => (minted++, { token: "ghs_minted", expiresAt: "x" }) },
});
expect((await worker.fetch(post(build("com.example.fresh", "1.0.0")), env)).status).toBe(401);
expect(minted).toBe(0);

expect((await worker.fetch(post(build("com.example.fresh", "1.0.0", generateSigningKey())), env)).status).toBe(202);
expect(minted).toBe(1);
expect(new Set(github.calls.map((c) => c.auth))).toEqual(new Set(["Bearer ghs_minted"]));
});

it("prefers a fixed GITHUB_PUBLISH_TOKEN, and answers 503 when the gateway cannot mint", async () => {
const fixed = makeEnv({ GITHUB_PUBLISH_TOKEN: "ghp_fixed" });
expect((await worker.fetch(post(build("com.example.fresh", "1.0.0", generateSigningKey())), fixed)).status).toBe(202);
expect(github.calls[0].auth).toBe("Bearer ghp_fixed");

const down = makeEnv({ GITHUB_TOKENS: { azphaltPublishToken: async () => { throw new Error("App not installed"); } } });
const res = await worker.fetch(post(build("com.example.other", "1.0.0", generateSigningKey())), down);
expect(res.status).toBe(503);
expect(await res.json()).toMatchObject({ error: { code: "unavailable" } });
});

it("rate-limits publishes per IP", async () => {
const res = await worker.fetch(
post(build("com.example.fresh", "1.0.0", generateSigningKey())),
Expand Down
9 changes: 8 additions & 1 deletion apps/storefront-worker/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
// POST /packages: each accepted publish opens a pull request, so 3 a minute per IP.
{ "name": "PUBLISH_LIMITER", "namespace_id": "4272", "simple": { "limit": 3, "period": 60 } }
],
// POST /packages gets a short-lived GitHub token, narrowed to HereLiesAz/azphalt, from the central
// gateway Worker (HereLiesAz/workflows worker/src/index.js § RepositoryTokens). A service binding is
// private to this account, so the minter is not reachable from the internet, and no GitHub secret
// is stored here.
"services": [
{ "binding": "GITHUB_TOKENS", "service": "workflows", "entrypoint": "RepositoryTokens" }
],
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["AzphaltState"] }
],
Expand All @@ -40,7 +47,7 @@
"PLATFORM_FEE_BPS": "1500",
"PROCESSOR_PCT_BPS": "290",
"PROCESSOR_FLAT_CENTS": "30",
// Where POST /packages opens its review pull requests (the token is the GITHUB_PUBLISH_TOKEN secret).
// Where POST /packages opens its review pull requests (token: the GITHUB_TOKENS binding above).
"PUBLISH_REPOSITORY": "HereLiesAz/azphalt"
}
}
Loading