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"