From 2d5473f00134e8b4b94dc227f2203de445ca8dcd Mon Sep 17 00:00:00 2001 From: Nick <40026523+Nick-CHI@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:16:53 +0800 Subject: [PATCH] feat(auth-worker): the deployable auth Worker (#41) The repository was six library crates with no artifact to deploy, so auth.factory0.ventures did not exist and every consumer (the Cratefield control plane, Yoginini) was blocked on it. This composes them into one harness venture and adds the deploy pipeline. - `crates/auth-worker` (`factory0-auth-worker`): `Harness::builder` mounts auth-core + oidc + passkeys + magic-link + password + meta over `cratefield-runtime-cloudflare`, with `#[event(fetch)]` and `#[event(scheduled)]`. Mailer is Resend from `RESEND_API_KEY` (a capture-only no-op until the key is set); magic-link's templates are registered. Builds to wasm32; clippy/fmt clean. - `wrangler.toml`: staging (from `main`) + production (`auth.factory0.ventures`, custom domain), a `DB` D1 binding per environment, `migrations_dir`, `worker-build` release build. - `.github/workflows/deploy.yml`: `main` -> staging, a `v*` tag -> production behind the `production` environment approval gate, each applying D1 migrations and running the smoke test. - `smoke.sh`: asserts `/__health`, `/.well-known/openid-configuration` and `/.well-known/jwks.json` all answer 200. Which login methods ship is which modules are mounted here; the rest mount later without redeploying consumers. **Remaining (needs-human / owner-only, per #41):** `wrangler d1 create` for each environment (paste the ids into wrangler.toml), set `HARNESS_SECRET` + `RESEND_API_KEY` secrets, add the auth.factory0.ventures custom domain on the Kontinuum account, run `fz migrations collect` to populate `migrations/`, and cut the first `v*` tag. Then `wrangler dev` / staging serve the discovery document + JWKS. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QScfNS82MGFi3Yn63Vj2rR --- .github/workflows/deploy.yml | 59 ++++++++++++++++++ Cargo.lock | 57 +++++++++++++++++ Cargo.toml | 5 ++ crates/auth-worker/Cargo.toml | 31 +++++++++ crates/auth-worker/src/lib.rs | 114 ++++++++++++++++++++++++++++++++++ migrations/README.md | 1 + smoke.sh | 26 ++++++++ wrangler.toml | 34 ++++++++++ 8 files changed, 327 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 crates/auth-worker/Cargo.toml create mode 100644 crates/auth-worker/src/lib.rs create mode 100644 migrations/README.md create mode 100755 smoke.sh create mode 100644 wrangler.toml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..48e844d --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,59 @@ +name: deploy + +# The deployable auth Worker (auth#41): `main` -> staging, a `v*` tag -> +# production behind the environment approval gate. +on: + push: + branches: [main] + tags: ["v*"] + +env: + CARGO_TERM_COLOR: always + +jobs: + staging: + name: deploy staging + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.98.1" + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - name: Deploy (staging) + apply migrations + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy + preCommands: | + cargo install -q worker-build + npx wrangler d1 migrations apply DB --remote + - name: Smoke staging + run: ./smoke.sh "${{ vars.AUTH_STAGING_URL }}" + + production: + name: deploy production + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + environment: production # the approval gate + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.98.1" + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - name: Deploy (production) + apply migrations + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + command: deploy --env production + preCommands: | + cargo install -q worker-build + npx wrangler d1 migrations apply DB --env production --remote + - name: Smoke production + run: ./smoke.sh "https://auth.factory0.ventures" diff --git a/Cargo.lock b/Cargo.lock index a6f2295..396df70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -371,6 +371,21 @@ dependencies = [ "libc", ] +[[package]] +name = "cratefield-adapter-resend" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad30b8db2c39222ed3e9d8491945a313e71891cfede4639b04e2891682f564b6" +dependencies = [ + "async-trait", + "bytes", + "cratefield-core", + "http", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "cratefield-adapter-sqlite" version = "0.1.1" @@ -413,6 +428,30 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "cratefield-runtime-cloudflare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6923ffa1fbd904d3fa34245eeac3e8589bed58705618e7f4532ed6f8be5d4d" +dependencies = [ + "async-trait", + "axum", + "bytes", + "cratefield-core", + "futures-core", + "futures-util", + "getrandom 0.4.3", + "http", + "sea-query", + "serde", + "serde_json", + "sha2 0.11.0", + "time", + "tower", + "tracing", + "worker", +] + [[package]] name = "cratefield-testing" version = "0.1.1" @@ -953,6 +992,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "factory0-auth-worker" +version = "0.1.0" +dependencies = [ + "async-trait", + "cratefield-adapter-resend", + "cratefield-core", + "cratefield-runtime-cloudflare", + "factory0-auth-core", + "factory0-auth-magic-link", + "factory0-auth-meta", + "factory0-auth-oidc", + "factory0-auth-passkeys", + "factory0-auth-password", + "getrandom 0.4.3", + "worker", +] + [[package]] name = "fallible-iterator" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 33a3cf6..ab42ecf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ askama = "0.14.0" cratefield-core = "0.3" cratefield-testing = "0.1" cratefield-adapter-sqlite = "0.1" +cratefield-runtime-cloudflare = "0.1" +cratefield-adapter-resend = "0.1" # Versions mirror the harness workspace so the git harness crates and our # direct dependencies unify into one dependency tree. @@ -134,6 +136,9 @@ sha2 = { version = "0.11.0", default-features = false } # Enabled by wasm leaf crates (same as the harness venture example): # getrandom (via ulid/rand through cratefield-core) needs the opt-in # wasm_js backend on wasm32-unknown-unknown. +# workers-rs runtime for the deployable auth Worker (auth#41). `http` stays +# OFF (harness note: it hangs D1); `d1` on for the database binding. +worker = { version = "0.8.5", default-features = false, features = ["d1"] } getrandom = { version = "0.4.3", default-features = false, features = [ "wasm_js", ] } diff --git a/crates/auth-worker/Cargo.toml b/crates/auth-worker/Cargo.toml new file mode 100644 index 0000000..ab143da --- /dev/null +++ b/crates/auth-worker/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "factory0-auth-worker" +description = "The deployable auth Worker: composes the auth modules into one harness venture (auth#41)" +publish = false +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true + +[dependencies] +factory0-auth-core = { path = "../auth-core" } +factory0-auth-oidc = { path = "../auth-oidc" } +factory0-auth-passkeys = { path = "../auth-passkeys" } +factory0-auth-magic-link = { path = "../auth-magic-link" } +factory0-auth-password = { path = "../auth-password" } +factory0-auth-meta = { path = "../auth-meta" } +cratefield-core = { workspace = true } +cratefield-runtime-cloudflare = { workspace = true } +cratefield-adapter-resend = { workspace = true } +worker = { workspace = true } +async-trait = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { workspace = true } diff --git a/crates/auth-worker/src/lib.rs b/crates/auth-worker/src/lib.rs new file mode 100644 index 0000000..038b466 --- /dev/null +++ b/crates/auth-worker/src/lib.rs @@ -0,0 +1,114 @@ +//! The deployable auth Worker (issue #41): composes every merged auth module +//! into one harness venture and exposes the Cloudflare `fetch`/`scheduled` +//! entry points. Before this, the repository was library crates with no +//! artifact to deploy, so `auth.factory0.ventures` did not exist and every +//! consumer (Yoginini, the Cratefield control plane) was blocked. +//! +//! It is an ordinary harness venture — one Worker, one D1 — following the +//! `venture-backend-template` layout. Which login methods ship is simply which +//! modules are mounted here; the rest mount later without redeploying consumers. +//! +//! **Owner-only (needs-human):** `wrangler d1 create` for each environment, the +//! `HARNESS_SECRET`/signing material, the `auth.factory0.ventures` route on the +//! Kontinuum Cloudflare account, and the first production tag (auth#41). + +#![forbid(unsafe_code)] + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use cratefield_adapter_resend::Resend; +use cratefield_core::{Harness, MailError, Mailer, Message, SendOutcome, Venture}; +use cratefield_runtime_cloudflare::{Cloudflare, FetchClient, serve, serve_scheduled}; +use worker::{Context, Env, Request, Response, event}; + +/// The verified sending address (a `send.` subdomain verified in Resend). +const MAIL_FROM: &str = "no-reply@auth.factory0.ventures"; + +/// Reports a send as done without sending — used until `RESEND_API_KEY` is set, +/// so magic-link/verification requests still create their rows rather than +/// failing the whole flow. Real delivery begins the moment the key is present. +struct NoopMailer; + +#[async_trait] +impl Mailer for NoopMailer { + async fn send(&self, _message: Message) -> Result { + Ok(SendOutcome::Sent { + id: "noop".to_owned(), + }) + } +} + +/// Resend when `RESEND_API_KEY` is present on the Worker `Env` (read from the +/// binding, since `std::env` is empty on Workers), else the capture-only no-op. +fn build_mailer(env: &Env) -> Arc { + let key = env + .secret("RESEND_API_KEY") + .ok() + .map(|secret| secret.to_string()) + .filter(|key| !key.is_empty()); + match key { + Some(key) => Arc::new(Resend::new( + Arc::new(FetchClient), + Some(key), + MAIL_FROM, + None, + )), + None => Arc::new(NoopMailer), + } +} + +static INSTANCE: OnceLock<(Harness, Cloudflare)> = OnceLock::new(); + +/// The composed auth venture: `auth-core` plus every merged login method, over +/// the Cloudflare runtime. Built once per isolate (ADR 0007: no ambient +/// request state). +fn instance(env: &Env) -> &'static (Harness, Cloudflare) { + INSTANCE.get_or_init(|| { + let mailer = build_mailer(env); + let harness = Harness::builder() + .venture( + Venture::new("factory0-auth", "auth.factory0.ventures") + .public_url("https://auth.factory0.ventures") + // Consumers call the discovery/JWKS documents (public GETs); + // the browser-facing authorize flow is a top-level redirect, + // not CORS. These are the first-party app origins. + .cors_origins([ + "https://app.cratefield.com", + "https://cratefield.com", + "https://yoginini.us", + ]), + ) + // Magic-link renders its mail through the shared registry. + .templates(factory0_auth_magic_link::default_templates()) + .module(factory0_auth_core::AuthCore::new()) + .module(factory0_auth_oidc::Oidc::new()) + .module(factory0_auth_passkeys::Passkeys::new()) + .module(factory0_auth_magic_link::MagicLink::new()) + .module(factory0_auth_password::Password::new()) + .module(factory0_auth_meta::Meta::new()) + .runtime(Cloudflare::new().db("DB").mailer_arc(Arc::clone(&mailer))) + .build() + .expect("the auth venture is a valid harness"); + // The runtime `serve` resolves ports from must carry the mailer too. + let runtime = Cloudflare::new().db("DB").mailer_arc(mailer); + (harness, runtime) + }) +} + +#[event(fetch)] +/// Worker fetch entry point. +/// +/// # Errors +/// +/// Propagates `worker::Error` from the harness router. +pub async fn fetch(req: Request, env: Env, ctx: Context) -> worker::Result { + let (harness, runtime) = instance(&env); + serve(harness, runtime, req, env, ctx).await +} + +#[event(scheduled)] +pub async fn scheduled(event: worker::ScheduledEvent, env: Env, ctx: worker::ScheduleContext) { + let (harness, runtime) = instance(&env); + serve_scheduled(harness, runtime, event, env, ctx).await; +} diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..95a6ac8 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1 @@ +# Collected by `fz migrations collect` (needs-human: run once the fz CLI is installed against this venture). Empty until then. diff --git a/smoke.sh b/smoke.sh new file mode 100755 index 0000000..6c03ff5 --- /dev/null +++ b/smoke.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Smoke test for the deployed auth Worker (auth#41): the health check and the +# OIDC discovery documents must all answer 200 over TLS. +# +# ./smoke.sh https://auth.factory0.ventures +set -euo pipefail + +BASE="${1:?usage: smoke.sh }" +BASE="${BASE%/}" + +paths=( + /__health + /.well-known/openid-configuration + /.well-known/jwks.json +) + +for path in "${paths[@]}"; do + code="$(curl -fsS -o /dev/null -w '%{http_code}' "${BASE}${path}" || true)" + if [ "$code" != "200" ]; then + echo "FAIL ${path} -> ${code}" + exit 1 + fi + echo "ok ${path}" +done + +echo "smoke passed against ${BASE}" diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..eb1d460 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,34 @@ +# The deployable auth Worker (auth#41). One Worker + one D1 per environment, +# the shape of any harness venture (venture-backend-template). +# +# needs-human before first deploy: `wrangler d1 create` for each environment +# and paste the ids below; set HARNESS_SECRET (+ HARNESS_SECRET_PREVIOUS on +# rotation) and RESEND_API_KEY as secrets per environment; add the +# auth.factory0.ventures custom domain on the Kontinuum Cloudflare account. +name = "factory0-auth" +main = "build/worker/shim.mjs" +compatibility_date = "2026-09-01" +workers_dev = false + +[build] +command = "cargo install -q worker-build && worker-build --release" + +[observability] +enabled = true + +# --- Staging (deployed from `main`) --- +[[d1_databases]] +binding = "DB" +database_name = "factory0-auth-staging" +database_id = "REPLACE_WITH_STAGING_D1_ID" +migrations_dir = "migrations" + +# --- Production (deployed from a v* tag, behind the approval gate) --- +[env.production] +route = { pattern = "auth.factory0.ventures", custom_domain = true } + +[[env.production.d1_databases]] +binding = "DB" +database_name = "factory0-auth-production" +database_id = "REPLACE_WITH_PRODUCTION_D1_ID" +migrations_dir = "migrations"