Auth architecture
Authentication is pluggable. The framework defines an AuthProvider interface in @drawnagency/primitives; client sites wire up a concrete adapter in portal.config.mjs; the core middleware and API routes call through the interface without knowing which adapter is in use.
AuthProvider interface
Section titled “AuthProvider interface”AuthProvider is the contract any auth adapter must satisfy. It is defined in packages/primitives/src/auth/ and re-exported from the root @drawnagency/primitives entry. Key method groups:
resolveSession(ctx)— extract and verify the current session from cookiessignIn(method, ctx)— handle a sign-in attemptsignOut(ctx)— clear session cookiesaudiences.list()— list viewer audiencesaudiences.verify(name, password)— verify a viewer passwordpasswordEnabled.get()— whether the viewer password gate is active
Adapters
Section titled “Adapters”supabaseAuth() — @drawnagency/auth-supabase
Section titled “supabaseAuth() — @drawnagency/auth-supabase”The production adapter. Uses Supabase Auth for editor authentication (email/OAuth flows) and manages viewer audiences in the Supabase database.
import { supabaseAuth } from "@drawnagency/auth-supabase";Env vars required at runtime: SUPABASE_URL, SUPABASE_ANON_KEY, and either SUPABASE_SERVICE_ROLE_KEY (standalone) or PLATFORM_API_URL + PLATFORM_API_KEY + PORTAL_SITE_ID (platform mode). Env is validated lazily — on the first auth method call, not at import time — so portal.config.mjs can be loaded in browser contexts without throwing.
OAuth redirect derives its origin from the live request (url.origin), not from import.meta.env.SITE. This is required because Netlify’s build-time URL env var may resolve to the *.netlify.app domain instead of the custom domain, which would make the OAuth redirect URI unmatched in the Supabase allow-list and break PKCE cookie cross-origin.
createPasswordAuth() — @drawnagency/core/password
Section titled “createPasswordAuth() — @drawnagency/core/password”The password-only adapter. No Supabase required. Discovers audiences from environment variables following the convention VIEWER_<NAME>_PASSWORD (bcrypt hash) and optionally VIEWER_<NAME>_COLOR. Editor logins use ADMIN_PASSWORD and EDITOR_PASSWORD (bcrypt hashes).
import { createPasswordAuth } from "@drawnagency/core/password";This adapter has no database dependency and no OAuth support. It is useful for simple deployments or during initial setup before Supabase is configured.
portal.config.mjs
Section titled “portal.config.mjs”Client sites wire up the adapter in portal.config.mjs:
import { defineConfig } from "@drawnagency/core/config";import { supabaseAuth } from "@drawnagency/auth-supabase";import { githubStorage } from "@drawnagency/github";
export default defineConfig({ auth: supabaseAuth(), storage: githubStorage(), site: { name: "My Brand Portal" },});defineConfig must be imported from @drawnagency/core/config — not the root @drawnagency/core. The root export includes the Astro integration, which imports node:url, node:fs, and other Node-only modules. portal.config.mjs is loaded in the browser during editor hydration (via the virtual:portal/config virtual module), so every import it touches must be browser-safe.
Middleware
Section titled “Middleware”packages/core/src/middleware.ts is the single auth gate for all routes. It runs before every request via Astro’s middleware system. Decision logic:
- Public routes — pass through with no auth:
/login,/edit/login,/edit/login/callback,/api/auth/sign-in,/api/auth/sign-out,/api/auth/verify-audience,/api/auth/oauth,/api/auth/reset-password,/api/auth/token-exchange,/api/webhooks/netlify. - Media route (
/api/media/*) — open to all tiers; session is resolved but not required (editors are identified so they can access draft-branch media). - Editor routes (
/editand/edit/*,/api/*) — require a valid session. Unauthenticated requests to API routes get a 401 JSON response; unauthenticated page requests redirect to/edit/login?next=<url>. - Owner-only API methods — a subset of API routes require
role === "owner"for specific HTTP methods (e.g. POST/PATCH/DELETE on/api/auth/audiences, GET/POST/DELETE on/api/auth/users). - Viewer routes — when the password gate is enabled, require a signed audience cookie (JWT signed with
SESSION_SECRET). Forged or expired cookies are cleared and redirected to/login.
Locals set by middleware:
locals.isEditor: booleanlocals.role: "owner" | "editor" | nulllocals.userId: string | nulllocals.audience: string | null
Route structure
Section titled “Route structure”Page routes injected by the integration:
| Pattern | Purpose |
|---|---|
/[...slug] | Viewer site — renders sections as server-side HTML |
/login | Viewer login (audience/password gate) |
/edit | Editor shell |
/edit/[...slug] | Editor shell with section context |
/edit/login | Editor login page (email or OAuth) |
/edit/login/callback | OAuth PKCE callback |
/edit/set-password | Set/change editor password |
API routes injected by the integration:
| Pattern | Purpose |
|---|---|
/api/save | Save section content to GitHub |
/api/publish | Publish a saved branch |
/api/content | Fetch current content |
/api/auth/sign-in | Sign in (password or Supabase email) |
/api/auth/sign-out | Sign out |
/api/auth/oauth | Initiate OAuth flow |
/api/auth/verify-audience | Exchange audience password for signed cookie |
/api/auth/audiences | CRUD for viewer audiences |
/api/auth/users | User management (owner only) |
/api/auth/password-enabled | Toggle password gate |
/api/auth/set-password | Set editor password |
/api/auth/reset-password | Password reset email |
/api/auth/token-exchange | Supabase PKCE token exchange |
/api/media/[id]/[...path] | Media serving with CDN caching |
/api/history | Content history |
/api/build-status | Netlify build status |
/api/webhooks/netlify | Netlify deploy webhook receiver |