Skip to content
Merged
3 changes: 2 additions & 1 deletion services/api/.dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ GITHUB_APP_INSTALLATION_ID="your-installation-id"
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
ASSERTION_SIGNING_SECRET="local-shared-secret" # Must match `FOSSBilling/extensions`
EXTENSIONS_REVALIDATE_SECRET="local-revalidate-secret"
EXTENSIONS_V2_MXROUTE_PASSWORD="local-mailbox-password"
EXTENSIONS_EMAIL_PROVIDER="disabled"
EXTENSIONS_EMAIL_PASSWORD="local-email-password"
82 changes: 59 additions & 23 deletions services/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://d

### Environment Variables

Production B2 connection details and MXroute server/username are managed in the
Production B2 connection details and email server/username are managed in the
Worker dashboard under **Settings > Variables and Secrets** as runtime variables.
Credentials are stored as secrets. Build environment variables do not configure
the running Worker. `keep_vars: true` preserves dashboard-managed variables on
Expand All @@ -103,12 +103,11 @@ Package storage configuration is listed in [PACKAGING.md](src/services/extension
- `ASSERTION_SIGNING_SECRET`: Shared HMAC secret used to verify the short-lived bearer assertions minted by the Extensions site. Configure the same value in both Workers; it is never sent to clients.
- `ASSERTION_SIGNING_SECRET_PREVIOUS`: Optional previous HMAC secret accepted during a signing-key rotation.
- `EXTENSIONS_REVALIDATE_SECRET`: Bearer token the API sends to the Extensions site's `POST /api/revalidate` (over the `EXTENSIONS_FRONTEND` service binding) to purge the site's CDN-cached extension pages after content mutations. Configure the same value in both Workers; it is never sent to clients.
- `EXTENSIONS_V2_EMAIL_PROVIDER`: `mxroute` or `resend` to send moderator notification emails, or unset/`disabled` to skip sending.
- `EXTENSIONS_V2_EMAIL_FROM`: Sender address for notification emails (default `noreply@fossbilling.org`).
- `EXTENSIONS_V2_EMAIL_REPLY_TO`: Optional Reply-To address for a monitored inbox. Omit when unset (no Reply-To header is sent) — notification emails do not invite replies.
- `EXTENSIONS_V2_MXROUTE_SERVER`, `EXTENSIONS_V2_MXROUTE_USERNAME`: Dashboard-managed runtime variables identifying the mailbox for the MXroute SMTP API (`https://smtpapi.mxroute.com/`). Required when `EXTENSIONS_V2_EMAIL_PROVIDER=mxroute`.
- `EXTENSIONS_V2_MXROUTE_PASSWORD`: Mailbox password, stored as a secret. Required when `EXTENSIONS_V2_EMAIL_PROVIDER=mxroute`.
- `EXTENSIONS_V2_RESEND_API_KEY`: API key for Resend. Required when `EXTENSIONS_V2_EMAIL_PROVIDER=resend`.
- `EXTENSIONS_EMAIL_PROVIDER`: Production secret managed in the Worker dashboard. Local development sets `disabled` in `.dev.vars`; Previews set `disabled` in `wrangler.jsonc`. Use `mxroute` or `resend` to send moderator notification emails, or unset/`disabled` to skip sending.
- `EXTENSIONS_EMAIL_FROM`: Sender address for notification emails (default `noreply@fossbilling.org`).
- `EXTENSIONS_EMAIL_REPLY_TO`: Optional Reply-To address for a monitored inbox. Omit when unset (no Reply-To header is sent) — notification emails do not invite replies.
- `EXTENSIONS_EMAIL_SERVER`, `EXTENSIONS_EMAIL_USERNAME`: Mail server and login used by the MXroute adapter. Required for `mxroute`; unused by `resend`.
- `EXTENSIONS_EMAIL_PASSWORD`: Secret containing the mailbox password for `mxroute` or API key for `resend`.
- `EXTENSIONS_RETENTION_MODE`: `dry-run` (report only) or `compact` (prune old revision bodies). See [extensions v2 README](src/services/extensions/v2/README.md#revisions-and-retention).
- `M2M_ISSUER`, `M2M_JWKS_URL`, `M2M_AUDIENCE`: Issuer, JWKS endpoint, and expected audience for machine-to-machine access tokens minted by the central auth service (`FOSSBilling/auth`). Defaults live in `wrangler.jsonc`; preview environments need them pointed at the issuing preview. See [extensions v2 README](src/services/extensions/v2/README.md#machine-publishing).

Expand Down Expand Up @@ -181,22 +180,40 @@ Validation and artifact builds run on branch pushes and PRs. A push can deploy o
after workspace validation, the build and D1 migrations succeed. `main` targets
production; other branches target Previews once `API_PREVIEW_RESOURCES` is configured.
Until then, branch builds report that Preview migrations and deployment are skipped;
validation and artifact checks still run. PR runs, including forks, never receive
deployment credentials or apply remote migrations.
validation and artifact checks still run. The configured PR jobs, including forks,
run validation and build checks without forwarding deployment credentials or
applying remote migrations.

The artifact contains the compiled Worker, `wrangler.json`, and SQL migrations.
Deployment does not rebuild the Worker. Production migrations use that artifact's
production bindings. Preview migrations use its generated
Deployment uses the same Node version as the build and does not rebuild the Worker.
Production migrations use its production bindings. Preview migrations use its generated
`wrangler.preview-migrations.json`, derived from the same bindings used by the Preview.
Migration jobs serialize access to the shared staging databases.

#### Setup before switching deployment
#### Deployment setup

1. Set `CLOUDFLARE_API_TOKEN` as a repository secret and `CLOUDFLARE_ACCOUNT_ID`
as a repository variable. Configure GitHub `production` and `preview`
environments, and restrict `production` to `main`.

Like `fossbilling.org`, this setup trusts repository maintainers with branch
push access to use the deployment credential. Repository secrets are available
to workflows those maintainers can modify; environment restrictions and the
workflow's `main` condition select deployment targets but do not protect a
repository secret from branch workflows. Fork PR runs do not receive the
deployment token.

Scope the token to the API Worker and its zone. It needs
`Individual Workers Editor` for that Worker, `Workers Routes Write` for route changes, `D1 Read`
and `D1 Write` for migrations, and `Queues Read` and `Queues Write` to configure
the existing consumer.
The Worker permission covers both production and Previews. Separate staging data
and runtime secrets isolate test state, not the deployment credential's authority.

If branch writers must not have production deployment authority, use protected
environment secrets with trusted deployment approval instead. That is a
different access policy, not a requirement of this trusted-maintainer setup.

1. Configure GitHub `production` and `preview` environments, each with its own
`CLOUDFLARE_API_TOKEN` secret. Set `CLOUDFLARE_ACCOUNT_ID` as a repository variable
shared by both deployment targets. Restrict the production environment to `main`.
Deployment tokens need Workers deployment and
D1 migration permissions; Preview credentials and resources must be non-production.
2. Provision staging D1 databases, KV namespaces, a download bucket and a separately
deployed staging Extensions frontend. Set the repository variable `API_PREVIEW_RESOURCES`
to their identifiers, using the following JSON shape:
Expand All @@ -219,19 +236,22 @@ Migration jobs serialize access to the shared staging databases.
staging resources; they do not automatically receive separate D1/KV/R2 data.

3. Configure Preview runtime secrets in the API Worker's **Previews Base** settings.
Use a separate assertion-signing secret, revalidation secret and GitHub App
preview key, alongside `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_INSTALLATION_ID`
(see [GitHub App authentication](#github-app-authentication)). Match the
revalidation secret with the staging frontend. Set M2M issuer,
Use separate assertion-signing and revalidation secrets, matching both with
the staging frontend. Set all three `GITHUB_APP_*` values, selecting the preview
private key (see [GitHub App authentication](#github-app-authentication)). Set M2M issuer,
JWKS and audience variables there when using a separate test issuer. Operational
production variables and secrets remain dashboard-managed with `keep_vars: true`.
4. Configure required checks for the new validation/build job names, then disable the
4. Configure required checks for the validation/build jobs, then disable the
API's Cloudflare Workers Builds Git integration to avoid duplicate deployments.

The workflow compiles the branch's Preview settings into the artifact. Each Preview
receives isolated Durable Object state; the data bindings above are shared staging
resources. A frontend service binding calls that Worker's normal deployment, so use a
staging Worker name rather than the production Extensions worker.
staging Worker name rather than the production Extensions worker. Configure that
frontend with HTTP API transport and a Preview API URL, without production routes
or API bindings. A long-lived Preview such as `staging` can back this frontend;
refresh it from the prepared artifact with `npm run cf-preview --workspace @fossbilling/api -- --name staging`.
Register its OAuth callback with a dedicated test client before testing sign-in.

Cloudflare Previews cannot consume Queues or receive Cron Triggers. Their configuration
therefore disables packaging and notification email, and omits Queue bindings. Full
Expand All @@ -253,3 +273,19 @@ before running `npm run cf-preview`; CI performs those steps automatically.
`npm run cf-deploy` remains a deliberate manual production command. It applies remote
migrations and deploys through Wrangler. Neither command deploys the separate
[extension packager](../extension-packager/README.md). Production packaging remains disabled.

A dashboard change can trigger Wrangler's remote configuration comparison. If its
custom-domain metadata lookup returns 403 with the Worker-scoped CI token, use a
one-off local-auth deployment rather than broadening the token. Build artifacts
expire after one day; after expiry, rerun the full CI workflow to rebuild them.
After validation, build and migrations pass, unset `CLOUDFLARE_API_TOKEN` locally.
Use an existing Wrangler OAuth login to deploy that `main` run's artifact (replace `RUN_ID`):

```sh
gh run download RUN_ID --name api-worker-build --dir .wrangler/deployment
Comment thread
admdly marked this conversation as resolved.
Resolved
npx wrangler deploy --config .wrangler/deployment/wrangler.json --keep-vars
```

Download into an empty directory, keep `EXTENSION_PACKAGES_MODE=disabled`, then
rerun the failed CI deployment job. Further dashboard changes can trigger the same
comparison again.
2 changes: 1 addition & 1 deletion services/api/src/services/extensions/v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ For organization developer IDs, GitHub membership auto-verifies only against a v

Revision approve/reject, delist/relist, developer approve, and claim approve/reject email the affected author unless the moderator passes `?notify=false`. Bot decisions use the same path, marked with an `[auto policy=…]` `review_note` prefix.

Recipients: developer `contact_email`, falling back to the owning account's email; claim decisions go to the claimant's account email. Sending is best-effort via `waitUntil` and never fails the write; the result carries `notified: boolean`. See `email/` for the provider abstraction (`mxroute`, `resend`, `disabled`) and the root README for `EXTENSIONS_V2_EMAIL_*`.
Recipients: developer `contact_email`, falling back to the owning account's email; claim decisions go to the claimant's account email. Sending is best-effort via `waitUntil` and never fails the write; the result carries `notified: boolean`. See `email/` for the provider abstraction (`mxroute`, `resend`, `disabled`) and the root README for `EXTENSIONS_EMAIL_*`.

## Limits and budgets

Expand Down
8 changes: 4 additions & 4 deletions services/api/src/services/extensions/v2/email/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export type EmailProviderName = "mxroute" | "resend";
// namespace (see DB_EXTENSIONS), so an unscoped EMAIL_FROM would collide
// with the next service that sends mail.
export function resolveEmailProvider(env: EnvReader): EmailProviderName | null {
switch (env.getEnv("EXTENSIONS_V2_EMAIL_PROVIDER")?.toLowerCase()) {
switch (env.getEnv("EXTENSIONS_EMAIL_PROVIDER")?.toLowerCase()) {
case "mxroute":
return "mxroute";
case "resend":
Expand All @@ -20,10 +20,10 @@ export function resolveEmailProvider(env: EnvReader): EmailProviderName | null {
}

export function loadEmailIdentity(env: EnvReader): EmailIdentity {
// Blank counts as unset: an empty EXTENSIONS_V2_EMAIL_FROM would otherwise
// Blank counts as unset: an empty EXTENSIONS_EMAIL_FROM would otherwise
// become an invalid sender address instead of falling back to the default.
const from = env.getEnv("EXTENSIONS_V2_EMAIL_FROM")?.trim();
const replyTo = env.getEnv("EXTENSIONS_V2_EMAIL_REPLY_TO")?.trim();
const from = env.getEnv("EXTENSIONS_EMAIL_FROM")?.trim();
const replyTo = env.getEnv("EXTENSIONS_EMAIL_REPLY_TO")?.trim();
return {
from: from ? from : "noreply@fossbilling.org",
replyTo: replyTo ? replyTo : undefined
Expand Down
6 changes: 3 additions & 3 deletions services/api/src/services/extensions/v2/email/mxroute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export function loadMxrouteConfig(
env: EnvReader,
identity: EmailIdentity
): MxrouteConfig | null {
const server = env.getEnv("EXTENSIONS_V2_MXROUTE_SERVER");
const username = env.getEnv("EXTENSIONS_V2_MXROUTE_USERNAME");
const password = env.getEnv("EXTENSIONS_V2_MXROUTE_PASSWORD");
const server = env.getEnv("EXTENSIONS_EMAIL_SERVER");
Comment thread
admdly marked this conversation as resolved.
Resolved
const username = env.getEnv("EXTENSIONS_EMAIL_USERNAME");
const password = env.getEnv("EXTENSIONS_EMAIL_PASSWORD");
if (!server || !username || !password) return null;
return { ...identity, server, username, password };
}
Expand Down
8 changes: 4 additions & 4 deletions services/api/src/services/extensions/v2/email/resend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ export function loadResendConfig(
env: EnvReader,
identity: EmailIdentity
): ResendConfig | null {
const apiKey = env.getEnv("EXTENSIONS_V2_RESEND_API_KEY");
const apiKey = env.getEnv("EXTENSIONS_EMAIL_PASSWORD");
if (!apiKey) return null;
return { ...identity, apiKey };
}

// Kept beside the MXroute sender so EXTENSIONS_V2_EMAIL_PROVIDER can switch
// without route changes. Activated by EXTENSIONS_V2_EMAIL_PROVIDER=resend
// with EXTENSIONS_V2_RESEND_API_KEY set.
// Kept beside the MXroute sender so EXTENSIONS_EMAIL_PROVIDER can switch
// without route changes. Activated by EXTENSIONS_EMAIL_PROVIDER=resend
// with EXTENSIONS_EMAIL_PASSWORD set.
export class ResendSender implements EmailSender {
constructor(
private config: ResendConfig,
Expand Down
32 changes: 16 additions & 16 deletions services/api/test/services/extensions/v2/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ function reader(vars: Record<string, string>): EnvReader {
const IDENTITY = { from: "extensions@fossbilling.org" };

const MXROUTE_VARS = {
EXTENSIONS_V2_EMAIL_PROVIDER: "mxroute",
EXTENSIONS_V2_MXROUTE_SERVER: "tuesday.mxrouting.net",
EXTENSIONS_V2_MXROUTE_USERNAME: "extensions@fossbilling.org",
EXTENSIONS_V2_MXROUTE_PASSWORD: "secret"
EXTENSIONS_EMAIL_PROVIDER: "mxroute",
EXTENSIONS_EMAIL_SERVER: "tuesday.mxrouting.net",
EXTENSIONS_EMAIL_USERNAME: "extensions@fossbilling.org",
EXTENSIONS_EMAIL_PASSWORD: "secret"
};

function jsonResponse(payload: unknown, status = 200): Response {
Expand All @@ -40,16 +40,16 @@ describe("email config", () => {
it("resolves no provider by default", () => {
expect(resolveEmailProvider(reader({}))).toBeNull();
expect(
resolveEmailProvider(reader({ EXTENSIONS_V2_EMAIL_PROVIDER: "pigeon" }))
resolveEmailProvider(reader({ EXTENSIONS_EMAIL_PROVIDER: "pigeon" }))
).toBeNull();
});

it("resolves the selected provider case-insensitively", () => {
expect(
resolveEmailProvider(reader({ EXTENSIONS_V2_EMAIL_PROVIDER: "MXRoute" }))
resolveEmailProvider(reader({ EXTENSIONS_EMAIL_PROVIDER: "MXRoute" }))
).toBe("mxroute");
expect(
resolveEmailProvider(reader({ EXTENSIONS_V2_EMAIL_PROVIDER: "resend" }))
resolveEmailProvider(reader({ EXTENSIONS_EMAIL_PROVIDER: "resend" }))
).toBe("resend");
});

Expand All @@ -61,16 +61,16 @@ describe("email config", () => {
expect(
loadEmailIdentity(
reader({
EXTENSIONS_V2_EMAIL_FROM: "extensions@fossbilling.org",
EXTENSIONS_V2_EMAIL_REPLY_TO: "extensions@fossbilling.org"
EXTENSIONS_EMAIL_FROM: "extensions@fossbilling.org",
EXTENSIONS_EMAIL_REPLY_TO: "extensions@fossbilling.org"
})
)
).toEqual({
from: "extensions@fossbilling.org",
replyTo: "extensions@fossbilling.org"
});
expect(
loadEmailIdentity(reader({ EXTENSIONS_V2_EMAIL_FROM: " " })).from
loadEmailIdentity(reader({ EXTENSIONS_EMAIL_FROM: " " })).from
).toBe("noreply@fossbilling.org");
});

Expand All @@ -83,7 +83,7 @@ describe("email config", () => {
});
expect(
loadMxrouteConfig(
reader({ EXTENSIONS_V2_EMAIL_PROVIDER: "mxroute" }),
reader({ EXTENSIONS_EMAIL_PROVIDER: "mxroute" }),
IDENTITY
)
).toBeNull();
Expand All @@ -92,7 +92,7 @@ describe("email config", () => {
it("loads a complete resend config and rejects a missing key", () => {
expect(
loadResendConfig(
reader({ EXTENSIONS_V2_RESEND_API_KEY: "re_key" }),
reader({ EXTENSIONS_EMAIL_PASSWORD: "re_key" }),
IDENTITY
)
).toEqual({ ...IDENTITY, apiKey: "re_key" });
Expand Down Expand Up @@ -190,7 +190,7 @@ describe("MxrouteSender", () => {

describe("ResendSender", () => {
const config = loadResendConfig(
reader({ EXTENSIONS_V2_RESEND_API_KEY: "re_key" }),
reader({ EXTENSIONS_EMAIL_PASSWORD: "re_key" }),
IDENTITY
)!;

Expand Down Expand Up @@ -235,7 +235,7 @@ describe("factory", () => {
);

const incompleteResend = createEmailSender(
reader({ EXTENSIONS_V2_EMAIL_PROVIDER: "resend" })
reader({ EXTENSIONS_EMAIL_PROVIDER: "resend" })
);
expect(incompleteResend).toBeInstanceOf(DisabledSender);
await expect(
Expand All @@ -246,7 +246,7 @@ describe("factory", () => {
});

const incomplete = createEmailSender(
reader({ EXTENSIONS_V2_EMAIL_PROVIDER: "mxroute" })
reader({ EXTENSIONS_EMAIL_PROVIDER: "mxroute" })
);
expect(incomplete).toBeInstanceOf(DisabledSender);
await expect(
Expand Down Expand Up @@ -292,7 +292,7 @@ describe("default fetch binding", () => {
).resolves.toEqual({ ok: true });

const resendConfig = loadResendConfig(
reader({ EXTENSIONS_V2_RESEND_API_KEY: "re_key" }),
reader({ EXTENSIONS_EMAIL_PASSWORD: "re_key" }),
IDENTITY
)!;
await expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ vi.mock("@octokit/request", async () =>
setupExtensionsV2Tests();

const MXROUTE_ENV = {
EXTENSIONS_V2_EMAIL_PROVIDER: "mxroute",
EXTENSIONS_V2_EMAIL_FROM: "extensions@fossbilling.org",
EXTENSIONS_V2_MXROUTE_SERVER: "tuesday.mxrouting.net",
EXTENSIONS_V2_MXROUTE_USERNAME: "extensions@fossbilling.org",
EXTENSIONS_V2_MXROUTE_PASSWORD: "secret"
EXTENSIONS_EMAIL_PROVIDER: "mxroute",
EXTENSIONS_EMAIL_FROM: "extensions@fossbilling.org",
EXTENSIONS_EMAIL_SERVER: "tuesday.mxrouting.net",
EXTENSIONS_EMAIL_USERNAME: "extensions@fossbilling.org",
EXTENSIONS_EMAIL_PASSWORD: "secret"
};

type EmailEnvKey = keyof typeof MXROUTE_ENV;
Expand Down
Loading
Loading