> ## Documentation Index
> Fetch the complete documentation index at: https://ddd-cqrs-es.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# 5.11. wasi-auth Fullstack Template

> Operate the consolidated authentication, authorization, Leptos islands, REST, and Spin gRPC reference application.

`examples/fullstack-app` is generated byte-for-byte from the CLI `fullstack`
preset. The embedded CLI template is the only editable source; run
`bash scripts/regenerate-fullstack-example.sh --check` to detect drift.

## Product boundary

The template depends on one auth product: `wasi-auth`. The former `ddd-auth`
and `ddd-authz` crates are removed. `ddd_cqrs_es` remains independent, while
the dependency points only from generated DDD consumers to `wasi-auth`.

Requests follow this boundary:

```text theme={null}
Browser / REST / gRPC
        -> native trusted ingress
        -> VerifiedAuthContext
        -> shared application services
        -> embedded Cedar
        -> PostgreSQL relational auth command kernel
        -> native mail / optional SpiceDB outbox worker
        -> DDD only for application business aggregates
```

The membership trigger atomically increments the organization authorization
revision and emits resource-scoped typed SpiceDB grants/revocations. The native
worker preserves per-tuple ordering and carries delivered consistency tokens.
SpiceDB remains preview only because its independent latency/soak gate is not
yet complete. Embedded Cedar remains the production authorization path.

Arbitrary headers cannot construct `VerifiedAuthContext`. Browser authority
comes from a secure host-only HttpOnly cookie; explicit API clients may use
bearer tokens. No request accepts `admin_token` or raw authorization tuples.

## Development and production profiles

### Spin is not the mail sender

`spin.toml` defines the **request-facing** product only (Leptos UI, REST, gRPC).
Transactional email is **not** a Spin component. Registration encrypts a mail
intent into PostgreSQL and returns success without calling Resend. Delivery is
owned by the native `wasi-auth-outbox-worker` process.

```text theme={null}
Browser / API  ->  Spin (spin.toml)  ->  Postgres outbox (pending)
                                              |
                                              v
                                   wasi-auth-outbox-worker
                                   (capture or Resend)
```

| Command              | Serves app | Delivers mail                   |
| -------------------- | ---------- | ------------------------------- |
| `make spin`          | yes        | **no** — intents stay `pending` |
| `make outbox-worker` | no         | yes                             |
| `make dev`           | yes        | yes (both processes)            |

`AUTH_MAIL_TRANSPORT=resend` (or `capture`) in `.env` configures the **worker**.
It does not make Spin send mail. Resend keys never appear in Spin variables.

### Local run

The generated application uses PostgreSQL with capture mail during local
development:

```bash theme={null}
# from monorepo root (recommended when working in this repository)
make -C examples/fullstack-app db-up
# preferred: Spin + worker so register/verify email works
make -C examples/fullstack-app dev transport=both

# app only — no mail delivery
make -C examples/fullstack-app spin transport=both

# equivalent after: cd examples/fullstack-app
make db-up
make dev transport=both
make spin transport=both
```

`make dev` installs the exact `wasi-auth-outbox-worker` release into the
example's `target/wasi-auth-tools` directory and stops it when Spin exits. HTTP
requests commit durable mail intents; the worker marks them delivered. The
default `capture` transport never sends internet email. The registration and
resend pages provide an **Open captured verification link** action after local
delivery. Run `make outbox-worker` and `make spin` separately only when you need
independent process logs. Full target list: `make -C examples/fullstack-app help`
or the example [README](../../examples/fullstack-app/README.md)
([Spin vs outbox worker](../../examples/fullstack-app/README.md#spin-vs-outbox-worker-why-two-processes)).

For real local or production delivery through Resend, use a verified sender:

```bash theme={null}
AUTH_MAIL_TRANSPORT=resend
AUTH_RESEND_API_KEY=re_replace_me
AUTH_RESEND_FROM="wasi-auth <auth@example.com>"
```

These values belong in the native worker environment. The Makefile never adds
the Resend API key to Spin variables. Resend delivery uses the outbox
correlation ID as the provider idempotency key, so worker retries do not send a
second message during the provider's idempotency window.

For local testing, put those values in `examples/fullstack-app/.env` and run
`make -C examples/fullstack-app dev` (worker required). For production, inject
them only into the worker service or its secret manager, not into the Spin
component.

### What the outbox worker is

The outbox worker is not an SMTP server, mail server, or replacement for
Resend. It is a native background delivery process — deliberately **outside**
`spin.toml` so provider secrets stay off the WASM guest and delivery can retry
after the HTTP response:

```text theme={null}
request -> PostgreSQL transaction
           user state + encrypted mail intent
        -> HTTP response
worker  -> leases pending intent -> capture or Resend -> records delivery ID
```

The application commits the account change and mail intent together. The
worker later delivers verification, reset, invitation, and security messages,
then records whether each intent was delivered, retried, or dead-lettered. If
the worker is down, requests remain usable but email jobs accumulate as
`pending`; restarting the worker drains them. This is why production runs the
Spin service and at least one outbox-worker process against the same
PostgreSQL database.

For local development, `make dev` owns both processes. Use this only when you
need separate logs:

```bash theme={null}
# monorepo root
make -C examples/fullstack-app spin transport=both
make -C examples/fullstack-app outbox-worker

# or from examples/fullstack-app
make spin transport=both
make outbox-worker
```

Running only `make spin` does not send mail (capture or Resend). Running only
the worker cannot serve pages or API requests. The worker owns Resend and
SpiceDB write credentials; the Spin guest receives neither.

### Email verification: register vs resend

Registering the **same email again never sends another verification mail**. A
second signup is an email conflict (`normalized_email` insert-once), not
“resend verification.” That is intentional (no duplicate accounts).

| Action                          | Result                                              |
| ------------------------------- | --------------------------------------------------- |
| 1st register                    | User + outbox mail; worker delivers                 |
| 2nd register (same email)       | Conflict → no new mail                              |
| Resend (`/verify-email/resend`) | New token + mail while still `pending_verification` |

To get another link while pending:

1. Open `/verify-email/resend` (or the link on `/verify-email/pending`).
2. Keep the outbox worker running (`make dev` or `outbox-worker`).

| Limit                       | Value                                                   |
| --------------------------- | ------------------------------------------------------- |
| Resends per email           | 5 / hour (over cap: accepted-looking response, no mail) |
| Register attempts per email | 5 / hour (still no re-mail on existing user)            |
| Token lifetime              | 24 hours from issue; one-time; hash at rest             |
| Already `active`            | Resend silent no-op; log in instead                     |

Checklist: same user needs another verify email → **resend**, not register
again → worker up → still pending → under 5 resends/hour.

Full operator detail (UI paths, token invalidation on resend, errors for spent
links):
[Email verification: register vs resend](../../examples/fullstack-app/README.md#email-verification-register-vs-resend).

The stale Spin SQLite migration-only feature was removed before the first RC.
It did not implement the product workflows and had already diverged from the
relational kernel. The generated identity product now has one authoritative
PostgreSQL execution path; a future development adapter must prove complete
schema, transaction, and workflow parity before it can re-enter the product.

The production profile uses PostgreSQL and the native HTTP-mail worker. The
Spin guest never receives mail or SpiceDB write credentials; optional direct
checks use a separate check-only token. Startup rejects capture mail,
development tools, insecure cookies, a non-HTTPS public base URL, missing CSRF
material, development outbox keys, and runtime-generated symmetric signing keys.
Ingress signing, credential-vault encryption, outbox encryption, and
recovery-code hashing must use distinct production secrets.
Production key rings accept ES256 private signing keys and publish only their
P-256 public coordinates. `wasi-auth` rejects RSA/PSS private signing because
the transitive RustCrypto RSA implementation is covered by a timing advisory;
RS256 is retained only for public-key verification of provider ID tokens.

`wasi-auth` owns the only auth migration source. Development and production
execute the same PostgreSQL migration contract. `make fresh` erases data and
the explicit migration step reapplies that canonical schema; the generated app
does not maintain a schema copy of its own.

The maintained Spin SDK revision declares Rust 1.93 as its MSRV, matching the
DDD, `wasi-auth`, and `leptos-wasi-runtime` release-candidate graph. The native
Spin host is built separately at its truthful Rust 1.94 floor because Wasmtime
46 and Cranelift 0.133 require it.

## Release topology

The dependency graph requires a staged RC release. Run the cross-repository
gate from the DDD checkout:

```bash theme={null}
make publish-fullstack dry-run
make publish-fullstack
```

The dry run performs all checks without uploading. Publish mode uploads only
after every repository is clean and its package dry-run, tests, formatting,
and generated-consumer checks pass; it then waits for each version to appear in
the crates.io index and verifies a registry-only generated consumer.

The package order is:

`leptos-wasi-runtime 0.4.2-rc.1` (aliased as `leptos_wasi`), then
`wasi-auth 0.1.0-rc.2`, the `ddd_cqrs_es 0.3.0-rc.7` library, and finally the
`ddd-cqrs-es-cli 0.3.0-rc.7` generator and generated consumers. The library
and CLI follow `wasi-auth` because the `fullstack` preset emits that exact
public dependency. Stable releases repeat the same dependency order.

The planned `leptos_wasi 0.4.0-alpha.3` identifier was superseded: this source
history already contains the `0.4.0` and `0.4.1` releases. The final-WASI and
islands work therefore continues as `0.4.2-rc.1` instead of moving version
history backward.

## User journey

Password registration creates a pending user and queues verification mail.
The start endpoint never returns a token or verification URL. After verifying,
the user accepts an invitation or creates an organization. Organization roles
are `owner`, `admin`, `member`, and `viewer`; custom roles cannot grant
ownership or system permissions. The final owner cannot be demoted, removed,
or disabled without another owner.

Account pages cover profile, passwords, providers, passkeys, MFA assurance, and
session revocation. Organization pages cover selection, members, invitations,
roles, permissions, and audit activity. System pages cover users, provider and
signing-key administration, Cedar policy versions, and configuration health.
Sensitive system operations require a system administrator at AAL2.

## Transport contract

One Spin HTTP component dispatches ordinary requests to Leptos or REST and
gRPC service paths to Tonic. The request and unary gRPC message limit is 256
KiB; authorization batches are limited to 100 checks.

* `auth.v1.AuthService` owns credentials, sessions, refresh, logout, and JWKS.
* `organization.v1.OrganizationService` owns organizations, invitations,
  memberships, roles, and permissions.
* `authorization.v1.AuthorizationService` exposes bounded checks and provider
  capabilities only.
* `admin.v1.AdminService` owns users, providers, signing keys, policy versions,
  and health behind AAL2 system authority.
* `audit.v1.AuditService.WatchAuditEvents` is cursor based, buffers at most 100
  events, reauthorizes every poll, terminates on revocation, and reconnects
  after five minutes.

The counter example demonstrates unary, server-streaming, client-streaming,
and bidirectional-streaming gRPC. Its optional `auth` feature delegates
`counter.change` and `counter.reset` checks to the generated fullstack app.

## Required verification

Before release, run formatting, warnings-denied Clippy, unit/doc tests, CLI
generation and drift checks, the PostgreSQL profile build and live relational
contracts, hydrate and split-WASM builds, package dry-runs, and WASM artifact inspection. Live Spin
promotion additionally requires browser flows, all four gRPC modes, five
paired performance samples, and a ten-minute concurrency-100 soak.

Native trusted ingress and embedded Cedar must stay within 10% paired overhead
and 25 ms p99 at concurrency 100 respectively. SpiceDB and portable component
middleware remain opt-in/experimental until they independently pass those
unchanged gates. A compile-only canary is not production support.

The 2026-07-12 exact-candidate run passed all five protected-path pairs: native
ingress was about 4.83 times faster in the paired median and reduced median p99
by 76.98%. Five 60-second samples reached 24,501.972 median requests/s with
10.847 ms worst p99. The final ten-minute sample reached 24,838.391 requests/s
at 10.278 ms p99 with zero status or transport failures, bounded HTTP 401
revocation, no sensitive-log findings, and no second-half RSS growth.
