From b0edcf57c70daf04cb068c3750e1e831b2d4ec3e Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Fri, 18 Sep 2026 14:42:07 -0400 Subject: [PATCH 1/4] Add SGAuth architecture design, ticket set, red-team review, and position seed Design summary, red-team findings, 138-ticket set (Linear CSVs per team, Markdown, and GitHub issue creation script with issue map), and the curated position seed (82 positions) delivered on 2026-09-18. SGAuth is built on Neon serverless Postgres and does not use Supabase. Co-Authored-By: Claude Fable 5.1 --- .gitignore | 3 + docs/sgauth-design/SGAuth-design-summary.md | 114 + docs/sgauth-design/SGAuth-red-team.md | 77 + docs/sgauth-design/build.mjs | 166 ++ docs/sgauth-design/gh-issues.mjs | 144 ++ docs/sgauth-design/issue-map.json | 147 ++ .../out/SGAuth-design-summary.md | 114 + docs/sgauth-design/out/SGAuth-red-team.md | 77 + docs/sgauth-design/out/SGAuth-tickets.md | 1879 +++++++++++++++++ .../sgauth-design/out/linear-import-APLIO.csv | 75 + .../out/linear-import-ATTENDANCE.csv | 56 + docs/sgauth-design/out/linear-import-AUTH.csv | 1169 ++++++++++ .../out/linear-import-SENATEPATH.csv | 37 + .../out/linear-import-VAULTZ.csv | 76 + docs/sgauth-design/out/positions.json | 415 ++++ docs/sgauth-design/positions.json | 415 ++++ docs/sgauth-design/tickets-auth.mjs | 1040 +++++++++ docs/sgauth-design/tickets-products.mjs | 170 ++ prisma/seed/positions.json | 415 ++++ 19 files changed, 6589 insertions(+) create mode 100644 docs/sgauth-design/SGAuth-design-summary.md create mode 100644 docs/sgauth-design/SGAuth-red-team.md create mode 100644 docs/sgauth-design/build.mjs create mode 100644 docs/sgauth-design/gh-issues.mjs create mode 100644 docs/sgauth-design/issue-map.json create mode 100644 docs/sgauth-design/out/SGAuth-design-summary.md create mode 100644 docs/sgauth-design/out/SGAuth-red-team.md create mode 100644 docs/sgauth-design/out/SGAuth-tickets.md create mode 100644 docs/sgauth-design/out/linear-import-APLIO.csv create mode 100644 docs/sgauth-design/out/linear-import-ATTENDANCE.csv create mode 100644 docs/sgauth-design/out/linear-import-AUTH.csv create mode 100644 docs/sgauth-design/out/linear-import-SENATEPATH.csv create mode 100644 docs/sgauth-design/out/linear-import-VAULTZ.csv create mode 100644 docs/sgauth-design/out/positions.json create mode 100644 docs/sgauth-design/positions.json create mode 100644 docs/sgauth-design/tickets-auth.mjs create mode 100644 docs/sgauth-design/tickets-products.mjs create mode 100644 prisma/seed/positions.json diff --git a/.gitignore b/.gitignore index 9a8ef84..beaf085 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ next-env.d.ts # supabase /supabase/.branches /supabase/.temp + +# SGAuth design tooling scratch +docs/sgauth-design/out/tmp/ diff --git a/docs/sgauth-design/SGAuth-design-summary.md b/docs/sgauth-design/SGAuth-design-summary.md new file mode 100644 index 0000000..6d942ca --- /dev/null +++ b/docs/sgauth-design/SGAuth-design-summary.md @@ -0,0 +1,114 @@ +# SGAuth — Design Summary + +**Date:** 2026-09-18 · **Status:** Design settled, red-teamed, tickets regenerated · **Owner:** SGA Digital Innovation + +> Revision after the red-team review (see `SGAuth-red-team.md`): Neon stays on Free as an accepted, monitored risk; scheduled jobs run from GitHub Actions instead of Vercel cron; user deletion is a tombstone; the rate limiter fails open; the SDK is published to public npm; lockout has a known-device exemption; JWKS rotation is manual with a Management API push; emailed links consume tokens on POST (Safe Links); sign-up overwrites unverified accounts (pre-hijack); Chambers items are a checklist, not a Linear import; the Chambers `auth.users` export is Eli's manual action item. + +SGAuth is the centralized authentication and identity service for Northeastern SGA. One login at `auth.northeasternsga.com` yields one session shared by every product on `*.northeasternsga.com`. **SGAuth is built on Neon serverless Postgres and does not use Supabase for any purpose.** SGAuth is the sole source of truth for credentials; no product keeps its own password store. + +The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linear-import-*.csv` (one Linear CSV per team). + +--- + +## 1. Decisions and rationale + +| Area | Decision | Rationale | +|---|---|---| +| Repo | In-place migration of the existing `auth` repo: strip Supabase, add Neon + Better Auth; close or rebase the open Supabase branches | Keeps CI, shadcn, Prisma 7 setup, and Linear history; the existing code is a thin scaffold with no auth logic worth preserving | +| Database | Neon serverless Postgres: `main` (prod), `dev` (shared), `test` (CI), per-PR preview branches via the Neon–Vercel integration; pooled URL at runtime, direct URL for migrations. **Free plan, scale-to-zero, quota monitored (accepted risk)** | Hard requirement; branching gives isolated previews without Docker. Free suspends compute when 100 CU-hours are used, which would take every product's login down; alerts at 50/70/85% and an upgrade runbook to Launch ($0.106/CU-hour) mitigate. Recommendation on record: upgrade before the Chambers cutover | +| Auth framework | Self-managed Better Auth 1.7.x with `@better-auth/prisma-adapter` | Aplio already uses it on Neon; provides cross-subdomain cookies, JWT/JWKS plugin, two-factor, admin plugin, session APIs. Neon's Managed Better Auth is beta and pins an older version; hand-rolled auth is unjustified for a small team | +| Hosting | Vercel (Node runtime), `auth.northeasternsga.com` for production, `auth-dev.northeasternsga.com` for the dev deployment (DEV banner, synthetic users, separate secrets). **No Vercel cron**: scheduled jobs run from GitHub Actions calling secret-protected routes | Matches Aplio and Chambers; Neon integration; custom domains. Vercel Hobby limits cron to daily, so the scheduler is plan-independent | +| Login method | Email + password (12+ chars, no composition rules, no breached-password check), scrypt hashing; self-sign-up restricted to `@northeastern.edu` with verification (every student has one; no exceptions); admins can create/invite any address; a later sign-up overwrites an unverified account (pre-hijack defense); every emailed link consumes its token on POST (Microsoft Safe Links) | Resend free tier is nearly exhausted org-wide, so passwordless OTP would be fragile; passwords make SGAuth the credential store the requirement demands. Email OTP is a deferred backlog item | +| Northeastern SSO | Not a launch dependency; Phase 5 spike | Registering an app in Northeastern's Entra ID tenant needs ITS approval; a multi-tenant app in an SGA tenant may require Northeastern admin consent. Unknown timeline | +| SSO mechanism | Parent-domain cookie `Domain=northeasternsga.com`, `__Secure-` prefix, HttpOnly, Secure, SameSite=Lax, no cookie cache | Simplest true single session; logout is instant everywhere. `__Host-` is impossible with a Domain attribute. Any compromised subdomain can read the cookie, so subdomain hygiene is a security control | +| Session lookup | Products call `GET /api/sgauth/session` server-side with the forwarded cookie (SDK caches 60 s, serves a stale cached session for up to 5 min if SGAuth is unreachable); the Next.js proxy only checks cookie presence; Supabase products additionally mint a 10-minute ES256 JWT from `/api/auth/token` | DB-backed lookup keeps revocation and position changes immediate; stale-if-error keeps a SGAuth blip from logging every product out; JWTs only where Supabase needs a bearer token | +| Session lifetime | 30-day sliding (`updateAge` 1 day), 90-day absolute cap enforced by a daily purge on `createdAt` (so Better Auth's own endpoints cannot bypass it), 10-minute re-auth window via `lastReauthAt` (Better Auth `freshAge` left at default) | Matches semester usage while bounding stolen-cookie exposure | +| Logout | Global: any sign-out clears the shared cookie and deletes the session row; "sign out everywhere" for users; admin revoke; `/logout?redirect=` validated against the registry | One session means one logout | +| Supabase products | Supabase **third-party auth** (JWT trust) is the standard path; server-side JWT verification documented as a fallback; moving to Neon recommended where possible | See §2 | +| Identity key | Products store the SGAuth user id (UUID v7) as their foreign key; email/name come from the session | Stable across email changes; Supabase `auth.uid()` requires a UUID `sub` | +| Admin rules | Enforced in a pure `authz.ts` module, mirrored by Postgres constraints/triggers, and audited. Admins grant/revoke admin for others only; never self; never the Primary Admin | Server-side enforcement with a DB backstop; UI only hides controls | +| Primary Admin | Exactly one (partial unique index). Transfer: PA re-auth + typed recipient email → recipient (active admin with MFA) accepts with re-auth → 24-hour cooling window with an emailed cancel link → scheduled execution revokes both parties' sessions and emails all admins. PA cannot be deleted, deactivated, or de-admined; lockout applies but self-unlock by email exists | Protects the most powerful account against both mistakes and hijacking while still allowing graduation handoffs | +| Break-glass | Offline script run with production Neon credentials, confirmation phrase, `BREAK_GLASS` audit row, emails to all admins; no HTTP path; runbook with at least two credential holders | Infra access is the real control; no web backdoor | +| Positions | Flat list; immutable slug `key` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`, 2–64) plus editable display `name` and optional `category`; max 50 per user; rename keeps holders; delete is soft with typed confirmation, keeps history, retires the key for 365 days | Products check keys so renames never break permissions; retirement prevents accidental privilege revival | +| Propagation | Immediate for Neon products (live DB read); ≤10 minutes for Supabase JWTs; "force re-login" admin action | Bounded staleness without forcing logouts on every change | +| Seed data | Curated SGA position list supplied by SGA on 2026-09-18: 81 offices in 9 categories plus `senator`; keys slugified from official names; product roles (`-`) created on demand by product owners, not seeded | Explicit choice over inferring from Chambers roles | +| User removal | Admin deactivate (soft, sessions revoked instantly, reactivatable); **tombstone** (status DELETED, PII scrubbed, id kept) 30 days after deactivation or on request; inactive 12 months → notice → tombstone (admins exempt for manual review); audit PII anonymized, events kept | Meets the retention policy without losing the audit trail or orphaning product rows keyed by SGAuth user id | +| Profile data | `name`, optional `preferredName`, `email`; no NUID, phone, pronouns, photo | Data minimization; products keep their own fields keyed by SGAuth id | +| User UI | Minimal account page: profile, positions (read-only), links to each product from the registry, sessions with revoke, sign out everywhere, password change, MFA enrollment | Users need a hub and self-service basics; nothing product-specific | +| Account creation | Admin-created/invited (set-password link) or bulk CSV import, plus `@northeastern.edu` self-sign-up with zero positions | Avoids admin bottlenecks at turnover; positions gate everything | +| Migration | Chambers: bcrypt hashes imported with a `bcrypt$` marker and lazily re-hashed to scrypt on first login (the `auth.users` export is Eli's manual action item, before the Supabase project is deleted). Aplio/SenatePath/Attendance: emails imported, set-password invites; non-NU Aplio addresses imported as `legacyEmail` accounts. Merge by lower-cased email | Chambers users keep their passwords; OTP users never had one | +| MFA | TOTP + backup codes; optional for users, required for admins and the PA (enforced at admin-action time); required for the transfer recipient | No SMS/email cost; protects privileged accounts | +| Rate limiting | Upstash Redis sliding windows on sign-in, sign-up, reset, token, re-auth, and admin endpoints only (session endpoint excluded to stay within 500K commands/month); **fails open with an alert** on Upstash outage; Better Auth's DB limiter disabled | Team already runs Upstash (Chambers); a vendor outage must never become an org-wide login outage | +| Lockout | 5 failures/15 min → 15-minute lock, doubling to a 24-hour cap, never permanent; emailed unlock link (POST-consumed); applies to the PA; **known-device cookie exempts the owner's usual browser** so the lock cannot be used to deny admins service | Mitigates credential stuffing without permanent self-DoS or a lockout DoS lever | +| Email | Resend on a dedicated SGAuth sending domain and API key, provider-agnostic mailer interface, per-recipient and global caps | Keeps SGAuth off Aplio's and Chambers' quota; volume is low with password login | +| Product registry | Admin-managed `Product` table (slug, name, base URL restricted to `https://*.northeasternsga.com`, visibility by positions); drives trusted origins, redirect allowlist, CORS, and account-page links | No deploy to add a product; one source for security allowlists | +| SDK | `@sgaoperations/sgauth` on **public npm** with trusted publishing: `getSession` (60 s cache, stale-if-error), cookie-presence proxy helper, position guards, URL builders, `getAccessToken` for Supabase | Five products, one place to fix bugs; GitHub Packages was rejected because it needs a personal token to install even public packages | +| Environments | Dev SGAuth on the real parent domain with a distinct cookie prefix; product previews on stable `-dev.northeasternsga.com` branch domains; local `*.sga.localhost` hostnames; `*.vercel.app` previews cannot use SSO | A parent-domain cookie cannot reach `vercel.app` or bare `localhost`; the topology must be explicit | +| Observability | Append-only `AuditEvent` table with a typed catalog and coverage test; structured JSON logs; PostHog (funnel events without PII, error tracking); health endpoint + uptime monitor; threshold alerts; retention jobs | Audit is a product requirement; PostHog free tier chosen over Sentry | +| Tokens | ES256, **manual** JWKS rotation (annual or incident) with the new key pushed to each Supabase project via `custom_jwks` before signing switches, 7-day overlap, `kid` header, issuer `https://auth.northeasternsga.com`, audience `authenticated`, 10-minute TTL, claims `sub` (uuid), `email`, `name`, `role: "authenticated"`, `positions`, `is_admin` | ES256 is universally supported (Supabase, jose); automatic rotation would reject fresh tokens for up to 30 minutes until Supabase re-fetches keys | +| Security extras | Origin/Referer enforcement on all mutations (SameSite=Lax still sends the cookie on sibling-subdomain POSTs), security headers (HSTS without `includeSubDomains` until every subdomain is confirmed HTTPS), subdomain DNS hygiene policy, enumeration-resistant responses, secrets rotation runbook, Dependabot/CodeQL, STRIDE-lite threat model | Consequences of the shared-cookie design | +| Rollout | SGAuth MVP → VaultZ → Chambers (already a Neon product) → Aplio (hard cutover outside an application window) → SenatePath (migrated to Neon by its owner) → Attendance Manager (Supabase or Neon path pending their decision) | VaultZ has no auth to unwind and proves the SDK; Chambers has the most users and pre-imported credentials | +| Ticketing | Fibonacci estimates; one Linear CSV per team (AUTH, VaultZ, Aplio, SenatePath, Attendance Manager); Chambers has no Linear team so its items are a Markdown checklist; epics as Linear Projects with `epic:*` labels and Epic/Phase/Depends-on lines in every description | Linear's importer does not create projects or parent links | + +## 2. Supabase compatibility findings + +**Scopable, with one real limitation.** + +**Path chosen: Supabase third-party auth (JWT trust).** Supabase's Management API endpoint `POST /v1/projects/{ref}/config/auth/third-party-auth` accepts a generic `oidc_issuer_url`, `jwks_url`, or inline `custom_jwks`, so a project can trust SGAuth-issued JWTs even though the dashboard advertises only Clerk, Firebase, Auth0, Cognito, and WorkOS. Requirements SGAuth meets: asymmetric signing (ES256), a `kid` header, a `role: "authenticated"` claim (maps the request to the `authenticated` Postgres role), a UUID `sub` (read by `auth.uid()`), and standard `iss`/`exp`. Positions ride in the token and are readable in RLS via `auth.jwt() -> 'positions'`. supabase-js takes an `accessToken` callback; the SDK supplies it. + +**Limitations to plan around:** +1. Third-party users have **no `auth.users` row**. Foreign keys, triggers, or joins on `auth.users` break. Chambers had these (now moot, since Chambers is a Neon product); Attendance Manager has a `supabaseAuthId` column that must become `sgauthUserId`. +2. Supabase Auth features are unavailable to these users: no Supabase sessions/refresh, password reset, or MFA. All of that is SGAuth's job, by design. +3. JWT staleness of up to 10 minutes for position changes and revocations; "force re-login" in SGAuth bounds it. Supabase refreshes JWKS within ~30 minutes, so SGAuth keeps rotated keys valid for 7 days. +4. Billing: $0.00325 per third-party monthly active user beyond plan quota. Negligible at SGA scale. +5. Generic-issuer configuration is an API operation, not a dashboard toggle; the guide ships a script. + +**Alternatives evaluated and rejected as the default:** +- *Supabase custom OIDC provider* (Supabase Auth as a relying party to SGAuth acting as an OpenID provider via Better Auth's OAuth 2.1 provider package). Keeps `auth.users` but creates a second session SGAuth cannot terminate, positions must be copied into `app_metadata` by a hook, and free projects are capped at three custom providers. Documented as an escape hatch only. +- *Server-side verification only* (verify SGAuth JWTs with `jose`, use the service-role key). Works everywhere but loses per-user RLS. Documented as a fallback for products that do not need RLS. + +**Product-by-product:** Website Creation has no user login today and will be hosted on Neon if it ever needs auth. SenatePath is migrated to Neon by its owner. Attendance Manager gets both paths ticketed; exactly one proceeds after the team's decision. + +## 3. Open inputs and flagged risks + +- ~~Curated position list~~ Received 2026-09-18: 82 positions (81 offices plus `senator`) in `prisma/seed/positions.json`; product-specific roles are created by product owners in the admin UI. +- **Attendance Manager directive** (Supabase vs Neon) selects ATTENDANCE-M02 or M03. +- **Chambers `auth.users` export** (Eli, manual) must happen before that Supabase project is deleted, or every Chambers user resets their password. +- **Neon Free quota** is an accepted risk with monitoring; the recorded recommendation is to upgrade to Launch before the Chambers cutover. +- **Cookie tossing from any subdomain** (login CSRF) is inherent to the parent-domain cookie; DNS hygiene and the Phase 1 cookie spike are the controls. +- **HSTS at the apex** with `includeSubDomains` would affect every product; enabled only after all subdomains are confirmed HTTPS-only. +- **Northeastern SSO** remains a spike; do not plan around it. +- **Resend volume**: SGAuth's dedicated domain isolates it, but org-wide totals should be watched; the mailer has caps and an 80% alert. + +Full red-team findings, dispositions, and accepted risks: `SGAuth-red-team.md`. + +## 4. Ticket set overview + +138 tickets across six teams (five with Linear CSVs): AUTH 103 tickets in 13 epics; VaultZ 8; Chambers 9 (checklist only); Aplio 8; SenatePath 4; Attendance Manager 6. Point totals are in the ticket-set header. Phases: 0 Foundation → 1 Core auth, sessions, SSO (MVP) → 2 Admin, Primary Admin, positions, UI, SDK, VaultZ → 3 Hardening, observability, Chambers → 4 Aplio, SenatePath, Attendance, retention → 5 Backlog/spikes. Every ticket has title, description, acceptance criteria, priority, Fibonacci estimate, labels, dependencies, and its parent epic. + +**Linear CSV format (verified against Linear's own importer source):** headers `Title, Description, Priority, Estimate, Status, Labels`; priority as the words Urgent/High/Medium/Low; estimate as an integer; labels separated by ", "; descriptions in Markdown. Parent issues and projects are not importable via CSV, so each description begins with its Ticket id, Epic, Phase, and Depends-on lines, and each ticket carries an `epic:*` label for bulk assignment into projects after import. + +## 5. Sources relied on + +- Supabase third-party auth overview: https://supabase.com/docs/guides/auth/third-party/overview +- Supabase third-party auth with Clerk (required `role` claim, `accessToken` callback): https://supabase.com/docs/guides/auth/third-party/clerk +- Supabase Management API, create third-party auth integration (`oidc_issuer_url`, `jwks_url`, `custom_jwks`): https://supabase.com/docs/reference/api/v1-create-project-tpa-integration +- Supabase custom OAuth/OIDC providers: https://supabase.com/docs/guides/auth/custom-oauth-providers +- Supabase row level security (`auth.uid()`, `auth.jwt()`, roles, performance): https://supabase.com/docs/guides/database/postgres/row-level-security +- Better Auth JWT plugin (algorithms, JWKS, rotation, `definePayload`): https://better-auth.com/docs/plugins/jwt +- Better Auth cookies (cross-subdomain cookies, prefixes): https://better-auth.com/docs/concepts/cookies +- Better Auth session management (expiry, freshness, revocation, customSession): https://better-auth.com/docs/concepts/session-management +- Better Auth admin plugin: https://better-auth.com/docs/plugins/admin +- Better Auth OAuth 2.1 provider (evaluated alternative): https://better-auth.com/docs/plugins/oauth-provider +- Better Auth releases and Prisma adapter versions: https://github.com/better-auth/better-auth/releases, https://www.npmjs.com/package/@better-auth/prisma-adapter +- Neon with Prisma ORM (pooled vs direct, driver adapters): https://neon.com/docs/guides/prisma +- Neon plans and free-tier limits: https://neon.com/docs/introduction/plans, https://neon.com/faqs/free-plan-limits-and-quotas +- Neon Managed Better Auth (evaluated, not chosen): https://neon.com/docs/auth/overview +- Connecting to Neon from Vercel: https://neon.com/docs/guides/vercel-connection-methods +- Linear CSV importer source (columns, priority words, label splitting): https://github.com/linear/linear/tree/master/packages/import +- Linear export columns: https://linear.app/docs/exporting-data +- MDN Set-Cookie (Domain, `__Secure-`/`__Host-`, SameSite): https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie +- Northeastern 1Login/SSO and Duo MFA: https://1login.its.northeastern.edu, https://security.its.northeastern.edu/mfa/ +- Upstash rate limiting for Next.js: https://upstash.com/blog/edge-rate-limiting +- FERPA directory information: https://studentprivacy.ed.gov/content/directory-information +- Repos inspected: SGAOperations/auth, vaultz, chambers, aplio, website-development, senate-path, attendance-manager (package.json, auth modules, Prisma schemas, migrations). diff --git a/docs/sgauth-design/SGAuth-red-team.md b/docs/sgauth-design/SGAuth-red-team.md new file mode 100644 index 0000000..7a5b557 --- /dev/null +++ b/docs/sgauth-design/SGAuth-red-team.md @@ -0,0 +1,77 @@ +# SGAuth — Red-Team Review + +**Date:** 2026-09-18 · **Scope:** the settled design and all 135 tickets · **Method:** adversarial walk-through of every component, verified against vendor documentation and the product repos. Each finding lists severity, the failure, and the disposition (decision taken with Eli, correction applied to tickets, or accepted risk). + +Severity: **S1** would take SGAuth or all products down, or hand over an account; **S2** breaks a core flow or a security property for some users; **S3** degrades quality, cost, or operability. + +## A. Findings resolved by decision + +| # | Sev | Finding | Disposition | +|---|---|---|---| +| A1 | S1 | **Neon Free suspends compute for the rest of the month at 100 CU-hours.** An auth database awake most of the day at 0.25 CU uses ~180 CU-hours/month. Suspension = every SGA login fails. Restore window on Free is 6 hours. | **Decision: stay on Free, scale-to-zero on, monitor quota.** Accepted risk. AUTH-T101 adds 50/70/85% alerts with an exhaustion projection, forbids keep-warm pings, and documents the one-click Launch upgrade ($0.106/CU-hour, roughly $10–25/month). Recommend upgrading before the Chambers cutover. | +| A2 | S1 | **Vercel Hobby cron: 2 jobs, once per day, enforced at deploy.** The 15-minute transfer executor and alert job would fail every deploy. | **Decision: GitHub Actions schedules calling secret-protected routes** (Chambers' existing pattern). AUTH-T38 rewritten; `vercel.json` must contain no `crons`. | +| A3 | S2 | **Physical deletion orphans product data.** VaultZ purchases, Aplio applications, and Chambers bookings are keyed by SGAuth user id. | **Decision: tombstone.** Status `DELETED`, PII scrubbed, id kept. AUTH-T10, T36, T78 updated. | +| A4 | S1 | **Upstash fail-closed on sign-in** turns a Redis vendor outage into an org-wide login outage. | **Decision: fail open with alert**; DB-backed lockout is the brute-force backstop. AUTH-T63 updated. | +| A5 | S3 | **Upstash free tier is 500K commands/month.** Rate-limiting the session endpoint (the hot path) would exhaust it in weeks. | Session endpoint is no longer Redis-limited; only mutations and the token endpoint are. Budget and 80% alert added. AUTH-T63. | +| A6 | S2 | **GitHub Packages requires a classic PAT to install even public packages.** A graduating student's token would sit in every product's Vercel env. | **Decision: public npm under `@sgaoperations`** with trusted publishing (no long-lived token). AUTH-T57, T61, VAULTZ-V01. | +| A7 | S2 | **Lockout as a denial-of-service lever.** Anyone knowing an admin's email locks them out for up to 24 h, repeatedly, including the Primary Admin during a transfer. | **Decision: known-device exemption.** A signed device cookie set after successful login bypasses the account lock; unknown devices are still locked. AUTH-T64 (estimate raised to 5). | +| A8 | S2 | **Automatic JWKS rotation vs Supabase's ≤30-minute key refresh.** New key is used immediately; Supabase rejects fresh tokens until it re-fetches. | **Decision: manual rotation** (annual or incident) with the combined JWKS pushed to each Supabase project via `custom_jwks` before switching. AUTH-T29, T69. Audience set to `authenticated` to match Supabase convention. | +| A9 | S2 | **Aplio users with non-northeastern.edu emails.** Every student has an NU address, but existing Aplio accounts are keyed by other addresses. | **Decision: import as-is with `legacyEmail = true`**; admins move them to the NU address later (AUTH-T24) preserving the SGAuth id and Aplio history. New applicants must use NU email (APLIO-P05). | +| A10 | S1 | **Chambers password hashes are lost if the Supabase project is deleted before export.** | **Eli exports `auth.users` manually; not a ticket.** Chambers has no Linear team, so Chambers items are a Markdown checklist only (no CSV). AUTH-T86 now validates the received file. | +| A11 | S3 | **Dev SGAuth on the real parent domain** is a production-looking login page with weaker controls. | **Decision: open dev with a persistent DEV banner, `noindex`, synthetic users only, separate secrets.** AUTH-T34. | + +## B. Corrections applied to tickets (no decision needed) + +| # | Sev | Finding | Fix | +|---|---|---|---| +| B1 | S1 | **Microsoft Safe Links consumes single-use links.** Northeastern mail is M365; Defender pre-fetches links, so GET-acting verify/reset/invite/unlock/transfer links die before the user clicks. | New AUTH-T103: every emailed link lands on a page and consumes the token only on a POST; HEAD/scanner requests are side-effect free. T18, T19, T22, T24, T37, T64 depend on it. | +| B2 | S1 | **Account pre-hijack via sign-up.** With `requireEmailVerification`, Better Auth answers a duplicate sign-up with success. An attacker pre-registers a victim's NU address; the victim "signs up", verifies, and the attacker's password is live. | AUTH-T17: a later sign-up overwrites an *unverified* account's password and name; verification revokes all sessions. Test added. AUTH-T70 aligned. | +| B3 | S2 | **90-day absolute cap enforced only in the session endpoint.** Better Auth's own `/token` and plugin endpoints never ran the custom check, so a 90-day-old session could still mint Supabase JWTs. | AUTH-T27/T78: the daily job deletes sessions by `createdAt` > 90 days; the endpoint check stays as defense in depth. | +| B4 | S2 | **`freshAge = 10 min` misuse.** Better Auth's `freshAge` gates its own fresh-session endpoints; setting it to 10 minutes would have broken those for any session older than 10 minutes and was not a re-auth mechanism. | Leave `freshAge` default; re-auth uses `lastReauthAt`. AUTH-T27, T32. | +| B5 | S2 | **SDK proxy calling SGAuth on every request.** Middleware runs per request and per instance; a network call there adds latency to every page and the per-instance cache rarely hits. | AUTH-T59: proxy checks cookie presence only; validation happens in server code via `requireSession()`. | +| B6 | S1 | **SGAuth is a single point of failure for every product**, and the SDK failed closed on any error. A short Neon or Vercel blip logged everyone out of everything. | AUTH-T58: stale-if-error — serve a previously cached session for that token for up to 5 minutes (max 15) when SGAuth is unreachable; fail closed only with no cache. | +| B7 | S2 | **SGAuth's own `*.vercel.app` previews cannot set a `northeasternsga.com` cookie**; previews would be un-loginable. | `SGAUTH_ENV=preview` host-only cookie mode. AUTH-T05, T26. | +| B8 | S2 | **Hijacked Primary Admin session could redirect the transfer-cancel email** by changing the PA's email, and any admin could change any admin's email then reset their password. | AUTH-T24/T35: email change requires re-auth; admins change only non-admin emails; only the PA changes an admin's email; PA's own email only by the PA. | +| B9 | S2 | **PA MFA lockout is unrecoverable** (lost phone and codes; admins cannot touch PA MFA). | AUTH-T39: break-glass `--reset-mfa` mode. AUTH-T36: admin MFA reset for non-PA users under authz rules. | +| B10 | S2 | **Cross-subdomain cookie behavior has unresolved community bug reports** (better-auth #5611, #3938) and local `*.localhost` cookie handling varies by browser. | New AUTH-T104 spike, Phase 1, before any product work: verify on the real domain in three browsers, plus preview mode, local scheme, and cookie tossing. | +| B11 | S3 | **Third-party analytics script on credential pages** (PostHog client SDK on login/reset) is a supply-chain risk. | AUTH-T75: client script only on authenticated pages; auth pages report errors first-party. | +| B12 | S3 | **`/logout` on GET is CSRF-able** (an external page logs users out via an image tag). | AUTH-T30: reject `Sec-Fetch-Site: cross-site`; sibling subdomains remain same-site. | +| B13 | S3 | **Registry outage breaks origin checks** and a wildcard `*.northeasternsga.com` would trust dangling hosts. | AUTH-T56: static fallback of SGAuth's own origin; wildcard explicitly forbidden. | +| B14 | S3 | **Migrations run during the build while the old deployment still serves.** A destructive migration breaks the live release. | AUTH-T16: expand/contract rule documented and required. | +| B15 | S3 | **Shared Neon test branch with parallel CI runs** collides; per-run branches hit the 10-branch cap. | AUTH-T06/T93: Actions concurrency group serializes runs; branch reset before each. | +| B16 | S3 | **Audit `type` CHECK constraint from the catalog** forces a migration for every new event. | TS-level validation only. AUTH-T13, T73. | +| B17 | S3 | **50 ms timing assertion in CI** would be flaky. | Non-gating benchmark; bodies and status codes asserted. AUTH-T70. | +| B18 | S3 | **Verification re-send on every sign-in attempt** by an unverified user (Better Auth behavior) is an email-flood lever. | Documented in AUTH-T17; bounded by mailer per-recipient caps and sign-in rate limits. | +| B19 | S3 | **Position key collisions** between org roles and product roles. | Naming convention in AUTH-T44: bare keys for org roles, `-` prefix for product roles. | +| B20 | S3 | **Better Auth admin plugin fields** (`impersonatedBy`) were listed but the plugin is not used. | Removed; custom authz only. AUTH-T10. | +| B21 | S3 | **Invited (password-less) user tries to self-sign-up** and gets a confusing no-op. | AUTH-T22: copy routes them to set-password/forgot. | + +## C. Accepted risks (documented, no change) + +| # | Sev | Risk | Why accepted / residual control | +|---|---|---|---| +| C1 | S2 | **Cookie tossing / login CSRF from any `*.northeasternsga.com` host.** A compromised or dangling subdomain can set a same-named cookie carrying an attacker's *valid* session, logging the victim into the attacker's account. `__Host-` cannot be used with a Domain attribute. | Inherent to the parent-domain cookie decision. Cookie values are signed (no forgery). Controls: AUTH-T68 DNS hygiene and subdomain policy, AUTH-T104 observes the behavior, threat model records it. Alternative (OIDC per product) was rejected for complexity. | +| C2 | S2 | **Neon Free cold starts** (1–3 s after 5 idle minutes) on the first login of a quiet period, and the A1 quota cliff. | Eli's decision; monitored by AUTH-T101. | +| C3 | S3 | **JWT staleness ≤10 minutes** for Supabase products after revocation or position changes. | Bounded by TTL; "force re-login" exists; documented in the Supabase guide. | +| C4 | S3 | **SDK stale-if-error window** (up to 5 minutes by default) delays revocation only while SGAuth is down. | Availability trade-off chosen deliberately; bounded and configurable. | +| C5 | S3 | **Any admin can strip the Primary Admin's positions.** Affects product access only, never SGAuth authority; audited. | Simplicity; PA can restore instantly. | +| C6 | S3 | **12-month inactivity tombstone vs co-op or leave.** A student away 12 months loses positions and must re-register. | With tombstones the data cost is low; admins are exempt; notice at 11 months. Could extend to 24 months later without code changes. | +| C7 | S3 | **Northeastern mailbox loss after graduation** blocks password reset for alumni. | Admin email change (AUTH-T24) or account naturally tombstones. | +| C8 | S3 | **10-branch cap** (Free and Launch) with personal, preview, and fixed branches. | AUTH-T09 cleanup, warning at 8; personal branches short-lived. | +| C9 | S3 | **Audit anonymization gate is not a security boundary** against the DB role. | Stated in AUTH-T13; DB credentials are the boundary. | +| C10 | S3 | **Chambers PWA offline login page** becomes a redirect to SGAuth when offline. | Chambers-C02 keeps the offline message; SGAuth cannot work offline by design. | + +## D. Items verified as sound + +- Supabase third-party auth accepts a generic issuer (`oidc_issuer_url` / `jwks_url` / `custom_jwks`) via the Management API; `role: authenticated` and UUID `sub` requirements are met by the JWT plugin configuration. +- Better Auth 1.7 supports custom `password.verify` (bcrypt import), request-based `trustedOrigins` functions, cross-subdomain cookies, `cookiePrefix`, `session.create.before` hooks that can throw, the 2FA plugin with backup codes and its own TOTP lockout, and returns success on duplicate sign-up when verification is required. +- Northeastern student addresses are `@northeastern.edu`; `husky.neu.edu` is a legacy alias that still delivers but is not the account identity. The domain rule stays exact-match. +- Upstash free tier (500K commands/month) suffices once the session endpoint is excluded. +- Resend volume with password login is low; invites are batched under caps. + +## E. Still open + +- ~~Curated position list~~ received; seed file in place, AUTH-T44 unblocked. +- Attendance Manager's Supabase-vs-Neon directive (selects ATTENDANCE-M02 or M03). +- The Chambers `auth.users` export (Eli's manual action item) must precede deletion of that Supabase project. +- Recommendation retained for the record: move Neon to Launch before the Chambers cutover. diff --git a/docs/sgauth-design/build.mjs b/docs/sgauth-design/build.mjs new file mode 100644 index 0000000..5db5d09 --- /dev/null +++ b/docs/sgauth-design/build.mjs @@ -0,0 +1,166 @@ +// Generates Linear-importable CSVs (one per team) and a readable Markdown ticket set. +// Usage: node build.mjs +import { writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { EPICS, TICKETS } from "./tickets-auth.mjs"; +import { PRODUCT_TEAMS, PRODUCT_TICKETS } from "./tickets-products.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const out = join(here, "out"); +mkdirSync(out, { recursive: true }); + +const PHASES = { + 0: "Phase 0 — Foundation", + 1: "Phase 1 — Core auth, sessions, SSO (MVP)", + 2: "Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ", + 3: "Phase 3 — Hardening, observability, Chambers", + 4: "Phase 4 — Aplio, SenatePath, Attendance Manager, retention", + 5: "Phase 5 — Backlog / spikes", +}; + +// ---------- validation ---------- +const all = [ + ...TICKETS.map((t) => ({ ...t, team: "AUTH", epicName: EPICS[t.epic].name, epicLabel: EPICS[t.epic].label })), + ...PRODUCT_TICKETS.map((t) => ({ ...t, epicName: PRODUCT_TEAMS[t.team].epic, epicLabel: PRODUCT_TEAMS[t.team].label, phase: PRODUCT_TEAMS[t.team].phase })), +]; +const byId = new Map(all.map((t) => [t.id, t])); +const errors = []; +for (const t of all) { + if (byId.get(t.id) !== t) errors.push(`duplicate id ${t.id}`); + if (!["Urgent", "High", "Medium", "Low"].includes(t.priority)) errors.push(`${t.id}: bad priority`); + if (![1, 2, 3, 5, 8].includes(t.estimate)) errors.push(`${t.id}: bad estimate`); + if (!t.acceptance?.length) errors.push(`${t.id}: no acceptance criteria`); + if (t.description.trim().startsWith("'")) errors.push(`${t.id}: description starts with an apostrophe (Linear strips it)`); + for (const d of t.deps) if (!byId.has(d)) errors.push(`${t.id}: unknown dep ${d}`); +} +// cycle check +const visiting = new Set(), done = new Set(); +function visit(id, path) { + if (done.has(id)) return; + if (visiting.has(id)) { errors.push(`dependency cycle: ${[...path, id].join(" -> ")}`); return; } + visiting.add(id); + for (const d of byId.get(id).deps) visit(d, [...path, id]); + visiting.delete(id); done.add(id); +} +for (const t of all) visit(t.id, []); +if (errors.length) { console.error(errors.join("\n")); process.exit(1); } + +// ---------- helpers ---------- +const csvCell = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; +const titleOf = (id) => byId.get(id)?.title ?? ""; +function descriptionFor(t) { + const deps = t.deps.length ? t.deps.map((d) => `${d} (${titleOf(d)})`).join("; ") : "none"; + const lines = [ + `**Ticket:** ${t.id} `, + `**Epic:** ${t.epicName} `, + `**Phase:** ${PHASES[t.phase]} `, + `**Depends on:** ${deps}`, + ``, + t.description.trim(), + ``, + `**Acceptance criteria**`, + ...t.acceptance.map((a) => `- [ ] ${a}`), + ]; + return lines.join("\n"); +} +function labelsFor(t) { + return [t.epicLabel, `phase:${t.phase}`, ...t.labels].join(", "); +} + +// ---------- CSV per team ---------- +// Columns honored by Linear's CSV importer (case-sensitive): Title, Description, Priority, Estimate, Status, Labels. +// Priority values must be the words Urgent/High/Medium/Low. Labels are ", "-separated. Status must match a workflow state name. +const HEADER = ["Title", "Description", "Priority", "Estimate", "Status", "Labels"]; +const teams = ["AUTH", ...Object.keys(PRODUCT_TEAMS)]; +const csvTeams = teams.filter((t) => !PRODUCT_TEAMS[t]?.noLinear); +const files = []; +for (const team of csvTeams) { + const rows = all.filter((t) => t.team === team); + const csv = [HEADER.map(csvCell).join(",")] + .concat(rows.map((t) => [t.title, descriptionFor(t), t.priority, t.estimate, "Backlog", labelsFor(t)].map(csvCell).join(","))) + .join("\r\n") + "\r\n"; + const name = `linear-import-${team}.csv`; + writeFileSync(join(out, name), "" + csv, "utf8"); // BOM helps Excel; Linear's parser tolerates it + files.push({ name, count: rows.length }); +} + +// ---------- Markdown ---------- +const md = []; +md.push(`# SGAuth ticket set`); +md.push(``); +md.push(`Generated from \`tickets-auth.mjs\` and \`tickets-products.mjs\`. ${all.length} tickets across ${teams.length} teams (${csvTeams.length} with Linear CSVs; Chambers has no Linear team and is a checklist only). CSVs in \`out/\` use Linear's importer columns (Title, Description, Priority, Estimate, Status, Labels); each ticket's description carries its epic, phase, and dependencies because the importer does not create projects or parent links. Red-team revisions are folded in; see \`SGAuth-red-team.md\`.`); +md.push(``); +md.push(`**Manual action item (not a ticket):** Eli exports the Chambers Supabase \`auth.users\` table (with \`encrypted_password\`) joined to \`public.users\` before that Supabase project is deleted, and stores the file in the team secrets vault. AUTH-T86 validates the file; AUTH-T87 imports it.`); +md.push(``); +md.push(`## Import instructions`); +md.push(``); +md.push(`1. In Linear, create the projects listed under **Epics** below (one per epic) in the AUTH team, and one "SGAuth integration" project in each product team.`); +md.push(`2. Settings → Import/Export → Import → CSV. Import \`linear-import-AUTH.csv\` into team AUTH; import each product file into that product's team. Map columns as-is (headers match Linear's expected names).`); +md.push(`3. After import, filter by label \`epic:*\` and bulk-move issues into the matching project. Dependencies are written as text (\`Depends on: AUTH-T03 (...)\`); add "blocked by" relations while triaging.`); +md.push(`4. Estimates are Fibonacci points (1/2/3/5/8). Set each team's estimate scale to Fibonacci before importing so values map cleanly.`); +md.push(``); +md.push(`## Phases`); +md.push(``); +for (const [k, v] of Object.entries(PHASES)) md.push(`- **${v}**`); +md.push(``); +md.push(`## Epics (Linear Projects)`); +md.push(``); +md.push(`### Team AUTH`); +md.push(``); +for (const [k, e] of Object.entries(EPICS)) { + const n = TICKETS.filter((t) => t.epic === k); + const pts = n.reduce((s, t) => s + t.estimate, 0); + md.push(`- **${e.name}** (label \`${e.label}\`, ${n.length} tickets, ${pts} points): ${e.description}`); +} +md.push(``); +md.push(`### Product teams`); +md.push(``); +for (const [k, e] of Object.entries(PRODUCT_TEAMS)) { + const n = PRODUCT_TICKETS.filter((t) => t.team === k); + const pts = n.reduce((s, t) => s + t.estimate, 0); + md.push(`- **${e.epic}** (team ${e.name}, ${n.length} tickets, ${pts} points, ${PHASES[e.phase]}): ${e.description}`); +} +md.push(``); +md.push(`## Summary table`); +md.push(``); +md.push(`| ID | Title | Team | Phase | Priority | Est. | Depends on |`); +md.push(`|---|---|---|---|---|---|---|`); +for (const t of all) md.push(`| ${t.id} | ${t.title} | ${t.team} | ${t.phase} | ${t.priority} | ${t.estimate} | ${t.deps.join(", ") || "—"} |`); +md.push(``); + +for (const team of teams) { + const rows = all.filter((t) => t.team === team); + md.push(`---`); + md.push(``); + md.push(`# Team ${team}`); + md.push(``); + const groups = new Map(); + for (const t of rows) { if (!groups.has(t.epicName)) groups.set(t.epicName, []); groups.get(t.epicName).push(t); } + for (const [epic, list] of groups) { + md.push(`## ${epic}`); + md.push(``); + for (const t of list) { + md.push(`### ${t.id} — ${t.title}`); + md.push(``); + md.push(`**Priority:** ${t.priority} · **Estimate:** ${t.estimate} · **Phase:** ${PHASES[t.phase]} · **Labels:** ${labelsFor(t)} `); + md.push(`**Depends on:** ${t.deps.length ? t.deps.map((d) => `${d} (${titleOf(d)})`).join("; ") : "none"}`); + md.push(``); + md.push(t.description.trim()); + md.push(``); + md.push(`**Acceptance criteria**`); + for (const a of t.acceptance) md.push(`- [ ] ${a}`); + md.push(``); + } + } +} +writeFileSync(join(out, "SGAuth-tickets.md"), md.join("\n"), "utf8"); + +// ---------- stats ---------- +const stats = {}; +for (const t of all) { + stats[t.team] ??= { tickets: 0, points: 0, byPhase: {} }; + stats[t.team].tickets++; stats[t.team].points += t.estimate; + stats[t.team].byPhase[t.phase] = (stats[t.team].byPhase[t.phase] ?? 0) + 1; +} +console.log(JSON.stringify({ files, stats, total: all.length }, null, 2)); diff --git a/docs/sgauth-design/gh-issues.mjs b/docs/sgauth-design/gh-issues.mjs new file mode 100644 index 0000000..af50c3a --- /dev/null +++ b/docs/sgauth-design/gh-issues.mjs @@ -0,0 +1,144 @@ +// Creates the SGAuth tickets as GitHub Issues in the SGAOperations repos, for Linear's GitHub importer. +// Usage: +// node gh-issues.mjs create AUTH|VAULTZ|CHAMBERS|APLIO|SENATEPATH # resumable; skips ids already in issue-map.json +// node gh-issues.mjs link # rewrites "Depends on" lines with issue links +// node gh-issues.mjs labels TEAM # creates the labels used by that team's tickets +// Requires the gh CLI authenticated with write access (issues) on the target repo. +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { EPICS, TICKETS } from "./tickets-auth.mjs"; +import { PRODUCT_TEAMS, PRODUCT_TICKETS } from "./tickets-products.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const MAP_FILE = join(here, "issue-map.json"); +const TMP = join(here, "out", "tmp"); +mkdirSync(TMP, { recursive: true }); + +const REPOS = { AUTH: "auth", VAULTZ: "vaultz", CHAMBERS: "chambers", APLIO: "aplio", SENATEPATH: "senate-path" }; // ATTENDANCE (SenatePortal) skipped +const ORG = "SGAOperations"; +const PHASES = { + 0: "Phase 0 — Foundation", + 1: "Phase 1 — Core auth, sessions, SSO (MVP)", + 2: "Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ", + 3: "Phase 3 — Hardening, observability, Chambers", + 4: "Phase 4 — Aplio, SenatePath, retention", + 5: "Phase 5 — Backlog / spikes", +}; +const LABEL_COLORS = { epic: "5319e7", phase: "0e8a16", default: "c5def5" }; + +const all = [ + ...TICKETS.map((t) => ({ ...t, team: "AUTH", epicName: EPICS[t.epic].name, epicLabel: EPICS[t.epic].label })), + ...PRODUCT_TICKETS.map((t) => ({ ...t, epicName: PRODUCT_TEAMS[t.team].epic, epicLabel: PRODUCT_TEAMS[t.team].label, phase: PRODUCT_TEAMS[t.team].phase })), +].filter((t) => REPOS[t.team]); +const byId = new Map(all.map((t) => [t.id, t])); + +const map = existsSync(MAP_FILE) ? JSON.parse(readFileSync(MAP_FILE, "utf8")) : {}; +const saveMap = () => writeFileSync(MAP_FILE, JSON.stringify(map, null, 2) + "\n"); + +function gh(args, input) { + const opts = { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }; + if (input !== undefined) opts.input = input; + return execFileSync("gh", args, opts); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const labelsFor = (t) => [t.epicLabel, `phase:${t.phase}`, ...t.labels]; + +function depRef(id, fromTeam) { + const t = byId.get(id); + const m = map[id]; + if (!t) return id; + if (!m) return `${id} (${t.title})`; + const sameRepo = REPOS[t.team] === REPOS[fromTeam]; + const link = sameRepo ? `#${m.number}` : `${ORG}/${REPOS[t.team]}#${m.number}`; + return `${link} — ${id} ${t.title}`; +} + +function body(t) { + const deps = t.deps.length ? t.deps.map((d) => `- ${depRef(d, t.team)}`).join("\n") : "- none"; + return [ + `**Ticket:** ${t.id} · **Epic:** ${t.epicName} · **Phase:** ${PHASES[t.phase]}`, + `**Priority:** ${t.priority} · **Estimate:** ${t.estimate} points · **Labels:** ${labelsFor(t).join(", ")}`, + ``, + `**Depends on:**`, + deps, + ``, + t.description.trim(), + ``, + `**Acceptance criteria**`, + ...t.acceptance.map((a) => `- [ ] ${a}`), + ``, + `Generated from the SGAuth design (docs/sgauth-design in SGAOperations/auth). SGAuth is built on Neon and does not use Supabase.`, + ].join("\n"); +} + +async function createLabels(team) { + const repo = `${ORG}/${REPOS[team]}`; + const names = new Set(); + for (const t of all.filter((x) => x.team === team)) labelsFor(t).forEach((l) => names.add(l)); + for (const name of names) { + const color = name.startsWith("epic:") ? LABEL_COLORS.epic : name.startsWith("phase:") ? LABEL_COLORS.phase : LABEL_COLORS.default; + try { + gh(["label", "create", name, "-R", repo, "--color", color, "--force"]); + console.log(`label ok: ${repo} ${name}`); + } catch (e) { + console.error(`label FAILED: ${repo} ${name}: ${String(e.stderr || e.message).trim()}`); + return false; + } + } + return true; +} + +async function create(team, { withLabels }) { + const repo = `${ORG}/${REPOS[team]}`; + const rows = all.filter((t) => t.team === team); + let n = 0; + for (const t of rows) { + if (map[t.id]) { console.log(`skip (exists): ${t.id} -> #${map[t.id].number}`); continue; } + const payload = { title: t.title, body: body(t) }; + if (withLabels) payload.labels = labelsFor(t); + const file = join(TMP, `${t.id}.json`); + writeFileSync(file, JSON.stringify(payload)); + try { + const res = JSON.parse(gh(["api", `repos/${repo}/issues`, "--method", "POST", "--input", file])); + map[t.id] = { repo, number: res.number, url: res.html_url }; + saveMap(); + console.log(`created: ${t.id} -> ${res.html_url}`); + } catch (e) { + console.error(`FAILED: ${t.id}: ${String(e.stderr || e.message).trim()}`); + process.exit(1); + } + n++; + await sleep(3000); // stay under GitHub's content-creation abuse limits + } + console.log(`done ${team}: ${n} created`); +} + +async function link() { + let n = 0; + for (const t of all) { + const m = map[t.id]; + if (!m) continue; + if (!t.deps.length) continue; + const payload = { body: body(t) }; + const file = join(TMP, `${t.id}.patch.json`); + writeFileSync(file, JSON.stringify(payload)); + try { + gh(["api", `repos/${m.repo}/issues/${m.number}`, "--method", "PATCH", "--input", file]); + console.log(`linked: ${t.id} (#${m.number})`); + n++; + } catch (e) { + console.error(`FAILED link ${t.id}: ${String(e.stderr || e.message).trim()}`); + process.exit(1); + } + await sleep(1500); + } + console.log(`done link: ${n} updated`); +} + +const [cmd, team, flag] = process.argv.slice(2); +if (cmd === "labels") { if (!REPOS[team]) throw new Error("unknown team"); await createLabels(team); } +else if (cmd === "create") { if (!REPOS[team]) throw new Error("unknown team"); await create(team, { withLabels: flag !== "--no-labels" }); } +else if (cmd === "link") await link(); +else console.log("usage: node gh-issues.mjs labels TEAM | create TEAM [--no-labels] | link"); diff --git a/docs/sgauth-design/issue-map.json b/docs/sgauth-design/issue-map.json new file mode 100644 index 0000000..fd2e4ab --- /dev/null +++ b/docs/sgauth-design/issue-map.json @@ -0,0 +1,147 @@ +{ + "VAULTZ-V01": { + "repo": "SGAOperations/vaultz", + "number": 747, + "url": "https://github.com/SGAOperations/vaultz/issues/747" + }, + "VAULTZ-V02": { + "repo": "SGAOperations/vaultz", + "number": 748, + "url": "https://github.com/SGAOperations/vaultz/issues/748" + }, + "VAULTZ-V03": { + "repo": "SGAOperations/vaultz", + "number": 749, + "url": "https://github.com/SGAOperations/vaultz/issues/749" + }, + "VAULTZ-V04": { + "repo": "SGAOperations/vaultz", + "number": 750, + "url": "https://github.com/SGAOperations/vaultz/issues/750" + }, + "VAULTZ-V05": { + "repo": "SGAOperations/vaultz", + "number": 751, + "url": "https://github.com/SGAOperations/vaultz/issues/751" + }, + "VAULTZ-V06": { + "repo": "SGAOperations/vaultz", + "number": 752, + "url": "https://github.com/SGAOperations/vaultz/issues/752" + }, + "VAULTZ-V07": { + "repo": "SGAOperations/vaultz", + "number": 753, + "url": "https://github.com/SGAOperations/vaultz/issues/753" + }, + "VAULTZ-V08": { + "repo": "SGAOperations/vaultz", + "number": 754, + "url": "https://github.com/SGAOperations/vaultz/issues/754" + }, + "CHAMBERS-C01": { + "repo": "SGAOperations/chambers", + "number": 148, + "url": "https://github.com/SGAOperations/chambers/issues/148" + }, + "CHAMBERS-C02": { + "repo": "SGAOperations/chambers", + "number": 149, + "url": "https://github.com/SGAOperations/chambers/issues/149" + }, + "CHAMBERS-C03": { + "repo": "SGAOperations/chambers", + "number": 150, + "url": "https://github.com/SGAOperations/chambers/issues/150" + }, + "CHAMBERS-C04": { + "repo": "SGAOperations/chambers", + "number": 151, + "url": "https://github.com/SGAOperations/chambers/issues/151" + }, + "CHAMBERS-C05": { + "repo": "SGAOperations/chambers", + "number": 152, + "url": "https://github.com/SGAOperations/chambers/issues/152" + }, + "CHAMBERS-C06": { + "repo": "SGAOperations/chambers", + "number": 153, + "url": "https://github.com/SGAOperations/chambers/issues/153" + }, + "CHAMBERS-C07": { + "repo": "SGAOperations/chambers", + "number": 154, + "url": "https://github.com/SGAOperations/chambers/issues/154" + }, + "CHAMBERS-C08": { + "repo": "SGAOperations/chambers", + "number": 155, + "url": "https://github.com/SGAOperations/chambers/issues/155" + }, + "CHAMBERS-C09": { + "repo": "SGAOperations/chambers", + "number": 156, + "url": "https://github.com/SGAOperations/chambers/issues/156" + }, + "APLIO-P01": { + "repo": "SGAOperations/aplio", + "number": 732, + "url": "https://github.com/SGAOperations/aplio/issues/732" + }, + "APLIO-P02": { + "repo": "SGAOperations/aplio", + "number": 733, + "url": "https://github.com/SGAOperations/aplio/issues/733" + }, + "APLIO-P03": { + "repo": "SGAOperations/aplio", + "number": 734, + "url": "https://github.com/SGAOperations/aplio/issues/734" + }, + "APLIO-P04": { + "repo": "SGAOperations/aplio", + "number": 735, + "url": "https://github.com/SGAOperations/aplio/issues/735" + }, + "APLIO-P05": { + "repo": "SGAOperations/aplio", + "number": 736, + "url": "https://github.com/SGAOperations/aplio/issues/736" + }, + "APLIO-P06": { + "repo": "SGAOperations/aplio", + "number": 737, + "url": "https://github.com/SGAOperations/aplio/issues/737" + }, + "APLIO-P07": { + "repo": "SGAOperations/aplio", + "number": 738, + "url": "https://github.com/SGAOperations/aplio/issues/738" + }, + "APLIO-P08": { + "repo": "SGAOperations/aplio", + "number": 739, + "url": "https://github.com/SGAOperations/aplio/issues/739" + }, + "SENATEPATH-S01": { + "repo": "SGAOperations/senate-path", + "number": 88, + "url": "https://github.com/SGAOperations/senate-path/issues/88" + }, + "SENATEPATH-S02": { + "repo": "SGAOperations/senate-path", + "number": 89, + "url": "https://github.com/SGAOperations/senate-path/issues/89" + }, + "SENATEPATH-S03": { + "repo": "SGAOperations/senate-path", + "number": 90, + "url": "https://github.com/SGAOperations/senate-path/issues/90" + }, + "SENATEPATH-S04": { + "repo": "SGAOperations/senate-path", + "number": 91, + "url": "https://github.com/SGAOperations/senate-path/issues/91" + } +} diff --git a/docs/sgauth-design/out/SGAuth-design-summary.md b/docs/sgauth-design/out/SGAuth-design-summary.md new file mode 100644 index 0000000..6d942ca --- /dev/null +++ b/docs/sgauth-design/out/SGAuth-design-summary.md @@ -0,0 +1,114 @@ +# SGAuth — Design Summary + +**Date:** 2026-09-18 · **Status:** Design settled, red-teamed, tickets regenerated · **Owner:** SGA Digital Innovation + +> Revision after the red-team review (see `SGAuth-red-team.md`): Neon stays on Free as an accepted, monitored risk; scheduled jobs run from GitHub Actions instead of Vercel cron; user deletion is a tombstone; the rate limiter fails open; the SDK is published to public npm; lockout has a known-device exemption; JWKS rotation is manual with a Management API push; emailed links consume tokens on POST (Safe Links); sign-up overwrites unverified accounts (pre-hijack); Chambers items are a checklist, not a Linear import; the Chambers `auth.users` export is Eli's manual action item. + +SGAuth is the centralized authentication and identity service for Northeastern SGA. One login at `auth.northeasternsga.com` yields one session shared by every product on `*.northeasternsga.com`. **SGAuth is built on Neon serverless Postgres and does not use Supabase for any purpose.** SGAuth is the sole source of truth for credentials; no product keeps its own password store. + +The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linear-import-*.csv` (one Linear CSV per team). + +--- + +## 1. Decisions and rationale + +| Area | Decision | Rationale | +|---|---|---| +| Repo | In-place migration of the existing `auth` repo: strip Supabase, add Neon + Better Auth; close or rebase the open Supabase branches | Keeps CI, shadcn, Prisma 7 setup, and Linear history; the existing code is a thin scaffold with no auth logic worth preserving | +| Database | Neon serverless Postgres: `main` (prod), `dev` (shared), `test` (CI), per-PR preview branches via the Neon–Vercel integration; pooled URL at runtime, direct URL for migrations. **Free plan, scale-to-zero, quota monitored (accepted risk)** | Hard requirement; branching gives isolated previews without Docker. Free suspends compute when 100 CU-hours are used, which would take every product's login down; alerts at 50/70/85% and an upgrade runbook to Launch ($0.106/CU-hour) mitigate. Recommendation on record: upgrade before the Chambers cutover | +| Auth framework | Self-managed Better Auth 1.7.x with `@better-auth/prisma-adapter` | Aplio already uses it on Neon; provides cross-subdomain cookies, JWT/JWKS plugin, two-factor, admin plugin, session APIs. Neon's Managed Better Auth is beta and pins an older version; hand-rolled auth is unjustified for a small team | +| Hosting | Vercel (Node runtime), `auth.northeasternsga.com` for production, `auth-dev.northeasternsga.com` for the dev deployment (DEV banner, synthetic users, separate secrets). **No Vercel cron**: scheduled jobs run from GitHub Actions calling secret-protected routes | Matches Aplio and Chambers; Neon integration; custom domains. Vercel Hobby limits cron to daily, so the scheduler is plan-independent | +| Login method | Email + password (12+ chars, no composition rules, no breached-password check), scrypt hashing; self-sign-up restricted to `@northeastern.edu` with verification (every student has one; no exceptions); admins can create/invite any address; a later sign-up overwrites an unverified account (pre-hijack defense); every emailed link consumes its token on POST (Microsoft Safe Links) | Resend free tier is nearly exhausted org-wide, so passwordless OTP would be fragile; passwords make SGAuth the credential store the requirement demands. Email OTP is a deferred backlog item | +| Northeastern SSO | Not a launch dependency; Phase 5 spike | Registering an app in Northeastern's Entra ID tenant needs ITS approval; a multi-tenant app in an SGA tenant may require Northeastern admin consent. Unknown timeline | +| SSO mechanism | Parent-domain cookie `Domain=northeasternsga.com`, `__Secure-` prefix, HttpOnly, Secure, SameSite=Lax, no cookie cache | Simplest true single session; logout is instant everywhere. `__Host-` is impossible with a Domain attribute. Any compromised subdomain can read the cookie, so subdomain hygiene is a security control | +| Session lookup | Products call `GET /api/sgauth/session` server-side with the forwarded cookie (SDK caches 60 s, serves a stale cached session for up to 5 min if SGAuth is unreachable); the Next.js proxy only checks cookie presence; Supabase products additionally mint a 10-minute ES256 JWT from `/api/auth/token` | DB-backed lookup keeps revocation and position changes immediate; stale-if-error keeps a SGAuth blip from logging every product out; JWTs only where Supabase needs a bearer token | +| Session lifetime | 30-day sliding (`updateAge` 1 day), 90-day absolute cap enforced by a daily purge on `createdAt` (so Better Auth's own endpoints cannot bypass it), 10-minute re-auth window via `lastReauthAt` (Better Auth `freshAge` left at default) | Matches semester usage while bounding stolen-cookie exposure | +| Logout | Global: any sign-out clears the shared cookie and deletes the session row; "sign out everywhere" for users; admin revoke; `/logout?redirect=` validated against the registry | One session means one logout | +| Supabase products | Supabase **third-party auth** (JWT trust) is the standard path; server-side JWT verification documented as a fallback; moving to Neon recommended where possible | See §2 | +| Identity key | Products store the SGAuth user id (UUID v7) as their foreign key; email/name come from the session | Stable across email changes; Supabase `auth.uid()` requires a UUID `sub` | +| Admin rules | Enforced in a pure `authz.ts` module, mirrored by Postgres constraints/triggers, and audited. Admins grant/revoke admin for others only; never self; never the Primary Admin | Server-side enforcement with a DB backstop; UI only hides controls | +| Primary Admin | Exactly one (partial unique index). Transfer: PA re-auth + typed recipient email → recipient (active admin with MFA) accepts with re-auth → 24-hour cooling window with an emailed cancel link → scheduled execution revokes both parties' sessions and emails all admins. PA cannot be deleted, deactivated, or de-admined; lockout applies but self-unlock by email exists | Protects the most powerful account against both mistakes and hijacking while still allowing graduation handoffs | +| Break-glass | Offline script run with production Neon credentials, confirmation phrase, `BREAK_GLASS` audit row, emails to all admins; no HTTP path; runbook with at least two credential holders | Infra access is the real control; no web backdoor | +| Positions | Flat list; immutable slug `key` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`, 2–64) plus editable display `name` and optional `category`; max 50 per user; rename keeps holders; delete is soft with typed confirmation, keeps history, retires the key for 365 days | Products check keys so renames never break permissions; retirement prevents accidental privilege revival | +| Propagation | Immediate for Neon products (live DB read); ≤10 minutes for Supabase JWTs; "force re-login" admin action | Bounded staleness without forcing logouts on every change | +| Seed data | Curated SGA position list supplied by SGA on 2026-09-18: 81 offices in 9 categories plus `senator`; keys slugified from official names; product roles (`-`) created on demand by product owners, not seeded | Explicit choice over inferring from Chambers roles | +| User removal | Admin deactivate (soft, sessions revoked instantly, reactivatable); **tombstone** (status DELETED, PII scrubbed, id kept) 30 days after deactivation or on request; inactive 12 months → notice → tombstone (admins exempt for manual review); audit PII anonymized, events kept | Meets the retention policy without losing the audit trail or orphaning product rows keyed by SGAuth user id | +| Profile data | `name`, optional `preferredName`, `email`; no NUID, phone, pronouns, photo | Data minimization; products keep their own fields keyed by SGAuth id | +| User UI | Minimal account page: profile, positions (read-only), links to each product from the registry, sessions with revoke, sign out everywhere, password change, MFA enrollment | Users need a hub and self-service basics; nothing product-specific | +| Account creation | Admin-created/invited (set-password link) or bulk CSV import, plus `@northeastern.edu` self-sign-up with zero positions | Avoids admin bottlenecks at turnover; positions gate everything | +| Migration | Chambers: bcrypt hashes imported with a `bcrypt$` marker and lazily re-hashed to scrypt on first login (the `auth.users` export is Eli's manual action item, before the Supabase project is deleted). Aplio/SenatePath/Attendance: emails imported, set-password invites; non-NU Aplio addresses imported as `legacyEmail` accounts. Merge by lower-cased email | Chambers users keep their passwords; OTP users never had one | +| MFA | TOTP + backup codes; optional for users, required for admins and the PA (enforced at admin-action time); required for the transfer recipient | No SMS/email cost; protects privileged accounts | +| Rate limiting | Upstash Redis sliding windows on sign-in, sign-up, reset, token, re-auth, and admin endpoints only (session endpoint excluded to stay within 500K commands/month); **fails open with an alert** on Upstash outage; Better Auth's DB limiter disabled | Team already runs Upstash (Chambers); a vendor outage must never become an org-wide login outage | +| Lockout | 5 failures/15 min → 15-minute lock, doubling to a 24-hour cap, never permanent; emailed unlock link (POST-consumed); applies to the PA; **known-device cookie exempts the owner's usual browser** so the lock cannot be used to deny admins service | Mitigates credential stuffing without permanent self-DoS or a lockout DoS lever | +| Email | Resend on a dedicated SGAuth sending domain and API key, provider-agnostic mailer interface, per-recipient and global caps | Keeps SGAuth off Aplio's and Chambers' quota; volume is low with password login | +| Product registry | Admin-managed `Product` table (slug, name, base URL restricted to `https://*.northeasternsga.com`, visibility by positions); drives trusted origins, redirect allowlist, CORS, and account-page links | No deploy to add a product; one source for security allowlists | +| SDK | `@sgaoperations/sgauth` on **public npm** with trusted publishing: `getSession` (60 s cache, stale-if-error), cookie-presence proxy helper, position guards, URL builders, `getAccessToken` for Supabase | Five products, one place to fix bugs; GitHub Packages was rejected because it needs a personal token to install even public packages | +| Environments | Dev SGAuth on the real parent domain with a distinct cookie prefix; product previews on stable `-dev.northeasternsga.com` branch domains; local `*.sga.localhost` hostnames; `*.vercel.app` previews cannot use SSO | A parent-domain cookie cannot reach `vercel.app` or bare `localhost`; the topology must be explicit | +| Observability | Append-only `AuditEvent` table with a typed catalog and coverage test; structured JSON logs; PostHog (funnel events without PII, error tracking); health endpoint + uptime monitor; threshold alerts; retention jobs | Audit is a product requirement; PostHog free tier chosen over Sentry | +| Tokens | ES256, **manual** JWKS rotation (annual or incident) with the new key pushed to each Supabase project via `custom_jwks` before signing switches, 7-day overlap, `kid` header, issuer `https://auth.northeasternsga.com`, audience `authenticated`, 10-minute TTL, claims `sub` (uuid), `email`, `name`, `role: "authenticated"`, `positions`, `is_admin` | ES256 is universally supported (Supabase, jose); automatic rotation would reject fresh tokens for up to 30 minutes until Supabase re-fetches keys | +| Security extras | Origin/Referer enforcement on all mutations (SameSite=Lax still sends the cookie on sibling-subdomain POSTs), security headers (HSTS without `includeSubDomains` until every subdomain is confirmed HTTPS), subdomain DNS hygiene policy, enumeration-resistant responses, secrets rotation runbook, Dependabot/CodeQL, STRIDE-lite threat model | Consequences of the shared-cookie design | +| Rollout | SGAuth MVP → VaultZ → Chambers (already a Neon product) → Aplio (hard cutover outside an application window) → SenatePath (migrated to Neon by its owner) → Attendance Manager (Supabase or Neon path pending their decision) | VaultZ has no auth to unwind and proves the SDK; Chambers has the most users and pre-imported credentials | +| Ticketing | Fibonacci estimates; one Linear CSV per team (AUTH, VaultZ, Aplio, SenatePath, Attendance Manager); Chambers has no Linear team so its items are a Markdown checklist; epics as Linear Projects with `epic:*` labels and Epic/Phase/Depends-on lines in every description | Linear's importer does not create projects or parent links | + +## 2. Supabase compatibility findings + +**Scopable, with one real limitation.** + +**Path chosen: Supabase third-party auth (JWT trust).** Supabase's Management API endpoint `POST /v1/projects/{ref}/config/auth/third-party-auth` accepts a generic `oidc_issuer_url`, `jwks_url`, or inline `custom_jwks`, so a project can trust SGAuth-issued JWTs even though the dashboard advertises only Clerk, Firebase, Auth0, Cognito, and WorkOS. Requirements SGAuth meets: asymmetric signing (ES256), a `kid` header, a `role: "authenticated"` claim (maps the request to the `authenticated` Postgres role), a UUID `sub` (read by `auth.uid()`), and standard `iss`/`exp`. Positions ride in the token and are readable in RLS via `auth.jwt() -> 'positions'`. supabase-js takes an `accessToken` callback; the SDK supplies it. + +**Limitations to plan around:** +1. Third-party users have **no `auth.users` row**. Foreign keys, triggers, or joins on `auth.users` break. Chambers had these (now moot, since Chambers is a Neon product); Attendance Manager has a `supabaseAuthId` column that must become `sgauthUserId`. +2. Supabase Auth features are unavailable to these users: no Supabase sessions/refresh, password reset, or MFA. All of that is SGAuth's job, by design. +3. JWT staleness of up to 10 minutes for position changes and revocations; "force re-login" in SGAuth bounds it. Supabase refreshes JWKS within ~30 minutes, so SGAuth keeps rotated keys valid for 7 days. +4. Billing: $0.00325 per third-party monthly active user beyond plan quota. Negligible at SGA scale. +5. Generic-issuer configuration is an API operation, not a dashboard toggle; the guide ships a script. + +**Alternatives evaluated and rejected as the default:** +- *Supabase custom OIDC provider* (Supabase Auth as a relying party to SGAuth acting as an OpenID provider via Better Auth's OAuth 2.1 provider package). Keeps `auth.users` but creates a second session SGAuth cannot terminate, positions must be copied into `app_metadata` by a hook, and free projects are capped at three custom providers. Documented as an escape hatch only. +- *Server-side verification only* (verify SGAuth JWTs with `jose`, use the service-role key). Works everywhere but loses per-user RLS. Documented as a fallback for products that do not need RLS. + +**Product-by-product:** Website Creation has no user login today and will be hosted on Neon if it ever needs auth. SenatePath is migrated to Neon by its owner. Attendance Manager gets both paths ticketed; exactly one proceeds after the team's decision. + +## 3. Open inputs and flagged risks + +- ~~Curated position list~~ Received 2026-09-18: 82 positions (81 offices plus `senator`) in `prisma/seed/positions.json`; product-specific roles are created by product owners in the admin UI. +- **Attendance Manager directive** (Supabase vs Neon) selects ATTENDANCE-M02 or M03. +- **Chambers `auth.users` export** (Eli, manual) must happen before that Supabase project is deleted, or every Chambers user resets their password. +- **Neon Free quota** is an accepted risk with monitoring; the recorded recommendation is to upgrade to Launch before the Chambers cutover. +- **Cookie tossing from any subdomain** (login CSRF) is inherent to the parent-domain cookie; DNS hygiene and the Phase 1 cookie spike are the controls. +- **HSTS at the apex** with `includeSubDomains` would affect every product; enabled only after all subdomains are confirmed HTTPS-only. +- **Northeastern SSO** remains a spike; do not plan around it. +- **Resend volume**: SGAuth's dedicated domain isolates it, but org-wide totals should be watched; the mailer has caps and an 80% alert. + +Full red-team findings, dispositions, and accepted risks: `SGAuth-red-team.md`. + +## 4. Ticket set overview + +138 tickets across six teams (five with Linear CSVs): AUTH 103 tickets in 13 epics; VaultZ 8; Chambers 9 (checklist only); Aplio 8; SenatePath 4; Attendance Manager 6. Point totals are in the ticket-set header. Phases: 0 Foundation → 1 Core auth, sessions, SSO (MVP) → 2 Admin, Primary Admin, positions, UI, SDK, VaultZ → 3 Hardening, observability, Chambers → 4 Aplio, SenatePath, Attendance, retention → 5 Backlog/spikes. Every ticket has title, description, acceptance criteria, priority, Fibonacci estimate, labels, dependencies, and its parent epic. + +**Linear CSV format (verified against Linear's own importer source):** headers `Title, Description, Priority, Estimate, Status, Labels`; priority as the words Urgent/High/Medium/Low; estimate as an integer; labels separated by ", "; descriptions in Markdown. Parent issues and projects are not importable via CSV, so each description begins with its Ticket id, Epic, Phase, and Depends-on lines, and each ticket carries an `epic:*` label for bulk assignment into projects after import. + +## 5. Sources relied on + +- Supabase third-party auth overview: https://supabase.com/docs/guides/auth/third-party/overview +- Supabase third-party auth with Clerk (required `role` claim, `accessToken` callback): https://supabase.com/docs/guides/auth/third-party/clerk +- Supabase Management API, create third-party auth integration (`oidc_issuer_url`, `jwks_url`, `custom_jwks`): https://supabase.com/docs/reference/api/v1-create-project-tpa-integration +- Supabase custom OAuth/OIDC providers: https://supabase.com/docs/guides/auth/custom-oauth-providers +- Supabase row level security (`auth.uid()`, `auth.jwt()`, roles, performance): https://supabase.com/docs/guides/database/postgres/row-level-security +- Better Auth JWT plugin (algorithms, JWKS, rotation, `definePayload`): https://better-auth.com/docs/plugins/jwt +- Better Auth cookies (cross-subdomain cookies, prefixes): https://better-auth.com/docs/concepts/cookies +- Better Auth session management (expiry, freshness, revocation, customSession): https://better-auth.com/docs/concepts/session-management +- Better Auth admin plugin: https://better-auth.com/docs/plugins/admin +- Better Auth OAuth 2.1 provider (evaluated alternative): https://better-auth.com/docs/plugins/oauth-provider +- Better Auth releases and Prisma adapter versions: https://github.com/better-auth/better-auth/releases, https://www.npmjs.com/package/@better-auth/prisma-adapter +- Neon with Prisma ORM (pooled vs direct, driver adapters): https://neon.com/docs/guides/prisma +- Neon plans and free-tier limits: https://neon.com/docs/introduction/plans, https://neon.com/faqs/free-plan-limits-and-quotas +- Neon Managed Better Auth (evaluated, not chosen): https://neon.com/docs/auth/overview +- Connecting to Neon from Vercel: https://neon.com/docs/guides/vercel-connection-methods +- Linear CSV importer source (columns, priority words, label splitting): https://github.com/linear/linear/tree/master/packages/import +- Linear export columns: https://linear.app/docs/exporting-data +- MDN Set-Cookie (Domain, `__Secure-`/`__Host-`, SameSite): https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie +- Northeastern 1Login/SSO and Duo MFA: https://1login.its.northeastern.edu, https://security.its.northeastern.edu/mfa/ +- Upstash rate limiting for Next.js: https://upstash.com/blog/edge-rate-limiting +- FERPA directory information: https://studentprivacy.ed.gov/content/directory-information +- Repos inspected: SGAOperations/auth, vaultz, chambers, aplio, website-development, senate-path, attendance-manager (package.json, auth modules, Prisma schemas, migrations). diff --git a/docs/sgauth-design/out/SGAuth-red-team.md b/docs/sgauth-design/out/SGAuth-red-team.md new file mode 100644 index 0000000..7a5b557 --- /dev/null +++ b/docs/sgauth-design/out/SGAuth-red-team.md @@ -0,0 +1,77 @@ +# SGAuth — Red-Team Review + +**Date:** 2026-09-18 · **Scope:** the settled design and all 135 tickets · **Method:** adversarial walk-through of every component, verified against vendor documentation and the product repos. Each finding lists severity, the failure, and the disposition (decision taken with Eli, correction applied to tickets, or accepted risk). + +Severity: **S1** would take SGAuth or all products down, or hand over an account; **S2** breaks a core flow or a security property for some users; **S3** degrades quality, cost, or operability. + +## A. Findings resolved by decision + +| # | Sev | Finding | Disposition | +|---|---|---|---| +| A1 | S1 | **Neon Free suspends compute for the rest of the month at 100 CU-hours.** An auth database awake most of the day at 0.25 CU uses ~180 CU-hours/month. Suspension = every SGA login fails. Restore window on Free is 6 hours. | **Decision: stay on Free, scale-to-zero on, monitor quota.** Accepted risk. AUTH-T101 adds 50/70/85% alerts with an exhaustion projection, forbids keep-warm pings, and documents the one-click Launch upgrade ($0.106/CU-hour, roughly $10–25/month). Recommend upgrading before the Chambers cutover. | +| A2 | S1 | **Vercel Hobby cron: 2 jobs, once per day, enforced at deploy.** The 15-minute transfer executor and alert job would fail every deploy. | **Decision: GitHub Actions schedules calling secret-protected routes** (Chambers' existing pattern). AUTH-T38 rewritten; `vercel.json` must contain no `crons`. | +| A3 | S2 | **Physical deletion orphans product data.** VaultZ purchases, Aplio applications, and Chambers bookings are keyed by SGAuth user id. | **Decision: tombstone.** Status `DELETED`, PII scrubbed, id kept. AUTH-T10, T36, T78 updated. | +| A4 | S1 | **Upstash fail-closed on sign-in** turns a Redis vendor outage into an org-wide login outage. | **Decision: fail open with alert**; DB-backed lockout is the brute-force backstop. AUTH-T63 updated. | +| A5 | S3 | **Upstash free tier is 500K commands/month.** Rate-limiting the session endpoint (the hot path) would exhaust it in weeks. | Session endpoint is no longer Redis-limited; only mutations and the token endpoint are. Budget and 80% alert added. AUTH-T63. | +| A6 | S2 | **GitHub Packages requires a classic PAT to install even public packages.** A graduating student's token would sit in every product's Vercel env. | **Decision: public npm under `@sgaoperations`** with trusted publishing (no long-lived token). AUTH-T57, T61, VAULTZ-V01. | +| A7 | S2 | **Lockout as a denial-of-service lever.** Anyone knowing an admin's email locks them out for up to 24 h, repeatedly, including the Primary Admin during a transfer. | **Decision: known-device exemption.** A signed device cookie set after successful login bypasses the account lock; unknown devices are still locked. AUTH-T64 (estimate raised to 5). | +| A8 | S2 | **Automatic JWKS rotation vs Supabase's ≤30-minute key refresh.** New key is used immediately; Supabase rejects fresh tokens until it re-fetches. | **Decision: manual rotation** (annual or incident) with the combined JWKS pushed to each Supabase project via `custom_jwks` before switching. AUTH-T29, T69. Audience set to `authenticated` to match Supabase convention. | +| A9 | S2 | **Aplio users with non-northeastern.edu emails.** Every student has an NU address, but existing Aplio accounts are keyed by other addresses. | **Decision: import as-is with `legacyEmail = true`**; admins move them to the NU address later (AUTH-T24) preserving the SGAuth id and Aplio history. New applicants must use NU email (APLIO-P05). | +| A10 | S1 | **Chambers password hashes are lost if the Supabase project is deleted before export.** | **Eli exports `auth.users` manually; not a ticket.** Chambers has no Linear team, so Chambers items are a Markdown checklist only (no CSV). AUTH-T86 now validates the received file. | +| A11 | S3 | **Dev SGAuth on the real parent domain** is a production-looking login page with weaker controls. | **Decision: open dev with a persistent DEV banner, `noindex`, synthetic users only, separate secrets.** AUTH-T34. | + +## B. Corrections applied to tickets (no decision needed) + +| # | Sev | Finding | Fix | +|---|---|---|---| +| B1 | S1 | **Microsoft Safe Links consumes single-use links.** Northeastern mail is M365; Defender pre-fetches links, so GET-acting verify/reset/invite/unlock/transfer links die before the user clicks. | New AUTH-T103: every emailed link lands on a page and consumes the token only on a POST; HEAD/scanner requests are side-effect free. T18, T19, T22, T24, T37, T64 depend on it. | +| B2 | S1 | **Account pre-hijack via sign-up.** With `requireEmailVerification`, Better Auth answers a duplicate sign-up with success. An attacker pre-registers a victim's NU address; the victim "signs up", verifies, and the attacker's password is live. | AUTH-T17: a later sign-up overwrites an *unverified* account's password and name; verification revokes all sessions. Test added. AUTH-T70 aligned. | +| B3 | S2 | **90-day absolute cap enforced only in the session endpoint.** Better Auth's own `/token` and plugin endpoints never ran the custom check, so a 90-day-old session could still mint Supabase JWTs. | AUTH-T27/T78: the daily job deletes sessions by `createdAt` > 90 days; the endpoint check stays as defense in depth. | +| B4 | S2 | **`freshAge = 10 min` misuse.** Better Auth's `freshAge` gates its own fresh-session endpoints; setting it to 10 minutes would have broken those for any session older than 10 minutes and was not a re-auth mechanism. | Leave `freshAge` default; re-auth uses `lastReauthAt`. AUTH-T27, T32. | +| B5 | S2 | **SDK proxy calling SGAuth on every request.** Middleware runs per request and per instance; a network call there adds latency to every page and the per-instance cache rarely hits. | AUTH-T59: proxy checks cookie presence only; validation happens in server code via `requireSession()`. | +| B6 | S1 | **SGAuth is a single point of failure for every product**, and the SDK failed closed on any error. A short Neon or Vercel blip logged everyone out of everything. | AUTH-T58: stale-if-error — serve a previously cached session for that token for up to 5 minutes (max 15) when SGAuth is unreachable; fail closed only with no cache. | +| B7 | S2 | **SGAuth's own `*.vercel.app` previews cannot set a `northeasternsga.com` cookie**; previews would be un-loginable. | `SGAUTH_ENV=preview` host-only cookie mode. AUTH-T05, T26. | +| B8 | S2 | **Hijacked Primary Admin session could redirect the transfer-cancel email** by changing the PA's email, and any admin could change any admin's email then reset their password. | AUTH-T24/T35: email change requires re-auth; admins change only non-admin emails; only the PA changes an admin's email; PA's own email only by the PA. | +| B9 | S2 | **PA MFA lockout is unrecoverable** (lost phone and codes; admins cannot touch PA MFA). | AUTH-T39: break-glass `--reset-mfa` mode. AUTH-T36: admin MFA reset for non-PA users under authz rules. | +| B10 | S2 | **Cross-subdomain cookie behavior has unresolved community bug reports** (better-auth #5611, #3938) and local `*.localhost` cookie handling varies by browser. | New AUTH-T104 spike, Phase 1, before any product work: verify on the real domain in three browsers, plus preview mode, local scheme, and cookie tossing. | +| B11 | S3 | **Third-party analytics script on credential pages** (PostHog client SDK on login/reset) is a supply-chain risk. | AUTH-T75: client script only on authenticated pages; auth pages report errors first-party. | +| B12 | S3 | **`/logout` on GET is CSRF-able** (an external page logs users out via an image tag). | AUTH-T30: reject `Sec-Fetch-Site: cross-site`; sibling subdomains remain same-site. | +| B13 | S3 | **Registry outage breaks origin checks** and a wildcard `*.northeasternsga.com` would trust dangling hosts. | AUTH-T56: static fallback of SGAuth's own origin; wildcard explicitly forbidden. | +| B14 | S3 | **Migrations run during the build while the old deployment still serves.** A destructive migration breaks the live release. | AUTH-T16: expand/contract rule documented and required. | +| B15 | S3 | **Shared Neon test branch with parallel CI runs** collides; per-run branches hit the 10-branch cap. | AUTH-T06/T93: Actions concurrency group serializes runs; branch reset before each. | +| B16 | S3 | **Audit `type` CHECK constraint from the catalog** forces a migration for every new event. | TS-level validation only. AUTH-T13, T73. | +| B17 | S3 | **50 ms timing assertion in CI** would be flaky. | Non-gating benchmark; bodies and status codes asserted. AUTH-T70. | +| B18 | S3 | **Verification re-send on every sign-in attempt** by an unverified user (Better Auth behavior) is an email-flood lever. | Documented in AUTH-T17; bounded by mailer per-recipient caps and sign-in rate limits. | +| B19 | S3 | **Position key collisions** between org roles and product roles. | Naming convention in AUTH-T44: bare keys for org roles, `-` prefix for product roles. | +| B20 | S3 | **Better Auth admin plugin fields** (`impersonatedBy`) were listed but the plugin is not used. | Removed; custom authz only. AUTH-T10. | +| B21 | S3 | **Invited (password-less) user tries to self-sign-up** and gets a confusing no-op. | AUTH-T22: copy routes them to set-password/forgot. | + +## C. Accepted risks (documented, no change) + +| # | Sev | Risk | Why accepted / residual control | +|---|---|---|---| +| C1 | S2 | **Cookie tossing / login CSRF from any `*.northeasternsga.com` host.** A compromised or dangling subdomain can set a same-named cookie carrying an attacker's *valid* session, logging the victim into the attacker's account. `__Host-` cannot be used with a Domain attribute. | Inherent to the parent-domain cookie decision. Cookie values are signed (no forgery). Controls: AUTH-T68 DNS hygiene and subdomain policy, AUTH-T104 observes the behavior, threat model records it. Alternative (OIDC per product) was rejected for complexity. | +| C2 | S2 | **Neon Free cold starts** (1–3 s after 5 idle minutes) on the first login of a quiet period, and the A1 quota cliff. | Eli's decision; monitored by AUTH-T101. | +| C3 | S3 | **JWT staleness ≤10 minutes** for Supabase products after revocation or position changes. | Bounded by TTL; "force re-login" exists; documented in the Supabase guide. | +| C4 | S3 | **SDK stale-if-error window** (up to 5 minutes by default) delays revocation only while SGAuth is down. | Availability trade-off chosen deliberately; bounded and configurable. | +| C5 | S3 | **Any admin can strip the Primary Admin's positions.** Affects product access only, never SGAuth authority; audited. | Simplicity; PA can restore instantly. | +| C6 | S3 | **12-month inactivity tombstone vs co-op or leave.** A student away 12 months loses positions and must re-register. | With tombstones the data cost is low; admins are exempt; notice at 11 months. Could extend to 24 months later without code changes. | +| C7 | S3 | **Northeastern mailbox loss after graduation** blocks password reset for alumni. | Admin email change (AUTH-T24) or account naturally tombstones. | +| C8 | S3 | **10-branch cap** (Free and Launch) with personal, preview, and fixed branches. | AUTH-T09 cleanup, warning at 8; personal branches short-lived. | +| C9 | S3 | **Audit anonymization gate is not a security boundary** against the DB role. | Stated in AUTH-T13; DB credentials are the boundary. | +| C10 | S3 | **Chambers PWA offline login page** becomes a redirect to SGAuth when offline. | Chambers-C02 keeps the offline message; SGAuth cannot work offline by design. | + +## D. Items verified as sound + +- Supabase third-party auth accepts a generic issuer (`oidc_issuer_url` / `jwks_url` / `custom_jwks`) via the Management API; `role: authenticated` and UUID `sub` requirements are met by the JWT plugin configuration. +- Better Auth 1.7 supports custom `password.verify` (bcrypt import), request-based `trustedOrigins` functions, cross-subdomain cookies, `cookiePrefix`, `session.create.before` hooks that can throw, the 2FA plugin with backup codes and its own TOTP lockout, and returns success on duplicate sign-up when verification is required. +- Northeastern student addresses are `@northeastern.edu`; `husky.neu.edu` is a legacy alias that still delivers but is not the account identity. The domain rule stays exact-match. +- Upstash free tier (500K commands/month) suffices once the session endpoint is excluded. +- Resend volume with password login is low; invites are batched under caps. + +## E. Still open + +- ~~Curated position list~~ received; seed file in place, AUTH-T44 unblocked. +- Attendance Manager's Supabase-vs-Neon directive (selects ATTENDANCE-M02 or M03). +- The Chambers `auth.users` export (Eli's manual action item) must precede deletion of that Supabase project. +- Recommendation retained for the record: move Neon to Launch before the Chambers cutover. diff --git a/docs/sgauth-design/out/SGAuth-tickets.md b/docs/sgauth-design/out/SGAuth-tickets.md new file mode 100644 index 0000000..e99b7c2 --- /dev/null +++ b/docs/sgauth-design/out/SGAuth-tickets.md @@ -0,0 +1,1879 @@ +# SGAuth ticket set + +Generated from `tickets-auth.mjs` and `tickets-products.mjs`. 138 tickets across 6 teams (5 with Linear CSVs; Chambers has no Linear team and is a checklist only). CSVs in `out/` use Linear's importer columns (Title, Description, Priority, Estimate, Status, Labels); each ticket's description carries its epic, phase, and dependencies because the importer does not create projects or parent links. Red-team revisions are folded in; see `SGAuth-red-team.md`. + +**Manual action item (not a ticket):** Eli exports the Chambers Supabase `auth.users` table (with `encrypted_password`) joined to `public.users` before that Supabase project is deleted, and stores the file in the team secrets vault. AUTH-T86 validates the file; AUTH-T87 imports it. + +## Import instructions + +1. In Linear, create the projects listed under **Epics** below (one per epic) in the AUTH team, and one "SGAuth integration" project in each product team. +2. Settings → Import/Export → Import → CSV. Import `linear-import-AUTH.csv` into team AUTH; import each product file into that product's team. Map columns as-is (headers match Linear's expected names). +3. After import, filter by label `epic:*` and bulk-move issues into the matching project. Dependencies are written as text (`Depends on: AUTH-T03 (...)`); add "blocked by" relations while triaging. +4. Estimates are Fibonacci points (1/2/3/5/8). Set each team's estimate scale to Fibonacci before importing so values map cleanly. + +## Phases + +- **Phase 0 — Foundation** +- **Phase 1 — Core auth, sessions, SSO (MVP)** +- **Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ** +- **Phase 3 — Hardening, observability, Chambers** +- **Phase 4 — Aplio, SenatePath, Attendance Manager, retention** +- **Phase 5 — Backlog / spikes** + +## Epics (Linear Projects) + +### Team AUTH + +- **E1 Foundation & Neon Migration** (label `epic:foundation`, 10 tickets, 23 points): Replace the Supabase foundation with Neon serverless Postgres, Better Auth, Vercel hosting, CI, and environment tooling. SGAuth MUST run on Neon and MUST NOT use Supabase for auth or data. +- **E2 Data Model & Migrations** (label `epic:data-model`, 7 tickets, 16 points): Prisma 7 schema on Neon: users, Better Auth tables, positions, admin/Primary Admin invariants, audit log, product registry, security tables, and the migration workflow. +- **E3 Authentication Core** (label `epic:auth-core`, 12 tickets, 29 points): Email + password authentication with Better Auth: sign-up restricted to northeastern.edu, verification, reset, invites, imported bcrypt hashes, and the login UI. +- **E4 Sessions & SSO** (label `epic:sessions`, 10 tickets, 24 points): One session across *.northeasternsga.com via a parent-domain cookie, the session endpoint products call, ES256 JWTs + JWKS for Supabase products, logout propagation, redirects, re-authentication, and the non-production topology. +- **E5 Admin & Primary Admin** (label `epic:admin`, 7 tickets, 23 points): Server-enforced admin rules, the single transferable Primary Admin with a guarded transfer flow, break-glass recovery, and bulk user administration. +- **E6 Positions** (label `epic:positions`, 5 tickets, 11 points): Flat, admin-managed positions (stable key + display name) carried in every session; assignment, soft delete, retirement, propagation, and history. +- **E7 Admin UI & Account UI** (label `epic:ui`, 9 tickets, 27 points): Light admin UI (users, positions, products, audit, PA transfer) and a minimal account page for every user (profile, positions, product links, sessions, password, MFA). +- **E8 Product Registry & SDK** (label `epic:sdk`, 7 tickets, 17 points): Admin-managed product registry that drives trusted origins and redirects, and the published @sgaoperations/sgauth package products use to read sessions and tokens. +- **E9 Security Hardening** (label `epic:security`, 10 tickets, 26 points): Upstash rate limiting, escalating account lockout, CSRF and origin enforcement, headers, TOTP MFA (required for admins), subdomain hygiene, secrets, enumeration resistance, scanning, and a threat model. +- **E10 Observability & Audit** (label `epic:observability`, 6 tickets, 13 points): Append-only audit log with full coverage, structured logs, PostHog analytics and error tracking, health and uptime, alerts, and retention/deletion jobs. +- **E11 Integration Guides & Docs** (label `epic:docs`, 7 tickets, 18 points): Architecture doc (Neon mandate), Neon-product and Supabase-product integration guides, admin runbooks, privacy notice, SDK reference, contributor guide. +- **E12 User Migration & Rollout** (label `epic:rollout`, 7 tickets, 14 points): Export/import of existing product users (Chambers hashes, Aplio/SenatePath/Attendance emails), launch checklist, rollout comms, cutover and rollback plans. +- **E13 Testing & QA** (label `epic:testing`, 6 tickets, 20 points): Test harness on a Neon test branch, integration tests for every auth flow, authorization matrix, Playwright SSO end-to-end, JWT/JWKS conformance, and load sanity. + +### Product teams + +- **SGAuth integration — VaultZ** (team VaultZ, 8 tickets, 17 points, Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ): Replace the shared-passphrase gate with SGAuth sessions via the SDK. First product integrated; proves the SDK and the Neon-product guide. +- **SGAuth integration — Chambers** (team Chambers, 9 tickets, 26 points, Phase 3 — Hardening, observability, Chambers): Chambers has no Linear team; these items are a Markdown checklist only (no CSV). Chambers is a Neon product (migration completes before integration). Replace Supabase Auth, live-role checks, and session revocation with SGAuth sessions and positions; users and password hashes are imported into SGAuth. The auth.users export is Eli's manual action item, not a ticket. +- **SGAuth integration — Aplio** (team Aplio, 8 tickets, 23 points, Phase 4 — Aplio, SenatePath, Attendance Manager, retention): Hard cutover from Aplio's local Better Auth (email OTP) to SGAuth via the SDK; users pre-imported and invited to set passwords. +- **SGAuth integration — SenatePath** (team SenatePath, 4 tickets, 12 points, Phase 4 — Aplio, SenatePath, Attendance Manager, retention): Migrate SenatePath's database from Supabase to Neon and gate the admin area with SGAuth positions. +- **SGAuth integration — Attendance Manager** (team Attendance Manager, 6 tickets, 17 points, Phase 4 — Aplio, SenatePath, Attendance Manager, retention): Decision pending from the team: either stay on Supabase and consume SGAuth via third-party auth (JWT trust), or move to Neon and use the SDK. Both paths are ticketed; only one will be executed. + +## Summary table + +| ID | Title | Team | Phase | Priority | Est. | Depends on | +|---|---|---|---|---|---|---| +| AUTH-T01 | Provision the Neon project, branches, and roles for SGAuth | AUTH | 0 | Urgent | 2 | — | +| AUTH-T02 | Remove Supabase from the auth repo (in-place migration to Neon) | AUTH | 0 | Urgent | 3 | — | +| AUTH-T03 | Install Better Auth 1.7 with the Prisma adapter and mount the handler | AUTH | 0 | Urgent | 3 | AUTH-T02, AUTH-T04 | +| AUTH-T04 | Configure Prisma 7 for Neon (pooled runtime, direct migrations) | AUTH | 0 | High | 2 | AUTH-T01 | +| AUTH-T05 | Create the Vercel project, custom domains, environments, and Neon preview branching | AUTH | 0 | High | 3 | AUTH-T01, AUTH-T03 | +| AUTH-T06 | Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch | AUTH | 0 | High | 3 | AUTH-T03, AUTH-T93 | +| AUTH-T07 | Validate environment variables at startup with zod | AUTH | 0 | Medium | 1 | AUTH-T02 | +| AUTH-T08 | Rewrite README and developer bootstrap for Neon branches | AUTH | 0 | Medium | 2 | AUTH-T10, AUTH-T44 | +| AUTH-T09 | Automate Neon branch hygiene to stay under plan limits | AUTH | 0 | Medium | 2 | AUTH-T05 | +| AUTH-T10 | Core schema: User plus Better Auth Session, Account, and Verification tables | AUTH | 0 | High | 3 | AUTH-T03, AUTH-T04 | +| AUTH-T11 | Positions schema: Position, UserPosition, and retired keys | AUTH | 1 | High | 3 | AUTH-T10 | +| AUTH-T12 | Primary Admin invariants at the database level and the PrimaryAdminTransfer table | AUTH | 1 | High | 3 | AUTH-T10 | +| AUTH-T13 | Append-only AuditEvent table | AUTH | 1 | High | 2 | AUTH-T10 | +| AUTH-T14 | Product registry schema | AUTH | 2 | Medium | 2 | AUTH-T10 | +| AUTH-T15 | Security tables: AccountLock, UnlockToken, Jwks, TwoFactor | AUTH | 1 | Medium | 2 | AUTH-T10 | +| AUTH-T16 | Migration workflow: deploy in build, never migrate dev against production | AUTH | 0 | Medium | 1 | AUTH-T04, AUTH-T05 | +| AUTH-T17 | Email + password sign-in and self-sign-up restricted to northeastern.edu | AUTH | 1 | Urgent | 3 | AUTH-T03, AUTH-T10 | +| AUTH-T18 | Email verification for self-sign-up with resend limits | AUTH | 1 | High | 2 | AUTH-T17, AUTH-T20, AUTH-T103 | +| AUTH-T19 | Password reset flow | AUTH | 1 | High | 3 | AUTH-T17, AUTH-T20, AUTH-T103 | +| AUTH-T20 | Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps | AUTH | 1 | High | 3 | AUTH-T07 | +| AUTH-T21 | Accept imported Chambers bcrypt hashes with lazy re-hash to scrypt | AUTH | 3 | High | 2 | AUTH-T17 | +| AUTH-T22 | Invite flow: admin-created users receive a set-password link | AUTH | 2 | Medium | 2 | AUTH-T20, AUTH-T17, AUTH-T103 | +| AUTH-T23 | Change password (current password + re-auth), revoke other sessions | AUTH | 2 | Medium | 2 | AUTH-T17, AUTH-T32 | +| AUTH-T24 | Admin-initiated email change with re-verification and privilege rules | AUTH | 3 | Low | 2 | AUTH-T36, AUTH-T20 | +| AUTH-T25 | Login, sign-up, forgot/reset, and verify pages | AUTH | 1 | High | 3 | AUTH-T17, AUTH-T18, AUTH-T19, AUTH-T31 | +| AUTH-T26 | Parent-domain session cookie for *.northeasternsga.com | AUTH | 1 | Urgent | 2 | AUTH-T03 | +| AUTH-T27 | Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache | AUTH | 1 | High | 2 | AUTH-T10, AUTH-T26 | +| AUTH-T28 | Session endpoint for products: user, email, positions, admin flags | AUTH | 1 | Urgent | 3 | AUTH-T27, AUTH-T11 | +| AUTH-T29 | ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products | AUTH | 2 | High | 3 | AUTH-T28, AUTH-T15 | +| AUTH-T30 | Global logout, sign out everywhere, and admin revocation | AUTH | 1 | High | 2 | AUTH-T26, AUTH-T56 | +| AUTH-T31 | Safe post-login redirects validated against the product registry | AUTH | 1 | High | 2 | AUTH-T56 | +| AUTH-T32 | Re-authentication (sudo mode) for sensitive actions | AUTH | 2 | High | 3 | AUTH-T27, AUTH-T67 | +| AUTH-T33 | List and revoke the user's own sessions | AUTH | 2 | Medium | 2 | AUTH-T27 | +| AUTH-T34 | Non-production SSO topology: dev deployment, local hostnames, product preview domains | AUTH | 1 | High | 3 | AUTH-T05, AUTH-T26 | +| AUTH-T35 | Authorization module with the admin/Primary Admin rule matrix | AUTH | 2 | Urgent | 3 | AUTH-T10, AUTH-T12 | +| AUTH-T36 | Admin user-management endpoints | AUTH | 2 | High | 5 | AUTH-T35, AUTH-T13, AUTH-T32, AUTH-T22 | +| AUTH-T37 | Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window) | AUTH | 2 | High | 5 | AUTH-T12, AUTH-T35, AUTH-T32, AUTH-T20, AUTH-T67, AUTH-T103, AUTH-T38 | +| AUTH-T38 | Scheduled-job runner: GitHub Actions schedules calling secret-protected routes | AUTH | 1 | High | 2 | AUTH-T05, AUTH-T07 | +| AUTH-T39 | Break-glass Primary Admin recovery script and runbook | AUTH | 2 | High | 3 | AUTH-T12, AUTH-T13 | +| AUTH-T40 | Primary Admin protection tests across API and database layers | AUTH | 2 | High | 2 | AUTH-T12, AUTH-T36 | +| AUTH-T41 | Bulk user import (CSV) with position assignment and batched invites | AUTH | 2 | Medium | 3 | AUTH-T36, AUTH-T43 | +| AUTH-T42 | Positions CRUD: create, edit name/category, soft delete with retirement | AUTH | 2 | High | 3 | AUTH-T11, AUTH-T35, AUTH-T13 | +| AUTH-T43 | Position assignment endpoints (assign, unassign, bulk) | AUTH | 2 | High | 3 | AUTH-T42 | +| AUTH-T44 | Seed the curated SGA position list | AUTH | 1 | Medium | 1 | AUTH-T11 | +| AUTH-T45 | Position propagation tests and forced re-login | AUTH | 2 | Medium | 2 | AUTH-T43, AUTH-T28, AUTH-T29 | +| AUTH-T46 | Position history queries | AUTH | 3 | Low | 2 | AUTH-T42, AUTH-T73 | +| AUTH-T47 | App shell, navigation, and route guards for /admin and /account | AUTH | 2 | High | 3 | AUTH-T25, AUTH-T28 | +| AUTH-T48 | Admin: users list and user detail pages | AUTH | 2 | High | 5 | AUTH-T47, AUTH-T36, AUTH-T43 | +| AUTH-T49 | Admin: positions pages | AUTH | 2 | High | 3 | AUTH-T47, AUTH-T42 | +| AUTH-T50 | Admin: product registry pages | AUTH | 2 | Medium | 2 | AUTH-T47, AUTH-T56 | +| AUTH-T51 | Admin: audit log viewer with filters and CSV export | AUTH | 3 | Medium | 3 | AUTH-T47, AUTH-T73 | +| AUTH-T52 | Admin: Primary Admin transfer wizard, status, acceptance, and cancel pages | AUTH | 2 | High | 3 | AUTH-T47, AUTH-T37 | +| AUTH-T53 | Account page: profile, positions, product links, sessions, sign out everywhere | AUTH | 2 | High | 3 | AUTH-T47, AUTH-T33, AUTH-T56 | +| AUTH-T54 | Account security page: change password, MFA enrollment, backup codes | AUTH | 3 | High | 3 | AUTH-T23, AUTH-T67 | +| AUTH-T55 | Accessibility, responsive, and empty/error state pass | AUTH | 3 | Medium | 2 | AUTH-T48, AUTH-T49, AUTH-T53, AUTH-T54 | +| AUTH-T56 | Product registry service: trusted origins and redirect allowlist at runtime | AUTH | 1 | High | 2 | AUTH-T14 | +| AUTH-T57 | Scaffold the @sgaoperations/sgauth package and publish pipeline (public npm) | AUTH | 2 | High | 3 | AUTH-T28 | +| AUTH-T58 | SDK: getSession() with cookie forwarding and a 60-second cache | AUTH | 2 | High | 3 | AUTH-T57 | +| AUTH-T59 | SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs) | AUTH | 2 | High | 3 | AUTH-T58 | +| AUTH-T60 | SDK: getAccessToken() for Supabase clients | AUTH | 3 | Medium | 2 | AUTH-T58, AUTH-T29 | +| AUTH-T61 | SDK documentation, example app, and versioning policy | AUTH | 2 | Medium | 2 | AUTH-T59 | +| AUTH-T62 | CORS for browser-side calls from registered products | AUTH | 3 | Medium | 2 | AUTH-T56, AUTH-T28 | +| AUTH-T63 | Upstash Redis rate limiting on auth and token endpoints | AUTH | 1 | High | 3 | AUTH-T03, AUTH-T07 | +| AUTH-T64 | Escalating account lockout with emailed unlock and known-device exemption | AUTH | 1 | High | 5 | AUTH-T15, AUTH-T17, AUTH-T20, AUTH-T103 | +| AUTH-T65 | CSRF and origin enforcement across subdomains | AUTH | 1 | High | 2 | AUTH-T56, AUTH-T26 | +| AUTH-T66 | Security headers (CSP, HSTS, frame, referrer) | AUTH | 2 | Medium | 2 | AUTH-T05 | +| AUTH-T67 | TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin) | AUTH | 2 | High | 5 | AUTH-T15, AUTH-T17 | +| AUTH-T68 | Subdomain hygiene: DNS inventory, dangling-record removal, and policy | AUTH | 2 | Medium | 2 | AUTH-T26 | +| AUTH-T69 | Secrets management and rotation procedures | AUTH | 2 | Medium | 1 | AUTH-T05, AUTH-T29 | +| AUTH-T70 | Account-enumeration resistance and timing uniformity | AUTH | 1 | Medium | 2 | AUTH-T17, AUTH-T19 | +| AUTH-T71 | Dependency and code scanning | AUTH | 3 | Low | 1 | AUTH-T06 | +| AUTH-T72 | Threat model and pre-launch security review checklist | AUTH | 2 | Medium | 3 | AUTH-T26, AUTH-T29, AUTH-T35 | +| AUTH-T73 | Audit event catalog, emitter, and coverage test | AUTH | 1 | High | 3 | AUTH-T13 | +| AUTH-T74 | Structured JSON logging with request IDs and redaction | AUTH | 1 | High | 2 | AUTH-T03 | +| AUTH-T75 | PostHog: server-side auth funnel events and error tracking | AUTH | 2 | Medium | 2 | AUTH-T74 | +| AUTH-T76 | Health endpoint and uptime monitor | AUTH | 1 | Medium | 1 | AUTH-T04, AUTH-T29 | +| AUTH-T77 | Threshold alerts for security events | AUTH | 3 | Low | 2 | AUTH-T73, AUTH-T20, AUTH-T38 | +| AUTH-T78 | Retention jobs: tombstone deactivated (30 d) and inactive (12 mo) users, purge sessions and PII | AUTH | 3 | Medium | 3 | AUTH-T13, AUTH-T36, AUTH-T20, AUTH-T38 | +| AUTH-T79 | ARCHITECTURE.md: Neon mandate, components, session and token flows | AUTH | 1 | High | 3 | AUTH-T26, AUTH-T28, AUTH-T29 | +| AUTH-T80 | Integration guide for Neon-based products (Next.js) | AUTH | 2 | High | 3 | AUTH-T61, AUTH-T34 | +| AUTH-T81 | Integration guide for Supabase-backed products (third-party auth) plus the move-to-Neon alternative | AUTH | 3 | High | 5 | AUTH-T29, AUTH-T60 | +| AUTH-T82 | Admin runbooks | AUTH | 2 | Medium | 3 | AUTH-T37, AUTH-T39, AUTH-T64, AUTH-T41 | +| AUTH-T83 | Privacy notice and data-handling document | AUTH | 3 | Medium | 2 | AUTH-T78 | +| AUTH-T84 | Generated SDK API reference and changelog discipline | AUTH | 3 | Low | 1 | AUTH-T61 | +| AUTH-T85 | CLAUDE.md and CONTRIBUTING.md for agents and humans | AUTH | 1 | Low | 1 | AUTH-T02, AUTH-T06 | +| AUTH-T86 | Receive the Chambers auth.users export and define the import file format | AUTH | 3 | High | 2 | — | +| AUTH-T87 | Import script: Chambers users with bcrypt hashes and position mapping | AUTH | 3 | High | 3 | AUTH-T86, AUTH-T21, AUTH-T44, AUTH-T13 | +| AUTH-T88 | Aplio user import (emails and names, no passwords) with invites and id mapping | AUTH | 4 | High | 2 | AUTH-T87, AUTH-T22 | +| AUTH-T89 | SenatePath and Attendance Manager user import | AUTH | 4 | Medium | 2 | AUTH-T88 | +| AUTH-T90 | Rollout plan and user communications | AUTH | 3 | Medium | 2 | AUTH-T80, AUTH-T87 | +| AUTH-T91 | Production launch checklist and Primary Admin bootstrap | AUTH | 2 | High | 2 | AUTH-T72, AUTH-T66, AUTH-T76, AUTH-T39, AUTH-T64 | +| AUTH-T92 | Post-launch review and legacy cleanup tracking | AUTH | 4 | Low | 1 | AUTH-T90 | +| AUTH-T93 | Test harness: vitest, Neon test branch, factories, and test mailer | AUTH | 0 | High | 3 | AUTH-T01, AUTH-T04 | +| AUTH-T94 | Integration tests for authentication flows | AUTH | 1 | High | 5 | AUTH-T93, AUTH-T17, AUTH-T18, AUTH-T19, AUTH-T27, AUTH-T64 | +| AUTH-T95 | Authorization, admin, positions, and transfer integration tests | AUTH | 2 | High | 3 | AUTH-T93, AUTH-T36, AUTH-T37, AUTH-T42, AUTH-T43 | +| AUTH-T96 | Playwright end-to-end SSO test across subdomains | AUTH | 2 | High | 5 | AUTH-T34, AUTH-T59, AUTH-T30 | +| AUTH-T97 | JWT and JWKS conformance tests for Supabase requirements | AUTH | 2 | Medium | 2 | AUTH-T29 | +| AUTH-T98 | Load sanity for the session endpoint on Neon | AUTH | 3 | Low | 2 | AUTH-T28, AUTH-T05 | +| AUTH-T99 | Spike: Northeastern Microsoft Entra ID sign-in feasibility | AUTH | 5 | Low | 3 | AUTH-T91 | +| AUTH-T100 | Backlog: optional email OTP login (deferred due to email volume) | AUTH | 5 | Low | 2 | AUTH-T91 | +| AUTH-T101 | Neon Free-plan quota monitoring, alerts, and upgrade runbook | AUTH | 0 | High | 2 | AUTH-T01 | +| AUTH-T103 | Scanner-safe email links: land on a page, consume the token on POST | AUTH | 1 | Urgent | 2 | AUTH-T20 | +| AUTH-T104 | Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them | AUTH | 1 | Urgent | 2 | AUTH-T05, AUTH-T26 | +| VAULTZ-V01 | Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy | VAULTZ | 2 | High | 3 | AUTH-T59, AUTH-T80, AUTH-T34 | +| VAULTZ-V02 | Remove the shared passphrase gate | VAULTZ | 2 | High | 2 | VAULTZ-V01 | +| VAULTZ-V03 | Link VaultZ purchaser records to SGAuth user ids | VAULTZ | 2 | Medium | 3 | VAULTZ-V01 | +| VAULTZ-V04 | Position-based permissions map for VaultZ | VAULTZ | 2 | High | 3 | VAULTZ-V01, AUTH-T44 | +| VAULTZ-V05 | Account and sign-out links in the VaultZ header | VAULTZ | 2 | Low | 1 | VAULTZ-V01 | +| VAULTZ-V06 | Dev and preview topology for VaultZ | VAULTZ | 2 | Medium | 2 | VAULTZ-V01, AUTH-T34 | +| VAULTZ-V07 | VaultZ cutover checklist and rollback | VAULTZ | 2 | Medium | 1 | VAULTZ-V02, VAULTZ-V04, AUTH-T91 | +| VAULTZ-V08 | Tests for SGAuth guards and permissions in VaultZ | VAULTZ | 2 | Medium | 2 | VAULTZ-V04 | +| CHAMBERS-C01 | Inventory every auth and authorization touchpoint in Chambers | CHAMBERS | 3 | High | 3 | — | +| CHAMBERS-C02 | Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal) | CHAMBERS | 3 | High | 5 | CHAMBERS-C01, AUTH-T59, AUTH-T80 | +| CHAMBERS-C03 | Key Chambers users by SGAuth user id | CHAMBERS | 3 | High | 3 | CHAMBERS-C02, AUTH-T87 | +| CHAMBERS-C04 | Map admin_role / iems_role to SGAuth positions; keep body memberships internal | CHAMBERS | 3 | High | 5 | CHAMBERS-C02, AUTH-T44 | +| CHAMBERS-C05 | Remove live-role-check and session-revocation mechanisms | CHAMBERS | 3 | Medium | 3 | CHAMBERS-C04 | +| CHAMBERS-C06 | Approve the Chambers role-to-position mapping file | CHAMBERS | 3 | High | 1 | — | +| CHAMBERS-C07 | Chambers cutover, comms, and rollback plan | CHAMBERS | 3 | High | 2 | CHAMBERS-C03, CHAMBERS-C04, CHAMBERS-C05, AUTH-T91 | +| CHAMBERS-C08 | Verify kiosk display key, Slack reminders, and cron routes are unaffected | CHAMBERS | 3 | Low | 1 | CHAMBERS-C02 | +| CHAMBERS-C09 | Tests for SGAuth-based authorization in Chambers | CHAMBERS | 3 | Medium | 3 | CHAMBERS-C04 | +| APLIO-P01 | Export Aplio users for the SGAuth import and receive the id mapping | APLIO | 4 | High | 2 | AUTH-T88 | +| APLIO-P02 | Replace local Better Auth with the SGAuth SDK | APLIO | 4 | High | 5 | APLIO-P01, AUTH-T59, AUTH-T80 | +| APLIO-P03 | Key Aplio users by SGAuth user id | APLIO | 4 | High | 5 | APLIO-P02 | +| APLIO-P04 | Derive Aplio admin from an SGAuth position; managers stay product-level | APLIO | 4 | High | 2 | APLIO-P02, AUTH-T44 | +| APLIO-P05 | Applicant flow on SGAuth accounts (northeastern.edu required) | APLIO | 4 | High | 3 | APLIO-P02 | +| APLIO-P06 | Local dev bypass and preview topology | APLIO | 4 | Low | 1 | APLIO-P02, AUTH-T34 | +| APLIO-P07 | Aplio hard cutover outside an application window | APLIO | 4 | High | 2 | APLIO-P03, APLIO-P04, APLIO-P05, AUTH-T91 | +| APLIO-P08 | Update Aplio tests for SDK-based auth | APLIO | 4 | Medium | 3 | APLIO-P04 | +| SENATEPATH-S01 | Migrate SenatePath's database from Supabase to Neon | SENATEPATH | 4 | High | 5 | — | +| SENATEPATH-S02 | Gate the SenatePath admin area with SGAuth positions | SENATEPATH | 4 | High | 3 | SENATEPATH-S01, AUTH-T59, AUTH-T80 | +| SENATEPATH-S03 | Import SenatePath admins into SGAuth and cut over | SENATEPATH | 4 | Medium | 2 | SENATEPATH-S02, AUTH-T89 | +| SENATEPATH-S04 | Tests for the SGAuth admin gate | SENATEPATH | 4 | Low | 2 | SENATEPATH-S02 | +| ATTENDANCE-M01 | Decision: stay on Supabase (third-party auth) or move to Neon (SDK) | ATTENDANCE | 4 | High | 1 | — | +| ATTENDANCE-M02 | (Supabase path) Consume SGAuth via Supabase third-party auth | ATTENDANCE | 4 | High | 5 | ATTENDANCE-M01, AUTH-T81, AUTH-T60 | +| ATTENDANCE-M03 | (Neon path) Migrate to Neon and integrate with the SDK | ATTENDANCE | 4 | High | 5 | ATTENDANCE-M01, AUTH-T59, AUTH-T80 | +| ATTENDANCE-M04 | Map roles to SGAuth positions; keep NUID product-side | ATTENDANCE | 4 | Medium | 2 | ATTENDANCE-M01, AUTH-T44 | +| ATTENDANCE-M05 | User import and cutover | ATTENDANCE | 4 | Medium | 2 | ATTENDANCE-M04, AUTH-T89 | +| ATTENDANCE-M06 | Update auth and middleware tests | ATTENDANCE | 4 | Low | 2 | ATTENDANCE-M04 | + +--- + +# Team AUTH + +## E1 Foundation & Neon Migration + +### AUTH-T01 — Provision the Neon project, branches, and roles for SGAuth + +**Priority:** Urgent · **Estimate:** 2 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, infra, neon +**Depends on:** none + +Create the production Neon project that SGAuth is built on. **SGAuth is built on Neon serverless Postgres and MUST NOT use Supabase in any form** (no Supabase Auth, no Supabase Postgres, no Supabase client libraries). + +Set up: +- Project `sgauth` in the SGAOperations Neon org, region closest to Vercel's default (us-east-1 / iad1). +- Branches: `main` (production), `dev` (shared non-production deployment), `test` (CI integration tests). Preview branches are created automatically later (AUTH-T05). +- A least-privilege application role for runtime queries and a separate migration role for `prisma migrate deploy`. +- Record the pooled connection string (host contains `-pooler`) as `DATABASE_URL` and the direct string as `DIRECT_URL` in a secrets store (Vercel env + the team password manager). Use `sslmode=verify-full`. +- Enable point-in-time restore on `main` (Neon history retention) and note the restore procedure. + +**Plan decision (red-team, accepted risk):** SGAuth launches on the Neon **Free** plan with scale-to-zero. Free suspends the compute for the rest of the month once 100 CU-hours are used, which would take every SGA product's login down; history/restore window is only 6 hours. AUTH-T101 adds quota monitoring and the upgrade runbook (Launch plan, pay-as-you-go at $0.106/CU-hour). Keep the SDK's 60-second cache so idle periods let the compute suspend. + +Reference: Neon Prisma guide (pooled vs direct URLs), Neon free plan limits (10 branches/project, 100 CU-hours/project/month, 0.5 GB storage per project, 6-hour history). + +**Acceptance criteria** +- [ ] Neon project exists with `main`, `dev`, and `test` branches; history retention set to the Free maximum on `main`. +- [ ] Two roles exist: runtime (no DDL) and migration (DDL); credentials stored in the team secrets store, not in git. +- [ ] `DATABASE_URL` (pooled) and `DIRECT_URL` (direct) connection strings are documented in `.env.example` with placeholder values and `sslmode=verify-full`. +- [ ] A short section in README states the Neon mandate and that Supabase is not used anywhere in SGAuth. + +### AUTH-T02 — Remove Supabase from the auth repo (in-place migration to Neon) + +**Priority:** Urgent · **Estimate:** 3 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, neon, chore +**Depends on:** none + +The current repo scaffolds Supabase auth and DB. Strip it entirely so the codebase reflects the Neon mandate. + +Remove: +- Dependencies: `@supabase/ssr`, `@supabase/supabase-js`, `supabase` (CLI). +- `supabase/` directory (config.toml), `src/app/auth/callback/route.ts` (Supabase OTP callback), Supabase env vars from `.env.example`, and the Supabase steps in README. +- The `supabaseUserId` column and any Supabase-shaped assumptions in `prisma/schema.prisma` and `prisma/seed.ts` (the schema is replaced in AUTH-T10; this ticket only removes Supabase references so the repo builds). + +Also triage open branches that build on Supabase (AUTH-7, AUTH-8, auth-9, auth-10, auth-11, AUTH-14): close them with a comment pointing at this design, or cherry-pick any UI-only work that is still useful. Do not merge Supabase code. + +**Acceptance criteria** +- [ ] `grep -ri supabase` across the repo (excluding the design doc folder) returns nothing. +- [ ] `npm ci && npm run build && npm run lint && npm run format:check` pass with Supabase removed. +- [ ] README setup section no longer mentions Docker or `supabase start`; it points at Neon branches (final wording lands in AUTH-T08). +- [ ] Each open Supabase-based branch has a closing comment or a note in the PR explaining what was salvaged. + +### AUTH-T03 — Install Better Auth 1.7 with the Prisma adapter and mount the handler + +**Priority:** Urgent · **Estimate:** 3 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, backend, better-auth +**Depends on:** AUTH-T02 (Remove Supabase from the auth repo (in-place migration to Neon)); AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)) + +Add self-managed Better Auth (latest 1.7.x) and `@better-auth/prisma-adapter` on Neon, mirroring the pattern Aplio already uses. + +- `src/lib/auth/config.ts`: `betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql' }), baseURL, secret, trustedOrigins, advanced: { database: { generateId: false } }, plugins: [nextCookies()] })`. Prisma generates `uuid(7)` ids. +- `src/app/api/auth/[...all]/route.ts` mounting `toNextJsHandler(auth)`. +- `baseURL` = `https://auth.northeasternsga.com` in production, `https://auth-dev.northeasternsga.com` on the dev deployment, and a local dev hostname (AUTH-T34) locally. Never derive it from `VERCEL_URL` for cookies (the cookie domain must be the parent domain). +- `trustedOrigins` is temporarily a static list; AUTH-T56 replaces it with the product registry. +- Add `server-only` guards and the `auth.api` typed server helper. + +Plugins (jwt, admin, twoFactor, customSession) are added in their own tickets. + +**Acceptance criteria** +- [ ] `GET /api/auth/ok` (or equivalent Better Auth health route) returns 200 on the dev deployment. +- [ ] Better Auth's initialization-time schema validation passes against the Prisma client (1.7 rejects requests on mismatch). +- [ ] `BETTER_AUTH_SECRET` is required (32+ chars) and the app refuses to start without it. +- [ ] Unit test confirms `baseURL` resolution per environment and that `VERCEL_URL` is never used for the production cookie domain. + +### AUTH-T04 — Configure Prisma 7 for Neon (pooled runtime, direct migrations) + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, backend, neon, prisma +**Depends on:** AUTH-T01 (Provision the Neon project, branches, and roles for SGAuth) + +Wire Prisma 7 to Neon following Neon's Prisma guide. + +- `prisma.config.ts`: `datasource.url = env('DIRECT_URL')` (used by the CLI for migrations). No `url` in the schema datasource block (Prisma 7 rule). +- Runtime client (`src/lib/prisma.ts`): driver adapter with the **pooled** `DATABASE_URL`. Use `@prisma/adapter-pg` with a `pg.Pool` sized for Vercel functions (`max: 5`, `idleTimeoutMillis: 10_000`), or `@prisma/adapter-neon` if WebSocket transport is preferred; document the choice. +- Global singleton in dev to avoid pool exhaustion on HMR. +- Add `?sslmode=verify-full`. Verify whether `pgbouncer=true` is needed with Neon's pooler when using a driver adapter and document the result. + +**Acceptance criteria** +- [ ] `npx prisma migrate deploy` runs against `DIRECT_URL`; runtime queries use the `-pooler` host (verified via Neon monitoring or `pg_stat_activity`). +- [ ] Cold start on Vercel executes a `SELECT 1` through the pooled connection in under 300 ms p50 (measured on the dev deployment). +- [ ] README documents why two URLs exist and which commands use which. + +### AUTH-T05 — Create the Vercel project, custom domains, environments, and Neon preview branching + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, infra, vercel, neon +**Depends on:** AUTH-T01 (Provision the Neon project, branches, and roles for SGAuth); AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler) + +Host SGAuth on Vercel (Node runtime, not Edge, because of Prisma). + +- Vercel project `sgauth` linked to the repo; production branch `main` → `auth.northeasternsga.com`; a protected `dev` branch → `auth-dev.northeasternsga.com` (see AUTH-T34 for why a dev SGAuth deployment on the real parent domain is required for product previews). +- Install the Neon–Vercel integration so each preview deployment gets its own Neon branch (from `dev`) and `DATABASE_URL`/`DIRECT_URL` injected; enable automatic deletion of preview branches when the deployment is deleted (free plan allows 10 branches). +- Environment variables scoped per environment (production / preview / development). Secrets never shared across environments; `BETTER_AUTH_SECRET` differs per environment. +- Build command runs `prisma generate` and `prisma migrate deploy` (AUTH-T16) before `next build`. +- Deployment protection: previews password- or SSO-protected via Vercel; production public. +- **No Vercel cron jobs.** Vercel Hobby allows two jobs at once-per-day granularity and rejects more frequent schedules at deploy time; all scheduled work runs from GitHub Actions (AUTH-T38). Do not add a `crons` section to `vercel.json`. +- SGAuth's own `*.vercel.app` preview deployments cannot set a `northeasternsga.com` cookie; they run with `SGAUTH_ENV=preview` (host-only cookie, AUTH-T26) so previews are testable in isolation. + +**Acceptance criteria** +- [ ] `https://auth.northeasternsga.com` and `https://auth-dev.northeasternsga.com` serve the app over TLS with valid certificates. +- [ ] Opening a PR creates a Neon preview branch and a preview deployment that runs migrations against it; closing the PR deletes the branch. +- [ ] Vercel env vars are documented in `docs/ENVIRONMENTS.md` with which environment each applies to. + +### AUTH-T06 — Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, ci, chore +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler); AUTH-T93 (Test harness: vitest, Neon test branch, factories, and test mailer) + +Extend `.github/workflows/ci.yml` for the new stack. + +Jobs: typecheck, lint, format:check (existing), plus: +- `migrations`: `prisma migrate diff` to ensure schema and migrations are in sync and no drift; fails if a migration is missing. +- `test`: vitest unit + integration tests against the Neon `test` branch (AUTH-T93). Use a Neon API key stored as a GitHub secret to reset the branch (`neonctl branches reset`) before the run so tests start clean. Serialize runs with a GitHub Actions `concurrency` group (`neon-test-branch`) so parallel PRs do not share the branch mid-run; per-run branches are avoided because of the 10-branch cap. +- Cache npm and Prisma engines. +- Required checks + branch protection on `main` and `dev` (PR required, at least one review, CI green). + +**Acceptance criteria** +- [ ] CI runs on every PR and push to `main`/`dev`, all jobs green on a clean checkout. +- [ ] A PR that changes `schema.prisma` without a migration fails the `migrations` job with a clear message. +- [ ] Branch protection rules are enabled and documented in CONTRIBUTING.md. + +### AUTH-T07 — Validate environment variables at startup with zod + +**Priority:** Medium · **Estimate:** 1 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, backend, chore +**Depends on:** AUTH-T02 (Remove Supabase from the auth repo (in-place migration to Neon)) + +Add `src/lib/env.ts` that parses `process.env` with zod (server and public schemas separated) and fails fast with a readable error listing missing/invalid vars. Rewrite `.env.example` to the final variable list: `DATABASE_URL`, `DIRECT_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `SGAUTH_COOKIE_DOMAIN`, `SGAUTH_COOKIE_PREFIX`, `SGAUTH_ENV` (production|dev|preview|local), `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`, `POSTHOG_KEY`, `POSTHOG_HOST`, `CRON_SECRET`, `NEON_API_KEY` (CI only). + +**Acceptance criteria** +- [ ] Starting the app with a missing required var prints the variable name and exits non-zero. +- [ ] `.env.example` lists every variable with a one-line purpose comment and no real values. +- [ ] No code reads `process.env` directly outside `src/lib/env.ts` (enforced by an ESLint `no-restricted-syntax` rule). + +### AUTH-T08 — Rewrite README and developer bootstrap for Neon branches + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, docs, chore +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables); AUTH-T44 (Seed the curated SGA position list) + +Replace the Docker/Supabase setup with: create a personal Neon branch from `dev` (`neonctl branches create --parent dev --name `), copy its URLs into `.env`, run `npx prisma migrate dev`, run `npm run seed` (curated positions + a local Primary Admin from `SEED_PRIMARY_ADMIN_EMAIL`), then `npm run dev`. Include the local SSO hostname setup from AUTH-T34 and the scripts table. + +**Acceptance criteria** +- [ ] A new contributor can go from clone to logged-in local Primary Admin in under 15 minutes following only the README. +- [ ] The seed is idempotent (re-running changes nothing) and refuses to run when `SGAUTH_ENV=production`. + +### AUTH-T09 — Automate Neon branch hygiene to stay under plan limits + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, infra, neon +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching) + +The free plan allows 10 branches per project. Add a scheduled GitHub Action (daily) that lists branches via the Neon API and deletes preview branches whose PR is closed/merged or that are older than 7 days, excluding `main`, `dev`, `test`, and branches matching `dev-*` (personal). Post a summary to the workflow log. + +**Acceptance criteria** +- [ ] The action runs daily, is idempotent, and never deletes protected branches (unit test on the filter). +- [ ] Branch count stays below 8 in steady state; a warning is logged at 8 or more. + +### AUTH-T101 — Neon Free-plan quota monitoring, alerts, and upgrade runbook + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 0 — Foundation · **Labels:** epic:foundation, phase:0, infra, neon, observability +**Depends on:** AUTH-T01 (Provision the Neon project, branches, and roles for SGAuth) + +Accepted risk from the red-team review: SGAuth runs on Neon Free, which suspends the compute for the remainder of the month once the project uses 100 CU-hours. A compute that stays awake most of the day at the 0.25 CU minimum uses about 180 CU-hours/month, so exhaustion is plausible once several products are live, and it would take every SGA login down at once. +Mitigations: (1) a scheduled job (AUTH-T38, daily) reads consumption via the Neon API and emails all admins at 50%, 70%, and 85% of the monthly CU-hour quota, with a projection of the exhaustion date; (2) keep scale-to-zero at 5 minutes and rely on the SDK cache so idle periods suspend the compute; (3) `docs/runbooks/neon-upgrade.md`: one-click upgrade to Launch (pay-as-you-go, $0.106/CU-hour), what changes (7-day restore window, scale-to-zero configurable), and who is authorized to approve the spend; (4) record the 6-hour restore window as a known limitation in the architecture doc; (5) never add keep-warm pings (they burn the quota). + +**Acceptance criteria** +- [ ] Alert emails fire at the thresholds (tested by lowering the threshold on dev). +- [ ] Runbook merged and the upgrade trigger (85% or any suspension) is written into the on-call notes. +- [ ] ARCHITECTURE.md states the Free-plan risk and the restore window explicitly. + +## E2 Data Model & Migrations + +### AUTH-T10 — Core schema: User plus Better Auth Session, Account, and Verification tables + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 0 — Foundation · **Labels:** epic:data-model, phase:0, backend, prisma +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler); AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)) + +Define the identity model in `prisma/schema.prisma` (Neon). Better Auth core tables plus SGAuth fields: + +**User**: `id` (uuid v7, PK), `email` (unique, stored lower-cased; use `citext` or a lower-case check), `emailVerified`, `name` (required display name), `preferredName` (optional), `isAdmin` (bool), `isPrimaryAdmin` (bool), `status` (enum ACTIVE | DEACTIVATED | DELETED — DELETED is a tombstone: the row and id survive with PII scrubbed so product foreign keys stay valid), `deactivatedAt`, `deletedAt`, `legacyEmail` (bool; true for imported accounts whose address is not northeastern.edu), `lastLoginAt`, `passwordChangedAt`, `twoFactorEnabled` (plugin), `createdAt`, `updatedAt`. +**Session**: Better Auth fields (`id`, `token` unique, `userId`, `expiresAt`, `ipAddress`, `userAgent`, `createdAt`, `updatedAt`) plus `absoluteExpiresAt` (createdAt + 90 days) and `lastReauthAt`. The Better Auth admin plugin is **not** used (custom authz instead), so no `impersonatedBy`/ban columns. +**KnownDevice**: `id`, `userId`, `tokenHash`, `createdAt`, `lastSeenAt`, `userAgent` — backs the lockout exemption (AUTH-T64). +**Account**: Better Auth fields; `password` holds either a scrypt hash or an imported `bcrypt$...` hash (AUTH-T21). **Verification**: Better Auth fields. + +No NUID, phone, pronouns, or photo. Products keep extra fields keyed by SGAuth user id. Write the initial migration and a Prisma-level test that the Better Auth adapter validates against this schema. + +**Acceptance criteria** +- [ ] Migration applies cleanly on an empty Neon branch and Better Auth schema validation passes at startup. +- [ ] Email uniqueness is case-insensitive (`Alice@Northeastern.edu` and `alice@northeastern.edu` collide) with a test. +- [ ] `isPrimaryAdmin = true` implies `isAdmin = true` via a CHECK constraint. +- [ ] Indexes exist on `Session.userId`, `Session.expiresAt`, `Account.userId`, `Verification.identifier`. + +### AUTH-T11 — Positions schema: Position, UserPosition, and retired keys + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:data-model, phase:1, backend, prisma, positions +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +**Position**: `id` (uuid v7), `key` (unique, immutable, regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`, 2–64 chars), `name` (display, 2–100 chars, editable), `category` (optional text for admin grouping), `description` (optional), `deletedAt` (soft delete), `createdById`, `updatedById`, timestamps. +**UserPosition**: composite PK (`userId`, `positionId`), `assignedById`, `assignedAt`. Unassignment deletes the row; history lives in the audit log. Soft-deleting a position keeps rows for history but they are excluded from sessions. +**RetiredPositionKey**: `key`, `retiredAt`, `positionId` — written on soft delete; the service layer refuses reuse of a key retired less than 365 days ago. +DB-level: partial unique index on `Position(key) WHERE deletedAt IS NULL`; CHECK constraint on key format; trigger or service check enforcing a maximum of 50 active positions per user. + +**Acceptance criteria** +- [ ] Creating a position with an invalid key or a key retired within 365 days fails at the service layer with a specific error code (and the DB rejects malformed keys). +- [ ] A user cannot hold more than 50 active positions (test at the boundary). +- [ ] Soft-deleted positions never appear in session or JWT output (covered by AUTH-T45). + +### AUTH-T12 — Primary Admin invariants at the database level and the PrimaryAdminTransfer table + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:data-model, phase:1, backend, prisma, admin, security +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +Enforce in Postgres, independent of application code: +- Partial unique index: exactly one row may have `isPrimaryAdmin = true` (`CREATE UNIQUE INDEX one_primary_admin ON "User" ((true)) WHERE "isPrimaryAdmin"`). +- Trigger `protect_primary_admin` BEFORE UPDATE/DELETE on User: reject setting `isAdmin = false`, `status = DEACTIVATED`, or deleting the row while `isPrimaryAdmin = true`; reject clearing `isPrimaryAdmin` unless the transaction has set the session variable `sgauth.pa_transfer = 'on'` (set only by the transfer service and the break-glass script). +**PrimaryAdminTransfer**: `id`, `fromUserId`, `toUserId`, `status` (PENDING_ACCEPTANCE | COOLING | COMPLETED | CANCELLED | EXPIRED), `createdAt`, `acceptanceExpiresAt` (+7 days), `acceptedAt`, `executesAt` (acceptedAt + 24 h), `cancelTokenHash`, `completedAt`, `cancelledAt`, `cancelledById`, `reason`. Partial unique index allowing at most one transfer in PENDING_ACCEPTANCE or COOLING. + +**Acceptance criteria** +- [ ] SQL tests: inserting a second Primary Admin fails; updating the PA to non-admin fails; deleting the PA fails; a transfer inside a transaction with the session variable set succeeds and leaves exactly one PA. +- [ ] At most one in-flight transfer can exist (unique index test). +- [ ] Migration is reversible and documented with the reasoning in a comment header. + +### AUTH-T13 — Append-only AuditEvent table + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:data-model, phase:1, backend, prisma, audit +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +**AuditEvent**: `id` (uuid v7), `type` (string enum from the event catalog in AUTH-T73), `actorUserId` (nullable; null for system/break-glass), `actorType` (USER | SYSTEM | BREAK_GLASS), `targetType` (USER | POSITION | PRODUCT | SESSION | TRANSFER), `targetId`, `metadata` (jsonb, no secrets), `ip`, `userAgent`, `createdAt`. Index on (`type`, `createdAt`), (`actorUserId`, `createdAt`), (`targetType`, `targetId`, `createdAt`). +Append-only: a trigger rejects UPDATE and DELETE except for the anonymization job (AUTH-T78), which may null `ip`/`userAgent` and replace user-identifying metadata when a user is tombstoned, gated by the same session-variable pattern as AUTH-T12. The session-variable gate protects against accidental writes from application code; it is not a security boundary against anyone holding the database role. +`type` is validated in TypeScript against the catalog (AUTH-T73), not by a DB CHECK, so adding an event type does not require a migration. + +**Acceptance criteria** +- [ ] UPDATE/DELETE on AuditEvent fails from the runtime role (test), while the anonymization path succeeds. +- [ ] A unit test rejects emitting an event whose `type` is not in the catalog. + +### AUTH-T14 — Product registry schema + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:data-model, phase:2, backend, prisma, registry +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +**Product**: `id`, `slug` (unique, kebab-case), `name`, `description`, `baseUrl` (must be `https://` and end with `.northeasternsga.com`; validated in service and by CHECK), `iconUrl` (optional, https only), `isActive`, `sortOrder`, `visibleToPositionKeys` (text[]; empty = visible to everyone), `loginRedirectPath` (optional path appended after login), `createdById`, timestamps. Seed the dev branch with SGAuth itself plus VaultZ, Chambers, Aplio placeholders. + +**Acceptance criteria** +- [ ] Inserting a product with an http:// or non-northeasternsga.com base URL fails. +- [ ] Slug uniqueness enforced; renames of `name` do not change `slug`. + +### AUTH-T15 — Security tables: AccountLock, UnlockToken, Jwks, TwoFactor + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:data-model, phase:1, backend, prisma, security +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +- **AccountLock**: `userId` (PK), `failedCount`, `windowStartedAt`, `lockedUntil`, `lockLevel` (0..N for escalation), `lastFailedAt`, `lastFailedIp`. +- **UnlockToken**: `id`, `userId`, `tokenHash`, `expiresAt`, `usedAt`. +- **Jwks** and **TwoFactor** tables as required by the Better Auth jwt and twoFactor plugins (generate via `npx @better-auth/cli generate` and reconcile with Prisma naming). +- **KnownDevice** (see AUTH-T10) for the lockout exemption. +- No rate-limit table: rate limiting uses Upstash (AUTH-T63); disable Better Auth's DB-backed limiter storage. + +**Acceptance criteria** +- [ ] Plugin schema validation passes for jwt and twoFactor at startup. +- [ ] Unlock tokens are stored hashed (SHA-256) and single-use (test). + +### AUTH-T16 — Migration workflow: deploy in build, never migrate dev against production + +**Priority:** Medium · **Estimate:** 1 · **Phase:** Phase 0 — Foundation · **Labels:** epic:data-model, phase:0, backend, prisma, docs +**Depends on:** AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)); AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching) + +Document and enforce: developers run `prisma migrate dev` only against personal/preview Neon branches; production and dev deployments run `prisma migrate deploy` during the Vercel build using `DIRECT_URL`. Add a guard script that aborts `migrate dev`/`db push`/`migrate reset` when `SGAUTH_ENV=production` or when `DIRECT_URL` points at the `main` branch host. Write `docs/MIGRATIONS.md` including how to hand-edit generated SQL for triggers/indexes Prisma cannot express, and the **expand/contract rule**: migrations run during the build while the previous deployment is still serving traffic, so every migration must be backward-compatible with the currently deployed code (add columns nullable first, drop in a later release). + +**Acceptance criteria** +- [ ] Running `npm run prisma:migrate-dev` with a production `DIRECT_URL` exits non-zero before touching the database (test with a fake host). +- [ ] Vercel production build logs show `migrate deploy` applying pending migrations. + +## E3 Authentication Core + +### AUTH-T17 — Email + password sign-in and self-sign-up restricted to northeastern.edu + +**Priority:** Urgent · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:auth-core, phase:1, backend, better-auth +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler); AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +Enable `emailAndPassword` in Better Auth: `minPasswordLength: 12`, `maxPasswordLength: 128`, default scrypt hashing, `requireEmailVerification: true`, `autoSignIn: false` after sign-up. +- Self-sign-up: a `databaseHooks.user.create.before` hook rejects emails whose domain is not `northeastern.edu` (exact match, lower-cased) unless the creation is admin/import initiated (flag passed via context). Admin-created users may have any domain. +- Normalize email (trim, lower-case) before lookup and storage. +- New self-signed-up users have no positions and no admin flags. +- Deactivated users are refused at sign-in and at session creation (`session.create.before` hook) with a stable error code. +- Record `lastLoginAt` and emit audit events for LOGIN_SUCCESS / LOGIN_FAILED (AUTH-T73). +- **Account pre-hijack defense (red-team):** with `requireEmailVerification`, Better Auth answers a sign-up for an existing email with success (enumeration protection). If that existing account is still **unverified**, the new sign-up must overwrite its password and name (the address owner is whoever verifies), and verification must revoke every existing session. Verified accounts are never overwritten. +- Note: Better Auth re-sends the verification email on every sign-in attempt by an unverified user; the mailer's per-recipient caps (AUTH-T20) and sign-in rate limits (AUTH-T63) bound the abuse. +No breached-password (HIBP) check, per decision. + +**Acceptance criteria** +- [ ] Sign-up with `x@gmail.com` is rejected with `EMAIL_DOMAIN_NOT_ALLOWED`; `x@northeastern.edu` succeeds and requires verification before login. +- [ ] Passwords under 12 characters are rejected client- and server-side. +- [ ] A deactivated user cannot sign in and any existing session returns 401 from the session endpoint. +- [ ] Pre-hijack test: attacker signs up with victim's email + password A; victim signs up with password B and verifies; password A no longer works and B does. +- [ ] Integration tests cover success, wrong password, unverified email, deactivated user, and domain rejection. + +### AUTH-T18 — Email verification for self-sign-up with resend limits + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:auth-core, phase:1, backend, email +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) + +Send a verification email on sign-up via the mailer (AUTH-T20) with a link valid for 24 hours; the link lands on a confirmation page and the token is consumed only on the button POST (AUTH-T103, Safe Links). Verifying revokes any pre-existing sessions, logs the user in, and redirects to the validated `redirect` target (AUTH-T31) or the account page. Resend is limited to 3 per address per hour (Upstash key `verify:`). Unverified accounts older than 7 days are purged by the retention job (AUTH-T78). The UI shows the same message whether or not the address exists. + +**Acceptance criteria** +- [ ] Clicking the link verifies the address once; a second click shows an already-verified message; expired links show a resend option. +- [ ] Fourth resend within an hour returns 429 without sending. + +### AUTH-T19 — Password reset flow + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:auth-core, phase:1, backend, email, security +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) + +Forgot-password request (email only; uniform response regardless of existence), reset token valid 1 hour, single use, delivered by email; the token is consumed only when the new-password form is submitted, never on link open (AUTH-T103). On successful reset: set `passwordChangedAt`, revoke all other sessions, clear any account lock, send a security notice email, emit PASSWORD_RESET audit event. Rate limit: 3 requests per email per 10 minutes and per IP (AUTH-T63). Imported bcrypt accounts (AUTH-T21) reset to scrypt. + +**Acceptance criteria** +- [ ] Reset for a non-existent email returns the same response and timing profile (within 50 ms) as for an existing one. +- [ ] Using a token twice fails; after reset, previously issued session cookies are rejected. +- [ ] Audit and notice email are produced (asserted with the test mailer). + +### AUTH-T20 — Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:auth-core, phase:1, backend, email, infra +**Depends on:** AUTH-T07 (Validate environment variables at startup with zod) + +Password login keeps email volume low (verification, reset, invite, lock/unlock, security notices, PA transfer steps). To avoid sharing Aplio's and Chambers' Resend quota: +- Separate Resend API key and a dedicated sending domain `mail.northeasternsga.com` with SPF, DKIM, and DMARC (p=quarantine) records; `from` = `SGA Auth `. +- `src/lib/email/mailer.ts` provider-agnostic interface (`sendEmail({ to, template, data })`) with a Resend implementation and a console/preview transport for local/test. +- Templates (React Email or plain HTML+text): verify, reset, invite/set-password, account locked, unlock, security notice (password changed / new admin grant), PA transfer initiated / accepted / cancelled / completed, inactivity notice. +- Caps: per-recipient 10 emails per hour and 30 per day; global daily cap (env, default 500) with an alert at 80%. Log every send with template and recipient hash to the log stream. + +**Acceptance criteria** +- [ ] DKIM/SPF/DMARC verified in the Resend dashboard; a test email to Gmail and Outlook lands in the inbox with aligned DMARC. +- [ ] Exceeding the per-recipient cap is refused with a logged warning, not an exception to the user. +- [ ] All templates render in both HTML and text and are snapshot-tested. + +### AUTH-T21 — Accept imported Chambers bcrypt hashes with lazy re-hash to scrypt + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:auth-core, phase:3, backend, migration, security +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu) + +Supabase Auth (GoTrue) stores bcrypt hashes (`$2a$`/`$2b$`). Configure Better Auth `emailAndPassword.password.verify`: if the stored hash starts with `bcrypt$` (marker set by the import, AUTH-T87), verify with `bcryptjs`; on success, re-hash the plaintext with scrypt and update the Account row in the same request, then emit PASSWORD_REHASHED. Otherwise use the default scrypt verify. Never log plaintext. Remove the bcrypt path after all imported accounts have re-hashed or been reset (tracked by a metric). + +**Acceptance criteria** +- [ ] A user imported with a bcrypt hash can log in with their Chambers password on the first try and their Account row is scrypt afterwards. +- [ ] Wrong password against a bcrypt hash fails without re-hashing. +- [ ] A metric/query reports how many `bcrypt$` hashes remain. + +### AUTH-T22 — Invite flow: admin-created users receive a set-password link + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:auth-core, phase:2, backend, email, admin +**Depends on:** AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) + +When an admin creates a user (or the bulk import runs), create the User with `emailVerified = true` (admin vouches for the address), no password, and send an invite email with a set-password token valid 7 days (consumed on form submit, AUTH-T103). Setting the password marks the account ready and logs the user in. Admins can resend an invite (rate-limited 3/day per user). Users who never accept are listed in the admin UI as 'Invited'. If an invited (password-less) user tries to self-sign-up with the same email, the login page copy points them to 'Forgot password / set password' rather than creating a second account. + +**Acceptance criteria** +- [ ] An invited user cannot log in with any password until they set one via the link. +- [ ] Expired invite shows a message and the admin sees a 'Resend invite' action. + +### AUTH-T23 — Change password (current password + re-auth), revoke other sessions + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:auth-core, phase:2, backend, security +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions) + +Authenticated users change their password by providing the current password; requires a fresh re-authentication (AUTH-T32). On success: update hash, set `passwordChangedAt`, revoke every other session, send a security notice, emit PASSWORD_CHANGED. + +**Acceptance criteria** +- [ ] Wrong current password fails and counts toward account lockout. +- [ ] Other devices are logged out after change (integration test with two sessions). + +### AUTH-T24 — Admin-initiated email change with re-verification and privilege rules + +**Priority:** Low · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:auth-core, phase:3, backend, admin +**Depends on:** AUTH-T36 (Admin user-management endpoints); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps) + +Users cannot change their own email in v1 (identity anchoring). Admins can set a new email on a **non-admin** user; only the Primary Admin can change an admin's email; the Primary Admin's own email can be changed only by the Primary Admin. Every email change requires fresh re-authentication (AUTH-T32) so a hijacked session cannot redirect reset or transfer-cancel emails. The account keeps working with the old address until the user verifies the new one via a link sent to the new address (POST-consumed, AUTH-T103); a notice goes to the old address. Changing a `legacyEmail` account to a northeastern.edu address clears the flag. Audit EMAIL_CHANGE_REQUESTED / EMAIL_CHANGED. + +**Acceptance criteria** +- [ ] Old address remains valid until verification; after verification the old address cannot log in. +- [ ] Attempting to change to an address already in use fails without revealing which account owns it. +- [ ] An admin cannot change another admin's or the PA's email (authz test); re-auth is required in every case. + +### AUTH-T25 — Login, sign-up, forgot/reset, and verify pages + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:auth-core, phase:1, frontend, ui +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T18 (Email verification for self-sign-up with resend limits); AUTH-T19 (Password reset flow); AUTH-T31 (Safe post-login redirects validated against the product registry) + +Build the public auth pages with shadcn/ui and react-hook-form + zod: `/login` (email, password, preserves redirect param, links to forgot and sign-up), `/sign-up` (northeastern.edu hint, name, password with live length feedback), `/forgot-password`, `/reset-password/[token]`, `/verify-email/[token]`, `/set-password/[token]` (invites), `/locked` (explains lock and unlock email). Map Better Auth error codes to friendly copy without leaking account existence. Show SGA branding and a one-line explanation that this login works across all SGA tools. + +**Acceptance criteria** +- [ ] All pages are keyboard navigable, pass axe with no serious violations, and work at 360 px width. +- [ ] Wrong password and unknown email share the same copy; unverified, locked, and deactivated states have distinct copy only when shown to the account's own verified session or email link. +- [ ] After login the user lands on the validated redirect target or `/account`. + +### AUTH-T99 — Spike: Northeastern Microsoft Entra ID sign-in feasibility + +**Priority:** Low · **Estimate:** 3 · **Phase:** Phase 5 — Backlog / spikes · **Labels:** epic:auth-core, phase:5, spike, sso +**Depends on:** AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Not a launch dependency. Investigate whether SGA can (a) get an app registered in Northeastern's Entra tenant via ITS, or (b) register a multi-tenant app in an SGA-owned tenant that Northeastern's tenant permits users to consent to, restricting sign-in to Northeastern's tenant id. If viable, prototype Better Auth's `microsoft` social provider behind a feature flag, linking to existing accounts by verified email. Report blockers, MFA inheritance (Duo), and the account-linking policy. + +**Acceptance criteria** +- [ ] Written findings with a go/no-go recommendation and, if go, a follow-up ticket set. + +### AUTH-T100 — Backlog: optional email OTP login (deferred due to email volume) + +**Priority:** Low · **Estimate:** 2 · **Phase:** Phase 5 — Backlog / spikes · **Labels:** epic:auth-core, phase:5, backlog, email +**Depends on:** AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Deferred by decision: password is the launch method because Resend free-tier volume is constrained. Revisit after launch: Better Auth `emailOTP` plugin as an alternative sign-in for users who forget passwords, with strict per-user caps. Requires a volume estimate against the org-wide Resend budget first. + +**Acceptance criteria** +- [ ] Decision recorded after reviewing 60 days of email volume metrics. + +### AUTH-T103 — Scanner-safe email links: land on a page, consume the token on POST + +**Priority:** Urgent · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:auth-core, phase:1, backend, email, security +**Depends on:** AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps) + +Northeastern mail is Microsoft 365, and Defender Safe Links pre-fetches every link in incoming mail. A link that acts on GET (verify, reset, invite/set-password, unlock, PA transfer accept/cancel) would be consumed by the scanner before the user clicks. Rule for every emailed link in SGAuth: the URL opens a page that shows what is about to happen and a button; the token is validated for display on GET (never consumed, never marks anything) and consumed only on the button's POST (same-origin, CSRF-protected). Better Auth's built-in verify-email link acts on GET, so send our own URL (`/verify-email?token=`) that renders the confirmation page and calls the Better Auth verification endpoint on submit. HEAD requests and known scanner user agents get a 200 with no side effects. Provide one shared `TokenActionPage` component and a helper used by AUTH-T18, T19, T22, T24, T37, T64. + +**Acceptance criteria** +- [ ] A HEAD or GET request to any emailed link does not consume the token (integration test); the subsequent POST does, exactly once. +- [ ] Every email template's link points at a page implementing the pattern (test enumerates templates). + +## E4 Sessions & SSO + +### AUTH-T26 — Parent-domain session cookie for *.northeasternsga.com + +**Priority:** Urgent · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:sessions, phase:1, backend, sso, security +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler) + +Configure Better Auth cookies so a single session spans every SGA subdomain: +- `advanced.crossSubDomainCookies = { enabled: true, domain: 'northeasternsga.com' }` in production and dev; `advanced.cookiePrefix` = `sgauth` (production) and `sgauth-dev` (dev deployment) so the two environments never collide on the shared parent domain. +- `defaultCookieAttributes`: `httpOnly: true`, `secure: true`, `sameSite: 'lax'`, `path: '/'`. Production cookie name becomes `__Secure-sgauth.session_token`. `__Host-` is impossible with a Domain attribute; document why. +- `session.cookieCache` disabled (no `session_data` cookie) so revocation is immediate. +- `SGAUTH_ENV=preview` (SGAuth's own `*.vercel.app` previews): cross-subdomain cookies **disabled**, host-only cookie, so previews can log in at all. +- Local dev uses the topology from AUTH-T34. Validate the whole configuration early with the spike in AUTH-T104 (there are unresolved community reports of cross-subdomain cookies being set then dropped in some setups). +Document the threat model: any compromised or dangling `*.northeasternsga.com` host can read the cookie **and can set a same-named cookie on the parent domain** (cookie tossing / login CSRF: the victim is silently logged into an attacker-controlled account). Cookie values are signed so they cannot be forged, but `__Host-` cannot be used with a Domain attribute, so subdomain hygiene (AUTH-T68) is the control. + +**Acceptance criteria** +- [ ] After login at auth.northeasternsga.com the browser holds one cookie with Domain=northeasternsga.com, Secure, HttpOnly, SameSite=Lax, and the `__Secure-` prefix. +- [ ] A request to a product stub on another subdomain carries the cookie and the session endpoint resolves it. +- [ ] Dev and production cookies coexist in one browser without interfering (different names). + +### AUTH-T27 — Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:sessions, phase:1, backend, sso +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables); AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +`session.expiresIn = 30 days`, `session.updateAge = 1 day` (sliding). Leave Better Auth's `freshAge` at its default: it gates Better Auth's own fresh-session endpoints and is **not** the re-auth mechanism (AUTH-T32 uses `lastReauthAt`). Absolute cap: set `absoluteExpiresAt = createdAt + 90 days` in `session.create.before`, reject sessions past it in the session endpoint, **and** have the daily retention job (AUTH-T78) delete any session whose `createdAt` is older than 90 days, because Better Auth's own endpoints (e.g. `/token`) do not run the custom check. Capture IP (from `x-forwarded-for` first hop on Vercel) and user agent. Sessions and JWTs are DB-backed; no cookie cache. + +**Acceptance criteria** +- [ ] A session used daily is still valid on day 29 and invalid on day 91 (time-travel test), including at `/api/auth/token`. +- [ ] A session unused for 31 days is invalid. +- [ ] Session rows store IP and user agent for the account page. + +### AUTH-T28 — Session endpoint for products: user, email, positions, admin flags + +**Priority:** Urgent · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:sessions, phase:1, backend, sso, api +**Depends on:** AUTH-T27 (Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache); AUTH-T11 (Positions schema: Position, UserPosition, and retired keys) + +Products resolve the shared cookie by calling SGAuth server-side. Implement `GET /api/sgauth/session` (a thin wrapper over Better Auth `getSession` plus the `customSession` plugin) returning: +```json +{ "user": { "id": "uuid", "email": "...", "name": "...", "preferredName": null, "isAdmin": false, "isPrimaryAdmin": false }, + "positions": [{ "key": "vp-finance", "name": "Vice President of Finance" }], + "session": { "id": "...", "expiresAt": "...", "absoluteExpiresAt": "...", "createdAt": "..." } } +``` +- 401 with `{ "error": "unauthenticated" }` when the cookie is missing, expired, revoked, past the absolute cap, or the user is deactivated. +- Positions read live from the DB (active, non-deleted), sorted by key. +- Headers: `Cache-Control: no-store`, `Vary: Cookie`. Accept the cookie via the `Cookie` header only (no query/body tokens). +- Also expose `GET /api/sgauth/session/minimal` returning only user id + positions for hot paths. Publish the JSON schema in the SDK. + +**Acceptance criteria** +- [ ] Contract tests cover the 200 shape, 401 cases, position ordering, and that deactivation/revocation is reflected on the very next call. +- [ ] p95 latency under 120 ms from a Vercel function in the same region (measured against dev). +- [ ] Response never includes password hashes, tokens, or MFA secrets (schema assertion). + +### AUTH-T29 — ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sessions, phase:2, backend, sso, supabase, jwt +**Depends on:** AUTH-T28 (Session endpoint for products: user, email, positions, admin flags); AUTH-T15 (Security tables: AccountLock, UnlockToken, Jwks, TwoFactor) + +Add the Better Auth `jwt` plugin configured for Supabase third-party auth: +- `jwks.keyPairConfig = { alg: 'ES256' }`; **automatic rotation disabled** (no `rotationInterval`). Supabase learns about a new key only when it re-fetches the JWKS (up to ~30 minutes), so an automatic rotation would reject fresh tokens for that window. Rotation is manual (annually or on incident) per the runbook in AUTH-T69: generate the new key, push the combined JWKS to every Supabase project via `custom_jwks`, then switch signing; keep the old key published for 7 days. +- `jwt.issuer = 'https://auth.northeasternsga.com'` (no trailing slash; discovery must resolve at `{issuer}/.well-known/openid-configuration`), `jwt.audience = 'authenticated'` (matches Supabase's own token convention so any audience check passes), `jwt.expirationTime = '10m'`, `getSubject = user.id` (UUID, required because Supabase `auth.uid()` casts `sub` to uuid). Private keys encrypted at rest (default). +- `definePayload`: `{ email, name, role: 'authenticated', positions: [keys], is_admin, is_primary_admin }`. Include the `kid` header (plugin default). `role: 'authenticated'` is required so Supabase maps the request to the `authenticated` Postgres role. +- Endpoints: `/api/auth/jwks` (plugin) and rewrites for `/.well-known/jwks.json` and `/.well-known/openid-configuration` (`issuer`, `jwks_uri`, `id_token_signing_alg_values_supported: ['ES256']`, minimal fields) so Supabase can be pointed at the issuer URL. +- Token endpoint `/api/auth/token` requires a valid session cookie (rate-limited in AUTH-T63). + +**Acceptance criteria** +- [ ] A token from `/api/auth/token` verifies with `jose` against the JWKS with issuer and audience checks and contains `sub` (uuid), `role: 'authenticated'`, and `positions`. +- [ ] `/.well-known/openid-configuration` returns valid JSON with `jwks_uri` resolving to the live key set. +- [ ] After a manual rotation on dev, tokens signed by the previous key verify for 7 days and new tokens carry the new `kid`; the JWKS lists both keys during the overlap. +- [ ] Token size stays under 2 KB with 50 positions of 64 characters (test). + +### AUTH-T30 — Global logout, sign out everywhere, and admin revocation + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:sessions, phase:1, backend, sso +**Depends on:** AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com); AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime) + +- `POST /api/auth/sign-out` deletes the current session row and clears the parent-domain cookie (same Domain/Path/prefix, `Max-Age=0`); every subdomain is logged out immediately because they share the cookie and the DB row is gone. +- `POST /api/sgauth/sessions/revoke-all` (user) revokes all sessions including the current one. +- Admin endpoint to revoke all sessions of a target user (AUTH-T36) with audit SESSIONS_REVOKED. +- `/logout?redirect=` convenience route for products: rejects requests whose `Sec-Fetch-Site` is `cross-site` (so an external page cannot log users out via an image or link), signs out, then redirects to a registry-validated URL (AUTH-T56) or to `/login`. Sibling subdomains are same-site and keep working. +Supabase-style JWTs already issued remain valid until their 10-minute expiry; document this in the Supabase guide. + +**Acceptance criteria** +- [ ] After sign-out on one subdomain, the session endpoint returns 401 for a request from another subdomain using the same browser. +- [ ] `/logout?redirect=https://evil.example` redirects to `/login`, not the attacker URL. +- [ ] Admin revocation invalidates every session of the target within one request. + +### AUTH-T31 — Safe post-login redirects validated against the product registry + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:sessions, phase:1, backend, security, sso +**Depends on:** AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime) + +`/login?redirect=` is the entry point every product uses. Validate: absolute `https://` URL whose origin exactly matches an active product's base URL origin (or SGAuth itself), or a relative path starting with `/` (not `//`). Strip fragments; cap length at 2 KB. Persist the redirect through sign-up, verification, and reset via a short-lived signed cookie rather than hidden form fields. Unknown or malformed targets fall back to `/account`. + +**Acceptance criteria** +- [ ] Open-redirect test suite (protocol-relative, backslashes, userinfo tricks, unicode homographs, unregistered subdomains) all land on `/account`. +- [ ] A valid `https://vaultz.northeasternsga.com/purchases/42` survives login → verify → login and is honored. + +### AUTH-T32 — Re-authentication (sudo mode) for sensitive actions + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sessions, phase:2, backend, security +**Depends on:** AUTH-T27 (Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache); AUTH-T67 (TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)) + +Sensitive actions require proof of presence within the last 10 minutes: Primary Admin transfer steps, granting/revoking admin, changing password, changing any email, enrolling/disabling/resetting MFA, revoking all sessions, deleting/deactivating users. Implement `POST /api/sgauth/reauth` accepting password (and TOTP code if enrolled) that stamps `Session.lastReauthAt`; a `requireFreshAuth()` guard checks the stamp. This is independent of Better Auth's `freshAge` (left at default). The UI shows a modal to re-enter credentials when the guard fails (403 `REAUTH_REQUIRED`). Failed re-auth attempts count toward lockout. + +**Acceptance criteria** +- [ ] Calling a sensitive endpoint 11 minutes after re-auth returns 403 `REAUTH_REQUIRED`; within 10 minutes it succeeds. +- [ ] Re-auth for an MFA-enrolled user requires both password and a valid TOTP. + +### AUTH-T33 — List and revoke the user's own sessions + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sessions, phase:2, backend +**Depends on:** AUTH-T27 (Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache) + +`GET /api/sgauth/sessions` returns the caller's active sessions (id, createdAt, lastActiveAt, ip, userAgent parsed to a friendly device string, isCurrent). `DELETE /api/sgauth/sessions/:id` revokes one of the caller's sessions. Audit SESSION_REVOKED. + +**Acceptance criteria** +- [ ] A user cannot list or revoke another user's session (404, not 403, to avoid id probing). +- [ ] Revoking the current session also clears the cookie. + +### AUTH-T34 — Non-production SSO topology: dev deployment, local hostnames, product preview domains + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:sessions, phase:1, infra, sso, docs +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching); AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +A parent-domain cookie cannot be read by `*.vercel.app` previews or plain `localhost`, so define the non-production topology once: +- **Dev SGAuth**: `auth-dev.northeasternsga.com` (branch `dev`, Neon `dev` branch, cookie prefix `sgauth-dev`). Products point their preview and dev environments here. Because it is a production-looking login page on the trusted domain: persistent 'DEVELOPMENT ENVIRONMENT' banner on every page, `noindex`, synthetic users only (real user imports are never run against dev), and fully separate secrets (Better Auth secret, Resend key, Upstash namespace). +- **Product previews**: each product gets a stable branch domain like `-dev.northeasternsga.com` (Vercel branch domain) so the dev cookie is shared; ad-hoc `*.vercel.app` previews cannot use SSO (document the limitation). +- **Local**: developers use `*.localhost` hostnames (Chrome resolves subdomains of localhost automatically; Firefox needs a preference) or hosts-file entries; SGAuth locally runs on `http://auth.sga.localhost:3000` with cookie domain `sga.localhost`, `secure: false`, no `__Secure-` prefix. Provide a `SGAUTH_ENV=local` switch that applies these settings. Browser handling of `Domain=.localhost` cookies differs (Safari is the usual problem); the spike in AUTH-T104 confirms the local scheme or falls back to a hosts-file domain such as `sga.test`. +- **SDK dev mode**: the SDK accepts `SGAUTH_URL` and `SGAUTH_COOKIE_NAME` overrides so products target dev or local SGAuth. Write `docs/ENVIRONMENTS.md` with a table of URLs, cookie names, and Neon branches per environment. + +**Acceptance criteria** +- [ ] A product stub running on `vaultz.sga.localhost:3001` sees a session created at `auth.sga.localhost:3000` (documented manual test plus a Playwright job in AUTH-T96). +- [ ] `docs/ENVIRONMENTS.md` exists and is linked from README and the integration guides. +- [ ] Production configuration cannot be started with a non-secure cookie setting (startup assertion). + +### AUTH-T104 — Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them + +**Priority:** Urgent · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:sessions, phase:1, spike, sso, better-auth +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching); AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +Community reports (better-auth issues #5611, #3938) describe cross-subdomain cookies being set and then dropped in some configurations. Before any product integration work, deploy the minimal Better Auth config to `auth-dev.northeasternsga.com` and a static stub on `stub-dev.northeasternsga.com`, then verify in Chrome, Firefox, and Safari: (1) login sets exactly one `__Secure-sgauth-dev.session_token` cookie with `Domain=northeasternsga.com`; (2) the stub's server receives it and the session endpoint resolves it; (3) sign-out clears it on both hosts; (4) the `SGAUTH_ENV=preview` host-only mode works on a `*.vercel.app` preview; (5) the local `*.sga.localhost` scheme works in all three browsers or the fallback (hosts-file `sga.test`) is adopted; (6) a stale cookie with the same name set by the stub host (cookie tossing) is observed and its effect documented. Record findings in `docs/ENVIRONMENTS.md` and adjust AUTH-T26/T34 accordingly. + +**Acceptance criteria** +- [ ] Findings documented per browser with screenshots or HAR excerpts; any Better Auth version pin or workaround recorded. +- [ ] Go/no-go on the parent-domain cookie approach signed off by the SGAuth lead. + +## E5 Admin & Primary Admin + +### AUTH-T35 — Authorization module with the admin/Primary Admin rule matrix + +**Priority:** Urgent · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:admin, phase:2, backend, admin, security +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables); AUTH-T12 (Primary Admin invariants at the database level and the PrimaryAdminTransfer table) + +Create `src/lib/authz.ts`: pure functions `can(actor, action, target)` used by every mutating endpoint. Rules: +- Only admins may perform admin actions; deactivated actors can do nothing. +- Admins may grant or revoke admin for **other** users freely; they can never change their **own** admin status or deactivate/delete themselves. +- Nobody except the transfer flow or break-glass may modify the Primary Admin's admin flag, status, PA flag, MFA, or email, or delete the account. The PA's positions and display name may be edited by any admin like any other user (accepted: an admin could strip the PA's product positions, which affects product access only, never SGAuth authority, and is audited). +- Email changes: admins may change non-admin users' emails; only the PA may change an admin's email; the PA changes their own. MFA reset: admins may reset non-admin and (only the PA) admin MFA; PA MFA reset is break-glass only. +- The Primary Admin may do everything an admin can, including changing other admins, and is the only one who can initiate a transfer. +- Position and product CRUD: any admin. +Return structured denials (`{ allowed: false, reason: 'SELF_MODIFICATION' | 'PRIMARY_ADMIN_PROTECTED' | 'NOT_ADMIN' | ... }`). UI hides controls using the same function, but enforcement is server-side only. + +**Acceptance criteria** +- [ ] Table-driven unit tests enumerate actor ∈ {user, admin, primary admin, deactivated admin} × target ∈ {self, other user, other admin, primary admin} × action ∈ {grantAdmin, revokeAdmin, deactivate, delete, assignPosition, initiateTransfer} with expected results; 100% branch coverage of `authz.ts`. +- [ ] Every mutating route imports `can()`; an ESLint rule or test asserts no admin route lacks the guard. + +### AUTH-T36 — Admin user-management endpoints + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:admin, phase:2, backend, admin, api +**Depends on:** AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T13 (Append-only AuditEvent table); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T22 (Invite flow: admin-created users receive a set-password link) + +Server actions or route handlers under `/api/sgauth/admin/users`: list/search (by email, name, position, status, admin flag; paginated), get, create (invite), update name/preferredName, deactivate (revokes all sessions immediately), reactivate, delete (= tombstone: status DELETED, PII scrubbed, sessions/accounts/MFA/positions removed, id retained), grant admin, revoke admin, revoke all sessions, unlock account, reset MFA (per the authz rules; emails the user), resend invite, force re-login. Every call passes `can()`, requires fresh re-auth for grant/revoke admin, deactivate, delete, and MFA reset, and emits an audit event with actor, target, and diff. Deactivation/deletion of the PA and self-modification are refused with the authz reason. + +**Acceptance criteria** +- [ ] Integration tests for every endpoint including denials (self-modify, PA-protected, non-admin, deactivated actor). +- [ ] Deactivating a user with three active sessions leaves zero sessions and their next product request returns 401. +- [ ] Each mutation produces exactly one audit row with the expected type and metadata. + +### AUTH-T37 — Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window) + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:admin, phase:2, backend, admin, security, email +**Depends on:** AUTH-T12 (Primary Admin invariants at the database level and the PrimaryAdminTransfer table); AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T67 (TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST); AUTH-T38 (Scheduled-job runner: GitHub Actions schedules calling secret-protected routes) + +Implement the guarded transfer as a state machine over `PrimaryAdminTransfer`: +1. **Initiate** (PA only, fresh re-auth, recipient must be an active admin with MFA enrolled, PA must type the recipient email exactly): creates PENDING_ACCEPTANCE (expires in 7 days), emails recipient (accept link) and PA (confirmation), audit PA_TRANSFER_INITIATED. +2. **Accept** (recipient only, fresh re-auth): moves to COOLING, sets `executesAt = now + 24h`, generates a cancel token emailed to the outgoing PA (and shown in-app), emails both, audit PA_TRANSFER_ACCEPTED. +3. **Cancel**: outgoing PA (in-app with re-auth, or via the emailed cancel link without login — the link opens a page with a Cancel button; the token is consumed on POST, AUTH-T103), or recipient declines; status CANCELLED, both emailed, audit PA_TRANSFER_CANCELLED. The accept link likewise lands on a page requiring login + re-auth before acting. +4. **Execute** (scheduler, AUTH-T38): when `executesAt` has passed and status is COOLING, in one transaction with the DB session variable set: clear PA on the old user, set it on the recipient, keep both as admins, revoke all sessions of both users (forces re-login with correct claims), status COMPLETED, emails to both and to every admin, audit PA_TRANSFER_COMPLETED. +5. **Expire**: PENDING_ACCEPTANCE older than 7 days → EXPIRED, emails PA. +Exactly one in-flight transfer is allowed. If the recipient loses admin or is deactivated mid-flight, the transfer is cancelled automatically. + +**Acceptance criteria** +- [ ] State-machine tests cover every transition and every illegal transition (e.g. accept by a third party, cancel after completion, initiate while one is in flight). +- [ ] The emailed cancel link works without a session and is single-use. +- [ ] After execution there is exactly one PA, both parties are admins, and both must log in again. +- [ ] Recipient deactivation during COOLING cancels the transfer and notifies the PA. + +### AUTH-T38 — Scheduled-job runner: GitHub Actions schedules calling secret-protected routes + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:admin, phase:1, backend, infra, ci +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching); AUTH-T07 (Validate environment variables at startup with zod) + +Vercel Hobby limits cron to two jobs at once-per-day and rejects finer schedules at deploy time, so **no Vercel cron is used**. Instead, a GitHub Actions workflow in the auth repo (same pattern Chambers uses) runs on schedules and calls `GET /api/jobs/` with `Authorization: Bearer $CRON_SECRET` (repo secret = Vercel env): +- `pa-transfer` every 15 minutes: executes due COOLING transfers and expires stale PENDING_ACCEPTANCE ones, idempotently (row-level lock, status re-check inside the transaction). +- `alerts` every 15 minutes (AUTH-T77). +- `retention` daily (AUTH-T78). +Each route is idempotent, logs outcomes, and returns quickly (under the function timeout); Actions schedules can be delayed several minutes under load, which is acceptable for these jobs. A missed run is caught by the next. + +**Acceptance criteria** +- [ ] Running the transfer job twice concurrently executes the transfer once (test with a simulated race). +- [ ] Requests without the correct bearer secret return 401 and do nothing. +- [ ] Workflow file exists with the three schedules; `vercel.json` contains no `crons`. + +### AUTH-T39 — Break-glass Primary Admin recovery script and runbook + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:admin, phase:2, backend, admin, security, docs +**Depends on:** AUTH-T12 (Primary Admin invariants at the database level and the PrimaryAdminTransfer table); AUTH-T13 (Append-only AuditEvent table) + +`scripts/recover-primary-admin.ts` run by a human with production Neon credentials (`npm run recover-primary-admin -- --email new-pa@northeastern.edu --reason "..."`). It: verifies the target user exists and is active; prompts for the confirmation phrase `TRANSFER PRIMARY ADMIN`; inside one transaction with the DB session variable set, moves the PA flag, ensures the new PA is admin, revokes all sessions of the previous PA; writes an AuditEvent with `actorType = BREAK_GLASS` and the operator's name/reason; emails every admin and the old PA address. Supports `--dry-run`. A second mode, `--reset-mfa --email `, clears the current PA's TOTP and backup codes (for the lost-phone-and-lost-codes case, since admins cannot touch PA MFA) and forces re-enrollment on next login. No HTTP endpoint exists for either. Write `docs/runbooks/break-glass.md`: when to use it, who holds credentials (at least two people), and how to verify afterwards. + +**Acceptance criteria** +- [ ] Dry run prints the plan and changes nothing; real run leaves exactly one PA and an audit row of type BREAK_GLASS_PA_RECOVERY. +- [ ] Script refuses to run without `DIRECT_URL` and the confirmation phrase. +- [ ] Runbook reviewed by the current Primary Admin. + +### AUTH-T40 — Primary Admin protection tests across API and database layers + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:admin, phase:2, testing, admin, security +**Depends on:** AUTH-T12 (Primary Admin invariants at the database level and the PrimaryAdminTransfer table); AUTH-T36 (Admin user-management endpoints) + +Integration tests proving the PA cannot be deleted, deactivated, banned, stripped of admin, or have the PA flag removed via any admin endpoint, Better Auth admin plugin endpoint (if mounted), or direct SQL from the runtime role; and that the PA is subject to account lockout but can self-unlock via the emailed link. + +**Acceptance criteria** +- [ ] All listed attack paths fail with the expected error at the API layer and, when bypassed, at the DB trigger. +- [ ] Lockout/unlock test for the PA passes. + +### AUTH-T41 — Bulk user import (CSV) with position assignment and batched invites + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:admin, phase:2, backend, admin, migration +**Depends on:** AUTH-T36 (Admin user-management endpoints); AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)) + +Admin endpoint + UI to upload a CSV (`email,name,positions` where positions is a `|`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as invited (AUTH-T22), assigns positions, and queues invite emails respecting mailer caps (AUTH-T20) in batches. Produces a downloadable report. Audit BULK_IMPORT with counts. + +**Acceptance criteria** +- [ ] A 200-row CSV with 5 invalid rows shows the 5 errors and imports nothing until fixed (all-or-nothing) or with an explicit 'skip invalid' toggle. +- [ ] Re-importing the same CSV is idempotent (no duplicate users or assignments). + +## E6 Positions + +### AUTH-T42 — Positions CRUD: create, edit name/category, soft delete with retirement + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:positions, phase:2, backend, positions, api +**Depends on:** AUTH-T11 (Positions schema: Position, UserPosition, and retired keys); AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T13 (Append-only AuditEvent table) + +Admin endpoints: create (key + name + optional category/description; key validated, uniqueness checked against active and retired-within-365-days keys), update (name/category/description only; key immutable), delete (soft; returns holder count first via a preflight, requires the admin to send `confirmKey` equal to the key; on delete: set `deletedAt`, write RetiredPositionKey, keep UserPosition rows, emit POSITION_DELETED with holder ids). Audit every change with before/after. List endpoint supports including deleted for history views. + +**Acceptance criteria** +- [ ] Renaming changes `name` only; the key and all holders are untouched (test asserts session output before/after). +- [ ] Delete without the matching `confirmKey` is refused; with it, holders lose the position on the next session call. +- [ ] Creating a key retired 100 days ago fails with `KEY_RETIRED`; 400 days ago succeeds. + +### AUTH-T43 — Position assignment endpoints (assign, unassign, bulk) + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:positions, phase:2, backend, positions, api +**Depends on:** AUTH-T42 (Positions CRUD: create, edit name/category, soft delete with retirement) + +Admin endpoints to assign/unassign one or many positions to a user and to assign one position to many users. Enforce the 50-position cap, refuse deleted positions, ignore duplicates, emit POSITION_ASSIGNED / POSITION_UNASSIGNED per pair. Assignments are visible in the session endpoint on the next call (no caching in SGAuth). + +**Acceptance criteria** +- [ ] Assigning the 51st position fails with `POSITION_LIMIT`. +- [ ] Bulk assign of 30 users is one transaction and one audit row per pair. + +### AUTH-T44 — Seed the curated SGA position list + +**Priority:** Medium · **Estimate:** 1 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:positions, phase:1, backend, positions +**Depends on:** AUTH-T11 (Positions schema: Position, UserPosition, and retired keys) + +**Input received 2026-09-18:** `prisma/seed/positions.json` already exists in the repo with 82 positions (81 offices across 9 categories — Office of the President, Academic Affairs, Campus Affairs, Diversity Equity and Inclusion, External Affairs, Student Involvement, Student Success, Operational Affairs, Senate — plus `senator`). Keys were generated by slugifying the official names and validated against the key format and length rules. Upsert by key in the seed script (safe to run in every environment; never deletes; name/category updates are applied, keys never change). Include a validation test that every key matches the format and names are unique. Product-specific roles (e.g. `aplio-admin`) are **not** seeded; product owners create them in the admin UI when they integrate. +Key naming convention (document in the admin UI help and the integration guides): organization roles use bare keys (`vp-finance`, `senator`); product-specific roles are prefixed with the product slug (`aplio-admin`, `chambers-iems`) so keys never collide and products can grep their own. + +**Acceptance criteria** +- [ ] Seed creates all 82 curated positions with zero holders; re-running is a no-op. +- [ ] A test loads `prisma/seed/positions.json` and asserts every key matches `^[a-z0-9]+(?:-[a-z0-9]+)*$`, is 2–64 chars, and that keys and names are unique. + +### AUTH-T45 — Position propagation tests and forced re-login + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:positions, phase:2, testing, positions, sso +**Depends on:** AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)); AUTH-T28 (Session endpoint for products: user, email, positions, admin flags); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +Tests: after assign/unassign/delete, the very next `/api/sgauth/session` call reflects the change; a JWT minted before the change stays valid until expiry (≤10 min) and a new token reflects it. Add an admin action 'Force re-login' (revoke all sessions of a user) surfaced in the UI as the way to invalidate outstanding JWTs immediately. Document the staleness model in the integration guides. + +**Acceptance criteria** +- [ ] Automated tests demonstrate immediate propagation for the session endpoint and bounded staleness for JWTs. +- [ ] Guides contain a 'Propagation and staleness' section. + +### AUTH-T46 — Position history queries + +**Priority:** Low · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:positions, phase:3, backend, positions, audit +**Depends on:** AUTH-T42 (Positions CRUD: create, edit name/category, soft delete with retirement); AUTH-T73 (Audit event catalog, emitter, and coverage test) + +Endpoints returning the audit trail for a position (who was assigned/unassigned, renames, deletion) and for a user (all position changes), backed by AuditEvent indexes. Used by the admin UI detail pages. + +**Acceptance criteria** +- [ ] Both queries paginate and return within 200 ms for 10k events on the test branch. + +## E7 Admin UI & Account UI + +### AUTH-T47 — App shell, navigation, and route guards for /admin and /account + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:ui, phase:2, frontend, ui +**Depends on:** AUTH-T25 (Login, sign-up, forgot/reset, and verify pages); AUTH-T28 (Session endpoint for products: user, email, positions, admin flags) + +Next.js App Router layouts: `(auth)` public pages, `(app)` authenticated pages with a header (user menu, sign out, sign out everywhere), `/account` for all users, `/admin` visible only to admins (server-side guard using `can()`; non-admins get 404). shadcn/ui components, light/dark via next-themes, SGA branding tokens. Toasts via sonner. Loading and error boundaries. + +**Acceptance criteria** +- [ ] Non-admin visiting `/admin` receives a 404 page; admin sees the dashboard. +- [ ] Layout works at 360 px and 1440 px; axe reports no serious violations. + +### AUTH-T48 — Admin: users list and user detail pages + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:ui, phase:2, frontend, ui, admin +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T36 (Admin user-management endpoints); AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)) + +Users list with search, filters (status, admin, position), pagination, and an 'Invited' badge. User detail: profile fields (editable), positions (assign/unassign with a searchable multi-select), admin toggle (disabled for self and PA with tooltip reason from `can()`), deactivate/reactivate, unlock, resend invite, sessions list with revoke, force re-login, and the user's audit timeline. Re-auth modal appears when the API returns REAUTH_REQUIRED. + +**Acceptance criteria** +- [ ] Every action shows success/failure toasts and refreshes data; disabled controls explain why. +- [ ] Playwright test: admin assigns a position, the user's session endpoint reflects it. + +### AUTH-T49 — Admin: positions pages + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:ui, phase:2, frontend, ui, positions +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T42 (Positions CRUD: create, edit name/category, soft delete with retirement) + +List (with holder counts and category grouping), create dialog (key auto-suggested from name, editable before save, immutable after), edit page (name, category, description; key shown read-only with an explanation), delete dialog showing holder count and requiring the key to be typed, and a 'deleted positions' tab with history. + +**Acceptance criteria** +- [ ] Key input rejects invalid characters live and shows the format rule. +- [ ] Delete dialog blocks submission until the typed key matches. + +### AUTH-T50 — Admin: product registry pages + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:ui, phase:2, frontend, ui, registry +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime) + +CRUD pages for products (name, slug, base URL, description, icon URL, active, sort order, visible-to-positions). Show which trusted origins and redirect targets the registry currently yields. + +**Acceptance criteria** +- [ ] Adding a product makes its origin trusted and its URL appear on account pages without a deploy (verified on dev). + +### AUTH-T51 — Admin: audit log viewer with filters and CSV export + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:ui, phase:3, frontend, ui, audit +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T73 (Audit event catalog, emitter, and coverage test) + +Table of AuditEvents with filters (type, actor, target, date range), detail drawer for metadata, and CSV export of the filtered set (server-streamed, capped at 50k rows). + +**Acceptance criteria** +- [ ] Filtering by a user shows both events they performed and events targeting them. +- [ ] Export matches the on-screen filter. + +### AUTH-T52 — Admin: Primary Admin transfer wizard, status, acceptance, and cancel pages + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:ui, phase:2, frontend, ui, admin +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T37 (Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)) + +PA-only wizard: full-screen warning explaining consequences, recipient selection limited to eligible admins (MFA enrolled), typed email confirmation, re-auth step, summary. Status card on the admin dashboard while a transfer is in flight (with cancel). Recipient acceptance page (`/admin/transfer/accept/[id]`) with re-auth and decline. Public cancel page for the emailed token. Completion banner for all admins. + +**Acceptance criteria** +- [ ] Wizard cannot be completed without typing the exact recipient email and passing re-auth. +- [ ] Ineligible recipients (no MFA, not admin, deactivated) are not selectable and the reason is shown. + +### AUTH-T53 — Account page: profile, positions, product links, sessions, sign out everywhere + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:ui, phase:2, frontend, ui +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T33 (List and revoke the user's own sessions); AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime) + +`/account`: edit name and preferredName; read-only list of positions (key and name); 'Your SGA tools' grid of active products from the registry filtered by `visibleToPositionKeys` with links to each product (this is the hub users land on after login without a redirect); sessions list with per-device revoke and 'Sign out everywhere'; links to security settings. + +**Acceptance criteria** +- [ ] A user with no positions sees only products visible to everyone. +- [ ] Revoking another device's session removes it from the list and that device gets 401. + +### AUTH-T54 — Account security page: change password, MFA enrollment, backup codes + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:ui, phase:3, frontend, ui, security +**Depends on:** AUTH-T23 (Change password (current password + re-auth), revoke other sessions); AUTH-T67 (TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)) + +`/account/security`: change password form; TOTP enrollment (QR + manual secret, verify code, download/copy backup codes once), regenerate backup codes, disable MFA (re-auth; disallowed for admins and PA with explanation). Show 'MFA required for admins' banner and block admin pages until enrolled. + +**Acceptance criteria** +- [ ] An admin without MFA is redirected to enrollment when opening `/admin`. +- [ ] Backup codes are shown once; using one logs a security notice. + +### AUTH-T55 — Accessibility, responsive, and empty/error state pass + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:ui, phase:3, frontend, ui, a11y +**Depends on:** AUTH-T48 (Admin: users list and user detail pages); AUTH-T49 (Admin: positions pages); AUTH-T53 (Account page: profile, positions, product links, sessions, sign out everywhere); AUTH-T54 (Account security page: change password, MFA enrollment, backup codes) + +Audit every page with axe and keyboard-only navigation; add empty states, loading skeletons, and error states; verify focus management in dialogs; confirm color contrast in both themes. + +**Acceptance criteria** +- [ ] axe: zero serious/critical issues on all pages; documented checklist completed. + +## E8 Product Registry & SDK + +### AUTH-T56 — Product registry service: trusted origins and redirect allowlist at runtime + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:sdk, phase:1, backend, registry, security +**Depends on:** AUTH-T14 (Product registry schema) + +`src/lib/products.ts`: loads active products (cached in-memory for 60 s, invalidated on write), exposes `getTrustedOrigins()` (product origins + SGAuth origin + local dev origins when `SGAUTH_ENV=local`) fed to Better Auth's `trustedOrigins` as a function, and `isAllowedRedirect(url)` used by AUTH-T31 and AUTH-T30. If the registry query fails, fall back to a static list containing only SGAuth's own origin (fail closed for products, but SGAuth's own pages keep working). Never trust `*.vercel.app` and never use a wildcard `https://*.northeasternsga.com` (it would trust dangling subdomains). + +**Acceptance criteria** +- [ ] Adding a product on dev makes cross-origin POSTs from its origin pass Better Auth's origin check within 60 s; removing it makes them fail. +- [ ] Unit tests for origin normalization (trailing slash, port, case). + +### AUTH-T57 — Scaffold the @sgaoperations/sgauth package and publish pipeline (public npm) + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sdk, phase:2, sdk, infra +**Depends on:** AUTH-T28 (Session endpoint for products: user, email, positions, admin flags) + +New repo `SGAOperations/sgauth-sdk` (TypeScript, tsup, vitest, ESM+CJS, Node 20+). Publish to the **public npm registry** under the `@sgaoperations` org scope (claim the scope on npmjs.com; the SDK contains no secrets, only calls to public SGAuth endpoints with the user's cookie). GitHub Packages was rejected because it requires a classic personal access token to install even public packages. Use npm **trusted publishing** (OIDC from GitHub Actions) so no long-lived npm token exists; publish on `v*` tags; semantic versioning; `CHANGELOG.md`; provenance attestations enabled. + +**Acceptance criteria** +- [ ] `npm install @sgaoperations/sgauth` works from any product repo with no `.npmrc` changes. +- [ ] CI publishes on `v*` tags via trusted publishing and fails on version collisions; the package page shows provenance. + +### AUTH-T58 — SDK: getSession() with cookie forwarding and a 60-second cache + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sdk, phase:2, sdk, sso +**Depends on:** AUTH-T57 (Scaffold the @sgaoperations/sgauth package and publish pipeline (public npm)) + +`getSession({ headers | cookieHeader })`: extracts the SGAuth cookie (name from `SGAUTH_COOKIE_NAME`, default `__Secure-sgauth.session_token`), calls `{SGAUTH_URL}/api/sgauth/session` with the `Cookie` header, returns a typed `SgaSession | null`. In-memory LRU cache keyed by SHA-256 of the token, TTL 60 s (configurable, max 300 s), negative cache 10 s. **Availability:** SGAuth is a single point of failure for every product, so on network error or 5xx the SDK serves a previously cached session for that token for up to `staleIfErrorSeconds` (default 300, max 900) and calls `onError`; with no cached entry it returns null (fail closed). Timeout 3 s. Never caches 5xx as a session. Products must never log the forwarded Cookie header (documented). Ships the JSON schema types from AUTH-T28. + +**Acceptance criteria** +- [ ] Unit tests with a mocked fetch: cache hit/miss, TTL expiry, 401 → null, timeout with cached entry → stale session + onError, timeout without cache → null + onError. +- [ ] Revocation observed within 60 s in an integration test against dev. + +### AUTH-T59 — SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs) + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sdk, phase:2, sdk, sso +**Depends on:** AUTH-T58 (SDK: getSession() with cookie forwarding and a 60-second cache) + +Exports: `createSgaProxy({ publicPaths })` for Next.js `proxy.ts`/`middleware.ts` that **only checks for the presence of the SGAuth cookie** (no network call, no per-request latency) and redirects to `loginUrl(currentUrl)` when absent; real validation happens in server code via `requireSession()` (throws/redirects on an invalid or revoked cookie). `hasPosition(session, key)`, `hasAnyPosition(session, keys)`, `hasAllPositions`, `isAdmin(session)`; `loginUrl(redirect)`, `logoutUrl(redirect)`, `accountUrl()`. React cache() wrapper for server components so one request resolves the session once. Document why the proxy must not call SGAuth: middleware runs per request and per instance, so a network round trip there adds latency to every page and defeats the cache. + +**Acceptance criteria** +- [ ] Example app in the repo demonstrates a protected page, a position-gated action, and logout. +- [ ] Helpers never run in the browser (server-only guard) except the URL builders. + +### AUTH-T60 — SDK: getAccessToken() for Supabase clients + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sdk, phase:3, sdk, supabase, jwt +**Depends on:** AUTH-T58 (SDK: getSession() with cookie forwarding and a 60-second cache); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +`getAccessToken({ headers })` calls `/api/auth/token` with the forwarded cookie and caches the JWT until `exp - 60 s` keyed by session token hash. Provide `createSupabaseAccessTokenProvider()` returning the `accessToken` callback shape supabase-js expects, and a browser-safe variant that calls a product-side route which proxies to SGAuth (so the token never requires exposing SGAuth cookies to client JS). + +**Acceptance criteria** +- [ ] Token refresh happens before expiry in a long-running test; a revoked session stops yielding tokens at the next refresh. + +### AUTH-T61 — SDK documentation, example app, and versioning policy + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sdk, phase:2, sdk, docs +**Depends on:** AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)) + +README with install (public npm, no registry config), env vars (`SGAUTH_URL`, `SGAUTH_COOKIE_NAME`), quick start, API reference (typedoc), dev/preview topology (AUTH-T34), the stale-if-error behavior and its bound, upgrade notes, and a support policy (latest two minors). + +**Acceptance criteria** +- [ ] A new product can integrate using only the README (validated by the VaultZ integration). + +### AUTH-T62 — CORS for browser-side calls from registered products + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sdk, phase:3, backend, security, sso +**Depends on:** AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime); AUTH-T28 (Session endpoint for products: user, email, positions, admin flags) + +Allow client components on registered product origins to call `/api/sgauth/session`, `/api/auth/token`, and `/api/auth/sign-out` with `credentials: 'include'`: dynamic `Access-Control-Allow-Origin` echoing only registry origins, `Allow-Credentials: true`, `Vary: Origin`, preflight handling, no wildcard. Server-side calls remain the recommended path. + +**Acceptance criteria** +- [ ] Preflight from an unregistered origin gets no CORS headers; from a registered one it succeeds with credentials. + +## E9 Security Hardening + +### AUTH-T63 — Upstash Redis rate limiting on auth and token endpoints + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:security, phase:1, backend, security, infra +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler); AUTH-T07 (Validate environment variables at startup with zod) + +Use `@upstash/ratelimit` (sliding window) inside the handlers for **mutating and expensive endpoints only**: sign-in 10/min and 50/hour per IP; sign-up 5/hour per IP; forgot-password 3/10 min per IP and per email; verification resend 3/hour per email; token endpoint 60/min per session; re-auth 5/min per user; admin mutations 100/min per user. The **session endpoint is not Redis-limited** (the Upstash free tier is 500K commands/month and the session endpoint is the hot path); it relies on the SDK cache, Vercel's platform protections, and a cheap in-process token bucket. Disable Better Auth's built-in limiter. **On Upstash outage: fail open everywhere with a logged alert** (decision: an Upstash outage must never become an org-wide login outage); the DB-backed account lockout (AUTH-T64) remains the brute-force backstop. Return 429 with `Retry-After`. Budget: estimate monthly Redis commands from expected logins and document the alert threshold at 80% of the free quota. + +**Acceptance criteria** +- [ ] Automated tests hit each limit and observe 429 + `Retry-After`; limits reset after the window. +- [ ] With Upstash unreachable (fake), sign-in still succeeds and an alert-level log line is emitted. +- [ ] Upstash keys are namespaced per environment (`sgauth:prod:`, `sgauth:dev:`). + +### AUTH-T64 — Escalating account lockout with emailed unlock and known-device exemption + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:security, phase:1, backend, security, email +**Depends on:** AUTH-T15 (Security tables: AccountLock, UnlockToken, Jwks, TwoFactor); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) + +Track failed password attempts per account (AccountLock). 5 failures within 15 minutes → lock 15 min; each subsequent lock doubles (30 min, 1 h, 2 h, … capped at 24 h); never permanent. **Known-device exemption (red-team):** a plain lockout lets anyone who knows an admin's email lock them out indefinitely. After every successful login SGAuth sets a signed, HttpOnly `__Secure-sgauth.device` cookie (host-only on auth.northeasternsga.com, 1-year, hashed in KnownDevice). Sign-in attempts that carry a valid known-device cookie for that account are exempt from the account lock (they remain subject to IP rate limits and their own separate 5-per-15-min counter); attempts without one count toward and are blocked by the lock. Attackers cannot obtain the cookie without a successful login. While locked, sign-in returns the same generic error as wrong password; the locked-account email tells the real owner what happened. On lock: email the user an unlock link (page + POST, AUTH-T103; single-use, 1 h) and a security notice; audit ACCOUNT_LOCKED / ACCOUNT_UNLOCKED. Successful login or password reset resets counters; lock level decays after 24 h clean. Applies to the Primary Admin. Admins can unlock from the UI. + +**Acceptance criteria** +- [ ] Sixth attempt within the window from an unknown device is refused even with the correct password; the unlock link restores access. +- [ ] The same account signing in from a browser holding a valid known-device cookie succeeds while the account is locked for unknown devices. +- [ ] Lock durations escalate and cap at 24 h (time-travel test). +- [ ] Unknown emails do not produce different responses or timing. + +### AUTH-T65 — CSRF and origin enforcement across subdomains + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:security, phase:1, backend, security, sso +**Depends on:** AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime); AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +Because the cookie is shared with every subdomain and SameSite=Lax still sends it on same-site POSTs from sibling subdomains, SGAuth must verify the `Origin` (fallback `Referer`) header on every state-changing request against the registry-derived trusted origins (Better Auth does this for its routes; extend the check to all `/api/sgauth/*` mutations). Reject missing Origin on non-GET. Add `Sec-Fetch-Site` checks as defense in depth. + +**Acceptance criteria** +- [ ] A POST to `/api/sgauth/admin/users` with `Origin: https://unregistered.northeasternsga.com` and a valid cookie is rejected 403. +- [ ] Same POST from a registered product origin succeeds. + +### AUTH-T66 — Security headers (CSP, HSTS, frame, referrer) + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:security, phase:2, backend, security +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching) + +Set via `next.config.ts` headers: strict CSP (self + PostHog host, nonce for inline scripts), `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Content-Type-Options: nosniff`, `Permissions-Policy` minimal. HSTS: `max-age=31536000` on auth.northeasternsga.com **without** `includeSubDomains`/`preload` unless every SGA subdomain is confirmed HTTPS-only (flag for the team; enabling it at the apex affects all products). + +**Acceptance criteria** +- [ ] securityheaders.com grade A on production; CSP violations reported to PostHog or a report-only endpoint first for one week. + +### AUTH-T67 — TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin) + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:security, phase:2, backend, security, better-auth +**Depends on:** AUTH-T15 (Security tables: AccountLock, UnlockToken, Jwks, TwoFactor); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu) + +Add the Better Auth `twoFactor` plugin (TOTP + backup codes; no SMS/email OTP). Login flow: after password, if enrolled, prompt for a 6-digit code or backup code; 'trust this device' is NOT enabled (keep it simple and consistent with sessions). Enforcement: users with `isAdmin` must have MFA to access admin endpoints/pages (403 `MFA_REQUIRED` → enrollment redirect); granting admin to a user without MFA is allowed but they are locked out of admin functions until enrolled; the PA transfer recipient must be enrolled. Backup codes hashed; regeneration invalidates old ones; audit MFA_ENROLLED / MFA_DISABLED / MFA_BACKUP_USED. + +**Acceptance criteria** +- [ ] Enrolled user must present a valid TOTP; replayed codes within the same step are rejected. +- [ ] Admin without MFA cannot call any admin endpoint; after enrollment the same call succeeds. +- [ ] Disabling MFA requires re-auth and is refused for admins/PA. + +### AUTH-T68 — Subdomain hygiene: DNS inventory, dangling-record removal, and policy + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:security, phase:2, infra, security, docs +**Depends on:** AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +Because the session cookie is readable by every `*.northeasternsga.com` host, inventory all DNS records for the domain, remove or reclaim records that point at unowned Vercel/other targets (subdomain takeover), and write `docs/SUBDOMAIN_POLICY.md`: only products in the SGAuth registry may receive a subdomain; wildcard records are prohibited; third-party services get a separate domain. Add a quarterly checklist item. + +**Acceptance criteria** +- [ ] Inventory spreadsheet/link attached; zero dangling records; policy merged and linked from the architecture doc. + +### AUTH-T69 — Secrets management and rotation procedures + +**Priority:** Medium · **Estimate:** 1 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:security, phase:2, infra, security, docs +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +Document and script rotation for `BETTER_AUTH_SECRET` (invalidates cookie signatures → all users re-login; schedule in a low-usage window), Resend key, Upstash token, Neon passwords, `CRON_SECRET`, and the **manual JWKS rotation** (AUTH-T29): (1) generate the new ES256 key pair in the Jwks table without switching signing; (2) run `scripts/supabase-push-jwks.ts` to PUT the combined JWKS into each Supabase project's third-party integration via `custom_jwks` (Management API) and confirm `resolved_jwks`; (3) switch signing to the new key; (4) after 7 days remove the old key and push again. Ensure secrets are scoped per Vercel environment and never printed in logs or preview builds. + +**Acceptance criteria** +- [ ] `docs/runbooks/rotate-secrets.md` exists and a dry run of the JWKS rotation on dev against a throwaway Supabase project produces zero token rejections during the switch. + +### AUTH-T70 — Account-enumeration resistance and timing uniformity + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:security, phase:1, backend, security, testing +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T19 (Password reset flow) + +Ensure sign-in, forgot-password, sign-up, and verification-resend responses do not reveal whether an email exists (identical bodies and status codes). Better Auth already returns success for a duplicate sign-up when `requireEmailVerification` is on; verify that path and add the unverified-account overwrite from AUTH-T17. Add a dummy hash comparison on unknown-email sign-in to equalize timing. + +**Acceptance criteria** +- [ ] Tests assert identical response bodies and status codes across known/unknown emails for each endpoint; a non-gating benchmark script reports timing deltas (a hard 50 ms CI assertion was rejected as flaky). + +### AUTH-T71 — Dependency and code scanning + +**Priority:** Low · **Estimate:** 1 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:security, phase:3, ci, security +**Depends on:** AUTH-T06 (Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch) + +Enable Dependabot (npm, weekly, grouped), `npm audit --audit-level=high` in CI, and GitHub CodeQL for JavaScript/TypeScript on PRs. + +**Acceptance criteria** +- [ ] All three run on the repo; a seeded vulnerable dependency fails CI in a test PR. + +### AUTH-T72 — Threat model and pre-launch security review checklist + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:security, phase:2, docs, security +**Depends on:** AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products); AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix) + +Write `docs/THREAT_MODEL.md` (STRIDE-lite) covering: shared-cookie exposure and subdomain takeover, session fixation/replay, JWT misuse by Supabase products, admin abuse and self-escalation, PA transfer hijack, break-glass misuse, email link phishing, rate-limit bypass, Neon credential leakage. For each: mitigation and residual risk. Derive a pre-launch checklist executed in AUTH-T91. + +**Acceptance criteria** +- [ ] Document reviewed by at least two team members; every residual risk has an owner or an accepted-risk note. + +## E10 Observability & Audit + +### AUTH-T73 — Audit event catalog, emitter, and coverage test + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:observability, phase:1, backend, audit +**Depends on:** AUTH-T13 (Append-only AuditEvent table) + +`src/lib/audit.ts`: a typed catalog (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, SESSION_REVOKED, SESSIONS_REVOKED, PASSWORD_RESET_REQUESTED, PASSWORD_RESET, PASSWORD_CHANGED, PASSWORD_REHASHED, EMAIL_VERIFIED, USER_CREATED, USER_INVITED, USER_DEACTIVATED, USER_REACTIVATED, USER_DELETED, ADMIN_GRANTED, ADMIN_REVOKED, POSITION_CREATED/UPDATED/DELETED/ASSIGNED/UNASSIGNED, PRODUCT_CREATED/UPDATED/DELETED, PA_TRANSFER_*, BREAK_GLASS_PA_RECOVERY, ACCOUNT_LOCKED/UNLOCKED, MFA_*, BULK_IMPORT, JWKS_ROTATED) and `audit(event)` that captures actor, IP, UA from request context and writes inside the caller's transaction when one is open. Add a test that every mutating endpoint emits at least one audit event (route table cross-checked against catalog usage). + +**Acceptance criteria** +- [ ] Catalog is the single source for the DB CHECK constraint (generated migration). +- [ ] Coverage test fails when a new mutating route is added without an audit call. + +### AUTH-T74 — Structured JSON logging with request IDs and redaction + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:observability, phase:1, backend, observability +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler) + +Use `pino` (or a thin console JSON logger compatible with Vercel log drains): request id (from `x-vercel-id` or generated), route, user id (never email), latency, outcome. Redact tokens, cookies, passwords, and email bodies. Log levels by environment. + +**Acceptance criteria** +- [ ] Sample production log line validated against a schema; a test asserts secrets are redacted. + +### AUTH-T75 — PostHog: server-side auth funnel events and error tracking + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:observability, phase:2, observability, posthog +**Depends on:** AUTH-T74 (Structured JSON logging with request IDs and redaction) + +Integrate PostHog (free tier): server-side capture of login_succeeded/login_failed (reason category only), signup_started/completed, reset_requested/completed, mfa_enrolled, session_endpoint_error; identify by SGAuth user id only (no email/name properties). Enable PostHog error tracking on the server; on the client, load the PostHog script **only on authenticated /admin and /account pages, never on login, sign-up, reset, or verification pages** (a third-party script on a credential form is a supply-chain risk; those pages report errors via a first-party endpoint). Respect a `POSTHOG_DISABLED` flag for local/test. Add a dashboard for daily logins, failure rate, lockouts. + +**Acceptance criteria** +- [ ] Events appear in PostHog from dev with no PII properties (verified by inspecting event payloads); an induced server error shows in error tracking. + +### AUTH-T76 — Health endpoint and uptime monitor + +**Priority:** Medium · **Estimate:** 1 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:observability, phase:1, backend, infra +**Depends on:** AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +`GET /api/health` returns 200 with `{ db: 'ok', jwks: 'ok', version }` after a cheap `SELECT 1` and a JWKS presence check; 503 otherwise. Configure a free external uptime monitor hitting it every 5 minutes with email alerts to the admins. + +**Acceptance criteria** +- [ ] Monitor is live and alerted correctly during a deliberate 10-minute dev outage test. + +### AUTH-T77 — Threshold alerts for security events + +**Priority:** Low · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:observability, phase:3, backend, observability, email +**Depends on:** AUTH-T73 (Audit event catalog, emitter, and coverage test); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T38 (Scheduled-job runner: GitHub Actions schedules calling secret-protected routes) + +A scheduled job (every 15 min via AUTH-T38) queries AuditEvent for spikes: >50 LOGIN_FAILED in 15 min, >5 ACCOUNT_LOCKED in an hour, any BREAK_GLASS_PA_RECOVERY, any PA_TRANSFER_INITIATED, JWKS rotation, job failures, Upstash fail-open events; emails all admins with a summary (deduplicated per hour). + +**Acceptance criteria** +- [ ] Simulated spike triggers exactly one alert email; the same condition an hour later triggers again. + +### AUTH-T78 — Retention jobs: tombstone deactivated (30 d) and inactive (12 mo) users, purge sessions and PII + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:observability, phase:3, backend, privacy, infra +**Depends on:** AUTH-T13 (Append-only AuditEvent table); AUTH-T36 (Admin user-management endpoints); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T38 (Scheduled-job runner: GitHub Actions schedules calling secret-protected routes) + +Daily job (via AUTH-T38): (1) **tombstone** users deactivated ≥30 days ago: status DELETED, email replaced by `deleted+@invalid`, name 'Deleted user', preferredName null, password/accounts/MFA/known devices/sessions/positions removed, audit PII anonymized via the gated path; the row and id are kept so product foreign keys stay valid and products render 'Deleted user' (never the PA); (2) flag users with no login for 11 months and email an inactivity notice; tombstone at 12 months if still inactive (admins and the PA are exempt and listed for manual review; holding positions does not exempt); (3) purge expired sessions, **sessions whose `createdAt` is older than 90 days (absolute cap enforcement, AUTH-T27)**, verifications, unlock tokens, and unverified sign-ups older than 7 days; (4) null IP/UA on audit rows and sessions older than 90 days. Everything logged with counts; `--dry-run` support; admin UI shows upcoming tombstones. A tombstoned email may be re-registered later as a brand-new account (new id). + +**Acceptance criteria** +- [ ] Time-travel tests for each rule; PA and admins are never auto-tombstoned. +- [ ] A tombstoned user's id still resolves (status DELETED) with no PII; audit rows keep `actorUserId` but lose IP/UA/metadata PII. +- [ ] Sessions older than 90 days are gone after the job even if recently refreshed. + +## E11 Integration Guides & Docs + +### AUTH-T79 — ARCHITECTURE.md: Neon mandate, components, session and token flows + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:docs, phase:1, docs +**Depends on:** AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com); AUTH-T28 (Session endpoint for products: user, email, positions, admin flags); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +Write the canonical architecture document: the Neon mandate in the first paragraph ('SGAuth runs on Neon serverless Postgres and does not use Supabase for any purpose'), component diagram (Vercel app, Neon, Upstash, Resend, PostHog), request flows (login, product session lookup, Supabase token flow, logout propagation, PA transfer), data model overview, environment topology, and links to runbooks and guides. Keep it current as a living document (owner: SGAuth lead). + +**Acceptance criteria** +- [ ] Doc merged at `docs/ARCHITECTURE.md` with Mermaid diagrams that render on GitHub; reviewed by the team lead. + +### AUTH-T80 — Integration guide for Neon-based products (Next.js) + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:docs, phase:2, docs, sdk +**Depends on:** AUTH-T61 (SDK documentation, example app, and versioning policy); AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains) + +`docs/integration/neon-products.md`: prerequisites (subdomain registered in the product registry), install SDK, env vars, add `proxy.ts`, read the session in server components/actions/route handlers, gate features by position keys (with a recommended per-product permission map file), key product tables by SGAuth user id (create-on-first-login pattern), link to the account page, logout, dev/preview topology, propagation/staleness, migration checklist for products with existing users, troubleshooting (cookie missing, 401 loops, origin rejected). + +**Acceptance criteria** +- [ ] VaultZ integration completed by following the guide with no undocumented steps (feedback folded back in). + +### AUTH-T81 — Integration guide for Supabase-backed products (third-party auth) plus the move-to-Neon alternative + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:docs, phase:3, docs, supabase, jwt +**Depends on:** AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products); AUTH-T60 (SDK: getAccessToken() for Supabase clients) + +`docs/integration/supabase-products.md` covering, with tested snippets: +1. **Register SGAuth as a third-party auth provider** using the Supabase Management API (`POST /v1/projects/{ref}/config/auth/third-party-auth` with `oidc_issuer_url: https://auth.northeasternsga.com`, or `jwks_url`), a script `scripts/supabase-register-tpa.ts`, and how to verify (`GET` the integration, check `resolved_jwks`). Note the dashboard may not expose a generic provider; the API does. +2. **Token requirements** SGAuth satisfies: ES256, `kid`, `role: 'authenticated'`, uuid `sub`, `iss`, `exp` ≤ 10 min. +3. **Client setup**: `createClient(url, key, { accessToken: () => getAccessToken() })` server-side, and the browser proxy pattern from the SDK. +4. **RLS**: `auth.uid()` = SGAuth user id; positions via `(select auth.jwt() -> 'positions')`; helper function `has_position(text)`; examples for select/insert policies; performance wrapping. +5. **Decoupling from auth.users**: third-party users have no `auth.users` row; replace FKs/triggers with a product `users` table keyed by SGAuth id, created on first request. +6. **Limitations**: no Supabase sessions/refresh/MFA/password features for these users; JWT staleness ≤10 min; Supabase JWKS refresh ≤30 min (why rotation has a 7-day grace); billing at $0.00325 per third-party MAU beyond quota; `custom_jwks` fallback if discovery fails. +7. **Alternative**: server-side verification with `jose` against SGAuth's JWKS for products that do not need RLS. +8. **Move to Neon instead**: checklist for migrating a Supabase product's Postgres to Neon and using the standard Neon guide, recommended for any product that needs auth and does not depend on Supabase-only features (Storage, Realtime). + +**Acceptance criteria** +- [ ] A throwaway Supabase project registered via the script accepts an SGAuth token and an RLS policy using `auth.uid()` and a position claim behaves as documented (recorded in the guide with a verification date). +- [ ] Guide includes the explicit limitations list and the MAU cost line. + +### AUTH-T82 — Admin runbooks + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:docs, phase:2, docs, admin +**Depends on:** AUTH-T37 (Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)); AUTH-T39 (Break-glass Primary Admin recovery script and runbook); AUTH-T64 (Escalating account lockout with emailed unlock and known-device exemption); AUTH-T41 (Bulk user import (CSV) with position assignment and batched invites) + +`docs/runbooks/`: Primary Admin transfer (step-by-step with screenshots), break-glass recovery, unlocking a user, bulk import, position lifecycle (create/rename/retire), key and secret rotation, incident response (revoke all sessions, rotate secret, notify), onboarding a new product (registry + subdomain + SDK), semester turnover checklist. + +**Acceptance criteria** +- [ ] Each runbook has been executed once on dev by someone other than its author and corrected accordingly. + +### AUTH-T83 — Privacy notice and data-handling document + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:docs, phase:3, docs, privacy +**Depends on:** AUTH-T78 (Retention jobs: tombstone deactivated (30 d) and inactive (12 mo) users, purge sessions and PII) + +Public page `/privacy` and `docs/DATA_HANDLING.md`: what SGAuth stores (name, preferred name, northeastern.edu email, positions, security metadata such as IP/user agent for 90 days, audit events), why, who can see it (admins), retention (deactivated 30 days, inactive 12 months), how to request deletion, and that SGAuth stores no NUID, grades, or academic records. Note that names and positions are directory-level information and that SGA, as a student organization, is not the university's FERPA steward; keep the data set that way. + +**Acceptance criteria** +- [ ] Page live and linked from the login footer; reviewed by the Primary Admin. + +### AUTH-T84 — Generated SDK API reference and changelog discipline + +**Priority:** Low · **Estimate:** 1 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:docs, phase:3, docs, sdk +**Depends on:** AUTH-T61 (SDK documentation, example app, and versioning policy) + +Typedoc site published to GitHub Pages from the SDK repo on release; enforce changelog entries via a CI check on PRs. + +**Acceptance criteria** +- [ ] Reference site live; a PR without a changelog entry fails CI. + +### AUTH-T85 — CLAUDE.md and CONTRIBUTING.md for agents and humans + +**Priority:** Low · **Estimate:** 1 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:docs, phase:1, docs, chore +**Depends on:** AUTH-T02 (Remove Supabase from the auth repo (in-place migration to Neon)); AUTH-T06 (Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch) + +Document conventions for coding agents and contributors: Neon-only (no Supabase), commands (dev, test, migrate, seed), folder layout, authz and audit requirements for any new mutating route, testing expectations, commit/PR conventions (labels), and links to the design docs. Update the repo's agent definitions that reference Supabase (prisma-migration-agent, security-reviewer) to Neon. + +**Acceptance criteria** +- [ ] CLAUDE.md merged; agent definitions no longer mention Supabase. + +## E12 User Migration & Rollout + +### AUTH-T86 — Receive the Chambers auth.users export and define the import file format + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:rollout, phase:3, migration, chambers +**Depends on:** none + +**The export itself is a manual action item owned by Eli, outside Linear** (Chambers has no Linear team): export the Chambers Supabase `auth.users` table (`id`, `email`, `encrypted_password`, `email_confirmed_at`, `last_sign_in_at`, `banned_until`) joined to `public.users` (name fields, `admin_role`, `iems_role`, `is_active`) and board memberships **before the Supabase project is deleted**, into a JSON file kept out of git. This ticket: publish the expected JSON schema and a validation script (`scripts/validate-export.ts`) that checks the file, reports row counts and a checksum, and confirms hashes look like bcrypt (`$2a$`/`$2b$`). The file is stored in the team secrets vault and deleted after import. + +**Acceptance criteria** +- [ ] Schema and validator merged; the received export validates with counts matching what Eli reports from Supabase. + +### AUTH-T87 — Import script: Chambers users with bcrypt hashes and position mapping + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:rollout, phase:3, migration, backend, chambers +**Depends on:** AUTH-T86 (Receive the Chambers auth.users export and define the import file format); AUTH-T21 (Accept imported Chambers bcrypt hashes with lazy re-hash to scrypt); AUTH-T44 (Seed the curated SGA position list); AUTH-T13 (Append-only AuditEvent table) + +`scripts/import-users.ts --source chambers.json --mapping chambers-positions.json --dry-run`: for each row, upsert User by lower-cased email (merge if it already exists from another import), store the bcrypt hash as `bcrypt$` in Account (only when no scrypt password exists), set `emailVerified` from `email_confirmed_at`, mark `is_active = false` users as DEACTIVATED, assign positions from an approved mapping file (Chambers roles → curated position keys), skip banned users, and write a report (created/merged/skipped with reasons) plus an id-mapping file (Supabase id → SGAuth id) for the Chambers team. Audit BULK_IMPORT. Never log hashes. + +**Acceptance criteria** +- [ ] Dry run against dev reports counts; real run is idempotent (second run: 0 created). +- [ ] Sample imported user logs in with their Chambers password on dev (AUTH-T21) and holds the mapped positions. +- [ ] Mapping file approved by the Primary Admin before the production run. + +### AUTH-T88 — Aplio user import (emails and names, no passwords) with invites and id mapping + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:rollout, phase:4, migration, aplio +**Depends on:** AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping); AUTH-T22 (Invite flow: admin-created users receive a set-password link) + +Aplio users authenticated with email OTP and have no passwords. Extend the import script with `--source aplio.json` (id, email, name, isAdmin, deletedAt): upsert by email, mark as invited (set-password link) unless they already exist with a password, do not grant SGAuth admin from Aplio's `isAdmin` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with `legacyEmail = true`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Invites are sent in batches under the mailer caps; stagger over days if needed. + +**Acceptance criteria** +- [ ] Report shows created/merged counts; a sample invited user sets a password and logs in; id-mapping file delivered to the Aplio team. + +### AUTH-T89 — SenatePath and Attendance Manager user import + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:rollout, phase:4, migration +**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with invites and id mapping) + +Same script with `--source senatepath.json` (admin users only) and `--source attendance.json` (email, first/last, role) — NUID is NOT imported. Positions mapping files approved per product. Invites batched. + +**Acceptance criteria** +- [ ] Both imports run on dev with reports; id-mapping files delivered to each team. + +### AUTH-T90 — Rollout plan and user communications + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:rollout, phase:3, docs, rollout +**Depends on:** AUTH-T80 (Integration guide for Neon-based products (Next.js)); AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping) + +Write `docs/ROLLOUT.md`: order SGAuth MVP → VaultZ → Chambers → Aplio (hard cutover) → SenatePath → Attendance Manager; per-product cutover checklist (pre-import users, registry entry, subdomain, env, SDK version, smoke test, announcement, rollback flag, support window of 3 days with a named contact); email templates announcing 'one login for all SGA tools' and, for Chambers users, that their existing password keeps working. + +**Acceptance criteria** +- [ ] Plan reviewed with each product owner; dates recorded; announcement emails drafted. + +### AUTH-T91 — Production launch checklist and Primary Admin bootstrap + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:rollout, phase:2, rollout, infra, security +**Depends on:** AUTH-T72 (Threat model and pre-launch security review checklist); AUTH-T66 (Security headers (CSP, HSTS, frame, referrer)); AUTH-T76 (Health endpoint and uptime monitor); AUTH-T39 (Break-glass Primary Admin recovery script and runbook); AUTH-T64 (Escalating account lockout with emailed unlock and known-device exemption) + +Execute before the first product goes live: DNS + DMARC verified; production env vars set; Neon PITR confirmed; Upstash and Resend production keys; PostHog project; uptime monitor; threat-model checklist items closed; bootstrap the first Primary Admin via a one-time script (`scripts/bootstrap-primary-admin.ts`, refuses to run if any PA exists) followed by MFA enrollment; at least two people hold break-glass credentials; backups of the curated positions; smoke test of login/logout/session endpoint from a product stub on a real subdomain. + +**Acceptance criteria** +- [ ] Checklist completed and signed off in the ticket by the Primary Admin; bootstrap script left disabled afterwards. + +### AUTH-T92 — Post-launch review and legacy cleanup tracking + +**Priority:** Low · **Estimate:** 1 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:rollout, phase:4, rollout, docs +**Depends on:** AUTH-T90 (Rollout plan and user communications) + +Two weeks after each product cutover: review PostHog funnel and audit metrics (failed logins, lockouts, support requests), remove the bcrypt verify path once no `bcrypt$` hashes remain, and confirm each product deleted its legacy auth code and secrets. Record findings in `docs/ROLLOUT.md`. + +**Acceptance criteria** +- [ ] Review notes recorded for every product; legacy-cleanup subtasks closed. + +## E13 Testing & QA + +### AUTH-T93 — Test harness: vitest, Neon test branch, factories, and test mailer + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 0 — Foundation · **Labels:** epic:testing, phase:0, testing, infra +**Depends on:** AUTH-T01 (Provision the Neon project, branches, and roles for SGAuth); AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)) + +Configure vitest (node environment) with a global setup that points Prisma at the Neon `test` branch (reset from `dev` before each CI run; runs serialized by a GitHub Actions concurrency group because the 10-branch cap rules out per-run branches), applies migrations, and truncates tables between test files. Provide factories (`createUser`, `createAdmin`, `createPrimaryAdmin`, `createSession`, `createPosition`), a fake clock helper, a capturing mailer, and a fake Upstash (in-memory) limiter. Add `npm test` and coverage thresholds (80% lines on `src/lib`). + +**Acceptance criteria** +- [ ] `npm test` runs locally against a personal branch and in CI against the test branch in under 5 minutes. +- [ ] Factories and fakes documented in CONTRIBUTING.md. + +### AUTH-T94 — Integration tests for authentication flows + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:testing, phase:1, testing, auth-core +**Depends on:** AUTH-T93 (Test harness: vitest, Neon test branch, factories, and test mailer); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T18 (Email verification for self-sign-up with resend limits); AUTH-T19 (Password reset flow); AUTH-T27 (Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache); AUTH-T64 (Escalating account lockout with emailed unlock and known-device exemption) + +End-to-end (HTTP-level) tests: sign-up + verify + login; domain rejection; wrong password; lockout and unlock; forgot/reset including session revocation; invite acceptance; deactivated user; session sliding and absolute expiry; logout and sign-out-everywhere; re-auth freshness; rate-limit responses with the fake limiter. + +**Acceptance criteria** +- [ ] All flows green in CI; each test asserts the expected audit events. + +### AUTH-T95 — Authorization, admin, positions, and transfer integration tests + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:testing, phase:2, testing, admin +**Depends on:** AUTH-T93 (Test harness: vitest, Neon test branch, factories, and test mailer); AUTH-T36 (Admin user-management endpoints); AUTH-T37 (Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)); AUTH-T42 (Positions CRUD: create, edit name/category, soft delete with retirement); AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)) + +HTTP-level tests exercising the admin endpoints with the full actor/target matrix, the position lifecycle (create/rename/assign/delete/retired-key reuse), and the complete PA transfer state machine including cron execution and mid-flight recipient deactivation. + +**Acceptance criteria** +- [ ] Matrix and state-machine tests pass; coverage of `authz.ts` and the transfer service ≥ 90%. + +### AUTH-T96 — Playwright end-to-end SSO test across subdomains + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:testing, phase:2, testing, sso, e2e +**Depends on:** AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T30 (Global logout, sign out everywhere, and admin revocation) + +Spin up SGAuth and two minimal product stubs (using the SDK) on `auth.sga.localhost`, `a.sga.localhost`, `b.sga.localhost` in CI. Scenarios: login at auth → both products see the session; position assigned by an admin → product B gates a page accordingly; logout on product A → product B is logged out; expired session → redirect to login with the correct redirect param; open-redirect attempts rejected. + +**Acceptance criteria** +- [ ] Playwright job green in CI with traces on failure; run time under 4 minutes. + +### AUTH-T97 — JWT and JWKS conformance tests for Supabase requirements + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:testing, phase:2, testing, jwt, supabase +**Depends on:** AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +Tests: token verifies with `jose` via remote JWKS; header has `alg: ES256` and `kid`; claims include uuid `sub`, `role: 'authenticated'`, `iss`, `aud`, `exp - iat ≤ 600`; discovery document is valid; rotation keeps the old key in the JWKS for the grace period; token endpoint rejects missing/revoked sessions. + +**Acceptance criteria** +- [ ] Conformance suite green; a checklist in the Supabase guide references these tests. + +### AUTH-T98 — Load sanity for the session endpoint on Neon + +**Priority:** Low · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:testing, phase:3, testing, performance +**Depends on:** AUTH-T28 (Session endpoint for products: user, email, positions, admin flags); AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching) + +Run a k6/autocannon script against the dev deployment: 100 concurrent virtual users hitting `/api/sgauth/session` with valid cookies for 2 minutes. Record p50/p95, Neon connection count, and any pooler saturation; tune pool size and Neon compute settings; document results. + +**Acceptance criteria** +- [ ] p95 < 250 ms and zero connection errors at 100 VUs; results in `docs/PERFORMANCE.md`. + +--- + +# Team VAULTZ + +## SGAuth integration — VaultZ + +### VAULTZ-V01 — Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, backend +**Depends on:** AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)); AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains) + +Add the SDK from public npm (no registry config), env vars (`SGAUTH_URL`, `SGAUTH_COOKIE_NAME`), and `proxy.ts` using `createSgaProxy({ publicPaths: ['/api/uploadthing', '/health'] })` (cookie-presence check only; real validation in server code via `requireSession()`). Unauthenticated requests redirect to `https://auth.northeasternsga.com/login?redirect=`. Register `https://vaultz.northeasternsga.com` (and `vaultz-dev.northeasternsga.com`) in the SGAuth product registry first. + +**Acceptance criteria** +- [ ] Visiting any VaultZ page without an SGAuth session redirects to SGAuth login and back to the original page after login. +- [ ] UploadThing callback route remains reachable without a session. + +### VAULTZ-V02 — Remove the shared passphrase gate + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, backend +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy) + +Delete `lib/access-gate.ts`, `lib/actions/access.ts`, the passphrase page, and the `VAULTZ_ACCESS_CODE`/`VAULTZ_ACCESS_SECRET` env vars after the cutover (keep behind a `SGAUTH_ENABLED` flag for one release so rollback is a flag flip, then delete). + +**Acceptance criteria** +- [ ] No references to the passphrase remain after the flag is removed; the `vaultz_access` cookie is no longer set. + +### VAULTZ-V03 — Link VaultZ purchaser records to SGAuth user ids + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, data +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy) + +VaultZ's `User` table records purchasers (first/last), not logins. Add `sgauthUserId String? @unique` and `email String?`. On first authenticated request, find-or-create the purchaser row for the session user (name from SGAuth split into first/last as a best effort, editable). Admin UI to link legacy purchaser rows to SGAuth users by email. Purchases created by the current user default `userId` to their linked row. + +**Acceptance criteria** +- [ ] A first-time SGAuth user gets exactly one purchaser row; re-login does not duplicate. +- [ ] Legacy rows can be linked once and the link is unique. + +### VAULTZ-V04 — Position-based permissions map for VaultZ + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, permissions +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy); AUTH-T44 (Seed the curated SGA position list) + +Create `lib/permissions.ts` mapping SGAuth position keys to VaultZ capabilities (e.g. `vp-finance`, `treasurer` → manage designations/budgets/transfers; `finance-committee` → create purchases; everyone else → read-only or no access). Gate server actions and pages with `hasAnyPosition`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list. + +**Acceptance criteria** +- [ ] Every mutating server action checks a capability; a user without positions cannot create or edit anything (tests). +- [ ] The map is the only place position keys appear in VaultZ code. + +### VAULTZ-V05 — Account and sign-out links in the VaultZ header + +**Priority:** Low · **Estimate:** 1 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, ui +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy) + +Show the session user's name, a link to `accountUrl()`, and a sign-out action using `logoutUrl('https://vaultz.northeasternsga.com')`. + +**Acceptance criteria** +- [ ] Sign out from VaultZ logs the user out of every SGA product (verified on dev). + +### VAULTZ-V06 — Dev and preview topology for VaultZ + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, infra +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy); AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains) + +Configure the Vercel branch domain `vaultz-dev.northeasternsga.com` pointing at the dev branch with `SGAUTH_URL=https://auth-dev.northeasternsga.com` and the dev cookie name; local dev on `vaultz.sga.localhost:3001` against local SGAuth. Document in the VaultZ README. + +**Acceptance criteria** +- [ ] Preview on the branch domain shares the dev SGAuth session; ad-hoc vercel.app previews show a documented 'SSO unavailable on this host' page instead of a redirect loop. + +### VAULTZ-V07 — VaultZ cutover checklist and rollback + +**Priority:** Medium · **Estimate:** 1 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, rollout +**Depends on:** VAULTZ-V02 (Remove the shared passphrase gate); VAULTZ-V04 (Position-based permissions map for VaultZ); AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Execute the per-product checklist from the rollout plan: registry entry, DNS, env vars, SDK version pinned, smoke test, announcement to finance users, `SGAUTH_ENABLED` rollback flag verified, 3-day support window. + +**Acceptance criteria** +- [ ] Checklist completed; rollback rehearsed on dev by flipping the flag. + +### VAULTZ-V08 — Tests for SGAuth guards and permissions in VaultZ + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, testing +**Depends on:** VAULTZ-V04 (Position-based permissions map for VaultZ) + +Unit tests for the permissions map and integration tests (mocked SDK) for a protected page, a gated server action, and the purchaser link-on-first-login behavior. + +**Acceptance criteria** +- [ ] Tests run in VaultZ CI and cover allow/deny for each capability. + +--- + +# Team CHAMBERS + +## SGAuth integration — Chambers + +### CHAMBERS-C01 — Inventory every auth and authorization touchpoint in Chambers + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, audit +**Depends on:** none + +List all uses of `getAuthedUser`, `getAuthedUserWithLiveRoles`, `hasLiveAdmin`, `app_metadata`, `is_admin()`, `is_iems()`, `my_body_ids()`, `revoke_user_sessions`, and any remaining Supabase Auth client calls (login, reset, password change) after the Neon migration. Produce a table: location → replacement (SGAuth session, position check, or Chambers-internal membership). + +**Acceptance criteria** +- [ ] Inventory document merged in the Chambers repo and reviewed by the Chambers lead. + +### CHAMBERS-C02 — Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal) + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, backend +**Depends on:** CHAMBERS-C01 (Inventory every auth and authorization touchpoint in Chambers); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) + +Install the SDK; add `proxy.ts` with public paths for kiosk/display pages (protected by `DISPLAY_KEY`), cron routes (`CRON_SECRET`), and the service worker assets; replace `lib/auth.ts`/`lib/authorization.ts` with thin wrappers over `getSession()`; delete `LoginCard`, forgot-password UI, and all `supabase.auth.*` calls; `/login` redirects to SGAuth with the current URL. Keep offline/network-retry UX for the session fetch failure case (show the existing offline message). + +**Acceptance criteria** +- [ ] No `@supabase/*` auth imports remain; every previously protected route is protected by the proxy. +- [ ] Kiosk and cron routes work without a user session. + +### CHAMBERS-C03 — Key Chambers users by SGAuth user id + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, data +**Depends on:** CHAMBERS-C02 (Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal)); AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping) + +Using the id-mapping file from the SGAuth import, migrate `users.id` (or add `sgauth_user_id` and repoint FKs) so the product's user row is keyed by SGAuth id; drop auth-only columns (`sessions_revoked_at`, password-related fields) and the dependency on `auth.users`. Create-on-first-login for users that exist in SGAuth but not in Chambers (e.g. self-registered students) with no memberships. + +**Acceptance criteria** +- [ ] Every pre-existing Chambers user resolves to the same SGAuth user by email (verification script), and bookings history is intact. + +### CHAMBERS-C04 — Map admin_role / iems_role to SGAuth positions; keep body memberships internal + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, permissions +**Depends on:** CHAMBERS-C02 (Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal)); AUTH-T44 (Seed the curated SGA position list) + +Define `lib/permissions.ts`: Chambers admin capabilities derive from position keys (e.g. `chambers-admin`, `iems`, and the relevant exec positions from the curated list); board/body memberships stay in Chambers tables keyed by SGAuth user id and continue to drive booking scopes. Replace `hasLiveAdmin`/`is_admin()` checks in app code with position checks; since Chambers is on Neon, RLS helpers are replaced by app-level checks in the data layer. + +**Acceptance criteria** +- [ ] Every admin-only route and action checks positions; a user whose position is removed in SGAuth loses admin ability within 60 s (SDK cache) without any Chambers-side change. +- [ ] Body-scoped booking rules unchanged (existing tests pass). + +### CHAMBERS-C05 — Remove live-role-check and session-revocation mechanisms + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, cleanup +**Depends on:** CHAMBERS-C04 (Map admin_role / iems_role to SGAuth positions; keep body memberships internal) + +Delete `getAuthedUserWithLiveRoles`, `sessions_revoked_at`, `revoke_user_sessions`, and related migrations/tests; revocation and deactivation are now SGAuth's job (admin revoke in SGAuth → 401 from the SDK). Update the user-management admin pages in Chambers to link to the SGAuth admin UI for deactivation/positions instead of local toggles. + +**Acceptance criteria** +- [ ] Chambers admin UI no longer offers role toggles that SGAuth owns; links open the SGAuth user page. + +### CHAMBERS-C06 — Approve the Chambers role-to-position mapping file + +**Priority:** High · **Estimate:** 1 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, migration +**Depends on:** none + +(The auth.users export is Eli's separate manual action item and is not tracked here.) Review and approve the mapping from `admin_role` / `iems_role` / board memberships to curated position keys used by the SGAuth import (AUTH-T87). + +**Acceptance criteria** +- [ ] Mapping file approved by the Chambers lead and the Primary Admin. + +### CHAMBERS-C07 — Chambers cutover, comms, and rollback plan + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, rollout +**Depends on:** CHAMBERS-C03 (Key Chambers users by SGAuth user id); CHAMBERS-C04 (Map admin_role / iems_role to SGAuth positions; keep body memberships internal); CHAMBERS-C05 (Remove live-role-check and session-revocation mechanisms); AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Per-product checklist: registry entry for chambers.northeasternsga.com, env, SDK pin, import verified (sample users log in with existing passwords), announcement that passwords carry over, rollback plan (previous deployment + Supabase auth still intact for 7 days), 3-day support window. + +**Acceptance criteria** +- [ ] Cutover completed with zero password resets required for imported users beyond expected stragglers; rollback rehearsed on dev. + +### CHAMBERS-C08 — Verify kiosk display key, Slack reminders, and cron routes are unaffected + +**Priority:** Low · **Estimate:** 1 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, verification +**Depends on:** CHAMBERS-C02 (Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal)) + +Confirm the display pages, Slack bot posting, Resend flows, and GitHub-Actions-driven cron routes still work with the SGAuth proxy in place (they must be on the public path list). + +**Acceptance criteria** +- [ ] Manual verification checklist completed on dev and production. + +### CHAMBERS-C09 — Tests for SGAuth-based authorization in Chambers + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, testing +**Depends on:** CHAMBERS-C04 (Map admin_role / iems_role to SGAuth positions; keep body memberships internal) + +Replace tests that mocked Supabase JWT claims with tests that mock the SDK session; cover admin/iems/body-member/none for the main booking and admin routes. + +**Acceptance criteria** +- [ ] Chambers CI green with the new tests; old Supabase auth tests removed. + +--- + +# Team APLIO + +## SGAuth integration — Aplio + +### APLIO-P01 — Export Aplio users for the SGAuth import and receive the id mapping + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, migration +**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with invites and id mapping) + +Export `User` rows (id, email, name, isAdmin, deletedAt) to JSON for the SGAuth import; receive the SGAuth id-mapping file; verify every active Aplio user maps to an SGAuth user. + +**Acceptance criteria** +- [ ] Mapping verified with a script; discrepancies resolved before cutover. + +### APLIO-P02 — Replace local Better Auth with the SGAuth SDK + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, backend +**Depends on:** APLIO-P01 (Export Aplio users for the SGAuth import and receive the id mapping); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) + +Remove `lib/auth/config.ts` (emailOTP, rate limits), `app/api/auth/[...path]`, the OTP email template and Resend usage for OTP, the login/OTP pages, and `resolveUser`'s Better Auth branch; implement `getCurrentUser`/`getOptionalUser`/`getDeactivatedSessionUser` over the SDK. Keep Aplio's deactivation concept (`deletedAt`) as a product-level state. Drop the `Session`, `Account`, and `Verification` tables in a follow-up migration after cutover. + +**Acceptance criteria** +- [ ] No `better-auth` dependency remains in Aplio; all `(auth)` routes are protected by the SGAuth proxy. +- [ ] Deactivated Aplio users still see the explanatory screen rather than a login loop. + +### APLIO-P03 — Key Aplio users by SGAuth user id + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, data +**Depends on:** APLIO-P02 (Replace local Better Auth with the SGAuth SDK) + +Add `sgauthUserId String @unique` to `User`, backfill from the id-mapping file, and make all lookups go through it; create-on-first-login for new applicants (name/email from the session). Keep Aplio's own `User.id` as the FK target for the many audit relations to avoid rewriting every table. Remove `neonAuthId`. + +**Acceptance criteria** +- [ ] Every existing application, answer, and email log still resolves to the right person after backfill (verification query). + +### APLIO-P04 — Derive Aplio admin from an SGAuth position; managers stay product-level + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, permissions +**Depends on:** APLIO-P02 (Replace local Better Auth with the SGAuth SDK); AUTH-T44 (Seed the curated SGA position list) + +Replace `User.isAdmin` with a position check (e.g. `aplio-admin` or the relevant exec positions) in `requireAdmin`/`requireAdminOr404` and `buildReviewablePositionWhere`; position managers remain an Aplio relation. Provide a one-time script that assigns the `aplio-admin` position in SGAuth to current Aplio admins (via the SGAuth bulk assign API or CSV). + +**Acceptance criteria** +- [ ] Admin pages gate on positions; the `isAdmin` column is removed after cutover. + +### APLIO-P05 — Applicant flow on SGAuth accounts (northeastern.edu required) + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, product +**Depends on:** APLIO-P02 (Replace local Better Auth with the SGAuth SDK) + +Applicants now create SGAuth accounts via northeastern.edu self-sign-up with verification. Decision: every student has a northeastern.edu address, so no domain exception exists; applicants who previously used another address must use their Northeastern one. Update the apply entry points to redirect to SGAuth sign-up with a redirect back to the position, and add copy explaining the Northeastern-email requirement. Existing Aplio users with non-NU addresses are imported as legacy accounts (AUTH-T88) and keep working until an admin moves them to their NU address. + +**Acceptance criteria** +- [ ] A new student can apply end-to-end: SGAuth sign-up → verify → land on the application form. +- [ ] Apply pages state the northeastern.edu requirement before the redirect. + +### APLIO-P06 — Local dev bypass and preview topology + +**Priority:** Low · **Estimate:** 1 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, dev +**Depends on:** APLIO-P02 (Replace local Better Auth with the SGAuth SDK); AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains) + +Keep the dev-bypass user cookie for local only (never on any deployed host), and configure `apply-dev.northeasternsga.com` with the dev SGAuth URL and cookie name. + +**Acceptance criteria** +- [ ] Bypass is a no-op on all Vercel deployments; preview branch domain shares the dev SSO session. + +### APLIO-P07 — Aplio hard cutover outside an application window + +**Priority:** High · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, rollout +**Depends on:** APLIO-P03 (Key Aplio users by SGAuth user id); APLIO-P04 (Derive Aplio admin from an SGAuth position; managers stay product-level); APLIO-P05 (Applicant flow on SGAuth accounts (northeastern.edu required)); AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Schedule the cutover when no application cycle is open; users were pre-imported and invited; announce; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window. + +**Acceptance criteria** +- [ ] Cutover completed; no applicant lost draft access (verified with a sample of drafts). + +### APLIO-P08 — Update Aplio tests for SDK-based auth + +**Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, testing +**Depends on:** APLIO-P04 (Derive Aplio admin from an SGAuth position; managers stay product-level) + +Replace Better Auth session mocks with SDK session mocks; cover admin, manager, applicant, deactivated, and anonymous paths for guards and scopes. + +**Acceptance criteria** +- [ ] Aplio CI green; guard coverage unchanged or higher. + +--- + +# Team SENATEPATH + +## SGAuth integration — SenatePath + +### SENATEPATH-S01 — Migrate SenatePath's database from Supabase to Neon + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, neon, migration +**Depends on:** none + +Create a Neon project/branches, dump and restore the Postgres schema and data (pg_dump from Supabase → psql into Neon), switch `DATABASE_URL`/`DIRECT_URL`, optionally upgrade Prisma 6 → 7 with the driver adapter, remove `@supabase/*` packages and Supabase Storage usage if any (or keep Storage only if truly needed and document it). SGAuth is Neon-only; SenatePath becomes a standard Neon product. + +**Acceptance criteria** +- [ ] App runs against Neon in dev and prod; row counts match the Supabase source; no Supabase Auth code remains after S02. + +### SENATEPATH-S02 — Gate the SenatePath admin area with SGAuth positions + +**Priority:** High · **Estimate:** 3 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, backend +**Depends on:** SENATEPATH-S01 (Migrate SenatePath's database from Supabase to Neon); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) + +Install the SDK; protect `/admin/**` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on a position (e.g. `elections-chair` / `senate-admin`). Public application/nomination forms remain unauthenticated as today. + +**Acceptance criteria** +- [ ] Admin pages require an SGAuth session with the configured position; public forms unaffected. + +### SENATEPATH-S03 — Import SenatePath admins into SGAuth and cut over + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, rollout +**Depends on:** SENATEPATH-S02 (Gate the SenatePath admin area with SGAuth positions); AUTH-T89 (SenatePath and Attendance Manager user import) + +Export admin emails for the SGAuth import; confirm they hold the gating position; run the per-product cutover checklist; delete Supabase project after a 14-day retention window. + +**Acceptance criteria** +- [ ] All previous admins can log in via SGAuth; Supabase project decommissioned. + +### SENATEPATH-S04 — Tests for the SGAuth admin gate + +**Priority:** Low · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, testing +**Depends on:** SENATEPATH-S02 (Gate the SenatePath admin area with SGAuth positions) + +Integration tests (mocked SDK) for admin access with/without the position and for public routes. + +**Acceptance criteria** +- [ ] Tests in CI cover allow/deny and public access. + +--- + +# Team ATTENDANCE + +## SGAuth integration — Attendance Manager + +### ATTENDANCE-M01 — Decision: stay on Supabase (third-party auth) or move to Neon (SDK) + +**Priority:** High · **Estimate:** 1 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, decision +**Depends on:** none + +Record the team's directive. Criteria: does Attendance Manager use Supabase-only features (Storage, Realtime, Edge Functions)? If not, moving to Neon is recommended (simpler, no JWT staleness, standard SDK). If yes, use the third-party auth path. Exactly one of M02 or M03 proceeds. + +**Acceptance criteria** +- [ ] Decision documented in the repo README with rationale; the unused path's tickets are cancelled. + +### ATTENDANCE-M02 — (Supabase path) Consume SGAuth via Supabase third-party auth + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, supabase +**Depends on:** ATTENDANCE-M01 (Decision: stay on Supabase (third-party auth) or move to Neon (SDK)); AUTH-T81 (Integration guide for Supabase-backed products (third-party auth) plus the move-to-Neon alternative); AUTH-T60 (SDK: getAccessToken() for Supabase clients) + +Follow the Supabase integration guide: register SGAuth as a third-party auth provider on the Attendance Supabase project via the Management API script; configure supabase-js with the SDK's access-token provider (server) and the proxy route (browser); replace `supabaseAuthId` with `sgauthUserId` on `User` and remove any `auth.users` FKs/triggers; rewrite RLS to `auth.uid()` and position claims (`has_position('...')`); remove Supabase login/signup pages and the custom middleware in favor of the SGAuth proxy; remove the unused `password` column. Note the ≤10-minute JWT staleness and the MAU billing line. + +**Acceptance criteria** +- [ ] A user with the required position can read/write per RLS using an SGAuth token; a user without it is denied at the database. +- [ ] No `auth.users` dependency remains; Supabase login pages removed. + +### ATTENDANCE-M03 — (Neon path) Migrate to Neon and integrate with the SDK + +**Priority:** High · **Estimate:** 5 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, neon +**Depends on:** ATTENDANCE-M01 (Decision: stay on Supabase (third-party auth) or move to Neon (SDK)); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) + +Dump/restore Postgres to Neon, switch Prisma URLs, remove `@supabase/*`, install the SDK, protect routes with the proxy, key `User` by `sgauthUserId`, and move authorization from RLS to app-level checks using positions. + +**Acceptance criteria** +- [ ] App runs on Neon with SGAuth sessions; row counts match; no Supabase code remains. + +### ATTENDANCE-M04 — Map roles to SGAuth positions; keep NUID product-side + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, permissions +**Depends on:** ATTENDANCE-M01 (Decision: stay on Supabase (third-party auth) or move to Neon (SDK)); AUTH-T44 (Seed the curated SGA position list) + +Define the permissions map from position keys to Attendance roles (MEMBER/OFFICER/etc.); NUID stays in Attendance's `User` table keyed by SGAuth user id and is never sent to SGAuth. + +**Acceptance criteria** +- [ ] Role checks derive from positions; NUID handling unchanged and documented. + +### ATTENDANCE-M05 — User import and cutover + +**Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, rollout +**Depends on:** ATTENDANCE-M04 (Map roles to SGAuth positions; keep NUID product-side); AUTH-T89 (SenatePath and Attendance Manager user import) + +Export users (email, names, role) for the SGAuth import, receive the id mapping, backfill `sgauthUserId`, run the per-product cutover checklist. + +**Acceptance criteria** +- [ ] All active members map to SGAuth users; cutover completed with rollback rehearsed. + +### ATTENDANCE-M06 — Update auth and middleware tests + +**Priority:** Low · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, testing +**Depends on:** ATTENDANCE-M04 (Map roles to SGAuth positions; keep NUID product-side) + +Replace Supabase session mocks with SDK (or token) mocks in the existing auth-flow, middleware, and api-auth tests. + +**Acceptance criteria** +- [ ] CI green; old Supabase auth tests removed. diff --git a/docs/sgauth-design/out/linear-import-APLIO.csv b/docs/sgauth-design/out/linear-import-APLIO.csv new file mode 100644 index 0000000..9050c57 --- /dev/null +++ b/docs/sgauth-design/out/linear-import-APLIO.csv @@ -0,0 +1,75 @@ +"Title","Description","Priority","Estimate","Status","Labels" +"Export Aplio users for the SGAuth import and receive the id mapping","**Ticket:** APLIO-P01 +**Epic:** SGAuth integration — Aplio +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with invites and id mapping) + +Export `User` rows (id, email, name, isAdmin, deletedAt) to JSON for the SGAuth import; receive the SGAuth id-mapping file; verify every active Aplio user maps to an SGAuth user. + +**Acceptance criteria** +- [ ] Mapping verified with a script; discrepancies resolved before cutover.","High","2","Backlog","epic:sgauth-integration, phase:4, sgauth, migration" +"Replace local Better Auth with the SGAuth SDK","**Ticket:** APLIO-P02 +**Epic:** SGAuth integration — Aplio +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** APLIO-P01 (Export Aplio users for the SGAuth import and receive the id mapping); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) + +Remove `lib/auth/config.ts` (emailOTP, rate limits), `app/api/auth/[...path]`, the OTP email template and Resend usage for OTP, the login/OTP pages, and `resolveUser`'s Better Auth branch; implement `getCurrentUser`/`getOptionalUser`/`getDeactivatedSessionUser` over the SDK. Keep Aplio's deactivation concept (`deletedAt`) as a product-level state. Drop the `Session`, `Account`, and `Verification` tables in a follow-up migration after cutover. + +**Acceptance criteria** +- [ ] No `better-auth` dependency remains in Aplio; all `(auth)` routes are protected by the SGAuth proxy. +- [ ] Deactivated Aplio users still see the explanatory screen rather than a login loop.","High","5","Backlog","epic:sgauth-integration, phase:4, sgauth, backend" +"Key Aplio users by SGAuth user id","**Ticket:** APLIO-P03 +**Epic:** SGAuth integration — Aplio +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** APLIO-P02 (Replace local Better Auth with the SGAuth SDK) + +Add `sgauthUserId String @unique` to `User`, backfill from the id-mapping file, and make all lookups go through it; create-on-first-login for new applicants (name/email from the session). Keep Aplio's own `User.id` as the FK target for the many audit relations to avoid rewriting every table. Remove `neonAuthId`. + +**Acceptance criteria** +- [ ] Every existing application, answer, and email log still resolves to the right person after backfill (verification query).","High","5","Backlog","epic:sgauth-integration, phase:4, sgauth, data" +"Derive Aplio admin from an SGAuth position; managers stay product-level","**Ticket:** APLIO-P04 +**Epic:** SGAuth integration — Aplio +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** APLIO-P02 (Replace local Better Auth with the SGAuth SDK); AUTH-T44 (Seed the curated SGA position list) + +Replace `User.isAdmin` with a position check (e.g. `aplio-admin` or the relevant exec positions) in `requireAdmin`/`requireAdminOr404` and `buildReviewablePositionWhere`; position managers remain an Aplio relation. Provide a one-time script that assigns the `aplio-admin` position in SGAuth to current Aplio admins (via the SGAuth bulk assign API or CSV). + +**Acceptance criteria** +- [ ] Admin pages gate on positions; the `isAdmin` column is removed after cutover.","High","2","Backlog","epic:sgauth-integration, phase:4, sgauth, permissions" +"Applicant flow on SGAuth accounts (northeastern.edu required)","**Ticket:** APLIO-P05 +**Epic:** SGAuth integration — Aplio +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** APLIO-P02 (Replace local Better Auth with the SGAuth SDK) + +Applicants now create SGAuth accounts via northeastern.edu self-sign-up with verification. Decision: every student has a northeastern.edu address, so no domain exception exists; applicants who previously used another address must use their Northeastern one. Update the apply entry points to redirect to SGAuth sign-up with a redirect back to the position, and add copy explaining the Northeastern-email requirement. Existing Aplio users with non-NU addresses are imported as legacy accounts (AUTH-T88) and keep working until an admin moves them to their NU address. + +**Acceptance criteria** +- [ ] A new student can apply end-to-end: SGAuth sign-up → verify → land on the application form. +- [ ] Apply pages state the northeastern.edu requirement before the redirect.","High","3","Backlog","epic:sgauth-integration, phase:4, sgauth, product" +"Local dev bypass and preview topology","**Ticket:** APLIO-P06 +**Epic:** SGAuth integration — Aplio +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** APLIO-P02 (Replace local Better Auth with the SGAuth SDK); AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains) + +Keep the dev-bypass user cookie for local only (never on any deployed host), and configure `apply-dev.northeasternsga.com` with the dev SGAuth URL and cookie name. + +**Acceptance criteria** +- [ ] Bypass is a no-op on all Vercel deployments; preview branch domain shares the dev SSO session.","Low","1","Backlog","epic:sgauth-integration, phase:4, sgauth, dev" +"Aplio hard cutover outside an application window","**Ticket:** APLIO-P07 +**Epic:** SGAuth integration — Aplio +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** APLIO-P03 (Key Aplio users by SGAuth user id); APLIO-P04 (Derive Aplio admin from an SGAuth position; managers stay product-level); APLIO-P05 (Applicant flow on SGAuth accounts (northeastern.edu required)); AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Schedule the cutover when no application cycle is open; users were pre-imported and invited; announce; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window. + +**Acceptance criteria** +- [ ] Cutover completed; no applicant lost draft access (verified with a sample of drafts).","High","2","Backlog","epic:sgauth-integration, phase:4, sgauth, rollout" +"Update Aplio tests for SDK-based auth","**Ticket:** APLIO-P08 +**Epic:** SGAuth integration — Aplio +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** APLIO-P04 (Derive Aplio admin from an SGAuth position; managers stay product-level) + +Replace Better Auth session mocks with SDK session mocks; cover admin, manager, applicant, deactivated, and anonymous paths for guards and scopes. + +**Acceptance criteria** +- [ ] Aplio CI green; guard coverage unchanged or higher.","Medium","3","Backlog","epic:sgauth-integration, phase:4, sgauth, testing" diff --git a/docs/sgauth-design/out/linear-import-ATTENDANCE.csv b/docs/sgauth-design/out/linear-import-ATTENDANCE.csv new file mode 100644 index 0000000..0e49589 --- /dev/null +++ b/docs/sgauth-design/out/linear-import-ATTENDANCE.csv @@ -0,0 +1,56 @@ +"Title","Description","Priority","Estimate","Status","Labels" +"Decision: stay on Supabase (third-party auth) or move to Neon (SDK)","**Ticket:** ATTENDANCE-M01 +**Epic:** SGAuth integration — Attendance Manager +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** none + +Record the team's directive. Criteria: does Attendance Manager use Supabase-only features (Storage, Realtime, Edge Functions)? If not, moving to Neon is recommended (simpler, no JWT staleness, standard SDK). If yes, use the third-party auth path. Exactly one of M02 or M03 proceeds. + +**Acceptance criteria** +- [ ] Decision documented in the repo README with rationale; the unused path's tickets are cancelled.","High","1","Backlog","epic:sgauth-integration, phase:4, sgauth, decision" +"(Supabase path) Consume SGAuth via Supabase third-party auth","**Ticket:** ATTENDANCE-M02 +**Epic:** SGAuth integration — Attendance Manager +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** ATTENDANCE-M01 (Decision: stay on Supabase (third-party auth) or move to Neon (SDK)); AUTH-T81 (Integration guide for Supabase-backed products (third-party auth) plus the move-to-Neon alternative); AUTH-T60 (SDK: getAccessToken() for Supabase clients) + +Follow the Supabase integration guide: register SGAuth as a third-party auth provider on the Attendance Supabase project via the Management API script; configure supabase-js with the SDK's access-token provider (server) and the proxy route (browser); replace `supabaseAuthId` with `sgauthUserId` on `User` and remove any `auth.users` FKs/triggers; rewrite RLS to `auth.uid()` and position claims (`has_position('...')`); remove Supabase login/signup pages and the custom middleware in favor of the SGAuth proxy; remove the unused `password` column. Note the ≤10-minute JWT staleness and the MAU billing line. + +**Acceptance criteria** +- [ ] A user with the required position can read/write per RLS using an SGAuth token; a user without it is denied at the database. +- [ ] No `auth.users` dependency remains; Supabase login pages removed.","High","5","Backlog","epic:sgauth-integration, phase:4, sgauth, supabase" +"(Neon path) Migrate to Neon and integrate with the SDK","**Ticket:** ATTENDANCE-M03 +**Epic:** SGAuth integration — Attendance Manager +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** ATTENDANCE-M01 (Decision: stay on Supabase (third-party auth) or move to Neon (SDK)); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) + +Dump/restore Postgres to Neon, switch Prisma URLs, remove `@supabase/*`, install the SDK, protect routes with the proxy, key `User` by `sgauthUserId`, and move authorization from RLS to app-level checks using positions. + +**Acceptance criteria** +- [ ] App runs on Neon with SGAuth sessions; row counts match; no Supabase code remains.","High","5","Backlog","epic:sgauth-integration, phase:4, sgauth, neon" +"Map roles to SGAuth positions; keep NUID product-side","**Ticket:** ATTENDANCE-M04 +**Epic:** SGAuth integration — Attendance Manager +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** ATTENDANCE-M01 (Decision: stay on Supabase (third-party auth) or move to Neon (SDK)); AUTH-T44 (Seed the curated SGA position list) + +Define the permissions map from position keys to Attendance roles (MEMBER/OFFICER/etc.); NUID stays in Attendance's `User` table keyed by SGAuth user id and is never sent to SGAuth. + +**Acceptance criteria** +- [ ] Role checks derive from positions; NUID handling unchanged and documented.","Medium","2","Backlog","epic:sgauth-integration, phase:4, sgauth, permissions" +"User import and cutover","**Ticket:** ATTENDANCE-M05 +**Epic:** SGAuth integration — Attendance Manager +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** ATTENDANCE-M04 (Map roles to SGAuth positions; keep NUID product-side); AUTH-T89 (SenatePath and Attendance Manager user import) + +Export users (email, names, role) for the SGAuth import, receive the id mapping, backfill `sgauthUserId`, run the per-product cutover checklist. + +**Acceptance criteria** +- [ ] All active members map to SGAuth users; cutover completed with rollback rehearsed.","Medium","2","Backlog","epic:sgauth-integration, phase:4, sgauth, rollout" +"Update auth and middleware tests","**Ticket:** ATTENDANCE-M06 +**Epic:** SGAuth integration — Attendance Manager +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** ATTENDANCE-M04 (Map roles to SGAuth positions; keep NUID product-side) + +Replace Supabase session mocks with SDK (or token) mocks in the existing auth-flow, middleware, and api-auth tests. + +**Acceptance criteria** +- [ ] CI green; old Supabase auth tests removed.","Low","2","Backlog","epic:sgauth-integration, phase:4, sgauth, testing" diff --git a/docs/sgauth-design/out/linear-import-AUTH.csv b/docs/sgauth-design/out/linear-import-AUTH.csv new file mode 100644 index 0000000..665f342 --- /dev/null +++ b/docs/sgauth-design/out/linear-import-AUTH.csv @@ -0,0 +1,1169 @@ +"Title","Description","Priority","Estimate","Status","Labels" +"Provision the Neon project, branches, and roles for SGAuth","**Ticket:** AUTH-T01 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** none + +Create the production Neon project that SGAuth is built on. **SGAuth is built on Neon serverless Postgres and MUST NOT use Supabase in any form** (no Supabase Auth, no Supabase Postgres, no Supabase client libraries). + +Set up: +- Project `sgauth` in the SGAOperations Neon org, region closest to Vercel's default (us-east-1 / iad1). +- Branches: `main` (production), `dev` (shared non-production deployment), `test` (CI integration tests). Preview branches are created automatically later (AUTH-T05). +- A least-privilege application role for runtime queries and a separate migration role for `prisma migrate deploy`. +- Record the pooled connection string (host contains `-pooler`) as `DATABASE_URL` and the direct string as `DIRECT_URL` in a secrets store (Vercel env + the team password manager). Use `sslmode=verify-full`. +- Enable point-in-time restore on `main` (Neon history retention) and note the restore procedure. + +**Plan decision (red-team, accepted risk):** SGAuth launches on the Neon **Free** plan with scale-to-zero. Free suspends the compute for the rest of the month once 100 CU-hours are used, which would take every SGA product's login down; history/restore window is only 6 hours. AUTH-T101 adds quota monitoring and the upgrade runbook (Launch plan, pay-as-you-go at $0.106/CU-hour). Keep the SDK's 60-second cache so idle periods let the compute suspend. + +Reference: Neon Prisma guide (pooled vs direct URLs), Neon free plan limits (10 branches/project, 100 CU-hours/project/month, 0.5 GB storage per project, 6-hour history). + +**Acceptance criteria** +- [ ] Neon project exists with `main`, `dev`, and `test` branches; history retention set to the Free maximum on `main`. +- [ ] Two roles exist: runtime (no DDL) and migration (DDL); credentials stored in the team secrets store, not in git. +- [ ] `DATABASE_URL` (pooled) and `DIRECT_URL` (direct) connection strings are documented in `.env.example` with placeholder values and `sslmode=verify-full`. +- [ ] A short section in README states the Neon mandate and that Supabase is not used anywhere in SGAuth.","Urgent","2","Backlog","epic:foundation, phase:0, infra, neon" +"Remove Supabase from the auth repo (in-place migration to Neon)","**Ticket:** AUTH-T02 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** none + +The current repo scaffolds Supabase auth and DB. Strip it entirely so the codebase reflects the Neon mandate. + +Remove: +- Dependencies: `@supabase/ssr`, `@supabase/supabase-js`, `supabase` (CLI). +- `supabase/` directory (config.toml), `src/app/auth/callback/route.ts` (Supabase OTP callback), Supabase env vars from `.env.example`, and the Supabase steps in README. +- The `supabaseUserId` column and any Supabase-shaped assumptions in `prisma/schema.prisma` and `prisma/seed.ts` (the schema is replaced in AUTH-T10; this ticket only removes Supabase references so the repo builds). + +Also triage open branches that build on Supabase (AUTH-7, AUTH-8, auth-9, auth-10, auth-11, AUTH-14): close them with a comment pointing at this design, or cherry-pick any UI-only work that is still useful. Do not merge Supabase code. + +**Acceptance criteria** +- [ ] `grep -ri supabase` across the repo (excluding the design doc folder) returns nothing. +- [ ] `npm ci && npm run build && npm run lint && npm run format:check` pass with Supabase removed. +- [ ] README setup section no longer mentions Docker or `supabase start`; it points at Neon branches (final wording lands in AUTH-T08). +- [ ] Each open Supabase-based branch has a closing comment or a note in the PR explaining what was salvaged.","Urgent","3","Backlog","epic:foundation, phase:0, neon, chore" +"Install Better Auth 1.7 with the Prisma adapter and mount the handler","**Ticket:** AUTH-T03 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T02 (Remove Supabase from the auth repo (in-place migration to Neon)); AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)) + +Add self-managed Better Auth (latest 1.7.x) and `@better-auth/prisma-adapter` on Neon, mirroring the pattern Aplio already uses. + +- `src/lib/auth/config.ts`: `betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql' }), baseURL, secret, trustedOrigins, advanced: { database: { generateId: false } }, plugins: [nextCookies()] })`. Prisma generates `uuid(7)` ids. +- `src/app/api/auth/[...all]/route.ts` mounting `toNextJsHandler(auth)`. +- `baseURL` = `https://auth.northeasternsga.com` in production, `https://auth-dev.northeasternsga.com` on the dev deployment, and a local dev hostname (AUTH-T34) locally. Never derive it from `VERCEL_URL` for cookies (the cookie domain must be the parent domain). +- `trustedOrigins` is temporarily a static list; AUTH-T56 replaces it with the product registry. +- Add `server-only` guards and the `auth.api` typed server helper. + +Plugins (jwt, admin, twoFactor, customSession) are added in their own tickets. + +**Acceptance criteria** +- [ ] `GET /api/auth/ok` (or equivalent Better Auth health route) returns 200 on the dev deployment. +- [ ] Better Auth's initialization-time schema validation passes against the Prisma client (1.7 rejects requests on mismatch). +- [ ] `BETTER_AUTH_SECRET` is required (32+ chars) and the app refuses to start without it. +- [ ] Unit test confirms `baseURL` resolution per environment and that `VERCEL_URL` is never used for the production cookie domain.","Urgent","3","Backlog","epic:foundation, phase:0, backend, better-auth" +"Configure Prisma 7 for Neon (pooled runtime, direct migrations)","**Ticket:** AUTH-T04 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T01 (Provision the Neon project, branches, and roles for SGAuth) + +Wire Prisma 7 to Neon following Neon's Prisma guide. + +- `prisma.config.ts`: `datasource.url = env('DIRECT_URL')` (used by the CLI for migrations). No `url` in the schema datasource block (Prisma 7 rule). +- Runtime client (`src/lib/prisma.ts`): driver adapter with the **pooled** `DATABASE_URL`. Use `@prisma/adapter-pg` with a `pg.Pool` sized for Vercel functions (`max: 5`, `idleTimeoutMillis: 10_000`), or `@prisma/adapter-neon` if WebSocket transport is preferred; document the choice. +- Global singleton in dev to avoid pool exhaustion on HMR. +- Add `?sslmode=verify-full`. Verify whether `pgbouncer=true` is needed with Neon's pooler when using a driver adapter and document the result. + +**Acceptance criteria** +- [ ] `npx prisma migrate deploy` runs against `DIRECT_URL`; runtime queries use the `-pooler` host (verified via Neon monitoring or `pg_stat_activity`). +- [ ] Cold start on Vercel executes a `SELECT 1` through the pooled connection in under 300 ms p50 (measured on the dev deployment). +- [ ] README documents why two URLs exist and which commands use which.","High","2","Backlog","epic:foundation, phase:0, backend, neon, prisma" +"Create the Vercel project, custom domains, environments, and Neon preview branching","**Ticket:** AUTH-T05 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T01 (Provision the Neon project, branches, and roles for SGAuth); AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler) + +Host SGAuth on Vercel (Node runtime, not Edge, because of Prisma). + +- Vercel project `sgauth` linked to the repo; production branch `main` → `auth.northeasternsga.com`; a protected `dev` branch → `auth-dev.northeasternsga.com` (see AUTH-T34 for why a dev SGAuth deployment on the real parent domain is required for product previews). +- Install the Neon–Vercel integration so each preview deployment gets its own Neon branch (from `dev`) and `DATABASE_URL`/`DIRECT_URL` injected; enable automatic deletion of preview branches when the deployment is deleted (free plan allows 10 branches). +- Environment variables scoped per environment (production / preview / development). Secrets never shared across environments; `BETTER_AUTH_SECRET` differs per environment. +- Build command runs `prisma generate` and `prisma migrate deploy` (AUTH-T16) before `next build`. +- Deployment protection: previews password- or SSO-protected via Vercel; production public. +- **No Vercel cron jobs.** Vercel Hobby allows two jobs at once-per-day granularity and rejects more frequent schedules at deploy time; all scheduled work runs from GitHub Actions (AUTH-T38). Do not add a `crons` section to `vercel.json`. +- SGAuth's own `*.vercel.app` preview deployments cannot set a `northeasternsga.com` cookie; they run with `SGAUTH_ENV=preview` (host-only cookie, AUTH-T26) so previews are testable in isolation. + +**Acceptance criteria** +- [ ] `https://auth.northeasternsga.com` and `https://auth-dev.northeasternsga.com` serve the app over TLS with valid certificates. +- [ ] Opening a PR creates a Neon preview branch and a preview deployment that runs migrations against it; closing the PR deletes the branch. +- [ ] Vercel env vars are documented in `docs/ENVIRONMENTS.md` with which environment each applies to.","High","3","Backlog","epic:foundation, phase:0, infra, vercel, neon" +"Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch","**Ticket:** AUTH-T06 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler); AUTH-T93 (Test harness: vitest, Neon test branch, factories, and test mailer) + +Extend `.github/workflows/ci.yml` for the new stack. + +Jobs: typecheck, lint, format:check (existing), plus: +- `migrations`: `prisma migrate diff` to ensure schema and migrations are in sync and no drift; fails if a migration is missing. +- `test`: vitest unit + integration tests against the Neon `test` branch (AUTH-T93). Use a Neon API key stored as a GitHub secret to reset the branch (`neonctl branches reset`) before the run so tests start clean. Serialize runs with a GitHub Actions `concurrency` group (`neon-test-branch`) so parallel PRs do not share the branch mid-run; per-run branches are avoided because of the 10-branch cap. +- Cache npm and Prisma engines. +- Required checks + branch protection on `main` and `dev` (PR required, at least one review, CI green). + +**Acceptance criteria** +- [ ] CI runs on every PR and push to `main`/`dev`, all jobs green on a clean checkout. +- [ ] A PR that changes `schema.prisma` without a migration fails the `migrations` job with a clear message. +- [ ] Branch protection rules are enabled and documented in CONTRIBUTING.md.","High","3","Backlog","epic:foundation, phase:0, ci, chore" +"Validate environment variables at startup with zod","**Ticket:** AUTH-T07 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T02 (Remove Supabase from the auth repo (in-place migration to Neon)) + +Add `src/lib/env.ts` that parses `process.env` with zod (server and public schemas separated) and fails fast with a readable error listing missing/invalid vars. Rewrite `.env.example` to the final variable list: `DATABASE_URL`, `DIRECT_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `SGAUTH_COOKIE_DOMAIN`, `SGAUTH_COOKIE_PREFIX`, `SGAUTH_ENV` (production|dev|preview|local), `RESEND_API_KEY`, `RESEND_FROM_EMAIL`, `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`, `POSTHOG_KEY`, `POSTHOG_HOST`, `CRON_SECRET`, `NEON_API_KEY` (CI only). + +**Acceptance criteria** +- [ ] Starting the app with a missing required var prints the variable name and exits non-zero. +- [ ] `.env.example` lists every variable with a one-line purpose comment and no real values. +- [ ] No code reads `process.env` directly outside `src/lib/env.ts` (enforced by an ESLint `no-restricted-syntax` rule).","Medium","1","Backlog","epic:foundation, phase:0, backend, chore" +"Rewrite README and developer bootstrap for Neon branches","**Ticket:** AUTH-T08 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables); AUTH-T44 (Seed the curated SGA position list) + +Replace the Docker/Supabase setup with: create a personal Neon branch from `dev` (`neonctl branches create --parent dev --name `), copy its URLs into `.env`, run `npx prisma migrate dev`, run `npm run seed` (curated positions + a local Primary Admin from `SEED_PRIMARY_ADMIN_EMAIL`), then `npm run dev`. Include the local SSO hostname setup from AUTH-T34 and the scripts table. + +**Acceptance criteria** +- [ ] A new contributor can go from clone to logged-in local Primary Admin in under 15 minutes following only the README. +- [ ] The seed is idempotent (re-running changes nothing) and refuses to run when `SGAUTH_ENV=production`.","Medium","2","Backlog","epic:foundation, phase:0, docs, chore" +"Automate Neon branch hygiene to stay under plan limits","**Ticket:** AUTH-T09 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching) + +The free plan allows 10 branches per project. Add a scheduled GitHub Action (daily) that lists branches via the Neon API and deletes preview branches whose PR is closed/merged or that are older than 7 days, excluding `main`, `dev`, `test`, and branches matching `dev-*` (personal). Post a summary to the workflow log. + +**Acceptance criteria** +- [ ] The action runs daily, is idempotent, and never deletes protected branches (unit test on the filter). +- [ ] Branch count stays below 8 in steady state; a warning is logged at 8 or more.","Medium","2","Backlog","epic:foundation, phase:0, infra, neon" +"Core schema: User plus Better Auth Session, Account, and Verification tables","**Ticket:** AUTH-T10 +**Epic:** E2 Data Model & Migrations +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler); AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)) + +Define the identity model in `prisma/schema.prisma` (Neon). Better Auth core tables plus SGAuth fields: + +**User**: `id` (uuid v7, PK), `email` (unique, stored lower-cased; use `citext` or a lower-case check), `emailVerified`, `name` (required display name), `preferredName` (optional), `isAdmin` (bool), `isPrimaryAdmin` (bool), `status` (enum ACTIVE | DEACTIVATED | DELETED — DELETED is a tombstone: the row and id survive with PII scrubbed so product foreign keys stay valid), `deactivatedAt`, `deletedAt`, `legacyEmail` (bool; true for imported accounts whose address is not northeastern.edu), `lastLoginAt`, `passwordChangedAt`, `twoFactorEnabled` (plugin), `createdAt`, `updatedAt`. +**Session**: Better Auth fields (`id`, `token` unique, `userId`, `expiresAt`, `ipAddress`, `userAgent`, `createdAt`, `updatedAt`) plus `absoluteExpiresAt` (createdAt + 90 days) and `lastReauthAt`. The Better Auth admin plugin is **not** used (custom authz instead), so no `impersonatedBy`/ban columns. +**KnownDevice**: `id`, `userId`, `tokenHash`, `createdAt`, `lastSeenAt`, `userAgent` — backs the lockout exemption (AUTH-T64). +**Account**: Better Auth fields; `password` holds either a scrypt hash or an imported `bcrypt$...` hash (AUTH-T21). **Verification**: Better Auth fields. + +No NUID, phone, pronouns, or photo. Products keep extra fields keyed by SGAuth user id. Write the initial migration and a Prisma-level test that the Better Auth adapter validates against this schema. + +**Acceptance criteria** +- [ ] Migration applies cleanly on an empty Neon branch and Better Auth schema validation passes at startup. +- [ ] Email uniqueness is case-insensitive (`Alice@Northeastern.edu` and `alice@northeastern.edu` collide) with a test. +- [ ] `isPrimaryAdmin = true` implies `isAdmin = true` via a CHECK constraint. +- [ ] Indexes exist on `Session.userId`, `Session.expiresAt`, `Account.userId`, `Verification.identifier`.","High","3","Backlog","epic:data-model, phase:0, backend, prisma" +"Positions schema: Position, UserPosition, and retired keys","**Ticket:** AUTH-T11 +**Epic:** E2 Data Model & Migrations +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +**Position**: `id` (uuid v7), `key` (unique, immutable, regex `^[a-z0-9]+(?:-[a-z0-9]+)*$`, 2–64 chars), `name` (display, 2–100 chars, editable), `category` (optional text for admin grouping), `description` (optional), `deletedAt` (soft delete), `createdById`, `updatedById`, timestamps. +**UserPosition**: composite PK (`userId`, `positionId`), `assignedById`, `assignedAt`. Unassignment deletes the row; history lives in the audit log. Soft-deleting a position keeps rows for history but they are excluded from sessions. +**RetiredPositionKey**: `key`, `retiredAt`, `positionId` — written on soft delete; the service layer refuses reuse of a key retired less than 365 days ago. +DB-level: partial unique index on `Position(key) WHERE deletedAt IS NULL`; CHECK constraint on key format; trigger or service check enforcing a maximum of 50 active positions per user. + +**Acceptance criteria** +- [ ] Creating a position with an invalid key or a key retired within 365 days fails at the service layer with a specific error code (and the DB rejects malformed keys). +- [ ] A user cannot hold more than 50 active positions (test at the boundary). +- [ ] Soft-deleted positions never appear in session or JWT output (covered by AUTH-T45).","High","3","Backlog","epic:data-model, phase:1, backend, prisma, positions" +"Primary Admin invariants at the database level and the PrimaryAdminTransfer table","**Ticket:** AUTH-T12 +**Epic:** E2 Data Model & Migrations +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +Enforce in Postgres, independent of application code: +- Partial unique index: exactly one row may have `isPrimaryAdmin = true` (`CREATE UNIQUE INDEX one_primary_admin ON ""User"" ((true)) WHERE ""isPrimaryAdmin""`). +- Trigger `protect_primary_admin` BEFORE UPDATE/DELETE on User: reject setting `isAdmin = false`, `status = DEACTIVATED`, or deleting the row while `isPrimaryAdmin = true`; reject clearing `isPrimaryAdmin` unless the transaction has set the session variable `sgauth.pa_transfer = 'on'` (set only by the transfer service and the break-glass script). +**PrimaryAdminTransfer**: `id`, `fromUserId`, `toUserId`, `status` (PENDING_ACCEPTANCE | COOLING | COMPLETED | CANCELLED | EXPIRED), `createdAt`, `acceptanceExpiresAt` (+7 days), `acceptedAt`, `executesAt` (acceptedAt + 24 h), `cancelTokenHash`, `completedAt`, `cancelledAt`, `cancelledById`, `reason`. Partial unique index allowing at most one transfer in PENDING_ACCEPTANCE or COOLING. + +**Acceptance criteria** +- [ ] SQL tests: inserting a second Primary Admin fails; updating the PA to non-admin fails; deleting the PA fails; a transfer inside a transaction with the session variable set succeeds and leaves exactly one PA. +- [ ] At most one in-flight transfer can exist (unique index test). +- [ ] Migration is reversible and documented with the reasoning in a comment header.","High","3","Backlog","epic:data-model, phase:1, backend, prisma, admin, security" +"Append-only AuditEvent table","**Ticket:** AUTH-T13 +**Epic:** E2 Data Model & Migrations +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +**AuditEvent**: `id` (uuid v7), `type` (string enum from the event catalog in AUTH-T73), `actorUserId` (nullable; null for system/break-glass), `actorType` (USER | SYSTEM | BREAK_GLASS), `targetType` (USER | POSITION | PRODUCT | SESSION | TRANSFER), `targetId`, `metadata` (jsonb, no secrets), `ip`, `userAgent`, `createdAt`. Index on (`type`, `createdAt`), (`actorUserId`, `createdAt`), (`targetType`, `targetId`, `createdAt`). +Append-only: a trigger rejects UPDATE and DELETE except for the anonymization job (AUTH-T78), which may null `ip`/`userAgent` and replace user-identifying metadata when a user is tombstoned, gated by the same session-variable pattern as AUTH-T12. The session-variable gate protects against accidental writes from application code; it is not a security boundary against anyone holding the database role. +`type` is validated in TypeScript against the catalog (AUTH-T73), not by a DB CHECK, so adding an event type does not require a migration. + +**Acceptance criteria** +- [ ] UPDATE/DELETE on AuditEvent fails from the runtime role (test), while the anonymization path succeeds. +- [ ] A unit test rejects emitting an event whose `type` is not in the catalog.","High","2","Backlog","epic:data-model, phase:1, backend, prisma, audit" +"Product registry schema","**Ticket:** AUTH-T14 +**Epic:** E2 Data Model & Migrations +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +**Product**: `id`, `slug` (unique, kebab-case), `name`, `description`, `baseUrl` (must be `https://` and end with `.northeasternsga.com`; validated in service and by CHECK), `iconUrl` (optional, https only), `isActive`, `sortOrder`, `visibleToPositionKeys` (text[]; empty = visible to everyone), `loginRedirectPath` (optional path appended after login), `createdById`, timestamps. Seed the dev branch with SGAuth itself plus VaultZ, Chambers, Aplio placeholders. + +**Acceptance criteria** +- [ ] Inserting a product with an http:// or non-northeasternsga.com base URL fails. +- [ ] Slug uniqueness enforced; renames of `name` do not change `slug`.","Medium","2","Backlog","epic:data-model, phase:2, backend, prisma, registry" +"Security tables: AccountLock, UnlockToken, Jwks, TwoFactor","**Ticket:** AUTH-T15 +**Epic:** E2 Data Model & Migrations +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +- **AccountLock**: `userId` (PK), `failedCount`, `windowStartedAt`, `lockedUntil`, `lockLevel` (0..N for escalation), `lastFailedAt`, `lastFailedIp`. +- **UnlockToken**: `id`, `userId`, `tokenHash`, `expiresAt`, `usedAt`. +- **Jwks** and **TwoFactor** tables as required by the Better Auth jwt and twoFactor plugins (generate via `npx @better-auth/cli generate` and reconcile with Prisma naming). +- **KnownDevice** (see AUTH-T10) for the lockout exemption. +- No rate-limit table: rate limiting uses Upstash (AUTH-T63); disable Better Auth's DB-backed limiter storage. + +**Acceptance criteria** +- [ ] Plugin schema validation passes for jwt and twoFactor at startup. +- [ ] Unlock tokens are stored hashed (SHA-256) and single-use (test).","Medium","2","Backlog","epic:data-model, phase:1, backend, prisma, security" +"Migration workflow: deploy in build, never migrate dev against production","**Ticket:** AUTH-T16 +**Epic:** E2 Data Model & Migrations +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)); AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching) + +Document and enforce: developers run `prisma migrate dev` only against personal/preview Neon branches; production and dev deployments run `prisma migrate deploy` during the Vercel build using `DIRECT_URL`. Add a guard script that aborts `migrate dev`/`db push`/`migrate reset` when `SGAUTH_ENV=production` or when `DIRECT_URL` points at the `main` branch host. Write `docs/MIGRATIONS.md` including how to hand-edit generated SQL for triggers/indexes Prisma cannot express, and the **expand/contract rule**: migrations run during the build while the previous deployment is still serving traffic, so every migration must be backward-compatible with the currently deployed code (add columns nullable first, drop in a later release). + +**Acceptance criteria** +- [ ] Running `npm run prisma:migrate-dev` with a production `DIRECT_URL` exits non-zero before touching the database (test with a fake host). +- [ ] Vercel production build logs show `migrate deploy` applying pending migrations.","Medium","1","Backlog","epic:data-model, phase:0, backend, prisma, docs" +"Email + password sign-in and self-sign-up restricted to northeastern.edu","**Ticket:** AUTH-T17 +**Epic:** E3 Authentication Core +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler); AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables) + +Enable `emailAndPassword` in Better Auth: `minPasswordLength: 12`, `maxPasswordLength: 128`, default scrypt hashing, `requireEmailVerification: true`, `autoSignIn: false` after sign-up. +- Self-sign-up: a `databaseHooks.user.create.before` hook rejects emails whose domain is not `northeastern.edu` (exact match, lower-cased) unless the creation is admin/import initiated (flag passed via context). Admin-created users may have any domain. +- Normalize email (trim, lower-case) before lookup and storage. +- New self-signed-up users have no positions and no admin flags. +- Deactivated users are refused at sign-in and at session creation (`session.create.before` hook) with a stable error code. +- Record `lastLoginAt` and emit audit events for LOGIN_SUCCESS / LOGIN_FAILED (AUTH-T73). +- **Account pre-hijack defense (red-team):** with `requireEmailVerification`, Better Auth answers a sign-up for an existing email with success (enumeration protection). If that existing account is still **unverified**, the new sign-up must overwrite its password and name (the address owner is whoever verifies), and verification must revoke every existing session. Verified accounts are never overwritten. +- Note: Better Auth re-sends the verification email on every sign-in attempt by an unverified user; the mailer's per-recipient caps (AUTH-T20) and sign-in rate limits (AUTH-T63) bound the abuse. +No breached-password (HIBP) check, per decision. + +**Acceptance criteria** +- [ ] Sign-up with `x@gmail.com` is rejected with `EMAIL_DOMAIN_NOT_ALLOWED`; `x@northeastern.edu` succeeds and requires verification before login. +- [ ] Passwords under 12 characters are rejected client- and server-side. +- [ ] A deactivated user cannot sign in and any existing session returns 401 from the session endpoint. +- [ ] Pre-hijack test: attacker signs up with victim's email + password A; victim signs up with password B and verifies; password A no longer works and B does. +- [ ] Integration tests cover success, wrong password, unverified email, deactivated user, and domain rejection.","Urgent","3","Backlog","epic:auth-core, phase:1, backend, better-auth" +"Email verification for self-sign-up with resend limits","**Ticket:** AUTH-T18 +**Epic:** E3 Authentication Core +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) + +Send a verification email on sign-up via the mailer (AUTH-T20) with a link valid for 24 hours; the link lands on a confirmation page and the token is consumed only on the button POST (AUTH-T103, Safe Links). Verifying revokes any pre-existing sessions, logs the user in, and redirects to the validated `redirect` target (AUTH-T31) or the account page. Resend is limited to 3 per address per hour (Upstash key `verify:`). Unverified accounts older than 7 days are purged by the retention job (AUTH-T78). The UI shows the same message whether or not the address exists. + +**Acceptance criteria** +- [ ] Clicking the link verifies the address once; a second click shows an already-verified message; expired links show a resend option. +- [ ] Fourth resend within an hour returns 429 without sending.","High","2","Backlog","epic:auth-core, phase:1, backend, email" +"Password reset flow","**Ticket:** AUTH-T19 +**Epic:** E3 Authentication Core +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) + +Forgot-password request (email only; uniform response regardless of existence), reset token valid 1 hour, single use, delivered by email; the token is consumed only when the new-password form is submitted, never on link open (AUTH-T103). On successful reset: set `passwordChangedAt`, revoke all other sessions, clear any account lock, send a security notice email, emit PASSWORD_RESET audit event. Rate limit: 3 requests per email per 10 minutes and per IP (AUTH-T63). Imported bcrypt accounts (AUTH-T21) reset to scrypt. + +**Acceptance criteria** +- [ ] Reset for a non-existent email returns the same response and timing profile (within 50 ms) as for an existing one. +- [ ] Using a token twice fails; after reset, previously issued session cookies are rejected. +- [ ] Audit and notice email are produced (asserted with the test mailer).","High","3","Backlog","epic:auth-core, phase:1, backend, email, security" +"Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps","**Ticket:** AUTH-T20 +**Epic:** E3 Authentication Core +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T07 (Validate environment variables at startup with zod) + +Password login keeps email volume low (verification, reset, invite, lock/unlock, security notices, PA transfer steps). To avoid sharing Aplio's and Chambers' Resend quota: +- Separate Resend API key and a dedicated sending domain `mail.northeasternsga.com` with SPF, DKIM, and DMARC (p=quarantine) records; `from` = `SGA Auth `. +- `src/lib/email/mailer.ts` provider-agnostic interface (`sendEmail({ to, template, data })`) with a Resend implementation and a console/preview transport for local/test. +- Templates (React Email or plain HTML+text): verify, reset, invite/set-password, account locked, unlock, security notice (password changed / new admin grant), PA transfer initiated / accepted / cancelled / completed, inactivity notice. +- Caps: per-recipient 10 emails per hour and 30 per day; global daily cap (env, default 500) with an alert at 80%. Log every send with template and recipient hash to the log stream. + +**Acceptance criteria** +- [ ] DKIM/SPF/DMARC verified in the Resend dashboard; a test email to Gmail and Outlook lands in the inbox with aligned DMARC. +- [ ] Exceeding the per-recipient cap is refused with a logged warning, not an exception to the user. +- [ ] All templates render in both HTML and text and are snapshot-tested.","High","3","Backlog","epic:auth-core, phase:1, backend, email, infra" +"Accept imported Chambers bcrypt hashes with lazy re-hash to scrypt","**Ticket:** AUTH-T21 +**Epic:** E3 Authentication Core +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu) + +Supabase Auth (GoTrue) stores bcrypt hashes (`$2a$`/`$2b$`). Configure Better Auth `emailAndPassword.password.verify`: if the stored hash starts with `bcrypt$` (marker set by the import, AUTH-T87), verify with `bcryptjs`; on success, re-hash the plaintext with scrypt and update the Account row in the same request, then emit PASSWORD_REHASHED. Otherwise use the default scrypt verify. Never log plaintext. Remove the bcrypt path after all imported accounts have re-hashed or been reset (tracked by a metric). + +**Acceptance criteria** +- [ ] A user imported with a bcrypt hash can log in with their Chambers password on the first try and their Account row is scrypt afterwards. +- [ ] Wrong password against a bcrypt hash fails without re-hashing. +- [ ] A metric/query reports how many `bcrypt$` hashes remain.","High","2","Backlog","epic:auth-core, phase:3, backend, migration, security" +"Invite flow: admin-created users receive a set-password link","**Ticket:** AUTH-T22 +**Epic:** E3 Authentication Core +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) + +When an admin creates a user (or the bulk import runs), create the User with `emailVerified = true` (admin vouches for the address), no password, and send an invite email with a set-password token valid 7 days (consumed on form submit, AUTH-T103). Setting the password marks the account ready and logs the user in. Admins can resend an invite (rate-limited 3/day per user). Users who never accept are listed in the admin UI as 'Invited'. If an invited (password-less) user tries to self-sign-up with the same email, the login page copy points them to 'Forgot password / set password' rather than creating a second account. + +**Acceptance criteria** +- [ ] An invited user cannot log in with any password until they set one via the link. +- [ ] Expired invite shows a message and the admin sees a 'Resend invite' action.","Medium","2","Backlog","epic:auth-core, phase:2, backend, email, admin" +"Change password (current password + re-auth), revoke other sessions","**Ticket:** AUTH-T23 +**Epic:** E3 Authentication Core +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions) + +Authenticated users change their password by providing the current password; requires a fresh re-authentication (AUTH-T32). On success: update hash, set `passwordChangedAt`, revoke every other session, send a security notice, emit PASSWORD_CHANGED. + +**Acceptance criteria** +- [ ] Wrong current password fails and counts toward account lockout. +- [ ] Other devices are logged out after change (integration test with two sessions).","Medium","2","Backlog","epic:auth-core, phase:2, backend, security" +"Admin-initiated email change with re-verification and privilege rules","**Ticket:** AUTH-T24 +**Epic:** E3 Authentication Core +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T36 (Admin user-management endpoints); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps) + +Users cannot change their own email in v1 (identity anchoring). Admins can set a new email on a **non-admin** user; only the Primary Admin can change an admin's email; the Primary Admin's own email can be changed only by the Primary Admin. Every email change requires fresh re-authentication (AUTH-T32) so a hijacked session cannot redirect reset or transfer-cancel emails. The account keeps working with the old address until the user verifies the new one via a link sent to the new address (POST-consumed, AUTH-T103); a notice goes to the old address. Changing a `legacyEmail` account to a northeastern.edu address clears the flag. Audit EMAIL_CHANGE_REQUESTED / EMAIL_CHANGED. + +**Acceptance criteria** +- [ ] Old address remains valid until verification; after verification the old address cannot log in. +- [ ] Attempting to change to an address already in use fails without revealing which account owns it. +- [ ] An admin cannot change another admin's or the PA's email (authz test); re-auth is required in every case.","Low","2","Backlog","epic:auth-core, phase:3, backend, admin" +"Login, sign-up, forgot/reset, and verify pages","**Ticket:** AUTH-T25 +**Epic:** E3 Authentication Core +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T18 (Email verification for self-sign-up with resend limits); AUTH-T19 (Password reset flow); AUTH-T31 (Safe post-login redirects validated against the product registry) + +Build the public auth pages with shadcn/ui and react-hook-form + zod: `/login` (email, password, preserves redirect param, links to forgot and sign-up), `/sign-up` (northeastern.edu hint, name, password with live length feedback), `/forgot-password`, `/reset-password/[token]`, `/verify-email/[token]`, `/set-password/[token]` (invites), `/locked` (explains lock and unlock email). Map Better Auth error codes to friendly copy without leaking account existence. Show SGA branding and a one-line explanation that this login works across all SGA tools. + +**Acceptance criteria** +- [ ] All pages are keyboard navigable, pass axe with no serious violations, and work at 360 px width. +- [ ] Wrong password and unknown email share the same copy; unverified, locked, and deactivated states have distinct copy only when shown to the account's own verified session or email link. +- [ ] After login the user lands on the validated redirect target or `/account`.","High","3","Backlog","epic:auth-core, phase:1, frontend, ui" +"Parent-domain session cookie for *.northeasternsga.com","**Ticket:** AUTH-T26 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler) + +Configure Better Auth cookies so a single session spans every SGA subdomain: +- `advanced.crossSubDomainCookies = { enabled: true, domain: 'northeasternsga.com' }` in production and dev; `advanced.cookiePrefix` = `sgauth` (production) and `sgauth-dev` (dev deployment) so the two environments never collide on the shared parent domain. +- `defaultCookieAttributes`: `httpOnly: true`, `secure: true`, `sameSite: 'lax'`, `path: '/'`. Production cookie name becomes `__Secure-sgauth.session_token`. `__Host-` is impossible with a Domain attribute; document why. +- `session.cookieCache` disabled (no `session_data` cookie) so revocation is immediate. +- `SGAUTH_ENV=preview` (SGAuth's own `*.vercel.app` previews): cross-subdomain cookies **disabled**, host-only cookie, so previews can log in at all. +- Local dev uses the topology from AUTH-T34. Validate the whole configuration early with the spike in AUTH-T104 (there are unresolved community reports of cross-subdomain cookies being set then dropped in some setups). +Document the threat model: any compromised or dangling `*.northeasternsga.com` host can read the cookie **and can set a same-named cookie on the parent domain** (cookie tossing / login CSRF: the victim is silently logged into an attacker-controlled account). Cookie values are signed so they cannot be forged, but `__Host-` cannot be used with a Domain attribute, so subdomain hygiene (AUTH-T68) is the control. + +**Acceptance criteria** +- [ ] After login at auth.northeasternsga.com the browser holds one cookie with Domain=northeasternsga.com, Secure, HttpOnly, SameSite=Lax, and the `__Secure-` prefix. +- [ ] A request to a product stub on another subdomain carries the cookie and the session endpoint resolves it. +- [ ] Dev and production cookies coexist in one browser without interfering (different names).","Urgent","2","Backlog","epic:sessions, phase:1, backend, sso, security" +"Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache","**Ticket:** AUTH-T27 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables); AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +`session.expiresIn = 30 days`, `session.updateAge = 1 day` (sliding). Leave Better Auth's `freshAge` at its default: it gates Better Auth's own fresh-session endpoints and is **not** the re-auth mechanism (AUTH-T32 uses `lastReauthAt`). Absolute cap: set `absoluteExpiresAt = createdAt + 90 days` in `session.create.before`, reject sessions past it in the session endpoint, **and** have the daily retention job (AUTH-T78) delete any session whose `createdAt` is older than 90 days, because Better Auth's own endpoints (e.g. `/token`) do not run the custom check. Capture IP (from `x-forwarded-for` first hop on Vercel) and user agent. Sessions and JWTs are DB-backed; no cookie cache. + +**Acceptance criteria** +- [ ] A session used daily is still valid on day 29 and invalid on day 91 (time-travel test), including at `/api/auth/token`. +- [ ] A session unused for 31 days is invalid. +- [ ] Session rows store IP and user agent for the account page.","High","2","Backlog","epic:sessions, phase:1, backend, sso" +"Session endpoint for products: user, email, positions, admin flags","**Ticket:** AUTH-T28 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T27 (Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache); AUTH-T11 (Positions schema: Position, UserPosition, and retired keys) + +Products resolve the shared cookie by calling SGAuth server-side. Implement `GET /api/sgauth/session` (a thin wrapper over Better Auth `getSession` plus the `customSession` plugin) returning: +```json +{ ""user"": { ""id"": ""uuid"", ""email"": ""..."", ""name"": ""..."", ""preferredName"": null, ""isAdmin"": false, ""isPrimaryAdmin"": false }, + ""positions"": [{ ""key"": ""vp-finance"", ""name"": ""Vice President of Finance"" }], + ""session"": { ""id"": ""..."", ""expiresAt"": ""..."", ""absoluteExpiresAt"": ""..."", ""createdAt"": ""..."" } } +``` +- 401 with `{ ""error"": ""unauthenticated"" }` when the cookie is missing, expired, revoked, past the absolute cap, or the user is deactivated. +- Positions read live from the DB (active, non-deleted), sorted by key. +- Headers: `Cache-Control: no-store`, `Vary: Cookie`. Accept the cookie via the `Cookie` header only (no query/body tokens). +- Also expose `GET /api/sgauth/session/minimal` returning only user id + positions for hot paths. Publish the JSON schema in the SDK. + +**Acceptance criteria** +- [ ] Contract tests cover the 200 shape, 401 cases, position ordering, and that deactivation/revocation is reflected on the very next call. +- [ ] p95 latency under 120 ms from a Vercel function in the same region (measured against dev). +- [ ] Response never includes password hashes, tokens, or MFA secrets (schema assertion).","Urgent","3","Backlog","epic:sessions, phase:1, backend, sso, api" +"ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products","**Ticket:** AUTH-T29 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T28 (Session endpoint for products: user, email, positions, admin flags); AUTH-T15 (Security tables: AccountLock, UnlockToken, Jwks, TwoFactor) + +Add the Better Auth `jwt` plugin configured for Supabase third-party auth: +- `jwks.keyPairConfig = { alg: 'ES256' }`; **automatic rotation disabled** (no `rotationInterval`). Supabase learns about a new key only when it re-fetches the JWKS (up to ~30 minutes), so an automatic rotation would reject fresh tokens for that window. Rotation is manual (annually or on incident) per the runbook in AUTH-T69: generate the new key, push the combined JWKS to every Supabase project via `custom_jwks`, then switch signing; keep the old key published for 7 days. +- `jwt.issuer = 'https://auth.northeasternsga.com'` (no trailing slash; discovery must resolve at `{issuer}/.well-known/openid-configuration`), `jwt.audience = 'authenticated'` (matches Supabase's own token convention so any audience check passes), `jwt.expirationTime = '10m'`, `getSubject = user.id` (UUID, required because Supabase `auth.uid()` casts `sub` to uuid). Private keys encrypted at rest (default). +- `definePayload`: `{ email, name, role: 'authenticated', positions: [keys], is_admin, is_primary_admin }`. Include the `kid` header (plugin default). `role: 'authenticated'` is required so Supabase maps the request to the `authenticated` Postgres role. +- Endpoints: `/api/auth/jwks` (plugin) and rewrites for `/.well-known/jwks.json` and `/.well-known/openid-configuration` (`issuer`, `jwks_uri`, `id_token_signing_alg_values_supported: ['ES256']`, minimal fields) so Supabase can be pointed at the issuer URL. +- Token endpoint `/api/auth/token` requires a valid session cookie (rate-limited in AUTH-T63). + +**Acceptance criteria** +- [ ] A token from `/api/auth/token` verifies with `jose` against the JWKS with issuer and audience checks and contains `sub` (uuid), `role: 'authenticated'`, and `positions`. +- [ ] `/.well-known/openid-configuration` returns valid JSON with `jwks_uri` resolving to the live key set. +- [ ] After a manual rotation on dev, tokens signed by the previous key verify for 7 days and new tokens carry the new `kid`; the JWKS lists both keys during the overlap. +- [ ] Token size stays under 2 KB with 50 positions of 64 characters (test).","High","3","Backlog","epic:sessions, phase:2, backend, sso, supabase, jwt" +"Global logout, sign out everywhere, and admin revocation","**Ticket:** AUTH-T30 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com); AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime) + +- `POST /api/auth/sign-out` deletes the current session row and clears the parent-domain cookie (same Domain/Path/prefix, `Max-Age=0`); every subdomain is logged out immediately because they share the cookie and the DB row is gone. +- `POST /api/sgauth/sessions/revoke-all` (user) revokes all sessions including the current one. +- Admin endpoint to revoke all sessions of a target user (AUTH-T36) with audit SESSIONS_REVOKED. +- `/logout?redirect=` convenience route for products: rejects requests whose `Sec-Fetch-Site` is `cross-site` (so an external page cannot log users out via an image or link), signs out, then redirects to a registry-validated URL (AUTH-T56) or to `/login`. Sibling subdomains are same-site and keep working. +Supabase-style JWTs already issued remain valid until their 10-minute expiry; document this in the Supabase guide. + +**Acceptance criteria** +- [ ] After sign-out on one subdomain, the session endpoint returns 401 for a request from another subdomain using the same browser. +- [ ] `/logout?redirect=https://evil.example` redirects to `/login`, not the attacker URL. +- [ ] Admin revocation invalidates every session of the target within one request.","High","2","Backlog","epic:sessions, phase:1, backend, sso" +"Safe post-login redirects validated against the product registry","**Ticket:** AUTH-T31 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime) + +`/login?redirect=` is the entry point every product uses. Validate: absolute `https://` URL whose origin exactly matches an active product's base URL origin (or SGAuth itself), or a relative path starting with `/` (not `//`). Strip fragments; cap length at 2 KB. Persist the redirect through sign-up, verification, and reset via a short-lived signed cookie rather than hidden form fields. Unknown or malformed targets fall back to `/account`. + +**Acceptance criteria** +- [ ] Open-redirect test suite (protocol-relative, backslashes, userinfo tricks, unicode homographs, unregistered subdomains) all land on `/account`. +- [ ] A valid `https://vaultz.northeasternsga.com/purchases/42` survives login → verify → login and is honored.","High","2","Backlog","epic:sessions, phase:1, backend, security, sso" +"Re-authentication (sudo mode) for sensitive actions","**Ticket:** AUTH-T32 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T27 (Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache); AUTH-T67 (TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)) + +Sensitive actions require proof of presence within the last 10 minutes: Primary Admin transfer steps, granting/revoking admin, changing password, changing any email, enrolling/disabling/resetting MFA, revoking all sessions, deleting/deactivating users. Implement `POST /api/sgauth/reauth` accepting password (and TOTP code if enrolled) that stamps `Session.lastReauthAt`; a `requireFreshAuth()` guard checks the stamp. This is independent of Better Auth's `freshAge` (left at default). The UI shows a modal to re-enter credentials when the guard fails (403 `REAUTH_REQUIRED`). Failed re-auth attempts count toward lockout. + +**Acceptance criteria** +- [ ] Calling a sensitive endpoint 11 minutes after re-auth returns 403 `REAUTH_REQUIRED`; within 10 minutes it succeeds. +- [ ] Re-auth for an MFA-enrolled user requires both password and a valid TOTP.","High","3","Backlog","epic:sessions, phase:2, backend, security" +"List and revoke the user's own sessions","**Ticket:** AUTH-T33 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T27 (Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache) + +`GET /api/sgauth/sessions` returns the caller's active sessions (id, createdAt, lastActiveAt, ip, userAgent parsed to a friendly device string, isCurrent). `DELETE /api/sgauth/sessions/:id` revokes one of the caller's sessions. Audit SESSION_REVOKED. + +**Acceptance criteria** +- [ ] A user cannot list or revoke another user's session (404, not 403, to avoid id probing). +- [ ] Revoking the current session also clears the cookie.","Medium","2","Backlog","epic:sessions, phase:2, backend" +"Non-production SSO topology: dev deployment, local hostnames, product preview domains","**Ticket:** AUTH-T34 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching); AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +A parent-domain cookie cannot be read by `*.vercel.app` previews or plain `localhost`, so define the non-production topology once: +- **Dev SGAuth**: `auth-dev.northeasternsga.com` (branch `dev`, Neon `dev` branch, cookie prefix `sgauth-dev`). Products point their preview and dev environments here. Because it is a production-looking login page on the trusted domain: persistent 'DEVELOPMENT ENVIRONMENT' banner on every page, `noindex`, synthetic users only (real user imports are never run against dev), and fully separate secrets (Better Auth secret, Resend key, Upstash namespace). +- **Product previews**: each product gets a stable branch domain like `-dev.northeasternsga.com` (Vercel branch domain) so the dev cookie is shared; ad-hoc `*.vercel.app` previews cannot use SSO (document the limitation). +- **Local**: developers use `*.localhost` hostnames (Chrome resolves subdomains of localhost automatically; Firefox needs a preference) or hosts-file entries; SGAuth locally runs on `http://auth.sga.localhost:3000` with cookie domain `sga.localhost`, `secure: false`, no `__Secure-` prefix. Provide a `SGAUTH_ENV=local` switch that applies these settings. Browser handling of `Domain=.localhost` cookies differs (Safari is the usual problem); the spike in AUTH-T104 confirms the local scheme or falls back to a hosts-file domain such as `sga.test`. +- **SDK dev mode**: the SDK accepts `SGAUTH_URL` and `SGAUTH_COOKIE_NAME` overrides so products target dev or local SGAuth. Write `docs/ENVIRONMENTS.md` with a table of URLs, cookie names, and Neon branches per environment. + +**Acceptance criteria** +- [ ] A product stub running on `vaultz.sga.localhost:3001` sees a session created at `auth.sga.localhost:3000` (documented manual test plus a Playwright job in AUTH-T96). +- [ ] `docs/ENVIRONMENTS.md` exists and is linked from README and the integration guides. +- [ ] Production configuration cannot be started with a non-secure cookie setting (startup assertion).","High","3","Backlog","epic:sessions, phase:1, infra, sso, docs" +"Authorization module with the admin/Primary Admin rule matrix","**Ticket:** AUTH-T35 +**Epic:** E5 Admin & Primary Admin +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T10 (Core schema: User plus Better Auth Session, Account, and Verification tables); AUTH-T12 (Primary Admin invariants at the database level and the PrimaryAdminTransfer table) + +Create `src/lib/authz.ts`: pure functions `can(actor, action, target)` used by every mutating endpoint. Rules: +- Only admins may perform admin actions; deactivated actors can do nothing. +- Admins may grant or revoke admin for **other** users freely; they can never change their **own** admin status or deactivate/delete themselves. +- Nobody except the transfer flow or break-glass may modify the Primary Admin's admin flag, status, PA flag, MFA, or email, or delete the account. The PA's positions and display name may be edited by any admin like any other user (accepted: an admin could strip the PA's product positions, which affects product access only, never SGAuth authority, and is audited). +- Email changes: admins may change non-admin users' emails; only the PA may change an admin's email; the PA changes their own. MFA reset: admins may reset non-admin and (only the PA) admin MFA; PA MFA reset is break-glass only. +- The Primary Admin may do everything an admin can, including changing other admins, and is the only one who can initiate a transfer. +- Position and product CRUD: any admin. +Return structured denials (`{ allowed: false, reason: 'SELF_MODIFICATION' | 'PRIMARY_ADMIN_PROTECTED' | 'NOT_ADMIN' | ... }`). UI hides controls using the same function, but enforcement is server-side only. + +**Acceptance criteria** +- [ ] Table-driven unit tests enumerate actor ∈ {user, admin, primary admin, deactivated admin} × target ∈ {self, other user, other admin, primary admin} × action ∈ {grantAdmin, revokeAdmin, deactivate, delete, assignPosition, initiateTransfer} with expected results; 100% branch coverage of `authz.ts`. +- [ ] Every mutating route imports `can()`; an ESLint rule or test asserts no admin route lacks the guard.","Urgent","3","Backlog","epic:admin, phase:2, backend, admin, security" +"Admin user-management endpoints","**Ticket:** AUTH-T36 +**Epic:** E5 Admin & Primary Admin +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T13 (Append-only AuditEvent table); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T22 (Invite flow: admin-created users receive a set-password link) + +Server actions or route handlers under `/api/sgauth/admin/users`: list/search (by email, name, position, status, admin flag; paginated), get, create (invite), update name/preferredName, deactivate (revokes all sessions immediately), reactivate, delete (= tombstone: status DELETED, PII scrubbed, sessions/accounts/MFA/positions removed, id retained), grant admin, revoke admin, revoke all sessions, unlock account, reset MFA (per the authz rules; emails the user), resend invite, force re-login. Every call passes `can()`, requires fresh re-auth for grant/revoke admin, deactivate, delete, and MFA reset, and emits an audit event with actor, target, and diff. Deactivation/deletion of the PA and self-modification are refused with the authz reason. + +**Acceptance criteria** +- [ ] Integration tests for every endpoint including denials (self-modify, PA-protected, non-admin, deactivated actor). +- [ ] Deactivating a user with three active sessions leaves zero sessions and their next product request returns 401. +- [ ] Each mutation produces exactly one audit row with the expected type and metadata.","High","5","Backlog","epic:admin, phase:2, backend, admin, api" +"Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)","**Ticket:** AUTH-T37 +**Epic:** E5 Admin & Primary Admin +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T12 (Primary Admin invariants at the database level and the PrimaryAdminTransfer table); AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T67 (TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST); AUTH-T38 (Scheduled-job runner: GitHub Actions schedules calling secret-protected routes) + +Implement the guarded transfer as a state machine over `PrimaryAdminTransfer`: +1. **Initiate** (PA only, fresh re-auth, recipient must be an active admin with MFA enrolled, PA must type the recipient email exactly): creates PENDING_ACCEPTANCE (expires in 7 days), emails recipient (accept link) and PA (confirmation), audit PA_TRANSFER_INITIATED. +2. **Accept** (recipient only, fresh re-auth): moves to COOLING, sets `executesAt = now + 24h`, generates a cancel token emailed to the outgoing PA (and shown in-app), emails both, audit PA_TRANSFER_ACCEPTED. +3. **Cancel**: outgoing PA (in-app with re-auth, or via the emailed cancel link without login — the link opens a page with a Cancel button; the token is consumed on POST, AUTH-T103), or recipient declines; status CANCELLED, both emailed, audit PA_TRANSFER_CANCELLED. The accept link likewise lands on a page requiring login + re-auth before acting. +4. **Execute** (scheduler, AUTH-T38): when `executesAt` has passed and status is COOLING, in one transaction with the DB session variable set: clear PA on the old user, set it on the recipient, keep both as admins, revoke all sessions of both users (forces re-login with correct claims), status COMPLETED, emails to both and to every admin, audit PA_TRANSFER_COMPLETED. +5. **Expire**: PENDING_ACCEPTANCE older than 7 days → EXPIRED, emails PA. +Exactly one in-flight transfer is allowed. If the recipient loses admin or is deactivated mid-flight, the transfer is cancelled automatically. + +**Acceptance criteria** +- [ ] State-machine tests cover every transition and every illegal transition (e.g. accept by a third party, cancel after completion, initiate while one is in flight). +- [ ] The emailed cancel link works without a session and is single-use. +- [ ] After execution there is exactly one PA, both parties are admins, and both must log in again. +- [ ] Recipient deactivation during COOLING cancels the transfer and notifies the PA.","High","5","Backlog","epic:admin, phase:2, backend, admin, security, email" +"Scheduled-job runner: GitHub Actions schedules calling secret-protected routes","**Ticket:** AUTH-T38 +**Epic:** E5 Admin & Primary Admin +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching); AUTH-T07 (Validate environment variables at startup with zod) + +Vercel Hobby limits cron to two jobs at once-per-day and rejects finer schedules at deploy time, so **no Vercel cron is used**. Instead, a GitHub Actions workflow in the auth repo (same pattern Chambers uses) runs on schedules and calls `GET /api/jobs/` with `Authorization: Bearer $CRON_SECRET` (repo secret = Vercel env): +- `pa-transfer` every 15 minutes: executes due COOLING transfers and expires stale PENDING_ACCEPTANCE ones, idempotently (row-level lock, status re-check inside the transaction). +- `alerts` every 15 minutes (AUTH-T77). +- `retention` daily (AUTH-T78). +Each route is idempotent, logs outcomes, and returns quickly (under the function timeout); Actions schedules can be delayed several minutes under load, which is acceptable for these jobs. A missed run is caught by the next. + +**Acceptance criteria** +- [ ] Running the transfer job twice concurrently executes the transfer once (test with a simulated race). +- [ ] Requests without the correct bearer secret return 401 and do nothing. +- [ ] Workflow file exists with the three schedules; `vercel.json` contains no `crons`.","High","2","Backlog","epic:admin, phase:1, backend, infra, ci" +"Break-glass Primary Admin recovery script and runbook","**Ticket:** AUTH-T39 +**Epic:** E5 Admin & Primary Admin +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T12 (Primary Admin invariants at the database level and the PrimaryAdminTransfer table); AUTH-T13 (Append-only AuditEvent table) + +`scripts/recover-primary-admin.ts` run by a human with production Neon credentials (`npm run recover-primary-admin -- --email new-pa@northeastern.edu --reason ""...""`). It: verifies the target user exists and is active; prompts for the confirmation phrase `TRANSFER PRIMARY ADMIN`; inside one transaction with the DB session variable set, moves the PA flag, ensures the new PA is admin, revokes all sessions of the previous PA; writes an AuditEvent with `actorType = BREAK_GLASS` and the operator's name/reason; emails every admin and the old PA address. Supports `--dry-run`. A second mode, `--reset-mfa --email `, clears the current PA's TOTP and backup codes (for the lost-phone-and-lost-codes case, since admins cannot touch PA MFA) and forces re-enrollment on next login. No HTTP endpoint exists for either. Write `docs/runbooks/break-glass.md`: when to use it, who holds credentials (at least two people), and how to verify afterwards. + +**Acceptance criteria** +- [ ] Dry run prints the plan and changes nothing; real run leaves exactly one PA and an audit row of type BREAK_GLASS_PA_RECOVERY. +- [ ] Script refuses to run without `DIRECT_URL` and the confirmation phrase. +- [ ] Runbook reviewed by the current Primary Admin.","High","3","Backlog","epic:admin, phase:2, backend, admin, security, docs" +"Primary Admin protection tests across API and database layers","**Ticket:** AUTH-T40 +**Epic:** E5 Admin & Primary Admin +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T12 (Primary Admin invariants at the database level and the PrimaryAdminTransfer table); AUTH-T36 (Admin user-management endpoints) + +Integration tests proving the PA cannot be deleted, deactivated, banned, stripped of admin, or have the PA flag removed via any admin endpoint, Better Auth admin plugin endpoint (if mounted), or direct SQL from the runtime role; and that the PA is subject to account lockout but can self-unlock via the emailed link. + +**Acceptance criteria** +- [ ] All listed attack paths fail with the expected error at the API layer and, when bypassed, at the DB trigger. +- [ ] Lockout/unlock test for the PA passes.","High","2","Backlog","epic:admin, phase:2, testing, admin, security" +"Bulk user import (CSV) with position assignment and batched invites","**Ticket:** AUTH-T41 +**Epic:** E5 Admin & Primary Admin +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T36 (Admin user-management endpoints); AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)) + +Admin endpoint + UI to upload a CSV (`email,name,positions` where positions is a `|`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as invited (AUTH-T22), assigns positions, and queues invite emails respecting mailer caps (AUTH-T20) in batches. Produces a downloadable report. Audit BULK_IMPORT with counts. + +**Acceptance criteria** +- [ ] A 200-row CSV with 5 invalid rows shows the 5 errors and imports nothing until fixed (all-or-nothing) or with an explicit 'skip invalid' toggle. +- [ ] Re-importing the same CSV is idempotent (no duplicate users or assignments).","Medium","3","Backlog","epic:admin, phase:2, backend, admin, migration" +"Positions CRUD: create, edit name/category, soft delete with retirement","**Ticket:** AUTH-T42 +**Epic:** E6 Positions +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T11 (Positions schema: Position, UserPosition, and retired keys); AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T13 (Append-only AuditEvent table) + +Admin endpoints: create (key + name + optional category/description; key validated, uniqueness checked against active and retired-within-365-days keys), update (name/category/description only; key immutable), delete (soft; returns holder count first via a preflight, requires the admin to send `confirmKey` equal to the key; on delete: set `deletedAt`, write RetiredPositionKey, keep UserPosition rows, emit POSITION_DELETED with holder ids). Audit every change with before/after. List endpoint supports including deleted for history views. + +**Acceptance criteria** +- [ ] Renaming changes `name` only; the key and all holders are untouched (test asserts session output before/after). +- [ ] Delete without the matching `confirmKey` is refused; with it, holders lose the position on the next session call. +- [ ] Creating a key retired 100 days ago fails with `KEY_RETIRED`; 400 days ago succeeds.","High","3","Backlog","epic:positions, phase:2, backend, positions, api" +"Position assignment endpoints (assign, unassign, bulk)","**Ticket:** AUTH-T43 +**Epic:** E6 Positions +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T42 (Positions CRUD: create, edit name/category, soft delete with retirement) + +Admin endpoints to assign/unassign one or many positions to a user and to assign one position to many users. Enforce the 50-position cap, refuse deleted positions, ignore duplicates, emit POSITION_ASSIGNED / POSITION_UNASSIGNED per pair. Assignments are visible in the session endpoint on the next call (no caching in SGAuth). + +**Acceptance criteria** +- [ ] Assigning the 51st position fails with `POSITION_LIMIT`. +- [ ] Bulk assign of 30 users is one transaction and one audit row per pair.","High","3","Backlog","epic:positions, phase:2, backend, positions, api" +"Seed the curated SGA position list","**Ticket:** AUTH-T44 +**Epic:** E6 Positions +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T11 (Positions schema: Position, UserPosition, and retired keys) + +**Input received 2026-09-18:** `prisma/seed/positions.json` already exists in the repo with 82 positions (81 offices across 9 categories — Office of the President, Academic Affairs, Campus Affairs, Diversity Equity and Inclusion, External Affairs, Student Involvement, Student Success, Operational Affairs, Senate — plus `senator`). Keys were generated by slugifying the official names and validated against the key format and length rules. Upsert by key in the seed script (safe to run in every environment; never deletes; name/category updates are applied, keys never change). Include a validation test that every key matches the format and names are unique. Product-specific roles (e.g. `aplio-admin`) are **not** seeded; product owners create them in the admin UI when they integrate. +Key naming convention (document in the admin UI help and the integration guides): organization roles use bare keys (`vp-finance`, `senator`); product-specific roles are prefixed with the product slug (`aplio-admin`, `chambers-iems`) so keys never collide and products can grep their own. + +**Acceptance criteria** +- [ ] Seed creates all 82 curated positions with zero holders; re-running is a no-op. +- [ ] A test loads `prisma/seed/positions.json` and asserts every key matches `^[a-z0-9]+(?:-[a-z0-9]+)*$`, is 2–64 chars, and that keys and names are unique.","Medium","1","Backlog","epic:positions, phase:1, backend, positions" +"Position propagation tests and forced re-login","**Ticket:** AUTH-T45 +**Epic:** E6 Positions +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)); AUTH-T28 (Session endpoint for products: user, email, positions, admin flags); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +Tests: after assign/unassign/delete, the very next `/api/sgauth/session` call reflects the change; a JWT minted before the change stays valid until expiry (≤10 min) and a new token reflects it. Add an admin action 'Force re-login' (revoke all sessions of a user) surfaced in the UI as the way to invalidate outstanding JWTs immediately. Document the staleness model in the integration guides. + +**Acceptance criteria** +- [ ] Automated tests demonstrate immediate propagation for the session endpoint and bounded staleness for JWTs. +- [ ] Guides contain a 'Propagation and staleness' section.","Medium","2","Backlog","epic:positions, phase:2, testing, positions, sso" +"Position history queries","**Ticket:** AUTH-T46 +**Epic:** E6 Positions +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T42 (Positions CRUD: create, edit name/category, soft delete with retirement); AUTH-T73 (Audit event catalog, emitter, and coverage test) + +Endpoints returning the audit trail for a position (who was assigned/unassigned, renames, deletion) and for a user (all position changes), backed by AuditEvent indexes. Used by the admin UI detail pages. + +**Acceptance criteria** +- [ ] Both queries paginate and return within 200 ms for 10k events on the test branch.","Low","2","Backlog","epic:positions, phase:3, backend, positions, audit" +"App shell, navigation, and route guards for /admin and /account","**Ticket:** AUTH-T47 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T25 (Login, sign-up, forgot/reset, and verify pages); AUTH-T28 (Session endpoint for products: user, email, positions, admin flags) + +Next.js App Router layouts: `(auth)` public pages, `(app)` authenticated pages with a header (user menu, sign out, sign out everywhere), `/account` for all users, `/admin` visible only to admins (server-side guard using `can()`; non-admins get 404). shadcn/ui components, light/dark via next-themes, SGA branding tokens. Toasts via sonner. Loading and error boundaries. + +**Acceptance criteria** +- [ ] Non-admin visiting `/admin` receives a 404 page; admin sees the dashboard. +- [ ] Layout works at 360 px and 1440 px; axe reports no serious violations.","High","3","Backlog","epic:ui, phase:2, frontend, ui" +"Admin: users list and user detail pages","**Ticket:** AUTH-T48 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T36 (Admin user-management endpoints); AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)) + +Users list with search, filters (status, admin, position), pagination, and an 'Invited' badge. User detail: profile fields (editable), positions (assign/unassign with a searchable multi-select), admin toggle (disabled for self and PA with tooltip reason from `can()`), deactivate/reactivate, unlock, resend invite, sessions list with revoke, force re-login, and the user's audit timeline. Re-auth modal appears when the API returns REAUTH_REQUIRED. + +**Acceptance criteria** +- [ ] Every action shows success/failure toasts and refreshes data; disabled controls explain why. +- [ ] Playwright test: admin assigns a position, the user's session endpoint reflects it.","High","5","Backlog","epic:ui, phase:2, frontend, ui, admin" +"Admin: positions pages","**Ticket:** AUTH-T49 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T42 (Positions CRUD: create, edit name/category, soft delete with retirement) + +List (with holder counts and category grouping), create dialog (key auto-suggested from name, editable before save, immutable after), edit page (name, category, description; key shown read-only with an explanation), delete dialog showing holder count and requiring the key to be typed, and a 'deleted positions' tab with history. + +**Acceptance criteria** +- [ ] Key input rejects invalid characters live and shows the format rule. +- [ ] Delete dialog blocks submission until the typed key matches.","High","3","Backlog","epic:ui, phase:2, frontend, ui, positions" +"Admin: product registry pages","**Ticket:** AUTH-T50 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime) + +CRUD pages for products (name, slug, base URL, description, icon URL, active, sort order, visible-to-positions). Show which trusted origins and redirect targets the registry currently yields. + +**Acceptance criteria** +- [ ] Adding a product makes its origin trusted and its URL appear on account pages without a deploy (verified on dev).","Medium","2","Backlog","epic:ui, phase:2, frontend, ui, registry" +"Admin: audit log viewer with filters and CSV export","**Ticket:** AUTH-T51 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T73 (Audit event catalog, emitter, and coverage test) + +Table of AuditEvents with filters (type, actor, target, date range), detail drawer for metadata, and CSV export of the filtered set (server-streamed, capped at 50k rows). + +**Acceptance criteria** +- [ ] Filtering by a user shows both events they performed and events targeting them. +- [ ] Export matches the on-screen filter.","Medium","3","Backlog","epic:ui, phase:3, frontend, ui, audit" +"Admin: Primary Admin transfer wizard, status, acceptance, and cancel pages","**Ticket:** AUTH-T52 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T37 (Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)) + +PA-only wizard: full-screen warning explaining consequences, recipient selection limited to eligible admins (MFA enrolled), typed email confirmation, re-auth step, summary. Status card on the admin dashboard while a transfer is in flight (with cancel). Recipient acceptance page (`/admin/transfer/accept/[id]`) with re-auth and decline. Public cancel page for the emailed token. Completion banner for all admins. + +**Acceptance criteria** +- [ ] Wizard cannot be completed without typing the exact recipient email and passing re-auth. +- [ ] Ineligible recipients (no MFA, not admin, deactivated) are not selectable and the reason is shown.","High","3","Backlog","epic:ui, phase:2, frontend, ui, admin" +"Account page: profile, positions, product links, sessions, sign out everywhere","**Ticket:** AUTH-T53 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T47 (App shell, navigation, and route guards for /admin and /account); AUTH-T33 (List and revoke the user's own sessions); AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime) + +`/account`: edit name and preferredName; read-only list of positions (key and name); 'Your SGA tools' grid of active products from the registry filtered by `visibleToPositionKeys` with links to each product (this is the hub users land on after login without a redirect); sessions list with per-device revoke and 'Sign out everywhere'; links to security settings. + +**Acceptance criteria** +- [ ] A user with no positions sees only products visible to everyone. +- [ ] Revoking another device's session removes it from the list and that device gets 401.","High","3","Backlog","epic:ui, phase:2, frontend, ui" +"Account security page: change password, MFA enrollment, backup codes","**Ticket:** AUTH-T54 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T23 (Change password (current password + re-auth), revoke other sessions); AUTH-T67 (TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)) + +`/account/security`: change password form; TOTP enrollment (QR + manual secret, verify code, download/copy backup codes once), regenerate backup codes, disable MFA (re-auth; disallowed for admins and PA with explanation). Show 'MFA required for admins' banner and block admin pages until enrolled. + +**Acceptance criteria** +- [ ] An admin without MFA is redirected to enrollment when opening `/admin`. +- [ ] Backup codes are shown once; using one logs a security notice.","High","3","Backlog","epic:ui, phase:3, frontend, ui, security" +"Accessibility, responsive, and empty/error state pass","**Ticket:** AUTH-T55 +**Epic:** E7 Admin UI & Account UI +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T48 (Admin: users list and user detail pages); AUTH-T49 (Admin: positions pages); AUTH-T53 (Account page: profile, positions, product links, sessions, sign out everywhere); AUTH-T54 (Account security page: change password, MFA enrollment, backup codes) + +Audit every page with axe and keyboard-only navigation; add empty states, loading skeletons, and error states; verify focus management in dialogs; confirm color contrast in both themes. + +**Acceptance criteria** +- [ ] axe: zero serious/critical issues on all pages; documented checklist completed.","Medium","2","Backlog","epic:ui, phase:3, frontend, ui, a11y" +"Product registry service: trusted origins and redirect allowlist at runtime","**Ticket:** AUTH-T56 +**Epic:** E8 Product Registry & SDK +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T14 (Product registry schema) + +`src/lib/products.ts`: loads active products (cached in-memory for 60 s, invalidated on write), exposes `getTrustedOrigins()` (product origins + SGAuth origin + local dev origins when `SGAUTH_ENV=local`) fed to Better Auth's `trustedOrigins` as a function, and `isAllowedRedirect(url)` used by AUTH-T31 and AUTH-T30. If the registry query fails, fall back to a static list containing only SGAuth's own origin (fail closed for products, but SGAuth's own pages keep working). Never trust `*.vercel.app` and never use a wildcard `https://*.northeasternsga.com` (it would trust dangling subdomains). + +**Acceptance criteria** +- [ ] Adding a product on dev makes cross-origin POSTs from its origin pass Better Auth's origin check within 60 s; removing it makes them fail. +- [ ] Unit tests for origin normalization (trailing slash, port, case).","High","2","Backlog","epic:sdk, phase:1, backend, registry, security" +"Scaffold the @sgaoperations/sgauth package and publish pipeline (public npm)","**Ticket:** AUTH-T57 +**Epic:** E8 Product Registry & SDK +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T28 (Session endpoint for products: user, email, positions, admin flags) + +New repo `SGAOperations/sgauth-sdk` (TypeScript, tsup, vitest, ESM+CJS, Node 20+). Publish to the **public npm registry** under the `@sgaoperations` org scope (claim the scope on npmjs.com; the SDK contains no secrets, only calls to public SGAuth endpoints with the user's cookie). GitHub Packages was rejected because it requires a classic personal access token to install even public packages. Use npm **trusted publishing** (OIDC from GitHub Actions) so no long-lived npm token exists; publish on `v*` tags; semantic versioning; `CHANGELOG.md`; provenance attestations enabled. + +**Acceptance criteria** +- [ ] `npm install @sgaoperations/sgauth` works from any product repo with no `.npmrc` changes. +- [ ] CI publishes on `v*` tags via trusted publishing and fails on version collisions; the package page shows provenance.","High","3","Backlog","epic:sdk, phase:2, sdk, infra" +"SDK: getSession() with cookie forwarding and a 60-second cache","**Ticket:** AUTH-T58 +**Epic:** E8 Product Registry & SDK +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T57 (Scaffold the @sgaoperations/sgauth package and publish pipeline (public npm)) + +`getSession({ headers | cookieHeader })`: extracts the SGAuth cookie (name from `SGAUTH_COOKIE_NAME`, default `__Secure-sgauth.session_token`), calls `{SGAUTH_URL}/api/sgauth/session` with the `Cookie` header, returns a typed `SgaSession | null`. In-memory LRU cache keyed by SHA-256 of the token, TTL 60 s (configurable, max 300 s), negative cache 10 s. **Availability:** SGAuth is a single point of failure for every product, so on network error or 5xx the SDK serves a previously cached session for that token for up to `staleIfErrorSeconds` (default 300, max 900) and calls `onError`; with no cached entry it returns null (fail closed). Timeout 3 s. Never caches 5xx as a session. Products must never log the forwarded Cookie header (documented). Ships the JSON schema types from AUTH-T28. + +**Acceptance criteria** +- [ ] Unit tests with a mocked fetch: cache hit/miss, TTL expiry, 401 → null, timeout with cached entry → stale session + onError, timeout without cache → null + onError. +- [ ] Revocation observed within 60 s in an integration test against dev.","High","3","Backlog","epic:sdk, phase:2, sdk, sso" +"SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)","**Ticket:** AUTH-T59 +**Epic:** E8 Product Registry & SDK +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T58 (SDK: getSession() with cookie forwarding and a 60-second cache) + +Exports: `createSgaProxy({ publicPaths })` for Next.js `proxy.ts`/`middleware.ts` that **only checks for the presence of the SGAuth cookie** (no network call, no per-request latency) and redirects to `loginUrl(currentUrl)` when absent; real validation happens in server code via `requireSession()` (throws/redirects on an invalid or revoked cookie). `hasPosition(session, key)`, `hasAnyPosition(session, keys)`, `hasAllPositions`, `isAdmin(session)`; `loginUrl(redirect)`, `logoutUrl(redirect)`, `accountUrl()`. React cache() wrapper for server components so one request resolves the session once. Document why the proxy must not call SGAuth: middleware runs per request and per instance, so a network round trip there adds latency to every page and defeats the cache. + +**Acceptance criteria** +- [ ] Example app in the repo demonstrates a protected page, a position-gated action, and logout. +- [ ] Helpers never run in the browser (server-only guard) except the URL builders.","High","3","Backlog","epic:sdk, phase:2, sdk, sso" +"SDK: getAccessToken() for Supabase clients","**Ticket:** AUTH-T60 +**Epic:** E8 Product Registry & SDK +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T58 (SDK: getSession() with cookie forwarding and a 60-second cache); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +`getAccessToken({ headers })` calls `/api/auth/token` with the forwarded cookie and caches the JWT until `exp - 60 s` keyed by session token hash. Provide `createSupabaseAccessTokenProvider()` returning the `accessToken` callback shape supabase-js expects, and a browser-safe variant that calls a product-side route which proxies to SGAuth (so the token never requires exposing SGAuth cookies to client JS). + +**Acceptance criteria** +- [ ] Token refresh happens before expiry in a long-running test; a revoked session stops yielding tokens at the next refresh.","Medium","2","Backlog","epic:sdk, phase:3, sdk, supabase, jwt" +"SDK documentation, example app, and versioning policy","**Ticket:** AUTH-T61 +**Epic:** E8 Product Registry & SDK +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)) + +README with install (public npm, no registry config), env vars (`SGAUTH_URL`, `SGAUTH_COOKIE_NAME`), quick start, API reference (typedoc), dev/preview topology (AUTH-T34), the stale-if-error behavior and its bound, upgrade notes, and a support policy (latest two minors). + +**Acceptance criteria** +- [ ] A new product can integrate using only the README (validated by the VaultZ integration).","Medium","2","Backlog","epic:sdk, phase:2, sdk, docs" +"CORS for browser-side calls from registered products","**Ticket:** AUTH-T62 +**Epic:** E8 Product Registry & SDK +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime); AUTH-T28 (Session endpoint for products: user, email, positions, admin flags) + +Allow client components on registered product origins to call `/api/sgauth/session`, `/api/auth/token`, and `/api/auth/sign-out` with `credentials: 'include'`: dynamic `Access-Control-Allow-Origin` echoing only registry origins, `Allow-Credentials: true`, `Vary: Origin`, preflight handling, no wildcard. Server-side calls remain the recommended path. + +**Acceptance criteria** +- [ ] Preflight from an unregistered origin gets no CORS headers; from a registered one it succeeds with credentials.","Medium","2","Backlog","epic:sdk, phase:3, backend, security, sso" +"Upstash Redis rate limiting on auth and token endpoints","**Ticket:** AUTH-T63 +**Epic:** E9 Security Hardening +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler); AUTH-T07 (Validate environment variables at startup with zod) + +Use `@upstash/ratelimit` (sliding window) inside the handlers for **mutating and expensive endpoints only**: sign-in 10/min and 50/hour per IP; sign-up 5/hour per IP; forgot-password 3/10 min per IP and per email; verification resend 3/hour per email; token endpoint 60/min per session; re-auth 5/min per user; admin mutations 100/min per user. The **session endpoint is not Redis-limited** (the Upstash free tier is 500K commands/month and the session endpoint is the hot path); it relies on the SDK cache, Vercel's platform protections, and a cheap in-process token bucket. Disable Better Auth's built-in limiter. **On Upstash outage: fail open everywhere with a logged alert** (decision: an Upstash outage must never become an org-wide login outage); the DB-backed account lockout (AUTH-T64) remains the brute-force backstop. Return 429 with `Retry-After`. Budget: estimate monthly Redis commands from expected logins and document the alert threshold at 80% of the free quota. + +**Acceptance criteria** +- [ ] Automated tests hit each limit and observe 429 + `Retry-After`; limits reset after the window. +- [ ] With Upstash unreachable (fake), sign-in still succeeds and an alert-level log line is emitted. +- [ ] Upstash keys are namespaced per environment (`sgauth:prod:`, `sgauth:dev:`).","High","3","Backlog","epic:security, phase:1, backend, security, infra" +"Escalating account lockout with emailed unlock and known-device exemption","**Ticket:** AUTH-T64 +**Epic:** E9 Security Hardening +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T15 (Security tables: AccountLock, UnlockToken, Jwks, TwoFactor); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) + +Track failed password attempts per account (AccountLock). 5 failures within 15 minutes → lock 15 min; each subsequent lock doubles (30 min, 1 h, 2 h, … capped at 24 h); never permanent. **Known-device exemption (red-team):** a plain lockout lets anyone who knows an admin's email lock them out indefinitely. After every successful login SGAuth sets a signed, HttpOnly `__Secure-sgauth.device` cookie (host-only on auth.northeasternsga.com, 1-year, hashed in KnownDevice). Sign-in attempts that carry a valid known-device cookie for that account are exempt from the account lock (they remain subject to IP rate limits and their own separate 5-per-15-min counter); attempts without one count toward and are blocked by the lock. Attackers cannot obtain the cookie without a successful login. While locked, sign-in returns the same generic error as wrong password; the locked-account email tells the real owner what happened. On lock: email the user an unlock link (page + POST, AUTH-T103; single-use, 1 h) and a security notice; audit ACCOUNT_LOCKED / ACCOUNT_UNLOCKED. Successful login or password reset resets counters; lock level decays after 24 h clean. Applies to the Primary Admin. Admins can unlock from the UI. + +**Acceptance criteria** +- [ ] Sixth attempt within the window from an unknown device is refused even with the correct password; the unlock link restores access. +- [ ] The same account signing in from a browser holding a valid known-device cookie succeeds while the account is locked for unknown devices. +- [ ] Lock durations escalate and cap at 24 h (time-travel test). +- [ ] Unknown emails do not produce different responses or timing.","High","5","Backlog","epic:security, phase:1, backend, security, email" +"CSRF and origin enforcement across subdomains","**Ticket:** AUTH-T65 +**Epic:** E9 Security Hardening +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T56 (Product registry service: trusted origins and redirect allowlist at runtime); AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +Because the cookie is shared with every subdomain and SameSite=Lax still sends it on same-site POSTs from sibling subdomains, SGAuth must verify the `Origin` (fallback `Referer`) header on every state-changing request against the registry-derived trusted origins (Better Auth does this for its routes; extend the check to all `/api/sgauth/*` mutations). Reject missing Origin on non-GET. Add `Sec-Fetch-Site` checks as defense in depth. + +**Acceptance criteria** +- [ ] A POST to `/api/sgauth/admin/users` with `Origin: https://unregistered.northeasternsga.com` and a valid cookie is rejected 403. +- [ ] Same POST from a registered product origin succeeds.","High","2","Backlog","epic:security, phase:1, backend, security, sso" +"Security headers (CSP, HSTS, frame, referrer)","**Ticket:** AUTH-T66 +**Epic:** E9 Security Hardening +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching) + +Set via `next.config.ts` headers: strict CSP (self + PostHog host, nonce for inline scripts), `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Content-Type-Options: nosniff`, `Permissions-Policy` minimal. HSTS: `max-age=31536000` on auth.northeasternsga.com **without** `includeSubDomains`/`preload` unless every SGA subdomain is confirmed HTTPS-only (flag for the team; enabling it at the apex affects all products). + +**Acceptance criteria** +- [ ] securityheaders.com grade A on production; CSP violations reported to PostHog or a report-only endpoint first for one week.","Medium","2","Backlog","epic:security, phase:2, backend, security" +"TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)","**Ticket:** AUTH-T67 +**Epic:** E9 Security Hardening +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T15 (Security tables: AccountLock, UnlockToken, Jwks, TwoFactor); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu) + +Add the Better Auth `twoFactor` plugin (TOTP + backup codes; no SMS/email OTP). Login flow: after password, if enrolled, prompt for a 6-digit code or backup code; 'trust this device' is NOT enabled (keep it simple and consistent with sessions). Enforcement: users with `isAdmin` must have MFA to access admin endpoints/pages (403 `MFA_REQUIRED` → enrollment redirect); granting admin to a user without MFA is allowed but they are locked out of admin functions until enrolled; the PA transfer recipient must be enrolled. Backup codes hashed; regeneration invalidates old ones; audit MFA_ENROLLED / MFA_DISABLED / MFA_BACKUP_USED. + +**Acceptance criteria** +- [ ] Enrolled user must present a valid TOTP; replayed codes within the same step are rejected. +- [ ] Admin without MFA cannot call any admin endpoint; after enrollment the same call succeeds. +- [ ] Disabling MFA requires re-auth and is refused for admins/PA.","High","5","Backlog","epic:security, phase:2, backend, security, better-auth" +"Subdomain hygiene: DNS inventory, dangling-record removal, and policy","**Ticket:** AUTH-T68 +**Epic:** E9 Security Hardening +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +Because the session cookie is readable by every `*.northeasternsga.com` host, inventory all DNS records for the domain, remove or reclaim records that point at unowned Vercel/other targets (subdomain takeover), and write `docs/SUBDOMAIN_POLICY.md`: only products in the SGAuth registry may receive a subdomain; wildcard records are prohibited; third-party services get a separate domain. Add a quarterly checklist item. + +**Acceptance criteria** +- [ ] Inventory spreadsheet/link attached; zero dangling records; policy merged and linked from the architecture doc.","Medium","2","Backlog","epic:security, phase:2, infra, security, docs" +"Secrets management and rotation procedures","**Ticket:** AUTH-T69 +**Epic:** E9 Security Hardening +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +Document and script rotation for `BETTER_AUTH_SECRET` (invalidates cookie signatures → all users re-login; schedule in a low-usage window), Resend key, Upstash token, Neon passwords, `CRON_SECRET`, and the **manual JWKS rotation** (AUTH-T29): (1) generate the new ES256 key pair in the Jwks table without switching signing; (2) run `scripts/supabase-push-jwks.ts` to PUT the combined JWKS into each Supabase project's third-party integration via `custom_jwks` (Management API) and confirm `resolved_jwks`; (3) switch signing to the new key; (4) after 7 days remove the old key and push again. Ensure secrets are scoped per Vercel environment and never printed in logs or preview builds. + +**Acceptance criteria** +- [ ] `docs/runbooks/rotate-secrets.md` exists and a dry run of the JWKS rotation on dev against a throwaway Supabase project produces zero token rejections during the switch.","Medium","1","Backlog","epic:security, phase:2, infra, security, docs" +"Account-enumeration resistance and timing uniformity","**Ticket:** AUTH-T70 +**Epic:** E9 Security Hardening +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T19 (Password reset flow) + +Ensure sign-in, forgot-password, sign-up, and verification-resend responses do not reveal whether an email exists (identical bodies and status codes). Better Auth already returns success for a duplicate sign-up when `requireEmailVerification` is on; verify that path and add the unverified-account overwrite from AUTH-T17. Add a dummy hash comparison on unknown-email sign-in to equalize timing. + +**Acceptance criteria** +- [ ] Tests assert identical response bodies and status codes across known/unknown emails for each endpoint; a non-gating benchmark script reports timing deltas (a hard 50 ms CI assertion was rejected as flaky).","Medium","2","Backlog","epic:security, phase:1, backend, security, testing" +"Dependency and code scanning","**Ticket:** AUTH-T71 +**Epic:** E9 Security Hardening +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T06 (Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch) + +Enable Dependabot (npm, weekly, grouped), `npm audit --audit-level=high` in CI, and GitHub CodeQL for JavaScript/TypeScript on PRs. + +**Acceptance criteria** +- [ ] All three run on the repo; a seeded vulnerable dependency fails CI in a test PR.","Low","1","Backlog","epic:security, phase:3, ci, security" +"Threat model and pre-launch security review checklist","**Ticket:** AUTH-T72 +**Epic:** E9 Security Hardening +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products); AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix) + +Write `docs/THREAT_MODEL.md` (STRIDE-lite) covering: shared-cookie exposure and subdomain takeover, session fixation/replay, JWT misuse by Supabase products, admin abuse and self-escalation, PA transfer hijack, break-glass misuse, email link phishing, rate-limit bypass, Neon credential leakage. For each: mitigation and residual risk. Derive a pre-launch checklist executed in AUTH-T91. + +**Acceptance criteria** +- [ ] Document reviewed by at least two team members; every residual risk has an owner or an accepted-risk note.","Medium","3","Backlog","epic:security, phase:2, docs, security" +"Audit event catalog, emitter, and coverage test","**Ticket:** AUTH-T73 +**Epic:** E10 Observability & Audit +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T13 (Append-only AuditEvent table) + +`src/lib/audit.ts`: a typed catalog (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, SESSION_REVOKED, SESSIONS_REVOKED, PASSWORD_RESET_REQUESTED, PASSWORD_RESET, PASSWORD_CHANGED, PASSWORD_REHASHED, EMAIL_VERIFIED, USER_CREATED, USER_INVITED, USER_DEACTIVATED, USER_REACTIVATED, USER_DELETED, ADMIN_GRANTED, ADMIN_REVOKED, POSITION_CREATED/UPDATED/DELETED/ASSIGNED/UNASSIGNED, PRODUCT_CREATED/UPDATED/DELETED, PA_TRANSFER_*, BREAK_GLASS_PA_RECOVERY, ACCOUNT_LOCKED/UNLOCKED, MFA_*, BULK_IMPORT, JWKS_ROTATED) and `audit(event)` that captures actor, IP, UA from request context and writes inside the caller's transaction when one is open. Add a test that every mutating endpoint emits at least one audit event (route table cross-checked against catalog usage). + +**Acceptance criteria** +- [ ] Catalog is the single source for the DB CHECK constraint (generated migration). +- [ ] Coverage test fails when a new mutating route is added without an audit call.","High","3","Backlog","epic:observability, phase:1, backend, audit" +"Structured JSON logging with request IDs and redaction","**Ticket:** AUTH-T74 +**Epic:** E10 Observability & Audit +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T03 (Install Better Auth 1.7 with the Prisma adapter and mount the handler) + +Use `pino` (or a thin console JSON logger compatible with Vercel log drains): request id (from `x-vercel-id` or generated), route, user id (never email), latency, outcome. Redact tokens, cookies, passwords, and email bodies. Log levels by environment. + +**Acceptance criteria** +- [ ] Sample production log line validated against a schema; a test asserts secrets are redacted.","High","2","Backlog","epic:observability, phase:1, backend, observability" +"PostHog: server-side auth funnel events and error tracking","**Ticket:** AUTH-T75 +**Epic:** E10 Observability & Audit +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T74 (Structured JSON logging with request IDs and redaction) + +Integrate PostHog (free tier): server-side capture of login_succeeded/login_failed (reason category only), signup_started/completed, reset_requested/completed, mfa_enrolled, session_endpoint_error; identify by SGAuth user id only (no email/name properties). Enable PostHog error tracking on the server; on the client, load the PostHog script **only on authenticated /admin and /account pages, never on login, sign-up, reset, or verification pages** (a third-party script on a credential form is a supply-chain risk; those pages report errors via a first-party endpoint). Respect a `POSTHOG_DISABLED` flag for local/test. Add a dashboard for daily logins, failure rate, lockouts. + +**Acceptance criteria** +- [ ] Events appear in PostHog from dev with no PII properties (verified by inspecting event payloads); an induced server error shows in error tracking.","Medium","2","Backlog","epic:observability, phase:2, observability, posthog" +"Health endpoint and uptime monitor","**Ticket:** AUTH-T76 +**Epic:** E10 Observability & Audit +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +`GET /api/health` returns 200 with `{ db: 'ok', jwks: 'ok', version }` after a cheap `SELECT 1` and a JWKS presence check; 503 otherwise. Configure a free external uptime monitor hitting it every 5 minutes with email alerts to the admins. + +**Acceptance criteria** +- [ ] Monitor is live and alerted correctly during a deliberate 10-minute dev outage test.","Medium","1","Backlog","epic:observability, phase:1, backend, infra" +"Threshold alerts for security events","**Ticket:** AUTH-T77 +**Epic:** E10 Observability & Audit +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T73 (Audit event catalog, emitter, and coverage test); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T38 (Scheduled-job runner: GitHub Actions schedules calling secret-protected routes) + +A scheduled job (every 15 min via AUTH-T38) queries AuditEvent for spikes: >50 LOGIN_FAILED in 15 min, >5 ACCOUNT_LOCKED in an hour, any BREAK_GLASS_PA_RECOVERY, any PA_TRANSFER_INITIATED, JWKS rotation, job failures, Upstash fail-open events; emails all admins with a summary (deduplicated per hour). + +**Acceptance criteria** +- [ ] Simulated spike triggers exactly one alert email; the same condition an hour later triggers again.","Low","2","Backlog","epic:observability, phase:3, backend, observability, email" +"Retention jobs: tombstone deactivated (30 d) and inactive (12 mo) users, purge sessions and PII","**Ticket:** AUTH-T78 +**Epic:** E10 Observability & Audit +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T13 (Append-only AuditEvent table); AUTH-T36 (Admin user-management endpoints); AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T38 (Scheduled-job runner: GitHub Actions schedules calling secret-protected routes) + +Daily job (via AUTH-T38): (1) **tombstone** users deactivated ≥30 days ago: status DELETED, email replaced by `deleted+@invalid`, name 'Deleted user', preferredName null, password/accounts/MFA/known devices/sessions/positions removed, audit PII anonymized via the gated path; the row and id are kept so product foreign keys stay valid and products render 'Deleted user' (never the PA); (2) flag users with no login for 11 months and email an inactivity notice; tombstone at 12 months if still inactive (admins and the PA are exempt and listed for manual review; holding positions does not exempt); (3) purge expired sessions, **sessions whose `createdAt` is older than 90 days (absolute cap enforcement, AUTH-T27)**, verifications, unlock tokens, and unverified sign-ups older than 7 days; (4) null IP/UA on audit rows and sessions older than 90 days. Everything logged with counts; `--dry-run` support; admin UI shows upcoming tombstones. A tombstoned email may be re-registered later as a brand-new account (new id). + +**Acceptance criteria** +- [ ] Time-travel tests for each rule; PA and admins are never auto-tombstoned. +- [ ] A tombstoned user's id still resolves (status DELETED) with no PII; audit rows keep `actorUserId` but lose IP/UA/metadata PII. +- [ ] Sessions older than 90 days are gone after the job even if recently refreshed.","Medium","3","Backlog","epic:observability, phase:3, backend, privacy, infra" +"ARCHITECTURE.md: Neon mandate, components, session and token flows","**Ticket:** AUTH-T79 +**Epic:** E11 Integration Guides & Docs +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com); AUTH-T28 (Session endpoint for products: user, email, positions, admin flags); AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +Write the canonical architecture document: the Neon mandate in the first paragraph ('SGAuth runs on Neon serverless Postgres and does not use Supabase for any purpose'), component diagram (Vercel app, Neon, Upstash, Resend, PostHog), request flows (login, product session lookup, Supabase token flow, logout propagation, PA transfer), data model overview, environment topology, and links to runbooks and guides. Keep it current as a living document (owner: SGAuth lead). + +**Acceptance criteria** +- [ ] Doc merged at `docs/ARCHITECTURE.md` with Mermaid diagrams that render on GitHub; reviewed by the team lead.","High","3","Backlog","epic:docs, phase:1, docs" +"Integration guide for Neon-based products (Next.js)","**Ticket:** AUTH-T80 +**Epic:** E11 Integration Guides & Docs +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T61 (SDK documentation, example app, and versioning policy); AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains) + +`docs/integration/neon-products.md`: prerequisites (subdomain registered in the product registry), install SDK, env vars, add `proxy.ts`, read the session in server components/actions/route handlers, gate features by position keys (with a recommended per-product permission map file), key product tables by SGAuth user id (create-on-first-login pattern), link to the account page, logout, dev/preview topology, propagation/staleness, migration checklist for products with existing users, troubleshooting (cookie missing, 401 loops, origin rejected). + +**Acceptance criteria** +- [ ] VaultZ integration completed by following the guide with no undocumented steps (feedback folded back in).","High","3","Backlog","epic:docs, phase:2, docs, sdk" +"Integration guide for Supabase-backed products (third-party auth) plus the move-to-Neon alternative","**Ticket:** AUTH-T81 +**Epic:** E11 Integration Guides & Docs +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products); AUTH-T60 (SDK: getAccessToken() for Supabase clients) + +`docs/integration/supabase-products.md` covering, with tested snippets: +1. **Register SGAuth as a third-party auth provider** using the Supabase Management API (`POST /v1/projects/{ref}/config/auth/third-party-auth` with `oidc_issuer_url: https://auth.northeasternsga.com`, or `jwks_url`), a script `scripts/supabase-register-tpa.ts`, and how to verify (`GET` the integration, check `resolved_jwks`). Note the dashboard may not expose a generic provider; the API does. +2. **Token requirements** SGAuth satisfies: ES256, `kid`, `role: 'authenticated'`, uuid `sub`, `iss`, `exp` ≤ 10 min. +3. **Client setup**: `createClient(url, key, { accessToken: () => getAccessToken() })` server-side, and the browser proxy pattern from the SDK. +4. **RLS**: `auth.uid()` = SGAuth user id; positions via `(select auth.jwt() -> 'positions')`; helper function `has_position(text)`; examples for select/insert policies; performance wrapping. +5. **Decoupling from auth.users**: third-party users have no `auth.users` row; replace FKs/triggers with a product `users` table keyed by SGAuth id, created on first request. +6. **Limitations**: no Supabase sessions/refresh/MFA/password features for these users; JWT staleness ≤10 min; Supabase JWKS refresh ≤30 min (why rotation has a 7-day grace); billing at $0.00325 per third-party MAU beyond quota; `custom_jwks` fallback if discovery fails. +7. **Alternative**: server-side verification with `jose` against SGAuth's JWKS for products that do not need RLS. +8. **Move to Neon instead**: checklist for migrating a Supabase product's Postgres to Neon and using the standard Neon guide, recommended for any product that needs auth and does not depend on Supabase-only features (Storage, Realtime). + +**Acceptance criteria** +- [ ] A throwaway Supabase project registered via the script accepts an SGAuth token and an RLS policy using `auth.uid()` and a position claim behaves as documented (recorded in the guide with a verification date). +- [ ] Guide includes the explicit limitations list and the MAU cost line.","High","5","Backlog","epic:docs, phase:3, docs, supabase, jwt" +"Admin runbooks","**Ticket:** AUTH-T82 +**Epic:** E11 Integration Guides & Docs +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T37 (Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)); AUTH-T39 (Break-glass Primary Admin recovery script and runbook); AUTH-T64 (Escalating account lockout with emailed unlock and known-device exemption); AUTH-T41 (Bulk user import (CSV) with position assignment and batched invites) + +`docs/runbooks/`: Primary Admin transfer (step-by-step with screenshots), break-glass recovery, unlocking a user, bulk import, position lifecycle (create/rename/retire), key and secret rotation, incident response (revoke all sessions, rotate secret, notify), onboarding a new product (registry + subdomain + SDK), semester turnover checklist. + +**Acceptance criteria** +- [ ] Each runbook has been executed once on dev by someone other than its author and corrected accordingly.","Medium","3","Backlog","epic:docs, phase:2, docs, admin" +"Privacy notice and data-handling document","**Ticket:** AUTH-T83 +**Epic:** E11 Integration Guides & Docs +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T78 (Retention jobs: tombstone deactivated (30 d) and inactive (12 mo) users, purge sessions and PII) + +Public page `/privacy` and `docs/DATA_HANDLING.md`: what SGAuth stores (name, preferred name, northeastern.edu email, positions, security metadata such as IP/user agent for 90 days, audit events), why, who can see it (admins), retention (deactivated 30 days, inactive 12 months), how to request deletion, and that SGAuth stores no NUID, grades, or academic records. Note that names and positions are directory-level information and that SGA, as a student organization, is not the university's FERPA steward; keep the data set that way. + +**Acceptance criteria** +- [ ] Page live and linked from the login footer; reviewed by the Primary Admin.","Medium","2","Backlog","epic:docs, phase:3, docs, privacy" +"Generated SDK API reference and changelog discipline","**Ticket:** AUTH-T84 +**Epic:** E11 Integration Guides & Docs +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T61 (SDK documentation, example app, and versioning policy) + +Typedoc site published to GitHub Pages from the SDK repo on release; enforce changelog entries via a CI check on PRs. + +**Acceptance criteria** +- [ ] Reference site live; a PR without a changelog entry fails CI.","Low","1","Backlog","epic:docs, phase:3, docs, sdk" +"CLAUDE.md and CONTRIBUTING.md for agents and humans","**Ticket:** AUTH-T85 +**Epic:** E11 Integration Guides & Docs +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T02 (Remove Supabase from the auth repo (in-place migration to Neon)); AUTH-T06 (Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch) + +Document conventions for coding agents and contributors: Neon-only (no Supabase), commands (dev, test, migrate, seed), folder layout, authz and audit requirements for any new mutating route, testing expectations, commit/PR conventions (labels), and links to the design docs. Update the repo's agent definitions that reference Supabase (prisma-migration-agent, security-reviewer) to Neon. + +**Acceptance criteria** +- [ ] CLAUDE.md merged; agent definitions no longer mention Supabase.","Low","1","Backlog","epic:docs, phase:1, docs, chore" +"Receive the Chambers auth.users export and define the import file format","**Ticket:** AUTH-T86 +**Epic:** E12 User Migration & Rollout +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** none + +**The export itself is a manual action item owned by Eli, outside Linear** (Chambers has no Linear team): export the Chambers Supabase `auth.users` table (`id`, `email`, `encrypted_password`, `email_confirmed_at`, `last_sign_in_at`, `banned_until`) joined to `public.users` (name fields, `admin_role`, `iems_role`, `is_active`) and board memberships **before the Supabase project is deleted**, into a JSON file kept out of git. This ticket: publish the expected JSON schema and a validation script (`scripts/validate-export.ts`) that checks the file, reports row counts and a checksum, and confirms hashes look like bcrypt (`$2a$`/`$2b$`). The file is stored in the team secrets vault and deleted after import. + +**Acceptance criteria** +- [ ] Schema and validator merged; the received export validates with counts matching what Eli reports from Supabase.","High","2","Backlog","epic:rollout, phase:3, migration, chambers" +"Import script: Chambers users with bcrypt hashes and position mapping","**Ticket:** AUTH-T87 +**Epic:** E12 User Migration & Rollout +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T86 (Receive the Chambers auth.users export and define the import file format); AUTH-T21 (Accept imported Chambers bcrypt hashes with lazy re-hash to scrypt); AUTH-T44 (Seed the curated SGA position list); AUTH-T13 (Append-only AuditEvent table) + +`scripts/import-users.ts --source chambers.json --mapping chambers-positions.json --dry-run`: for each row, upsert User by lower-cased email (merge if it already exists from another import), store the bcrypt hash as `bcrypt$` in Account (only when no scrypt password exists), set `emailVerified` from `email_confirmed_at`, mark `is_active = false` users as DEACTIVATED, assign positions from an approved mapping file (Chambers roles → curated position keys), skip banned users, and write a report (created/merged/skipped with reasons) plus an id-mapping file (Supabase id → SGAuth id) for the Chambers team. Audit BULK_IMPORT. Never log hashes. + +**Acceptance criteria** +- [ ] Dry run against dev reports counts; real run is idempotent (second run: 0 created). +- [ ] Sample imported user logs in with their Chambers password on dev (AUTH-T21) and holds the mapped positions. +- [ ] Mapping file approved by the Primary Admin before the production run.","High","3","Backlog","epic:rollout, phase:3, migration, backend, chambers" +"Aplio user import (emails and names, no passwords) with invites and id mapping","**Ticket:** AUTH-T88 +**Epic:** E12 User Migration & Rollout +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping); AUTH-T22 (Invite flow: admin-created users receive a set-password link) + +Aplio users authenticated with email OTP and have no passwords. Extend the import script with `--source aplio.json` (id, email, name, isAdmin, deletedAt): upsert by email, mark as invited (set-password link) unless they already exist with a password, do not grant SGAuth admin from Aplio's `isAdmin` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with `legacyEmail = true`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Invites are sent in batches under the mailer caps; stagger over days if needed. + +**Acceptance criteria** +- [ ] Report shows created/merged counts; a sample invited user sets a password and logs in; id-mapping file delivered to the Aplio team.","High","2","Backlog","epic:rollout, phase:4, migration, aplio" +"SenatePath and Attendance Manager user import","**Ticket:** AUTH-T89 +**Epic:** E12 User Migration & Rollout +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with invites and id mapping) + +Same script with `--source senatepath.json` (admin users only) and `--source attendance.json` (email, first/last, role) — NUID is NOT imported. Positions mapping files approved per product. Invites batched. + +**Acceptance criteria** +- [ ] Both imports run on dev with reports; id-mapping files delivered to each team.","Medium","2","Backlog","epic:rollout, phase:4, migration" +"Rollout plan and user communications","**Ticket:** AUTH-T90 +**Epic:** E12 User Migration & Rollout +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T80 (Integration guide for Neon-based products (Next.js)); AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping) + +Write `docs/ROLLOUT.md`: order SGAuth MVP → VaultZ → Chambers → Aplio (hard cutover) → SenatePath → Attendance Manager; per-product cutover checklist (pre-import users, registry entry, subdomain, env, SDK version, smoke test, announcement, rollback flag, support window of 3 days with a named contact); email templates announcing 'one login for all SGA tools' and, for Chambers users, that their existing password keeps working. + +**Acceptance criteria** +- [ ] Plan reviewed with each product owner; dates recorded; announcement emails drafted.","Medium","2","Backlog","epic:rollout, phase:3, docs, rollout" +"Production launch checklist and Primary Admin bootstrap","**Ticket:** AUTH-T91 +**Epic:** E12 User Migration & Rollout +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T72 (Threat model and pre-launch security review checklist); AUTH-T66 (Security headers (CSP, HSTS, frame, referrer)); AUTH-T76 (Health endpoint and uptime monitor); AUTH-T39 (Break-glass Primary Admin recovery script and runbook); AUTH-T64 (Escalating account lockout with emailed unlock and known-device exemption) + +Execute before the first product goes live: DNS + DMARC verified; production env vars set; Neon PITR confirmed; Upstash and Resend production keys; PostHog project; uptime monitor; threat-model checklist items closed; bootstrap the first Primary Admin via a one-time script (`scripts/bootstrap-primary-admin.ts`, refuses to run if any PA exists) followed by MFA enrollment; at least two people hold break-glass credentials; backups of the curated positions; smoke test of login/logout/session endpoint from a product stub on a real subdomain. + +**Acceptance criteria** +- [ ] Checklist completed and signed off in the ticket by the Primary Admin; bootstrap script left disabled afterwards.","High","2","Backlog","epic:rollout, phase:2, rollout, infra, security" +"Post-launch review and legacy cleanup tracking","**Ticket:** AUTH-T92 +**Epic:** E12 User Migration & Rollout +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** AUTH-T90 (Rollout plan and user communications) + +Two weeks after each product cutover: review PostHog funnel and audit metrics (failed logins, lockouts, support requests), remove the bcrypt verify path once no `bcrypt$` hashes remain, and confirm each product deleted its legacy auth code and secrets. Record findings in `docs/ROLLOUT.md`. + +**Acceptance criteria** +- [ ] Review notes recorded for every product; legacy-cleanup subtasks closed.","Low","1","Backlog","epic:rollout, phase:4, rollout, docs" +"Test harness: vitest, Neon test branch, factories, and test mailer","**Ticket:** AUTH-T93 +**Epic:** E13 Testing & QA +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T01 (Provision the Neon project, branches, and roles for SGAuth); AUTH-T04 (Configure Prisma 7 for Neon (pooled runtime, direct migrations)) + +Configure vitest (node environment) with a global setup that points Prisma at the Neon `test` branch (reset from `dev` before each CI run; runs serialized by a GitHub Actions concurrency group because the 10-branch cap rules out per-run branches), applies migrations, and truncates tables between test files. Provide factories (`createUser`, `createAdmin`, `createPrimaryAdmin`, `createSession`, `createPosition`), a fake clock helper, a capturing mailer, and a fake Upstash (in-memory) limiter. Add `npm test` and coverage thresholds (80% lines on `src/lib`). + +**Acceptance criteria** +- [ ] `npm test` runs locally against a personal branch and in CI against the test branch in under 5 minutes. +- [ ] Factories and fakes documented in CONTRIBUTING.md.","High","3","Backlog","epic:testing, phase:0, testing, infra" +"Integration tests for authentication flows","**Ticket:** AUTH-T94 +**Epic:** E13 Testing & QA +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T93 (Test harness: vitest, Neon test branch, factories, and test mailer); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T18 (Email verification for self-sign-up with resend limits); AUTH-T19 (Password reset flow); AUTH-T27 (Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache); AUTH-T64 (Escalating account lockout with emailed unlock and known-device exemption) + +End-to-end (HTTP-level) tests: sign-up + verify + login; domain rejection; wrong password; lockout and unlock; forgot/reset including session revocation; invite acceptance; deactivated user; session sliding and absolute expiry; logout and sign-out-everywhere; re-auth freshness; rate-limit responses with the fake limiter. + +**Acceptance criteria** +- [ ] All flows green in CI; each test asserts the expected audit events.","High","5","Backlog","epic:testing, phase:1, testing, auth-core" +"Authorization, admin, positions, and transfer integration tests","**Ticket:** AUTH-T95 +**Epic:** E13 Testing & QA +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T93 (Test harness: vitest, Neon test branch, factories, and test mailer); AUTH-T36 (Admin user-management endpoints); AUTH-T37 (Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)); AUTH-T42 (Positions CRUD: create, edit name/category, soft delete with retirement); AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)) + +HTTP-level tests exercising the admin endpoints with the full actor/target matrix, the position lifecycle (create/rename/assign/delete/retired-key reuse), and the complete PA transfer state machine including cron execution and mid-flight recipient deactivation. + +**Acceptance criteria** +- [ ] Matrix and state-machine tests pass; coverage of `authz.ts` and the transfer service ≥ 90%.","High","3","Backlog","epic:testing, phase:2, testing, admin" +"Playwright end-to-end SSO test across subdomains","**Ticket:** AUTH-T96 +**Epic:** E13 Testing & QA +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T30 (Global logout, sign out everywhere, and admin revocation) + +Spin up SGAuth and two minimal product stubs (using the SDK) on `auth.sga.localhost`, `a.sga.localhost`, `b.sga.localhost` in CI. Scenarios: login at auth → both products see the session; position assigned by an admin → product B gates a page accordingly; logout on product A → product B is logged out; expired session → redirect to login with the correct redirect param; open-redirect attempts rejected. + +**Acceptance criteria** +- [ ] Playwright job green in CI with traces on failure; run time under 4 minutes.","High","5","Backlog","epic:testing, phase:2, testing, sso, e2e" +"JWT and JWKS conformance tests for Supabase requirements","**Ticket:** AUTH-T97 +**Epic:** E13 Testing & QA +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T29 (ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products) + +Tests: token verifies with `jose` via remote JWKS; header has `alg: ES256` and `kid`; claims include uuid `sub`, `role: 'authenticated'`, `iss`, `aud`, `exp - iat ≤ 600`; discovery document is valid; rotation keeps the old key in the JWKS for the grace period; token endpoint rejects missing/revoked sessions. + +**Acceptance criteria** +- [ ] Conformance suite green; a checklist in the Supabase guide references these tests.","Medium","2","Backlog","epic:testing, phase:2, testing, jwt, supabase" +"Load sanity for the session endpoint on Neon","**Ticket:** AUTH-T98 +**Epic:** E13 Testing & QA +**Phase:** Phase 3 — Hardening, observability, Chambers +**Depends on:** AUTH-T28 (Session endpoint for products: user, email, positions, admin flags); AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching) + +Run a k6/autocannon script against the dev deployment: 100 concurrent virtual users hitting `/api/sgauth/session` with valid cookies for 2 minutes. Record p50/p95, Neon connection count, and any pooler saturation; tune pool size and Neon compute settings; document results. + +**Acceptance criteria** +- [ ] p95 < 250 ms and zero connection errors at 100 VUs; results in `docs/PERFORMANCE.md`.","Low","2","Backlog","epic:testing, phase:3, testing, performance" +"Spike: Northeastern Microsoft Entra ID sign-in feasibility","**Ticket:** AUTH-T99 +**Epic:** E3 Authentication Core +**Phase:** Phase 5 — Backlog / spikes +**Depends on:** AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Not a launch dependency. Investigate whether SGA can (a) get an app registered in Northeastern's Entra tenant via ITS, or (b) register a multi-tenant app in an SGA-owned tenant that Northeastern's tenant permits users to consent to, restricting sign-in to Northeastern's tenant id. If viable, prototype Better Auth's `microsoft` social provider behind a feature flag, linking to existing accounts by verified email. Report blockers, MFA inheritance (Duo), and the account-linking policy. + +**Acceptance criteria** +- [ ] Written findings with a go/no-go recommendation and, if go, a follow-up ticket set.","Low","3","Backlog","epic:auth-core, phase:5, spike, sso" +"Backlog: optional email OTP login (deferred due to email volume)","**Ticket:** AUTH-T100 +**Epic:** E3 Authentication Core +**Phase:** Phase 5 — Backlog / spikes +**Depends on:** AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Deferred by decision: password is the launch method because Resend free-tier volume is constrained. Revisit after launch: Better Auth `emailOTP` plugin as an alternative sign-in for users who forget passwords, with strict per-user caps. Requires a volume estimate against the org-wide Resend budget first. + +**Acceptance criteria** +- [ ] Decision recorded after reviewing 60 days of email volume metrics.","Low","2","Backlog","epic:auth-core, phase:5, backlog, email" +"Neon Free-plan quota monitoring, alerts, and upgrade runbook","**Ticket:** AUTH-T101 +**Epic:** E1 Foundation & Neon Migration +**Phase:** Phase 0 — Foundation +**Depends on:** AUTH-T01 (Provision the Neon project, branches, and roles for SGAuth) + +Accepted risk from the red-team review: SGAuth runs on Neon Free, which suspends the compute for the remainder of the month once the project uses 100 CU-hours. A compute that stays awake most of the day at the 0.25 CU minimum uses about 180 CU-hours/month, so exhaustion is plausible once several products are live, and it would take every SGA login down at once. +Mitigations: (1) a scheduled job (AUTH-T38, daily) reads consumption via the Neon API and emails all admins at 50%, 70%, and 85% of the monthly CU-hour quota, with a projection of the exhaustion date; (2) keep scale-to-zero at 5 minutes and rely on the SDK cache so idle periods suspend the compute; (3) `docs/runbooks/neon-upgrade.md`: one-click upgrade to Launch (pay-as-you-go, $0.106/CU-hour), what changes (7-day restore window, scale-to-zero configurable), and who is authorized to approve the spend; (4) record the 6-hour restore window as a known limitation in the architecture doc; (5) never add keep-warm pings (they burn the quota). + +**Acceptance criteria** +- [ ] Alert emails fire at the thresholds (tested by lowering the threshold on dev). +- [ ] Runbook merged and the upgrade trigger (85% or any suspension) is written into the on-call notes. +- [ ] ARCHITECTURE.md states the Free-plan risk and the restore window explicitly.","High","2","Backlog","epic:foundation, phase:0, infra, neon, observability" +"Scanner-safe email links: land on a page, consume the token on POST","**Ticket:** AUTH-T103 +**Epic:** E3 Authentication Core +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps) + +Northeastern mail is Microsoft 365, and Defender Safe Links pre-fetches every link in incoming mail. A link that acts on GET (verify, reset, invite/set-password, unlock, PA transfer accept/cancel) would be consumed by the scanner before the user clicks. Rule for every emailed link in SGAuth: the URL opens a page that shows what is about to happen and a button; the token is validated for display on GET (never consumed, never marks anything) and consumed only on the button's POST (same-origin, CSRF-protected). Better Auth's built-in verify-email link acts on GET, so send our own URL (`/verify-email?token=`) that renders the confirmation page and calls the Better Auth verification endpoint on submit. HEAD requests and known scanner user agents get a 200 with no side effects. Provide one shared `TokenActionPage` component and a helper used by AUTH-T18, T19, T22, T24, T37, T64. + +**Acceptance criteria** +- [ ] A HEAD or GET request to any emailed link does not consume the token (integration test); the subsequent POST does, exactly once. +- [ ] Every email template's link points at a page implementing the pattern (test enumerates templates).","Urgent","2","Backlog","epic:auth-core, phase:1, backend, email, security" +"Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them","**Ticket:** AUTH-T104 +**Epic:** E4 Sessions & SSO +**Phase:** Phase 1 — Core auth, sessions, SSO (MVP) +**Depends on:** AUTH-T05 (Create the Vercel project, custom domains, environments, and Neon preview branching); AUTH-T26 (Parent-domain session cookie for *.northeasternsga.com) + +Community reports (better-auth issues #5611, #3938) describe cross-subdomain cookies being set and then dropped in some configurations. Before any product integration work, deploy the minimal Better Auth config to `auth-dev.northeasternsga.com` and a static stub on `stub-dev.northeasternsga.com`, then verify in Chrome, Firefox, and Safari: (1) login sets exactly one `__Secure-sgauth-dev.session_token` cookie with `Domain=northeasternsga.com`; (2) the stub's server receives it and the session endpoint resolves it; (3) sign-out clears it on both hosts; (4) the `SGAUTH_ENV=preview` host-only mode works on a `*.vercel.app` preview; (5) the local `*.sga.localhost` scheme works in all three browsers or the fallback (hosts-file `sga.test`) is adopted; (6) a stale cookie with the same name set by the stub host (cookie tossing) is observed and its effect documented. Record findings in `docs/ENVIRONMENTS.md` and adjust AUTH-T26/T34 accordingly. + +**Acceptance criteria** +- [ ] Findings documented per browser with screenshots or HAR excerpts; any Better Auth version pin or workaround recorded. +- [ ] Go/no-go on the parent-domain cookie approach signed off by the SGAuth lead.","Urgent","2","Backlog","epic:sessions, phase:1, spike, sso, better-auth" diff --git a/docs/sgauth-design/out/linear-import-SENATEPATH.csv b/docs/sgauth-design/out/linear-import-SENATEPATH.csv new file mode 100644 index 0000000..ab7fb4a --- /dev/null +++ b/docs/sgauth-design/out/linear-import-SENATEPATH.csv @@ -0,0 +1,37 @@ +"Title","Description","Priority","Estimate","Status","Labels" +"Migrate SenatePath's database from Supabase to Neon","**Ticket:** SENATEPATH-S01 +**Epic:** SGAuth integration — SenatePath +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** none + +Create a Neon project/branches, dump and restore the Postgres schema and data (pg_dump from Supabase → psql into Neon), switch `DATABASE_URL`/`DIRECT_URL`, optionally upgrade Prisma 6 → 7 with the driver adapter, remove `@supabase/*` packages and Supabase Storage usage if any (or keep Storage only if truly needed and document it). SGAuth is Neon-only; SenatePath becomes a standard Neon product. + +**Acceptance criteria** +- [ ] App runs against Neon in dev and prod; row counts match the Supabase source; no Supabase Auth code remains after S02.","High","5","Backlog","epic:sgauth-integration, phase:4, neon, migration" +"Gate the SenatePath admin area with SGAuth positions","**Ticket:** SENATEPATH-S02 +**Epic:** SGAuth integration — SenatePath +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** SENATEPATH-S01 (Migrate SenatePath's database from Supabase to Neon); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) + +Install the SDK; protect `/admin/**` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on a position (e.g. `elections-chair` / `senate-admin`). Public application/nomination forms remain unauthenticated as today. + +**Acceptance criteria** +- [ ] Admin pages require an SGAuth session with the configured position; public forms unaffected.","High","3","Backlog","epic:sgauth-integration, phase:4, sgauth, backend" +"Import SenatePath admins into SGAuth and cut over","**Ticket:** SENATEPATH-S03 +**Epic:** SGAuth integration — SenatePath +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** SENATEPATH-S02 (Gate the SenatePath admin area with SGAuth positions); AUTH-T89 (SenatePath and Attendance Manager user import) + +Export admin emails for the SGAuth import; confirm they hold the gating position; run the per-product cutover checklist; delete Supabase project after a 14-day retention window. + +**Acceptance criteria** +- [ ] All previous admins can log in via SGAuth; Supabase project decommissioned.","Medium","2","Backlog","epic:sgauth-integration, phase:4, sgauth, rollout" +"Tests for the SGAuth admin gate","**Ticket:** SENATEPATH-S04 +**Epic:** SGAuth integration — SenatePath +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** SENATEPATH-S02 (Gate the SenatePath admin area with SGAuth positions) + +Integration tests (mocked SDK) for admin access with/without the position and for public routes. + +**Acceptance criteria** +- [ ] Tests in CI cover allow/deny and public access.","Low","2","Backlog","epic:sgauth-integration, phase:4, sgauth, testing" diff --git a/docs/sgauth-design/out/linear-import-VAULTZ.csv b/docs/sgauth-design/out/linear-import-VAULTZ.csv new file mode 100644 index 0000000..82ca924 --- /dev/null +++ b/docs/sgauth-design/out/linear-import-VAULTZ.csv @@ -0,0 +1,76 @@ +"Title","Description","Priority","Estimate","Status","Labels" +"Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy","**Ticket:** VAULTZ-V01 +**Epic:** SGAuth integration — VaultZ +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)); AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains) + +Add the SDK from public npm (no registry config), env vars (`SGAUTH_URL`, `SGAUTH_COOKIE_NAME`), and `proxy.ts` using `createSgaProxy({ publicPaths: ['/api/uploadthing', '/health'] })` (cookie-presence check only; real validation in server code via `requireSession()`). Unauthenticated requests redirect to `https://auth.northeasternsga.com/login?redirect=`. Register `https://vaultz.northeasternsga.com` (and `vaultz-dev.northeasternsga.com`) in the SGAuth product registry first. + +**Acceptance criteria** +- [ ] Visiting any VaultZ page without an SGAuth session redirects to SGAuth login and back to the original page after login. +- [ ] UploadThing callback route remains reachable without a session.","High","3","Backlog","epic:sgauth-integration, phase:2, sgauth, backend" +"Remove the shared passphrase gate","**Ticket:** VAULTZ-V02 +**Epic:** SGAuth integration — VaultZ +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy) + +Delete `lib/access-gate.ts`, `lib/actions/access.ts`, the passphrase page, and the `VAULTZ_ACCESS_CODE`/`VAULTZ_ACCESS_SECRET` env vars after the cutover (keep behind a `SGAUTH_ENABLED` flag for one release so rollback is a flag flip, then delete). + +**Acceptance criteria** +- [ ] No references to the passphrase remain after the flag is removed; the `vaultz_access` cookie is no longer set.","High","2","Backlog","epic:sgauth-integration, phase:2, sgauth, backend" +"Link VaultZ purchaser records to SGAuth user ids","**Ticket:** VAULTZ-V03 +**Epic:** SGAuth integration — VaultZ +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy) + +VaultZ's `User` table records purchasers (first/last), not logins. Add `sgauthUserId String? @unique` and `email String?`. On first authenticated request, find-or-create the purchaser row for the session user (name from SGAuth split into first/last as a best effort, editable). Admin UI to link legacy purchaser rows to SGAuth users by email. Purchases created by the current user default `userId` to their linked row. + +**Acceptance criteria** +- [ ] A first-time SGAuth user gets exactly one purchaser row; re-login does not duplicate. +- [ ] Legacy rows can be linked once and the link is unique.","Medium","3","Backlog","epic:sgauth-integration, phase:2, sgauth, data" +"Position-based permissions map for VaultZ","**Ticket:** VAULTZ-V04 +**Epic:** SGAuth integration — VaultZ +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy); AUTH-T44 (Seed the curated SGA position list) + +Create `lib/permissions.ts` mapping SGAuth position keys to VaultZ capabilities (e.g. `vp-finance`, `treasurer` → manage designations/budgets/transfers; `finance-committee` → create purchases; everyone else → read-only or no access). Gate server actions and pages with `hasAnyPosition`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list. + +**Acceptance criteria** +- [ ] Every mutating server action checks a capability; a user without positions cannot create or edit anything (tests). +- [ ] The map is the only place position keys appear in VaultZ code.","High","3","Backlog","epic:sgauth-integration, phase:2, sgauth, permissions" +"Account and sign-out links in the VaultZ header","**Ticket:** VAULTZ-V05 +**Epic:** SGAuth integration — VaultZ +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy) + +Show the session user's name, a link to `accountUrl()`, and a sign-out action using `logoutUrl('https://vaultz.northeasternsga.com')`. + +**Acceptance criteria** +- [ ] Sign out from VaultZ logs the user out of every SGA product (verified on dev).","Low","1","Backlog","epic:sgauth-integration, phase:2, sgauth, ui" +"Dev and preview topology for VaultZ","**Ticket:** VAULTZ-V06 +**Epic:** SGAuth integration — VaultZ +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy); AUTH-T34 (Non-production SSO topology: dev deployment, local hostnames, product preview domains) + +Configure the Vercel branch domain `vaultz-dev.northeasternsga.com` pointing at the dev branch with `SGAUTH_URL=https://auth-dev.northeasternsga.com` and the dev cookie name; local dev on `vaultz.sga.localhost:3001` against local SGAuth. Document in the VaultZ README. + +**Acceptance criteria** +- [ ] Preview on the branch domain shares the dev SGAuth session; ad-hoc vercel.app previews show a documented 'SSO unavailable on this host' page instead of a redirect loop.","Medium","2","Backlog","epic:sgauth-integration, phase:2, sgauth, infra" +"VaultZ cutover checklist and rollback","**Ticket:** VAULTZ-V07 +**Epic:** SGAuth integration — VaultZ +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** VAULTZ-V02 (Remove the shared passphrase gate); VAULTZ-V04 (Position-based permissions map for VaultZ); AUTH-T91 (Production launch checklist and Primary Admin bootstrap) + +Execute the per-product checklist from the rollout plan: registry entry, DNS, env vars, SDK version pinned, smoke test, announcement to finance users, `SGAUTH_ENABLED` rollback flag verified, 3-day support window. + +**Acceptance criteria** +- [ ] Checklist completed; rollback rehearsed on dev by flipping the flag.","Medium","1","Backlog","epic:sgauth-integration, phase:2, sgauth, rollout" +"Tests for SGAuth guards and permissions in VaultZ","**Ticket:** VAULTZ-V08 +**Epic:** SGAuth integration — VaultZ +**Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ +**Depends on:** VAULTZ-V04 (Position-based permissions map for VaultZ) + +Unit tests for the permissions map and integration tests (mocked SDK) for a protected page, a gated server action, and the purchaser link-on-first-login behavior. + +**Acceptance criteria** +- [ ] Tests run in VaultZ CI and cover allow/deny for each capability.","Medium","2","Backlog","epic:sgauth-integration, phase:2, sgauth, testing" diff --git a/docs/sgauth-design/out/positions.json b/docs/sgauth-design/out/positions.json new file mode 100644 index 0000000..247c12e --- /dev/null +++ b/docs/sgauth-design/out/positions.json @@ -0,0 +1,415 @@ +{ + "$comment": "Curated SGA position list supplied by SGA on 2026-09-18. key = immutable slug used by products; name = editable display name; category = admin-UI grouping. Upserted by key by the seed; never deletes.", + "positions": [ + { + "key": "student-body-president", + "name": "Student Body President", + "category": "Office of the President" + }, + { + "key": "executive-vice-president", + "name": "Executive Vice President", + "category": "Office of the President" + }, + { + "key": "parliamentarian", + "name": "Parliamentarian", + "category": "Office of the President" + }, + { + "key": "appeals-board-justice", + "name": "Appeals Board Justice", + "category": "Office of the President" + }, + { + "key": "director-of-elections", + "name": "Director of Elections", + "category": "Office of the President" + }, + { + "key": "vice-chair-of-elections", + "name": "Vice-Chair of Elections", + "category": "Office of the President" + }, + { + "key": "elections-board-member", + "name": "Elections Board Member", + "category": "Office of the President" + }, + { + "key": "t-shirt-team-chair", + "name": "T-Shirt Team Chair", + "category": "Office of the President" + }, + { + "key": "t-shirt-team-member", + "name": "T-Shirt Team Member", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-chair", + "name": "Governmental Relations Team Chair", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-vice-chair", + "name": "Governmental Relations Team Vice-Chair", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-member", + "name": "Governmental Relations Team Member", + "category": "Office of the President" + }, + { + "key": "vice-president-of-academic-affairs", + "name": "Vice President of Academic Affairs", + "category": "Academic Affairs" + }, + { + "key": "director-of-academic-services", + "name": "Director of Academic Services", + "category": "Academic Affairs" + }, + { + "key": "director-of-co-op", + "name": "Director of Co-op", + "category": "Academic Affairs" + }, + { + "key": "chair-of-ai-advisory-board", + "name": "Chair of AI Advisory Board", + "category": "Academic Affairs" + }, + { + "key": "faculty-senate-student-representative", + "name": "Faculty Senate Student Representative", + "category": "Academic Affairs" + }, + { + "key": "university-undergraduate-curriculum-committee-representative", + "name": "University Undergraduate Curriculum Committee Representative", + "category": "Academic Affairs" + }, + { + "key": "college-liaison", + "name": "College Liaison", + "category": "Academic Affairs" + }, + { + "key": "academic-services-committee-member", + "name": "Academic Services Committee Member", + "category": "Academic Affairs" + }, + { + "key": "ai-advisory-board-member", + "name": "AI Advisory Board Member", + "category": "Academic Affairs" + }, + { + "key": "co-op-working-group-member", + "name": "Co-op Working Group Member", + "category": "Academic Affairs" + }, + { + "key": "vice-president-of-campus-affairs", + "name": "Vice President of Campus Affairs", + "category": "Campus Affairs" + }, + { + "key": "director-of-campus-services", + "name": "Director of Campus Services", + "category": "Campus Affairs" + }, + { + "key": "director-of-sustainability", + "name": "Director of Sustainability", + "category": "Campus Affairs" + }, + { + "key": "director-of-green-initiatives", + "name": "Director of Green Initiatives", + "category": "Campus Affairs" + }, + { + "key": "chair-of-campus-planning-advisory-board", + "name": "Chair of Campus Planning Advisory Board", + "category": "Campus Affairs" + }, + { + "key": "chair-of-dining-advisory-board", + "name": "Chair of Dining Advisory Board", + "category": "Campus Affairs" + }, + { + "key": "campus-services-committee-member", + "name": "Campus Services Committee Member", + "category": "Campus Affairs" + }, + { + "key": "sustainability-committee-member", + "name": "Sustainability Committee Member", + "category": "Campus Affairs" + }, + { + "key": "campus-planning-advisory-board-member", + "name": "Campus Planning Advisory Board Member", + "category": "Campus Affairs" + }, + { + "key": "dining-advisory-board-member", + "name": "Dining Advisory Board Member", + "category": "Campus Affairs" + }, + { + "key": "green-initiatives-board-member", + "name": "Green Initiatives Board Member", + "category": "Campus Affairs" + }, + { + "key": "vice-president-of-diversity-equity-and-inclusion", + "name": "Vice President of Diversity, Equity, and Inclusion", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "director-of-diversity-equity-and-inclusion", + "name": "Director of Diversity, Equity, and Inclusion", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "director-of-internal-advancement", + "name": "Director of Internal Advancement", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "diversity-equity-and-inclusion-committee-member", + "name": "Diversity, Equity, and Inclusion Committee Member", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "internal-advancement-board-member", + "name": "Internal Advancement Board Member", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "vice-president-of-external-affairs", + "name": "Vice President of External Affairs", + "category": "External Affairs" + }, + { + "key": "assistant-vice-president-of-external-affairs", + "name": "Assistant Vice President of External Affairs", + "category": "External Affairs" + }, + { + "key": "director-of-communications", + "name": "Director of Communications", + "category": "External Affairs" + }, + { + "key": "director-of-events", + "name": "Director of Events", + "category": "External Affairs" + }, + { + "key": "assistant-director-of-events", + "name": "Assistant Director of Events", + "category": "External Affairs" + }, + { + "key": "graphic-design-manager", + "name": "Graphic Design Manager", + "category": "External Affairs" + }, + { + "key": "finance-manager", + "name": "Finance Manager", + "category": "External Affairs" + }, + { + "key": "boston-intercollegiate-government-representative", + "name": "Boston Intercollegiate Government Representative", + "category": "External Affairs" + }, + { + "key": "communications-and-events-committee-member", + "name": "Communications and Events Committee Member", + "category": "External Affairs" + }, + { + "key": "vice-president-of-student-involvement", + "name": "Vice President of Student Involvement", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-approvals", + "name": "Director of Student Organization Approvals", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-compliance", + "name": "Director of Student Organization Compliance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-governance", + "name": "Director of Student Organization Governance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-finance", + "name": "Director of Student Organization Finance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-operations", + "name": "Director of Student Organization Operations", + "category": "Student Involvement" + }, + { + "key": "student-organization-approvals-board-member", + "name": "Student Organization Approvals Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-compliance-board-member", + "name": "Student Organization Compliance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-governance-board-member", + "name": "Student Organization Governance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-finance-board-member", + "name": "Student Organization Finance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-operations-committee-member", + "name": "Student Organization Operations Committee Member", + "category": "Student Involvement" + }, + { + "key": "vice-president-of-student-success", + "name": "Vice President of Student Success", + "category": "Student Success" + }, + { + "key": "director-of-student-engagement", + "name": "Director of Student Engagement", + "category": "Student Success" + }, + { + "key": "director-of-global-experience", + "name": "Director of Global Experience", + "category": "Student Success" + }, + { + "key": "director-of-wellness", + "name": "Director of Wellness", + "category": "Student Success" + }, + { + "key": "student-engagement-committee-member", + "name": "Student Engagement Committee Member", + "category": "Student Success" + }, + { + "key": "global-experience-committee-member", + "name": "Global Experience Committee Member", + "category": "Student Success" + }, + { + "key": "wellness-committee-member", + "name": "Wellness Committee Member", + "category": "Student Success" + }, + { + "key": "global-experience-office-working-group-member", + "name": "Global Experience Office Working Group Member", + "category": "Student Success" + }, + { + "key": "holistic-wellness-working-group-member", + "name": "Holistic Wellness Working Group Member", + "category": "Student Success" + }, + { + "key": "vice-president-of-operational-affairs", + "name": "Vice President of Operational Affairs", + "category": "Operational Affairs" + }, + { + "key": "comptroller", + "name": "Comptroller", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-manager", + "name": "Digital Innovation Manager", + "category": "Operational Affairs" + }, + { + "key": "information-manager", + "name": "Information Manager", + "category": "Operational Affairs" + }, + { + "key": "webmaster", + "name": "Webmaster", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-team-lead", + "name": "Digital Innovation Team Lead", + "category": "Operational Affairs" + }, + { + "key": "vice-chair-of-website-management-team", + "name": "Vice-Chair of Website Management Team", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-senateportal-team-member", + "name": "Digital Innovation SenatePortal Team Member", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-website-creation-team-member", + "name": "Digital Innovation Website Creation Team Member", + "category": "Operational Affairs" + }, + { + "key": "website-management-team-member", + "name": "Website Management Team Member", + "category": "Operational Affairs" + }, + { + "key": "speaker-of-the-senate", + "name": "Speaker of the Senate", + "category": "Senate" + }, + { + "key": "senate-operations-coordinator", + "name": "Senate Operations Coordinator", + "category": "Senate" + }, + { + "key": "legislative-affairs-coordinator", + "name": "Legislative Affairs Coordinator", + "category": "Senate" + }, + { + "key": "senate-communications-coordinator", + "name": "Senate Communications Coordinator", + "category": "Senate" + }, + { + "key": "senator", + "name": "Senator", + "category": "Senate" + } + ] +} diff --git a/docs/sgauth-design/positions.json b/docs/sgauth-design/positions.json new file mode 100644 index 0000000..247c12e --- /dev/null +++ b/docs/sgauth-design/positions.json @@ -0,0 +1,415 @@ +{ + "$comment": "Curated SGA position list supplied by SGA on 2026-09-18. key = immutable slug used by products; name = editable display name; category = admin-UI grouping. Upserted by key by the seed; never deletes.", + "positions": [ + { + "key": "student-body-president", + "name": "Student Body President", + "category": "Office of the President" + }, + { + "key": "executive-vice-president", + "name": "Executive Vice President", + "category": "Office of the President" + }, + { + "key": "parliamentarian", + "name": "Parliamentarian", + "category": "Office of the President" + }, + { + "key": "appeals-board-justice", + "name": "Appeals Board Justice", + "category": "Office of the President" + }, + { + "key": "director-of-elections", + "name": "Director of Elections", + "category": "Office of the President" + }, + { + "key": "vice-chair-of-elections", + "name": "Vice-Chair of Elections", + "category": "Office of the President" + }, + { + "key": "elections-board-member", + "name": "Elections Board Member", + "category": "Office of the President" + }, + { + "key": "t-shirt-team-chair", + "name": "T-Shirt Team Chair", + "category": "Office of the President" + }, + { + "key": "t-shirt-team-member", + "name": "T-Shirt Team Member", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-chair", + "name": "Governmental Relations Team Chair", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-vice-chair", + "name": "Governmental Relations Team Vice-Chair", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-member", + "name": "Governmental Relations Team Member", + "category": "Office of the President" + }, + { + "key": "vice-president-of-academic-affairs", + "name": "Vice President of Academic Affairs", + "category": "Academic Affairs" + }, + { + "key": "director-of-academic-services", + "name": "Director of Academic Services", + "category": "Academic Affairs" + }, + { + "key": "director-of-co-op", + "name": "Director of Co-op", + "category": "Academic Affairs" + }, + { + "key": "chair-of-ai-advisory-board", + "name": "Chair of AI Advisory Board", + "category": "Academic Affairs" + }, + { + "key": "faculty-senate-student-representative", + "name": "Faculty Senate Student Representative", + "category": "Academic Affairs" + }, + { + "key": "university-undergraduate-curriculum-committee-representative", + "name": "University Undergraduate Curriculum Committee Representative", + "category": "Academic Affairs" + }, + { + "key": "college-liaison", + "name": "College Liaison", + "category": "Academic Affairs" + }, + { + "key": "academic-services-committee-member", + "name": "Academic Services Committee Member", + "category": "Academic Affairs" + }, + { + "key": "ai-advisory-board-member", + "name": "AI Advisory Board Member", + "category": "Academic Affairs" + }, + { + "key": "co-op-working-group-member", + "name": "Co-op Working Group Member", + "category": "Academic Affairs" + }, + { + "key": "vice-president-of-campus-affairs", + "name": "Vice President of Campus Affairs", + "category": "Campus Affairs" + }, + { + "key": "director-of-campus-services", + "name": "Director of Campus Services", + "category": "Campus Affairs" + }, + { + "key": "director-of-sustainability", + "name": "Director of Sustainability", + "category": "Campus Affairs" + }, + { + "key": "director-of-green-initiatives", + "name": "Director of Green Initiatives", + "category": "Campus Affairs" + }, + { + "key": "chair-of-campus-planning-advisory-board", + "name": "Chair of Campus Planning Advisory Board", + "category": "Campus Affairs" + }, + { + "key": "chair-of-dining-advisory-board", + "name": "Chair of Dining Advisory Board", + "category": "Campus Affairs" + }, + { + "key": "campus-services-committee-member", + "name": "Campus Services Committee Member", + "category": "Campus Affairs" + }, + { + "key": "sustainability-committee-member", + "name": "Sustainability Committee Member", + "category": "Campus Affairs" + }, + { + "key": "campus-planning-advisory-board-member", + "name": "Campus Planning Advisory Board Member", + "category": "Campus Affairs" + }, + { + "key": "dining-advisory-board-member", + "name": "Dining Advisory Board Member", + "category": "Campus Affairs" + }, + { + "key": "green-initiatives-board-member", + "name": "Green Initiatives Board Member", + "category": "Campus Affairs" + }, + { + "key": "vice-president-of-diversity-equity-and-inclusion", + "name": "Vice President of Diversity, Equity, and Inclusion", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "director-of-diversity-equity-and-inclusion", + "name": "Director of Diversity, Equity, and Inclusion", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "director-of-internal-advancement", + "name": "Director of Internal Advancement", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "diversity-equity-and-inclusion-committee-member", + "name": "Diversity, Equity, and Inclusion Committee Member", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "internal-advancement-board-member", + "name": "Internal Advancement Board Member", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "vice-president-of-external-affairs", + "name": "Vice President of External Affairs", + "category": "External Affairs" + }, + { + "key": "assistant-vice-president-of-external-affairs", + "name": "Assistant Vice President of External Affairs", + "category": "External Affairs" + }, + { + "key": "director-of-communications", + "name": "Director of Communications", + "category": "External Affairs" + }, + { + "key": "director-of-events", + "name": "Director of Events", + "category": "External Affairs" + }, + { + "key": "assistant-director-of-events", + "name": "Assistant Director of Events", + "category": "External Affairs" + }, + { + "key": "graphic-design-manager", + "name": "Graphic Design Manager", + "category": "External Affairs" + }, + { + "key": "finance-manager", + "name": "Finance Manager", + "category": "External Affairs" + }, + { + "key": "boston-intercollegiate-government-representative", + "name": "Boston Intercollegiate Government Representative", + "category": "External Affairs" + }, + { + "key": "communications-and-events-committee-member", + "name": "Communications and Events Committee Member", + "category": "External Affairs" + }, + { + "key": "vice-president-of-student-involvement", + "name": "Vice President of Student Involvement", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-approvals", + "name": "Director of Student Organization Approvals", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-compliance", + "name": "Director of Student Organization Compliance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-governance", + "name": "Director of Student Organization Governance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-finance", + "name": "Director of Student Organization Finance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-operations", + "name": "Director of Student Organization Operations", + "category": "Student Involvement" + }, + { + "key": "student-organization-approvals-board-member", + "name": "Student Organization Approvals Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-compliance-board-member", + "name": "Student Organization Compliance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-governance-board-member", + "name": "Student Organization Governance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-finance-board-member", + "name": "Student Organization Finance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-operations-committee-member", + "name": "Student Organization Operations Committee Member", + "category": "Student Involvement" + }, + { + "key": "vice-president-of-student-success", + "name": "Vice President of Student Success", + "category": "Student Success" + }, + { + "key": "director-of-student-engagement", + "name": "Director of Student Engagement", + "category": "Student Success" + }, + { + "key": "director-of-global-experience", + "name": "Director of Global Experience", + "category": "Student Success" + }, + { + "key": "director-of-wellness", + "name": "Director of Wellness", + "category": "Student Success" + }, + { + "key": "student-engagement-committee-member", + "name": "Student Engagement Committee Member", + "category": "Student Success" + }, + { + "key": "global-experience-committee-member", + "name": "Global Experience Committee Member", + "category": "Student Success" + }, + { + "key": "wellness-committee-member", + "name": "Wellness Committee Member", + "category": "Student Success" + }, + { + "key": "global-experience-office-working-group-member", + "name": "Global Experience Office Working Group Member", + "category": "Student Success" + }, + { + "key": "holistic-wellness-working-group-member", + "name": "Holistic Wellness Working Group Member", + "category": "Student Success" + }, + { + "key": "vice-president-of-operational-affairs", + "name": "Vice President of Operational Affairs", + "category": "Operational Affairs" + }, + { + "key": "comptroller", + "name": "Comptroller", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-manager", + "name": "Digital Innovation Manager", + "category": "Operational Affairs" + }, + { + "key": "information-manager", + "name": "Information Manager", + "category": "Operational Affairs" + }, + { + "key": "webmaster", + "name": "Webmaster", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-team-lead", + "name": "Digital Innovation Team Lead", + "category": "Operational Affairs" + }, + { + "key": "vice-chair-of-website-management-team", + "name": "Vice-Chair of Website Management Team", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-senateportal-team-member", + "name": "Digital Innovation SenatePortal Team Member", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-website-creation-team-member", + "name": "Digital Innovation Website Creation Team Member", + "category": "Operational Affairs" + }, + { + "key": "website-management-team-member", + "name": "Website Management Team Member", + "category": "Operational Affairs" + }, + { + "key": "speaker-of-the-senate", + "name": "Speaker of the Senate", + "category": "Senate" + }, + { + "key": "senate-operations-coordinator", + "name": "Senate Operations Coordinator", + "category": "Senate" + }, + { + "key": "legislative-affairs-coordinator", + "name": "Legislative Affairs Coordinator", + "category": "Senate" + }, + { + "key": "senate-communications-coordinator", + "name": "Senate Communications Coordinator", + "category": "Senate" + }, + { + "key": "senator", + "name": "Senator", + "category": "Senate" + } + ] +} diff --git a/docs/sgauth-design/tickets-auth.mjs b/docs/sgauth-design/tickets-auth.mjs new file mode 100644 index 0000000..f36fb57 --- /dev/null +++ b/docs/sgauth-design/tickets-auth.mjs @@ -0,0 +1,1040 @@ +// SGAuth ticket source — team AUTH (the SGAuth service itself). +// Fields: id, title, epic, phase, priority (Urgent|High|Medium|Low), estimate (1|2|3|5|8), +// labels (array), deps (array of ids), description (markdown), acceptance (array of strings). + +export const EPICS = { + foundation: { name: "E1 Foundation & Neon Migration", label: "epic:foundation", description: "Replace the Supabase foundation with Neon serverless Postgres, Better Auth, Vercel hosting, CI, and environment tooling. SGAuth MUST run on Neon and MUST NOT use Supabase for auth or data." }, + datamodel: { name: "E2 Data Model & Migrations", label: "epic:data-model", description: "Prisma 7 schema on Neon: users, Better Auth tables, positions, admin/Primary Admin invariants, audit log, product registry, security tables, and the migration workflow." }, + authcore: { name: "E3 Authentication Core", label: "epic:auth-core", description: "Email + password authentication with Better Auth: sign-up restricted to northeastern.edu, verification, reset, invites, imported bcrypt hashes, and the login UI." }, + sessions: { name: "E4 Sessions & SSO", label: "epic:sessions", description: "One session across *.northeasternsga.com via a parent-domain cookie, the session endpoint products call, ES256 JWTs + JWKS for Supabase products, logout propagation, redirects, re-authentication, and the non-production topology." }, + admin: { name: "E5 Admin & Primary Admin", label: "epic:admin", description: "Server-enforced admin rules, the single transferable Primary Admin with a guarded transfer flow, break-glass recovery, and bulk user administration." }, + positions: { name: "E6 Positions", label: "epic:positions", description: "Flat, admin-managed positions (stable key + display name) carried in every session; assignment, soft delete, retirement, propagation, and history." }, + ui: { name: "E7 Admin UI & Account UI", label: "epic:ui", description: "Light admin UI (users, positions, products, audit, PA transfer) and a minimal account page for every user (profile, positions, product links, sessions, password, MFA)." }, + sdk: { name: "E8 Product Registry & SDK", label: "epic:sdk", description: "Admin-managed product registry that drives trusted origins and redirects, and the published @sgaoperations/sgauth package products use to read sessions and tokens." }, + security: { name: "E9 Security Hardening", label: "epic:security", description: "Upstash rate limiting, escalating account lockout, CSRF and origin enforcement, headers, TOTP MFA (required for admins), subdomain hygiene, secrets, enumeration resistance, scanning, and a threat model." }, + observability: { name: "E10 Observability & Audit", label: "epic:observability", description: "Append-only audit log with full coverage, structured logs, PostHog analytics and error tracking, health and uptime, alerts, and retention/deletion jobs." }, + docs: { name: "E11 Integration Guides & Docs", label: "epic:docs", description: "Architecture doc (Neon mandate), Neon-product and Supabase-product integration guides, admin runbooks, privacy notice, SDK reference, contributor guide." }, + rollout: { name: "E12 User Migration & Rollout", label: "epic:rollout", description: "Export/import of existing product users (Chambers hashes, Aplio/SenatePath/Attendance emails), launch checklist, rollout comms, cutover and rollback plans." }, + testing: { name: "E13 Testing & QA", label: "epic:testing", description: "Test harness on a Neon test branch, integration tests for every auth flow, authorization matrix, Playwright SSO end-to-end, JWT/JWKS conformance, and load sanity." }, +}; + +export const TICKETS = [ + // ───────────────────────── E1 Foundation & Neon Migration ───────────────────────── + { + id: "AUTH-T01", epic: "foundation", phase: 0, priority: "Urgent", estimate: 2, labels: ["infra", "neon"], deps: [], + title: "Provision the Neon project, branches, and roles for SGAuth", + description: `Create the production Neon project that SGAuth is built on. **SGAuth is built on Neon serverless Postgres and MUST NOT use Supabase in any form** (no Supabase Auth, no Supabase Postgres, no Supabase client libraries). + +Set up: +- Project \`sgauth\` in the SGAOperations Neon org, region closest to Vercel's default (us-east-1 / iad1). +- Branches: \`main\` (production), \`dev\` (shared non-production deployment), \`test\` (CI integration tests). Preview branches are created automatically later (AUTH-T05). +- A least-privilege application role for runtime queries and a separate migration role for \`prisma migrate deploy\`. +- Record the pooled connection string (host contains \`-pooler\`) as \`DATABASE_URL\` and the direct string as \`DIRECT_URL\` in a secrets store (Vercel env + the team password manager). Use \`sslmode=verify-full\`. +- Enable point-in-time restore on \`main\` (Neon history retention) and note the restore procedure. + +**Plan decision (red-team, accepted risk):** SGAuth launches on the Neon **Free** plan with scale-to-zero. Free suspends the compute for the rest of the month once 100 CU-hours are used, which would take every SGA product's login down; history/restore window is only 6 hours. AUTH-T101 adds quota monitoring and the upgrade runbook (Launch plan, pay-as-you-go at $0.106/CU-hour). Keep the SDK's 60-second cache so idle periods let the compute suspend. + +Reference: Neon Prisma guide (pooled vs direct URLs), Neon free plan limits (10 branches/project, 100 CU-hours/project/month, 0.5 GB storage per project, 6-hour history).`, + acceptance: [ + "Neon project exists with `main`, `dev`, and `test` branches; history retention set to the Free maximum on `main`.", + "Two roles exist: runtime (no DDL) and migration (DDL); credentials stored in the team secrets store, not in git.", + "`DATABASE_URL` (pooled) and `DIRECT_URL` (direct) connection strings are documented in `.env.example` with placeholder values and `sslmode=verify-full`.", + "A short section in README states the Neon mandate and that Supabase is not used anywhere in SGAuth.", + ], + }, + { + id: "AUTH-T02", epic: "foundation", phase: 0, priority: "Urgent", estimate: 3, labels: ["neon", "chore"], deps: [], + title: "Remove Supabase from the auth repo (in-place migration to Neon)", + description: `The current repo scaffolds Supabase auth and DB. Strip it entirely so the codebase reflects the Neon mandate. + +Remove: +- Dependencies: \`@supabase/ssr\`, \`@supabase/supabase-js\`, \`supabase\` (CLI). +- \`supabase/\` directory (config.toml), \`src/app/auth/callback/route.ts\` (Supabase OTP callback), Supabase env vars from \`.env.example\`, and the Supabase steps in README. +- The \`supabaseUserId\` column and any Supabase-shaped assumptions in \`prisma/schema.prisma\` and \`prisma/seed.ts\` (the schema is replaced in AUTH-T10; this ticket only removes Supabase references so the repo builds). + +Also triage open branches that build on Supabase (AUTH-7, AUTH-8, auth-9, auth-10, auth-11, AUTH-14): close them with a comment pointing at this design, or cherry-pick any UI-only work that is still useful. Do not merge Supabase code.`, + acceptance: [ + "`grep -ri supabase` across the repo (excluding the design doc folder) returns nothing.", + "`npm ci && npm run build && npm run lint && npm run format:check` pass with Supabase removed.", + "README setup section no longer mentions Docker or `supabase start`; it points at Neon branches (final wording lands in AUTH-T08).", + "Each open Supabase-based branch has a closing comment or a note in the PR explaining what was salvaged.", + ], + }, + { + id: "AUTH-T03", epic: "foundation", phase: 0, priority: "Urgent", estimate: 3, labels: ["backend", "better-auth"], deps: ["AUTH-T02", "AUTH-T04"], + title: "Install Better Auth 1.7 with the Prisma adapter and mount the handler", + description: `Add self-managed Better Auth (latest 1.7.x) and \`@better-auth/prisma-adapter\` on Neon, mirroring the pattern Aplio already uses. + +- \`src/lib/auth/config.ts\`: \`betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql' }), baseURL, secret, trustedOrigins, advanced: { database: { generateId: false } }, plugins: [nextCookies()] })\`. Prisma generates \`uuid(7)\` ids. +- \`src/app/api/auth/[...all]/route.ts\` mounting \`toNextJsHandler(auth)\`. +- \`baseURL\` = \`https://auth.northeasternsga.com\` in production, \`https://auth-dev.northeasternsga.com\` on the dev deployment, and a local dev hostname (AUTH-T34) locally. Never derive it from \`VERCEL_URL\` for cookies (the cookie domain must be the parent domain). +- \`trustedOrigins\` is temporarily a static list; AUTH-T56 replaces it with the product registry. +- Add \`server-only\` guards and the \`auth.api\` typed server helper. + +Plugins (jwt, admin, twoFactor, customSession) are added in their own tickets.`, + acceptance: [ + "`GET /api/auth/ok` (or equivalent Better Auth health route) returns 200 on the dev deployment.", + "Better Auth's initialization-time schema validation passes against the Prisma client (1.7 rejects requests on mismatch).", + "`BETTER_AUTH_SECRET` is required (32+ chars) and the app refuses to start without it.", + "Unit test confirms `baseURL` resolution per environment and that `VERCEL_URL` is never used for the production cookie domain.", + ], + }, + { + id: "AUTH-T04", epic: "foundation", phase: 0, priority: "High", estimate: 2, labels: ["backend", "neon", "prisma"], deps: ["AUTH-T01"], + title: "Configure Prisma 7 for Neon (pooled runtime, direct migrations)", + description: `Wire Prisma 7 to Neon following Neon's Prisma guide. + +- \`prisma.config.ts\`: \`datasource.url = env('DIRECT_URL')\` (used by the CLI for migrations). No \`url\` in the schema datasource block (Prisma 7 rule). +- Runtime client (\`src/lib/prisma.ts\`): driver adapter with the **pooled** \`DATABASE_URL\`. Use \`@prisma/adapter-pg\` with a \`pg.Pool\` sized for Vercel functions (\`max: 5\`, \`idleTimeoutMillis: 10_000\`), or \`@prisma/adapter-neon\` if WebSocket transport is preferred; document the choice. +- Global singleton in dev to avoid pool exhaustion on HMR. +- Add \`?sslmode=verify-full\`. Verify whether \`pgbouncer=true\` is needed with Neon's pooler when using a driver adapter and document the result.`, + acceptance: [ + "`npx prisma migrate deploy` runs against `DIRECT_URL`; runtime queries use the `-pooler` host (verified via Neon monitoring or `pg_stat_activity`).", + "Cold start on Vercel executes a `SELECT 1` through the pooled connection in under 300 ms p50 (measured on the dev deployment).", + "README documents why two URLs exist and which commands use which.", + ], + }, + { + id: "AUTH-T05", epic: "foundation", phase: 0, priority: "High", estimate: 3, labels: ["infra", "vercel", "neon"], deps: ["AUTH-T01", "AUTH-T03"], + title: "Create the Vercel project, custom domains, environments, and Neon preview branching", + description: `Host SGAuth on Vercel (Node runtime, not Edge, because of Prisma). + +- Vercel project \`sgauth\` linked to the repo; production branch \`main\` → \`auth.northeasternsga.com\`; a protected \`dev\` branch → \`auth-dev.northeasternsga.com\` (see AUTH-T34 for why a dev SGAuth deployment on the real parent domain is required for product previews). +- Install the Neon–Vercel integration so each preview deployment gets its own Neon branch (from \`dev\`) and \`DATABASE_URL\`/\`DIRECT_URL\` injected; enable automatic deletion of preview branches when the deployment is deleted (free plan allows 10 branches). +- Environment variables scoped per environment (production / preview / development). Secrets never shared across environments; \`BETTER_AUTH_SECRET\` differs per environment. +- Build command runs \`prisma generate\` and \`prisma migrate deploy\` (AUTH-T16) before \`next build\`. +- Deployment protection: previews password- or SSO-protected via Vercel; production public. +- **No Vercel cron jobs.** Vercel Hobby allows two jobs at once-per-day granularity and rejects more frequent schedules at deploy time; all scheduled work runs from GitHub Actions (AUTH-T38). Do not add a \`crons\` section to \`vercel.json\`. +- SGAuth's own \`*.vercel.app\` preview deployments cannot set a \`northeasternsga.com\` cookie; they run with \`SGAUTH_ENV=preview\` (host-only cookie, AUTH-T26) so previews are testable in isolation.`, + acceptance: [ + "`https://auth.northeasternsga.com` and `https://auth-dev.northeasternsga.com` serve the app over TLS with valid certificates.", + "Opening a PR creates a Neon preview branch and a preview deployment that runs migrations against it; closing the PR deletes the branch.", + "Vercel env vars are documented in `docs/ENVIRONMENTS.md` with which environment each applies to.", + ], + }, + { + id: "AUTH-T06", epic: "foundation", phase: 0, priority: "High", estimate: 3, labels: ["ci", "chore"], deps: ["AUTH-T03", "AUTH-T93"], + title: "Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch", + description: `Extend \`.github/workflows/ci.yml\` for the new stack. + +Jobs: typecheck, lint, format:check (existing), plus: +- \`migrations\`: \`prisma migrate diff\` to ensure schema and migrations are in sync and no drift; fails if a migration is missing. +- \`test\`: vitest unit + integration tests against the Neon \`test\` branch (AUTH-T93). Use a Neon API key stored as a GitHub secret to reset the branch (\`neonctl branches reset\`) before the run so tests start clean. Serialize runs with a GitHub Actions \`concurrency\` group (\`neon-test-branch\`) so parallel PRs do not share the branch mid-run; per-run branches are avoided because of the 10-branch cap. +- Cache npm and Prisma engines. +- Required checks + branch protection on \`main\` and \`dev\` (PR required, at least one review, CI green).`, + acceptance: [ + "CI runs on every PR and push to `main`/`dev`, all jobs green on a clean checkout.", + "A PR that changes `schema.prisma` without a migration fails the `migrations` job with a clear message.", + "Branch protection rules are enabled and documented in CONTRIBUTING.md.", + ], + }, + { + id: "AUTH-T07", epic: "foundation", phase: 0, priority: "Medium", estimate: 1, labels: ["backend", "chore"], deps: ["AUTH-T02"], + title: "Validate environment variables at startup with zod", + description: `Add \`src/lib/env.ts\` that parses \`process.env\` with zod (server and public schemas separated) and fails fast with a readable error listing missing/invalid vars. Rewrite \`.env.example\` to the final variable list: \`DATABASE_URL\`, \`DIRECT_URL\`, \`BETTER_AUTH_SECRET\`, \`BETTER_AUTH_URL\`, \`SGAUTH_COOKIE_DOMAIN\`, \`SGAUTH_COOKIE_PREFIX\`, \`SGAUTH_ENV\` (production|dev|preview|local), \`RESEND_API_KEY\`, \`RESEND_FROM_EMAIL\`, \`UPSTASH_REDIS_REST_URL\`, \`UPSTASH_REDIS_REST_TOKEN\`, \`POSTHOG_KEY\`, \`POSTHOG_HOST\`, \`CRON_SECRET\`, \`NEON_API_KEY\` (CI only).`, + acceptance: [ + "Starting the app with a missing required var prints the variable name and exits non-zero.", + "`.env.example` lists every variable with a one-line purpose comment and no real values.", + "No code reads `process.env` directly outside `src/lib/env.ts` (enforced by an ESLint `no-restricted-syntax` rule).", + ], + }, + { + id: "AUTH-T08", epic: "foundation", phase: 0, priority: "Medium", estimate: 2, labels: ["docs", "chore"], deps: ["AUTH-T10", "AUTH-T44"], + title: "Rewrite README and developer bootstrap for Neon branches", + description: `Replace the Docker/Supabase setup with: create a personal Neon branch from \`dev\` (\`neonctl branches create --parent dev --name \`), copy its URLs into \`.env\`, run \`npx prisma migrate dev\`, run \`npm run seed\` (curated positions + a local Primary Admin from \`SEED_PRIMARY_ADMIN_EMAIL\`), then \`npm run dev\`. Include the local SSO hostname setup from AUTH-T34 and the scripts table.`, + acceptance: [ + "A new contributor can go from clone to logged-in local Primary Admin in under 15 minutes following only the README.", + "The seed is idempotent (re-running changes nothing) and refuses to run when `SGAUTH_ENV=production`.", + ], + }, + { + id: "AUTH-T09", epic: "foundation", phase: 0, priority: "Medium", estimate: 2, labels: ["infra", "neon"], deps: ["AUTH-T05"], + title: "Automate Neon branch hygiene to stay under plan limits", + description: `The free plan allows 10 branches per project. Add a scheduled GitHub Action (daily) that lists branches via the Neon API and deletes preview branches whose PR is closed/merged or that are older than 7 days, excluding \`main\`, \`dev\`, \`test\`, and branches matching \`dev-*\` (personal). Post a summary to the workflow log.`, + acceptance: [ + "The action runs daily, is idempotent, and never deletes protected branches (unit test on the filter).", + "Branch count stays below 8 in steady state; a warning is logged at 8 or more.", + ], + }, + + // ───────────────────────── E2 Data Model & Migrations ───────────────────────── + { + id: "AUTH-T10", epic: "datamodel", phase: 0, priority: "High", estimate: 3, labels: ["backend", "prisma"], deps: ["AUTH-T03", "AUTH-T04"], + title: "Core schema: User plus Better Auth Session, Account, and Verification tables", + description: `Define the identity model in \`prisma/schema.prisma\` (Neon). Better Auth core tables plus SGAuth fields: + +**User**: \`id\` (uuid v7, PK), \`email\` (unique, stored lower-cased; use \`citext\` or a lower-case check), \`emailVerified\`, \`name\` (required display name), \`preferredName\` (optional), \`isAdmin\` (bool), \`isPrimaryAdmin\` (bool), \`status\` (enum ACTIVE | DEACTIVATED | DELETED — DELETED is a tombstone: the row and id survive with PII scrubbed so product foreign keys stay valid), \`deactivatedAt\`, \`deletedAt\`, \`legacyEmail\` (bool; true for imported accounts whose address is not northeastern.edu), \`lastLoginAt\`, \`passwordChangedAt\`, \`twoFactorEnabled\` (plugin), \`createdAt\`, \`updatedAt\`. +**Session**: Better Auth fields (\`id\`, \`token\` unique, \`userId\`, \`expiresAt\`, \`ipAddress\`, \`userAgent\`, \`createdAt\`, \`updatedAt\`) plus \`absoluteExpiresAt\` (createdAt + 90 days) and \`lastReauthAt\`. The Better Auth admin plugin is **not** used (custom authz instead), so no \`impersonatedBy\`/ban columns. +**KnownDevice**: \`id\`, \`userId\`, \`tokenHash\`, \`createdAt\`, \`lastSeenAt\`, \`userAgent\` — backs the lockout exemption (AUTH-T64). +**Account**: Better Auth fields; \`password\` holds either a scrypt hash or an imported \`bcrypt$...\` hash (AUTH-T21). **Verification**: Better Auth fields. + +No NUID, phone, pronouns, or photo. Products keep extra fields keyed by SGAuth user id. Write the initial migration and a Prisma-level test that the Better Auth adapter validates against this schema.`, + acceptance: [ + "Migration applies cleanly on an empty Neon branch and Better Auth schema validation passes at startup.", + "Email uniqueness is case-insensitive (`Alice@Northeastern.edu` and `alice@northeastern.edu` collide) with a test.", + "`isPrimaryAdmin = true` implies `isAdmin = true` via a CHECK constraint.", + "Indexes exist on `Session.userId`, `Session.expiresAt`, `Account.userId`, `Verification.identifier`.", + ], + }, + { + id: "AUTH-T11", epic: "datamodel", phase: 1, priority: "High", estimate: 3, labels: ["backend", "prisma", "positions"], deps: ["AUTH-T10"], + title: "Positions schema: Position, UserPosition, and retired keys", + description: `**Position**: \`id\` (uuid v7), \`key\` (unique, immutable, regex \`^[a-z0-9]+(?:-[a-z0-9]+)*$\`, 2–64 chars), \`name\` (display, 2–100 chars, editable), \`category\` (optional text for admin grouping), \`description\` (optional), \`deletedAt\` (soft delete), \`createdById\`, \`updatedById\`, timestamps. +**UserPosition**: composite PK (\`userId\`, \`positionId\`), \`assignedById\`, \`assignedAt\`. Unassignment deletes the row; history lives in the audit log. Soft-deleting a position keeps rows for history but they are excluded from sessions. +**RetiredPositionKey**: \`key\`, \`retiredAt\`, \`positionId\` — written on soft delete; the service layer refuses reuse of a key retired less than 365 days ago. +DB-level: partial unique index on \`Position(key) WHERE deletedAt IS NULL\`; CHECK constraint on key format; trigger or service check enforcing a maximum of 50 active positions per user.`, + acceptance: [ + "Creating a position with an invalid key or a key retired within 365 days fails at the service layer with a specific error code (and the DB rejects malformed keys).", + "A user cannot hold more than 50 active positions (test at the boundary).", + "Soft-deleted positions never appear in session or JWT output (covered by AUTH-T45).", + ], + }, + { + id: "AUTH-T12", epic: "datamodel", phase: 1, priority: "High", estimate: 3, labels: ["backend", "prisma", "admin", "security"], deps: ["AUTH-T10"], + title: "Primary Admin invariants at the database level and the PrimaryAdminTransfer table", + description: `Enforce in Postgres, independent of application code: +- Partial unique index: exactly one row may have \`isPrimaryAdmin = true\` (\`CREATE UNIQUE INDEX one_primary_admin ON "User" ((true)) WHERE "isPrimaryAdmin"\`). +- Trigger \`protect_primary_admin\` BEFORE UPDATE/DELETE on User: reject setting \`isAdmin = false\`, \`status = DEACTIVATED\`, or deleting the row while \`isPrimaryAdmin = true\`; reject clearing \`isPrimaryAdmin\` unless the transaction has set the session variable \`sgauth.pa_transfer = 'on'\` (set only by the transfer service and the break-glass script). +**PrimaryAdminTransfer**: \`id\`, \`fromUserId\`, \`toUserId\`, \`status\` (PENDING_ACCEPTANCE | COOLING | COMPLETED | CANCELLED | EXPIRED), \`createdAt\`, \`acceptanceExpiresAt\` (+7 days), \`acceptedAt\`, \`executesAt\` (acceptedAt + 24 h), \`cancelTokenHash\`, \`completedAt\`, \`cancelledAt\`, \`cancelledById\`, \`reason\`. Partial unique index allowing at most one transfer in PENDING_ACCEPTANCE or COOLING.`, + acceptance: [ + "SQL tests: inserting a second Primary Admin fails; updating the PA to non-admin fails; deleting the PA fails; a transfer inside a transaction with the session variable set succeeds and leaves exactly one PA.", + "At most one in-flight transfer can exist (unique index test).", + "Migration is reversible and documented with the reasoning in a comment header.", + ], + }, + { + id: "AUTH-T13", epic: "datamodel", phase: 1, priority: "High", estimate: 2, labels: ["backend", "prisma", "audit"], deps: ["AUTH-T10"], + title: "Append-only AuditEvent table", + description: `**AuditEvent**: \`id\` (uuid v7), \`type\` (string enum from the event catalog in AUTH-T73), \`actorUserId\` (nullable; null for system/break-glass), \`actorType\` (USER | SYSTEM | BREAK_GLASS), \`targetType\` (USER | POSITION | PRODUCT | SESSION | TRANSFER), \`targetId\`, \`metadata\` (jsonb, no secrets), \`ip\`, \`userAgent\`, \`createdAt\`. Index on (\`type\`, \`createdAt\`), (\`actorUserId\`, \`createdAt\`), (\`targetType\`, \`targetId\`, \`createdAt\`). +Append-only: a trigger rejects UPDATE and DELETE except for the anonymization job (AUTH-T78), which may null \`ip\`/\`userAgent\` and replace user-identifying metadata when a user is tombstoned, gated by the same session-variable pattern as AUTH-T12. The session-variable gate protects against accidental writes from application code; it is not a security boundary against anyone holding the database role. +\`type\` is validated in TypeScript against the catalog (AUTH-T73), not by a DB CHECK, so adding an event type does not require a migration.`, + acceptance: [ + "UPDATE/DELETE on AuditEvent fails from the runtime role (test), while the anonymization path succeeds.", + "A unit test rejects emitting an event whose `type` is not in the catalog.", + ], + }, + { + id: "AUTH-T14", epic: "datamodel", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "prisma", "registry"], deps: ["AUTH-T10"], + title: "Product registry schema", + description: `**Product**: \`id\`, \`slug\` (unique, kebab-case), \`name\`, \`description\`, \`baseUrl\` (must be \`https://\` and end with \`.northeasternsga.com\`; validated in service and by CHECK), \`iconUrl\` (optional, https only), \`isActive\`, \`sortOrder\`, \`visibleToPositionKeys\` (text[]; empty = visible to everyone), \`loginRedirectPath\` (optional path appended after login), \`createdById\`, timestamps. Seed the dev branch with SGAuth itself plus VaultZ, Chambers, Aplio placeholders.`, + acceptance: [ + "Inserting a product with an http:// or non-northeasternsga.com base URL fails.", + "Slug uniqueness enforced; renames of `name` do not change `slug`.", + ], + }, + { + id: "AUTH-T15", epic: "datamodel", phase: 1, priority: "Medium", estimate: 2, labels: ["backend", "prisma", "security"], deps: ["AUTH-T10"], + title: "Security tables: AccountLock, UnlockToken, Jwks, TwoFactor", + description: `- **AccountLock**: \`userId\` (PK), \`failedCount\`, \`windowStartedAt\`, \`lockedUntil\`, \`lockLevel\` (0..N for escalation), \`lastFailedAt\`, \`lastFailedIp\`. +- **UnlockToken**: \`id\`, \`userId\`, \`tokenHash\`, \`expiresAt\`, \`usedAt\`. +- **Jwks** and **TwoFactor** tables as required by the Better Auth jwt and twoFactor plugins (generate via \`npx @better-auth/cli generate\` and reconcile with Prisma naming). +- **KnownDevice** (see AUTH-T10) for the lockout exemption. +- No rate-limit table: rate limiting uses Upstash (AUTH-T63); disable Better Auth's DB-backed limiter storage.`, + acceptance: [ + "Plugin schema validation passes for jwt and twoFactor at startup.", + "Unlock tokens are stored hashed (SHA-256) and single-use (test).", + ], + }, + { + id: "AUTH-T16", epic: "datamodel", phase: 0, priority: "Medium", estimate: 1, labels: ["backend", "prisma", "docs"], deps: ["AUTH-T04", "AUTH-T05"], + title: "Migration workflow: deploy in build, never migrate dev against production", + description: `Document and enforce: developers run \`prisma migrate dev\` only against personal/preview Neon branches; production and dev deployments run \`prisma migrate deploy\` during the Vercel build using \`DIRECT_URL\`. Add a guard script that aborts \`migrate dev\`/\`db push\`/\`migrate reset\` when \`SGAUTH_ENV=production\` or when \`DIRECT_URL\` points at the \`main\` branch host. Write \`docs/MIGRATIONS.md\` including how to hand-edit generated SQL for triggers/indexes Prisma cannot express, and the **expand/contract rule**: migrations run during the build while the previous deployment is still serving traffic, so every migration must be backward-compatible with the currently deployed code (add columns nullable first, drop in a later release).`, + acceptance: [ + "Running `npm run prisma:migrate-dev` with a production `DIRECT_URL` exits non-zero before touching the database (test with a fake host).", + "Vercel production build logs show `migrate deploy` applying pending migrations.", + ], + }, + + // ───────────────────────── E3 Authentication Core ───────────────────────── + { + id: "AUTH-T17", epic: "authcore", phase: 1, priority: "Urgent", estimate: 3, labels: ["backend", "better-auth"], deps: ["AUTH-T03", "AUTH-T10"], + title: "Email + password sign-in and self-sign-up restricted to northeastern.edu", + description: `Enable \`emailAndPassword\` in Better Auth: \`minPasswordLength: 12\`, \`maxPasswordLength: 128\`, default scrypt hashing, \`requireEmailVerification: true\`, \`autoSignIn: false\` after sign-up. +- Self-sign-up: a \`databaseHooks.user.create.before\` hook rejects emails whose domain is not \`northeastern.edu\` (exact match, lower-cased) unless the creation is admin/import initiated (flag passed via context). Admin-created users may have any domain. +- Normalize email (trim, lower-case) before lookup and storage. +- New self-signed-up users have no positions and no admin flags. +- Deactivated users are refused at sign-in and at session creation (\`session.create.before\` hook) with a stable error code. +- Record \`lastLoginAt\` and emit audit events for LOGIN_SUCCESS / LOGIN_FAILED (AUTH-T73). +- **Account pre-hijack defense (red-team):** with \`requireEmailVerification\`, Better Auth answers a sign-up for an existing email with success (enumeration protection). If that existing account is still **unverified**, the new sign-up must overwrite its password and name (the address owner is whoever verifies), and verification must revoke every existing session. Verified accounts are never overwritten. +- Note: Better Auth re-sends the verification email on every sign-in attempt by an unverified user; the mailer's per-recipient caps (AUTH-T20) and sign-in rate limits (AUTH-T63) bound the abuse. +No breached-password (HIBP) check, per decision.`, + acceptance: [ + "Sign-up with `x@gmail.com` is rejected with `EMAIL_DOMAIN_NOT_ALLOWED`; `x@northeastern.edu` succeeds and requires verification before login.", + "Passwords under 12 characters are rejected client- and server-side.", + "A deactivated user cannot sign in and any existing session returns 401 from the session endpoint.", + "Pre-hijack test: attacker signs up with victim's email + password A; victim signs up with password B and verifies; password A no longer works and B does.", + "Integration tests cover success, wrong password, unverified email, deactivated user, and domain rejection.", + ], + }, + { + id: "AUTH-T18", epic: "authcore", phase: 1, priority: "High", estimate: 2, labels: ["backend", "email"], deps: ["AUTH-T17", "AUTH-T20", "AUTH-T103"], + title: "Email verification for self-sign-up with resend limits", + description: `Send a verification email on sign-up via the mailer (AUTH-T20) with a link valid for 24 hours; the link lands on a confirmation page and the token is consumed only on the button POST (AUTH-T103, Safe Links). Verifying revokes any pre-existing sessions, logs the user in, and redirects to the validated \`redirect\` target (AUTH-T31) or the account page. Resend is limited to 3 per address per hour (Upstash key \`verify:\`). Unverified accounts older than 7 days are purged by the retention job (AUTH-T78). The UI shows the same message whether or not the address exists.`, + acceptance: [ + "Clicking the link verifies the address once; a second click shows an already-verified message; expired links show a resend option.", + "Fourth resend within an hour returns 429 without sending.", + ], + }, + { + id: "AUTH-T19", epic: "authcore", phase: 1, priority: "High", estimate: 3, labels: ["backend", "email", "security"], deps: ["AUTH-T17", "AUTH-T20", "AUTH-T103"], + title: "Password reset flow", + description: `Forgot-password request (email only; uniform response regardless of existence), reset token valid 1 hour, single use, delivered by email; the token is consumed only when the new-password form is submitted, never on link open (AUTH-T103). On successful reset: set \`passwordChangedAt\`, revoke all other sessions, clear any account lock, send a security notice email, emit PASSWORD_RESET audit event. Rate limit: 3 requests per email per 10 minutes and per IP (AUTH-T63). Imported bcrypt accounts (AUTH-T21) reset to scrypt.`, + acceptance: [ + "Reset for a non-existent email returns the same response and timing profile (within 50 ms) as for an existing one.", + "Using a token twice fails; after reset, previously issued session cookies are rejected.", + "Audit and notice email are produced (asserted with the test mailer).", + ], + }, + { + id: "AUTH-T20", epic: "authcore", phase: 1, priority: "High", estimate: 3, labels: ["backend", "email", "infra"], deps: ["AUTH-T07"], + title: "Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps", + description: `Password login keeps email volume low (verification, reset, invite, lock/unlock, security notices, PA transfer steps). To avoid sharing Aplio's and Chambers' Resend quota: +- Separate Resend API key and a dedicated sending domain \`mail.northeasternsga.com\` with SPF, DKIM, and DMARC (p=quarantine) records; \`from\` = \`SGA Auth \`. +- \`src/lib/email/mailer.ts\` provider-agnostic interface (\`sendEmail({ to, template, data })\`) with a Resend implementation and a console/preview transport for local/test. +- Templates (React Email or plain HTML+text): verify, reset, invite/set-password, account locked, unlock, security notice (password changed / new admin grant), PA transfer initiated / accepted / cancelled / completed, inactivity notice. +- Caps: per-recipient 10 emails per hour and 30 per day; global daily cap (env, default 500) with an alert at 80%. Log every send with template and recipient hash to the log stream.`, + acceptance: [ + "DKIM/SPF/DMARC verified in the Resend dashboard; a test email to Gmail and Outlook lands in the inbox with aligned DMARC.", + "Exceeding the per-recipient cap is refused with a logged warning, not an exception to the user.", + "All templates render in both HTML and text and are snapshot-tested.", + ], + }, + { + id: "AUTH-T21", epic: "authcore", phase: 3, priority: "High", estimate: 2, labels: ["backend", "migration", "security"], deps: ["AUTH-T17"], + title: "Accept imported Chambers bcrypt hashes with lazy re-hash to scrypt", + description: `Supabase Auth (GoTrue) stores bcrypt hashes (\`$2a$\`/\`$2b$\`). Configure Better Auth \`emailAndPassword.password.verify\`: if the stored hash starts with \`bcrypt$\` (marker set by the import, AUTH-T87), verify with \`bcryptjs\`; on success, re-hash the plaintext with scrypt and update the Account row in the same request, then emit PASSWORD_REHASHED. Otherwise use the default scrypt verify. Never log plaintext. Remove the bcrypt path after all imported accounts have re-hashed or been reset (tracked by a metric).`, + acceptance: [ + "A user imported with a bcrypt hash can log in with their Chambers password on the first try and their Account row is scrypt afterwards.", + "Wrong password against a bcrypt hash fails without re-hashing.", + "A metric/query reports how many `bcrypt$` hashes remain.", + ], + }, + { + id: "AUTH-T22", epic: "authcore", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "email", "admin"], deps: ["AUTH-T20", "AUTH-T17", "AUTH-T103"], + title: "Invite flow: admin-created users receive a set-password link", + description: `When an admin creates a user (or the bulk import runs), create the User with \`emailVerified = true\` (admin vouches for the address), no password, and send an invite email with a set-password token valid 7 days (consumed on form submit, AUTH-T103). Setting the password marks the account ready and logs the user in. Admins can resend an invite (rate-limited 3/day per user). Users who never accept are listed in the admin UI as 'Invited'. If an invited (password-less) user tries to self-sign-up with the same email, the login page copy points them to 'Forgot password / set password' rather than creating a second account.`, + acceptance: [ + "An invited user cannot log in with any password until they set one via the link.", + "Expired invite shows a message and the admin sees a 'Resend invite' action.", + ], + }, + { + id: "AUTH-T23", epic: "authcore", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "security"], deps: ["AUTH-T17", "AUTH-T32"], + title: "Change password (current password + re-auth), revoke other sessions", + description: `Authenticated users change their password by providing the current password; requires a fresh re-authentication (AUTH-T32). On success: update hash, set \`passwordChangedAt\`, revoke every other session, send a security notice, emit PASSWORD_CHANGED.`, + acceptance: [ + "Wrong current password fails and counts toward account lockout.", + "Other devices are logged out after change (integration test with two sessions).", + ], + }, + { + id: "AUTH-T24", epic: "authcore", phase: 3, priority: "Low", estimate: 2, labels: ["backend", "admin"], deps: ["AUTH-T36", "AUTH-T20"], + title: "Admin-initiated email change with re-verification and privilege rules", + description: `Users cannot change their own email in v1 (identity anchoring). Admins can set a new email on a **non-admin** user; only the Primary Admin can change an admin's email; the Primary Admin's own email can be changed only by the Primary Admin. Every email change requires fresh re-authentication (AUTH-T32) so a hijacked session cannot redirect reset or transfer-cancel emails. The account keeps working with the old address until the user verifies the new one via a link sent to the new address (POST-consumed, AUTH-T103); a notice goes to the old address. Changing a \`legacyEmail\` account to a northeastern.edu address clears the flag. Audit EMAIL_CHANGE_REQUESTED / EMAIL_CHANGED.`, + acceptance: [ + "Old address remains valid until verification; after verification the old address cannot log in.", + "Attempting to change to an address already in use fails without revealing which account owns it.", + "An admin cannot change another admin's or the PA's email (authz test); re-auth is required in every case.", + ], + }, + { + id: "AUTH-T25", epic: "authcore", phase: 1, priority: "High", estimate: 3, labels: ["frontend", "ui"], deps: ["AUTH-T17", "AUTH-T18", "AUTH-T19", "AUTH-T31"], + title: "Login, sign-up, forgot/reset, and verify pages", + description: `Build the public auth pages with shadcn/ui and react-hook-form + zod: \`/login\` (email, password, preserves redirect param, links to forgot and sign-up), \`/sign-up\` (northeastern.edu hint, name, password with live length feedback), \`/forgot-password\`, \`/reset-password/[token]\`, \`/verify-email/[token]\`, \`/set-password/[token]\` (invites), \`/locked\` (explains lock and unlock email). Map Better Auth error codes to friendly copy without leaking account existence. Show SGA branding and a one-line explanation that this login works across all SGA tools.`, + acceptance: [ + "All pages are keyboard navigable, pass axe with no serious violations, and work at 360 px width.", + "Wrong password and unknown email share the same copy; unverified, locked, and deactivated states have distinct copy only when shown to the account's own verified session or email link.", + "After login the user lands on the validated redirect target or `/account`.", + ], + }, + + // ───────────────────────── E4 Sessions & SSO ───────────────────────── + { + id: "AUTH-T26", epic: "sessions", phase: 1, priority: "Urgent", estimate: 2, labels: ["backend", "sso", "security"], deps: ["AUTH-T03"], + title: "Parent-domain session cookie for *.northeasternsga.com", + description: `Configure Better Auth cookies so a single session spans every SGA subdomain: +- \`advanced.crossSubDomainCookies = { enabled: true, domain: 'northeasternsga.com' }\` in production and dev; \`advanced.cookiePrefix\` = \`sgauth\` (production) and \`sgauth-dev\` (dev deployment) so the two environments never collide on the shared parent domain. +- \`defaultCookieAttributes\`: \`httpOnly: true\`, \`secure: true\`, \`sameSite: 'lax'\`, \`path: '/'\`. Production cookie name becomes \`__Secure-sgauth.session_token\`. \`__Host-\` is impossible with a Domain attribute; document why. +- \`session.cookieCache\` disabled (no \`session_data\` cookie) so revocation is immediate. +- \`SGAUTH_ENV=preview\` (SGAuth's own \`*.vercel.app\` previews): cross-subdomain cookies **disabled**, host-only cookie, so previews can log in at all. +- Local dev uses the topology from AUTH-T34. Validate the whole configuration early with the spike in AUTH-T104 (there are unresolved community reports of cross-subdomain cookies being set then dropped in some setups). +Document the threat model: any compromised or dangling \`*.northeasternsga.com\` host can read the cookie **and can set a same-named cookie on the parent domain** (cookie tossing / login CSRF: the victim is silently logged into an attacker-controlled account). Cookie values are signed so they cannot be forged, but \`__Host-\` cannot be used with a Domain attribute, so subdomain hygiene (AUTH-T68) is the control.`, + acceptance: [ + "After login at auth.northeasternsga.com the browser holds one cookie with Domain=northeasternsga.com, Secure, HttpOnly, SameSite=Lax, and the `__Secure-` prefix.", + "A request to a product stub on another subdomain carries the cookie and the session endpoint resolves it.", + "Dev and production cookies coexist in one browser without interfering (different names).", + ], + }, + { + id: "AUTH-T27", epic: "sessions", phase: 1, priority: "High", estimate: 2, labels: ["backend", "sso"], deps: ["AUTH-T10", "AUTH-T26"], + title: "Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache", + description: `\`session.expiresIn = 30 days\`, \`session.updateAge = 1 day\` (sliding). Leave Better Auth's \`freshAge\` at its default: it gates Better Auth's own fresh-session endpoints and is **not** the re-auth mechanism (AUTH-T32 uses \`lastReauthAt\`). Absolute cap: set \`absoluteExpiresAt = createdAt + 90 days\` in \`session.create.before\`, reject sessions past it in the session endpoint, **and** have the daily retention job (AUTH-T78) delete any session whose \`createdAt\` is older than 90 days, because Better Auth's own endpoints (e.g. \`/token\`) do not run the custom check. Capture IP (from \`x-forwarded-for\` first hop on Vercel) and user agent. Sessions and JWTs are DB-backed; no cookie cache.`, + acceptance: [ + "A session used daily is still valid on day 29 and invalid on day 91 (time-travel test), including at `/api/auth/token`.", + "A session unused for 31 days is invalid.", + "Session rows store IP and user agent for the account page.", + ], + }, + { + id: "AUTH-T28", epic: "sessions", phase: 1, priority: "Urgent", estimate: 3, labels: ["backend", "sso", "api"], deps: ["AUTH-T27", "AUTH-T11"], + title: "Session endpoint for products: user, email, positions, admin flags", + description: `Products resolve the shared cookie by calling SGAuth server-side. Implement \`GET /api/sgauth/session\` (a thin wrapper over Better Auth \`getSession\` plus the \`customSession\` plugin) returning: +\`\`\`json +{ "user": { "id": "uuid", "email": "...", "name": "...", "preferredName": null, "isAdmin": false, "isPrimaryAdmin": false }, + "positions": [{ "key": "vp-finance", "name": "Vice President of Finance" }], + "session": { "id": "...", "expiresAt": "...", "absoluteExpiresAt": "...", "createdAt": "..." } } +\`\`\` +- 401 with \`{ "error": "unauthenticated" }\` when the cookie is missing, expired, revoked, past the absolute cap, or the user is deactivated. +- Positions read live from the DB (active, non-deleted), sorted by key. +- Headers: \`Cache-Control: no-store\`, \`Vary: Cookie\`. Accept the cookie via the \`Cookie\` header only (no query/body tokens). +- Also expose \`GET /api/sgauth/session/minimal\` returning only user id + positions for hot paths. Publish the JSON schema in the SDK.`, + acceptance: [ + "Contract tests cover the 200 shape, 401 cases, position ordering, and that deactivation/revocation is reflected on the very next call.", + "p95 latency under 120 ms from a Vercel function in the same region (measured against dev).", + "Response never includes password hashes, tokens, or MFA secrets (schema assertion).", + ], + }, + { + id: "AUTH-T29", epic: "sessions", phase: 2, priority: "High", estimate: 3, labels: ["backend", "sso", "supabase", "jwt"], deps: ["AUTH-T28", "AUTH-T15"], + title: "ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products", + description: `Add the Better Auth \`jwt\` plugin configured for Supabase third-party auth: +- \`jwks.keyPairConfig = { alg: 'ES256' }\`; **automatic rotation disabled** (no \`rotationInterval\`). Supabase learns about a new key only when it re-fetches the JWKS (up to ~30 minutes), so an automatic rotation would reject fresh tokens for that window. Rotation is manual (annually or on incident) per the runbook in AUTH-T69: generate the new key, push the combined JWKS to every Supabase project via \`custom_jwks\`, then switch signing; keep the old key published for 7 days. +- \`jwt.issuer = 'https://auth.northeasternsga.com'\` (no trailing slash; discovery must resolve at \`{issuer}/.well-known/openid-configuration\`), \`jwt.audience = 'authenticated'\` (matches Supabase's own token convention so any audience check passes), \`jwt.expirationTime = '10m'\`, \`getSubject = user.id\` (UUID, required because Supabase \`auth.uid()\` casts \`sub\` to uuid). Private keys encrypted at rest (default). +- \`definePayload\`: \`{ email, name, role: 'authenticated', positions: [keys], is_admin, is_primary_admin }\`. Include the \`kid\` header (plugin default). \`role: 'authenticated'\` is required so Supabase maps the request to the \`authenticated\` Postgres role. +- Endpoints: \`/api/auth/jwks\` (plugin) and rewrites for \`/.well-known/jwks.json\` and \`/.well-known/openid-configuration\` (\`issuer\`, \`jwks_uri\`, \`id_token_signing_alg_values_supported: ['ES256']\`, minimal fields) so Supabase can be pointed at the issuer URL. +- Token endpoint \`/api/auth/token\` requires a valid session cookie (rate-limited in AUTH-T63).`, + acceptance: [ + "A token from `/api/auth/token` verifies with `jose` against the JWKS with issuer and audience checks and contains `sub` (uuid), `role: 'authenticated'`, and `positions`.", + "`/.well-known/openid-configuration` returns valid JSON with `jwks_uri` resolving to the live key set.", + "After a manual rotation on dev, tokens signed by the previous key verify for 7 days and new tokens carry the new `kid`; the JWKS lists both keys during the overlap.", + "Token size stays under 2 KB with 50 positions of 64 characters (test).", + ], + }, + { + id: "AUTH-T30", epic: "sessions", phase: 1, priority: "High", estimate: 2, labels: ["backend", "sso"], deps: ["AUTH-T26", "AUTH-T56"], + title: "Global logout, sign out everywhere, and admin revocation", + description: `- \`POST /api/auth/sign-out\` deletes the current session row and clears the parent-domain cookie (same Domain/Path/prefix, \`Max-Age=0\`); every subdomain is logged out immediately because they share the cookie and the DB row is gone. +- \`POST /api/sgauth/sessions/revoke-all\` (user) revokes all sessions including the current one. +- Admin endpoint to revoke all sessions of a target user (AUTH-T36) with audit SESSIONS_REVOKED. +- \`/logout?redirect=\` convenience route for products: rejects requests whose \`Sec-Fetch-Site\` is \`cross-site\` (so an external page cannot log users out via an image or link), signs out, then redirects to a registry-validated URL (AUTH-T56) or to \`/login\`. Sibling subdomains are same-site and keep working. +Supabase-style JWTs already issued remain valid until their 10-minute expiry; document this in the Supabase guide.`, + acceptance: [ + "After sign-out on one subdomain, the session endpoint returns 401 for a request from another subdomain using the same browser.", + "`/logout?redirect=https://evil.example` redirects to `/login`, not the attacker URL.", + "Admin revocation invalidates every session of the target within one request.", + ], + }, + { + id: "AUTH-T31", epic: "sessions", phase: 1, priority: "High", estimate: 2, labels: ["backend", "security", "sso"], deps: ["AUTH-T56"], + title: "Safe post-login redirects validated against the product registry", + description: `\`/login?redirect=\` is the entry point every product uses. Validate: absolute \`https://\` URL whose origin exactly matches an active product's base URL origin (or SGAuth itself), or a relative path starting with \`/\` (not \`//\`). Strip fragments; cap length at 2 KB. Persist the redirect through sign-up, verification, and reset via a short-lived signed cookie rather than hidden form fields. Unknown or malformed targets fall back to \`/account\`.`, + acceptance: [ + "Open-redirect test suite (protocol-relative, backslashes, userinfo tricks, unicode homographs, unregistered subdomains) all land on `/account`.", + "A valid `https://vaultz.northeasternsga.com/purchases/42` survives login → verify → login and is honored.", + ], + }, + { + id: "AUTH-T32", epic: "sessions", phase: 2, priority: "High", estimate: 3, labels: ["backend", "security"], deps: ["AUTH-T27", "AUTH-T67"], + title: "Re-authentication (sudo mode) for sensitive actions", + description: `Sensitive actions require proof of presence within the last 10 minutes: Primary Admin transfer steps, granting/revoking admin, changing password, changing any email, enrolling/disabling/resetting MFA, revoking all sessions, deleting/deactivating users. Implement \`POST /api/sgauth/reauth\` accepting password (and TOTP code if enrolled) that stamps \`Session.lastReauthAt\`; a \`requireFreshAuth()\` guard checks the stamp. This is independent of Better Auth's \`freshAge\` (left at default). The UI shows a modal to re-enter credentials when the guard fails (403 \`REAUTH_REQUIRED\`). Failed re-auth attempts count toward lockout.`, + acceptance: [ + "Calling a sensitive endpoint 11 minutes after re-auth returns 403 `REAUTH_REQUIRED`; within 10 minutes it succeeds.", + "Re-auth for an MFA-enrolled user requires both password and a valid TOTP.", + ], + }, + { + id: "AUTH-T33", epic: "sessions", phase: 2, priority: "Medium", estimate: 2, labels: ["backend"], deps: ["AUTH-T27"], + title: "List and revoke the user's own sessions", + description: `\`GET /api/sgauth/sessions\` returns the caller's active sessions (id, createdAt, lastActiveAt, ip, userAgent parsed to a friendly device string, isCurrent). \`DELETE /api/sgauth/sessions/:id\` revokes one of the caller's sessions. Audit SESSION_REVOKED.`, + acceptance: [ + "A user cannot list or revoke another user's session (404, not 403, to avoid id probing).", + "Revoking the current session also clears the cookie.", + ], + }, + { + id: "AUTH-T34", epic: "sessions", phase: 1, priority: "High", estimate: 3, labels: ["infra", "sso", "docs"], deps: ["AUTH-T05", "AUTH-T26"], + title: "Non-production SSO topology: dev deployment, local hostnames, product preview domains", + description: `A parent-domain cookie cannot be read by \`*.vercel.app\` previews or plain \`localhost\`, so define the non-production topology once: +- **Dev SGAuth**: \`auth-dev.northeasternsga.com\` (branch \`dev\`, Neon \`dev\` branch, cookie prefix \`sgauth-dev\`). Products point their preview and dev environments here. Because it is a production-looking login page on the trusted domain: persistent 'DEVELOPMENT ENVIRONMENT' banner on every page, \`noindex\`, synthetic users only (real user imports are never run against dev), and fully separate secrets (Better Auth secret, Resend key, Upstash namespace). +- **Product previews**: each product gets a stable branch domain like \`-dev.northeasternsga.com\` (Vercel branch domain) so the dev cookie is shared; ad-hoc \`*.vercel.app\` previews cannot use SSO (document the limitation). +- **Local**: developers use \`*.localhost\` hostnames (Chrome resolves subdomains of localhost automatically; Firefox needs a preference) or hosts-file entries; SGAuth locally runs on \`http://auth.sga.localhost:3000\` with cookie domain \`sga.localhost\`, \`secure: false\`, no \`__Secure-\` prefix. Provide a \`SGAUTH_ENV=local\` switch that applies these settings. Browser handling of \`Domain=.localhost\` cookies differs (Safari is the usual problem); the spike in AUTH-T104 confirms the local scheme or falls back to a hosts-file domain such as \`sga.test\`. +- **SDK dev mode**: the SDK accepts \`SGAUTH_URL\` and \`SGAUTH_COOKIE_NAME\` overrides so products target dev or local SGAuth. Write \`docs/ENVIRONMENTS.md\` with a table of URLs, cookie names, and Neon branches per environment.`, + acceptance: [ + "A product stub running on `vaultz.sga.localhost:3001` sees a session created at `auth.sga.localhost:3000` (documented manual test plus a Playwright job in AUTH-T96).", + "`docs/ENVIRONMENTS.md` exists and is linked from README and the integration guides.", + "Production configuration cannot be started with a non-secure cookie setting (startup assertion).", + ], + }, + + // ───────────────────────── E5 Admin & Primary Admin ───────────────────────── + { + id: "AUTH-T35", epic: "admin", phase: 2, priority: "Urgent", estimate: 3, labels: ["backend", "admin", "security"], deps: ["AUTH-T10", "AUTH-T12"], + title: "Authorization module with the admin/Primary Admin rule matrix", + description: `Create \`src/lib/authz.ts\`: pure functions \`can(actor, action, target)\` used by every mutating endpoint. Rules: +- Only admins may perform admin actions; deactivated actors can do nothing. +- Admins may grant or revoke admin for **other** users freely; they can never change their **own** admin status or deactivate/delete themselves. +- Nobody except the transfer flow or break-glass may modify the Primary Admin's admin flag, status, PA flag, MFA, or email, or delete the account. The PA's positions and display name may be edited by any admin like any other user (accepted: an admin could strip the PA's product positions, which affects product access only, never SGAuth authority, and is audited). +- Email changes: admins may change non-admin users' emails; only the PA may change an admin's email; the PA changes their own. MFA reset: admins may reset non-admin and (only the PA) admin MFA; PA MFA reset is break-glass only. +- The Primary Admin may do everything an admin can, including changing other admins, and is the only one who can initiate a transfer. +- Position and product CRUD: any admin. +Return structured denials (\`{ allowed: false, reason: 'SELF_MODIFICATION' | 'PRIMARY_ADMIN_PROTECTED' | 'NOT_ADMIN' | ... }\`). UI hides controls using the same function, but enforcement is server-side only.`, + acceptance: [ + "Table-driven unit tests enumerate actor ∈ {user, admin, primary admin, deactivated admin} × target ∈ {self, other user, other admin, primary admin} × action ∈ {grantAdmin, revokeAdmin, deactivate, delete, assignPosition, initiateTransfer} with expected results; 100% branch coverage of `authz.ts`.", + "Every mutating route imports `can()`; an ESLint rule or test asserts no admin route lacks the guard.", + ], + }, + { + id: "AUTH-T36", epic: "admin", phase: 2, priority: "High", estimate: 5, labels: ["backend", "admin", "api"], deps: ["AUTH-T35", "AUTH-T13", "AUTH-T32", "AUTH-T22"], + title: "Admin user-management endpoints", + description: `Server actions or route handlers under \`/api/sgauth/admin/users\`: list/search (by email, name, position, status, admin flag; paginated), get, create (invite), update name/preferredName, deactivate (revokes all sessions immediately), reactivate, delete (= tombstone: status DELETED, PII scrubbed, sessions/accounts/MFA/positions removed, id retained), grant admin, revoke admin, revoke all sessions, unlock account, reset MFA (per the authz rules; emails the user), resend invite, force re-login. Every call passes \`can()\`, requires fresh re-auth for grant/revoke admin, deactivate, delete, and MFA reset, and emits an audit event with actor, target, and diff. Deactivation/deletion of the PA and self-modification are refused with the authz reason.`, + acceptance: [ + "Integration tests for every endpoint including denials (self-modify, PA-protected, non-admin, deactivated actor).", + "Deactivating a user with three active sessions leaves zero sessions and their next product request returns 401.", + "Each mutation produces exactly one audit row with the expected type and metadata.", + ], + }, + { + id: "AUTH-T37", epic: "admin", phase: 2, priority: "High", estimate: 5, labels: ["backend", "admin", "security", "email"], deps: ["AUTH-T12", "AUTH-T35", "AUTH-T32", "AUTH-T20", "AUTH-T67", "AUTH-T103", "AUTH-T38"], + title: "Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)", + description: `Implement the guarded transfer as a state machine over \`PrimaryAdminTransfer\`: +1. **Initiate** (PA only, fresh re-auth, recipient must be an active admin with MFA enrolled, PA must type the recipient email exactly): creates PENDING_ACCEPTANCE (expires in 7 days), emails recipient (accept link) and PA (confirmation), audit PA_TRANSFER_INITIATED. +2. **Accept** (recipient only, fresh re-auth): moves to COOLING, sets \`executesAt = now + 24h\`, generates a cancel token emailed to the outgoing PA (and shown in-app), emails both, audit PA_TRANSFER_ACCEPTED. +3. **Cancel**: outgoing PA (in-app with re-auth, or via the emailed cancel link without login — the link opens a page with a Cancel button; the token is consumed on POST, AUTH-T103), or recipient declines; status CANCELLED, both emailed, audit PA_TRANSFER_CANCELLED. The accept link likewise lands on a page requiring login + re-auth before acting. +4. **Execute** (scheduler, AUTH-T38): when \`executesAt\` has passed and status is COOLING, in one transaction with the DB session variable set: clear PA on the old user, set it on the recipient, keep both as admins, revoke all sessions of both users (forces re-login with correct claims), status COMPLETED, emails to both and to every admin, audit PA_TRANSFER_COMPLETED. +5. **Expire**: PENDING_ACCEPTANCE older than 7 days → EXPIRED, emails PA. +Exactly one in-flight transfer is allowed. If the recipient loses admin or is deactivated mid-flight, the transfer is cancelled automatically.`, + acceptance: [ + "State-machine tests cover every transition and every illegal transition (e.g. accept by a third party, cancel after completion, initiate while one is in flight).", + "The emailed cancel link works without a session and is single-use.", + "After execution there is exactly one PA, both parties are admins, and both must log in again.", + "Recipient deactivation during COOLING cancels the transfer and notifies the PA.", + ], + }, + { + id: "AUTH-T38", epic: "admin", phase: 1, priority: "High", estimate: 2, labels: ["backend", "infra", "ci"], deps: ["AUTH-T05", "AUTH-T07"], + title: "Scheduled-job runner: GitHub Actions schedules calling secret-protected routes", + description: `Vercel Hobby limits cron to two jobs at once-per-day and rejects finer schedules at deploy time, so **no Vercel cron is used**. Instead, a GitHub Actions workflow in the auth repo (same pattern Chambers uses) runs on schedules and calls \`GET /api/jobs/\` with \`Authorization: Bearer $CRON_SECRET\` (repo secret = Vercel env): +- \`pa-transfer\` every 15 minutes: executes due COOLING transfers and expires stale PENDING_ACCEPTANCE ones, idempotently (row-level lock, status re-check inside the transaction). +- \`alerts\` every 15 minutes (AUTH-T77). +- \`retention\` daily (AUTH-T78). +Each route is idempotent, logs outcomes, and returns quickly (under the function timeout); Actions schedules can be delayed several minutes under load, which is acceptable for these jobs. A missed run is caught by the next.`, + acceptance: [ + "Running the transfer job twice concurrently executes the transfer once (test with a simulated race).", + "Requests without the correct bearer secret return 401 and do nothing.", + "Workflow file exists with the three schedules; `vercel.json` contains no `crons`.", + ], + }, + { + id: "AUTH-T39", epic: "admin", phase: 2, priority: "High", estimate: 3, labels: ["backend", "admin", "security", "docs"], deps: ["AUTH-T12", "AUTH-T13"], + title: "Break-glass Primary Admin recovery script and runbook", + description: `\`scripts/recover-primary-admin.ts\` run by a human with production Neon credentials (\`npm run recover-primary-admin -- --email new-pa@northeastern.edu --reason "..."\`). It: verifies the target user exists and is active; prompts for the confirmation phrase \`TRANSFER PRIMARY ADMIN\`; inside one transaction with the DB session variable set, moves the PA flag, ensures the new PA is admin, revokes all sessions of the previous PA; writes an AuditEvent with \`actorType = BREAK_GLASS\` and the operator's name/reason; emails every admin and the old PA address. Supports \`--dry-run\`. A second mode, \`--reset-mfa --email \`, clears the current PA's TOTP and backup codes (for the lost-phone-and-lost-codes case, since admins cannot touch PA MFA) and forces re-enrollment on next login. No HTTP endpoint exists for either. Write \`docs/runbooks/break-glass.md\`: when to use it, who holds credentials (at least two people), and how to verify afterwards.`, + acceptance: [ + "Dry run prints the plan and changes nothing; real run leaves exactly one PA and an audit row of type BREAK_GLASS_PA_RECOVERY.", + "Script refuses to run without `DIRECT_URL` and the confirmation phrase.", + "Runbook reviewed by the current Primary Admin.", + ], + }, + { + id: "AUTH-T40", epic: "admin", phase: 2, priority: "High", estimate: 2, labels: ["testing", "admin", "security"], deps: ["AUTH-T12", "AUTH-T36"], + title: "Primary Admin protection tests across API and database layers", + description: `Integration tests proving the PA cannot be deleted, deactivated, banned, stripped of admin, or have the PA flag removed via any admin endpoint, Better Auth admin plugin endpoint (if mounted), or direct SQL from the runtime role; and that the PA is subject to account lockout but can self-unlock via the emailed link.`, + acceptance: [ + "All listed attack paths fail with the expected error at the API layer and, when bypassed, at the DB trigger.", + "Lockout/unlock test for the PA passes.", + ], + }, + { + id: "AUTH-T41", epic: "admin", phase: 2, priority: "Medium", estimate: 3, labels: ["backend", "admin", "migration"], deps: ["AUTH-T36", "AUTH-T43"], + title: "Bulk user import (CSV) with position assignment and batched invites", + description: `Admin endpoint + UI to upload a CSV (\`email,name,positions\` where positions is a \`|\`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as invited (AUTH-T22), assigns positions, and queues invite emails respecting mailer caps (AUTH-T20) in batches. Produces a downloadable report. Audit BULK_IMPORT with counts.`, + acceptance: [ + "A 200-row CSV with 5 invalid rows shows the 5 errors and imports nothing until fixed (all-or-nothing) or with an explicit 'skip invalid' toggle.", + "Re-importing the same CSV is idempotent (no duplicate users or assignments).", + ], + }, + + // ───────────────────────── E6 Positions ───────────────────────── + { + id: "AUTH-T42", epic: "positions", phase: 2, priority: "High", estimate: 3, labels: ["backend", "positions", "api"], deps: ["AUTH-T11", "AUTH-T35", "AUTH-T13"], + title: "Positions CRUD: create, edit name/category, soft delete with retirement", + description: `Admin endpoints: create (key + name + optional category/description; key validated, uniqueness checked against active and retired-within-365-days keys), update (name/category/description only; key immutable), delete (soft; returns holder count first via a preflight, requires the admin to send \`confirmKey\` equal to the key; on delete: set \`deletedAt\`, write RetiredPositionKey, keep UserPosition rows, emit POSITION_DELETED with holder ids). Audit every change with before/after. List endpoint supports including deleted for history views.`, + acceptance: [ + "Renaming changes `name` only; the key and all holders are untouched (test asserts session output before/after).", + "Delete without the matching `confirmKey` is refused; with it, holders lose the position on the next session call.", + "Creating a key retired 100 days ago fails with `KEY_RETIRED`; 400 days ago succeeds.", + ], + }, + { + id: "AUTH-T43", epic: "positions", phase: 2, priority: "High", estimate: 3, labels: ["backend", "positions", "api"], deps: ["AUTH-T42"], + title: "Position assignment endpoints (assign, unassign, bulk)", + description: `Admin endpoints to assign/unassign one or many positions to a user and to assign one position to many users. Enforce the 50-position cap, refuse deleted positions, ignore duplicates, emit POSITION_ASSIGNED / POSITION_UNASSIGNED per pair. Assignments are visible in the session endpoint on the next call (no caching in SGAuth).`, + acceptance: [ + "Assigning the 51st position fails with `POSITION_LIMIT`.", + "Bulk assign of 30 users is one transaction and one audit row per pair.", + ], + }, + { + id: "AUTH-T44", epic: "positions", phase: 1, priority: "Medium", estimate: 1, labels: ["backend", "positions"], deps: ["AUTH-T11"], + title: "Seed the curated SGA position list", + description: `**Input received 2026-09-18:** \`prisma/seed/positions.json\` already exists in the repo with 82 positions (81 offices across 9 categories — Office of the President, Academic Affairs, Campus Affairs, Diversity Equity and Inclusion, External Affairs, Student Involvement, Student Success, Operational Affairs, Senate — plus \`senator\`). Keys were generated by slugifying the official names and validated against the key format and length rules. Upsert by key in the seed script (safe to run in every environment; never deletes; name/category updates are applied, keys never change). Include a validation test that every key matches the format and names are unique. Product-specific roles (e.g. \`aplio-admin\`) are **not** seeded; product owners create them in the admin UI when they integrate. +Key naming convention (document in the admin UI help and the integration guides): organization roles use bare keys (\`vp-finance\`, \`senator\`); product-specific roles are prefixed with the product slug (\`aplio-admin\`, \`chambers-iems\`) so keys never collide and products can grep their own.`, + acceptance: [ + "Seed creates all 82 curated positions with zero holders; re-running is a no-op.", + "A test loads `prisma/seed/positions.json` and asserts every key matches `^[a-z0-9]+(?:-[a-z0-9]+)*$`, is 2–64 chars, and that keys and names are unique.", + ], + }, + { + id: "AUTH-T45", epic: "positions", phase: 2, priority: "Medium", estimate: 2, labels: ["testing", "positions", "sso"], deps: ["AUTH-T43", "AUTH-T28", "AUTH-T29"], + title: "Position propagation tests and forced re-login", + description: `Tests: after assign/unassign/delete, the very next \`/api/sgauth/session\` call reflects the change; a JWT minted before the change stays valid until expiry (≤10 min) and a new token reflects it. Add an admin action 'Force re-login' (revoke all sessions of a user) surfaced in the UI as the way to invalidate outstanding JWTs immediately. Document the staleness model in the integration guides.`, + acceptance: [ + "Automated tests demonstrate immediate propagation for the session endpoint and bounded staleness for JWTs.", + "Guides contain a 'Propagation and staleness' section.", + ], + }, + { + id: "AUTH-T46", epic: "positions", phase: 3, priority: "Low", estimate: 2, labels: ["backend", "positions", "audit"], deps: ["AUTH-T42", "AUTH-T73"], + title: "Position history queries", + description: `Endpoints returning the audit trail for a position (who was assigned/unassigned, renames, deletion) and for a user (all position changes), backed by AuditEvent indexes. Used by the admin UI detail pages.`, + acceptance: ["Both queries paginate and return within 200 ms for 10k events on the test branch."], + }, + + // ───────────────────────── E7 Admin UI & Account UI ───────────────────────── + { + id: "AUTH-T47", epic: "ui", phase: 2, priority: "High", estimate: 3, labels: ["frontend", "ui"], deps: ["AUTH-T25", "AUTH-T28"], + title: "App shell, navigation, and route guards for /admin and /account", + description: `Next.js App Router layouts: \`(auth)\` public pages, \`(app)\` authenticated pages with a header (user menu, sign out, sign out everywhere), \`/account\` for all users, \`/admin\` visible only to admins (server-side guard using \`can()\`; non-admins get 404). shadcn/ui components, light/dark via next-themes, SGA branding tokens. Toasts via sonner. Loading and error boundaries.`, + acceptance: [ + "Non-admin visiting `/admin` receives a 404 page; admin sees the dashboard.", + "Layout works at 360 px and 1440 px; axe reports no serious violations.", + ], + }, + { + id: "AUTH-T48", epic: "ui", phase: 2, priority: "High", estimate: 5, labels: ["frontend", "ui", "admin"], deps: ["AUTH-T47", "AUTH-T36", "AUTH-T43"], + title: "Admin: users list and user detail pages", + description: `Users list with search, filters (status, admin, position), pagination, and an 'Invited' badge. User detail: profile fields (editable), positions (assign/unassign with a searchable multi-select), admin toggle (disabled for self and PA with tooltip reason from \`can()\`), deactivate/reactivate, unlock, resend invite, sessions list with revoke, force re-login, and the user's audit timeline. Re-auth modal appears when the API returns REAUTH_REQUIRED.`, + acceptance: [ + "Every action shows success/failure toasts and refreshes data; disabled controls explain why.", + "Playwright test: admin assigns a position, the user's session endpoint reflects it.", + ], + }, + { + id: "AUTH-T49", epic: "ui", phase: 2, priority: "High", estimate: 3, labels: ["frontend", "ui", "positions"], deps: ["AUTH-T47", "AUTH-T42"], + title: "Admin: positions pages", + description: `List (with holder counts and category grouping), create dialog (key auto-suggested from name, editable before save, immutable after), edit page (name, category, description; key shown read-only with an explanation), delete dialog showing holder count and requiring the key to be typed, and a 'deleted positions' tab with history.`, + acceptance: [ + "Key input rejects invalid characters live and shows the format rule.", + "Delete dialog blocks submission until the typed key matches.", + ], + }, + { + id: "AUTH-T50", epic: "ui", phase: 2, priority: "Medium", estimate: 2, labels: ["frontend", "ui", "registry"], deps: ["AUTH-T47", "AUTH-T56"], + title: "Admin: product registry pages", + description: `CRUD pages for products (name, slug, base URL, description, icon URL, active, sort order, visible-to-positions). Show which trusted origins and redirect targets the registry currently yields.`, + acceptance: ["Adding a product makes its origin trusted and its URL appear on account pages without a deploy (verified on dev)."], + }, + { + id: "AUTH-T51", epic: "ui", phase: 3, priority: "Medium", estimate: 3, labels: ["frontend", "ui", "audit"], deps: ["AUTH-T47", "AUTH-T73"], + title: "Admin: audit log viewer with filters and CSV export", + description: `Table of AuditEvents with filters (type, actor, target, date range), detail drawer for metadata, and CSV export of the filtered set (server-streamed, capped at 50k rows).`, + acceptance: ["Filtering by a user shows both events they performed and events targeting them.", "Export matches the on-screen filter."], + }, + { + id: "AUTH-T52", epic: "ui", phase: 2, priority: "High", estimate: 3, labels: ["frontend", "ui", "admin"], deps: ["AUTH-T47", "AUTH-T37"], + title: "Admin: Primary Admin transfer wizard, status, acceptance, and cancel pages", + description: `PA-only wizard: full-screen warning explaining consequences, recipient selection limited to eligible admins (MFA enrolled), typed email confirmation, re-auth step, summary. Status card on the admin dashboard while a transfer is in flight (with cancel). Recipient acceptance page (\`/admin/transfer/accept/[id]\`) with re-auth and decline. Public cancel page for the emailed token. Completion banner for all admins.`, + acceptance: [ + "Wizard cannot be completed without typing the exact recipient email and passing re-auth.", + "Ineligible recipients (no MFA, not admin, deactivated) are not selectable and the reason is shown.", + ], + }, + { + id: "AUTH-T53", epic: "ui", phase: 2, priority: "High", estimate: 3, labels: ["frontend", "ui"], deps: ["AUTH-T47", "AUTH-T33", "AUTH-T56"], + title: "Account page: profile, positions, product links, sessions, sign out everywhere", + description: `\`/account\`: edit name and preferredName; read-only list of positions (key and name); 'Your SGA tools' grid of active products from the registry filtered by \`visibleToPositionKeys\` with links to each product (this is the hub users land on after login without a redirect); sessions list with per-device revoke and 'Sign out everywhere'; links to security settings.`, + acceptance: [ + "A user with no positions sees only products visible to everyone.", + "Revoking another device's session removes it from the list and that device gets 401.", + ], + }, + { + id: "AUTH-T54", epic: "ui", phase: 3, priority: "High", estimate: 3, labels: ["frontend", "ui", "security"], deps: ["AUTH-T23", "AUTH-T67"], + title: "Account security page: change password, MFA enrollment, backup codes", + description: `\`/account/security\`: change password form; TOTP enrollment (QR + manual secret, verify code, download/copy backup codes once), regenerate backup codes, disable MFA (re-auth; disallowed for admins and PA with explanation). Show 'MFA required for admins' banner and block admin pages until enrolled.`, + acceptance: [ + "An admin without MFA is redirected to enrollment when opening `/admin`.", + "Backup codes are shown once; using one logs a security notice.", + ], + }, + { + id: "AUTH-T55", epic: "ui", phase: 3, priority: "Medium", estimate: 2, labels: ["frontend", "ui", "a11y"], deps: ["AUTH-T48", "AUTH-T49", "AUTH-T53", "AUTH-T54"], + title: "Accessibility, responsive, and empty/error state pass", + description: `Audit every page with axe and keyboard-only navigation; add empty states, loading skeletons, and error states; verify focus management in dialogs; confirm color contrast in both themes.`, + acceptance: ["axe: zero serious/critical issues on all pages; documented checklist completed."], + }, + + // ───────────────────────── E8 Product Registry & SDK ───────────────────────── + { + id: "AUTH-T56", epic: "sdk", phase: 1, priority: "High", estimate: 2, labels: ["backend", "registry", "security"], deps: ["AUTH-T14"], + title: "Product registry service: trusted origins and redirect allowlist at runtime", + description: `\`src/lib/products.ts\`: loads active products (cached in-memory for 60 s, invalidated on write), exposes \`getTrustedOrigins()\` (product origins + SGAuth origin + local dev origins when \`SGAUTH_ENV=local\`) fed to Better Auth's \`trustedOrigins\` as a function, and \`isAllowedRedirect(url)\` used by AUTH-T31 and AUTH-T30. If the registry query fails, fall back to a static list containing only SGAuth's own origin (fail closed for products, but SGAuth's own pages keep working). Never trust \`*.vercel.app\` and never use a wildcard \`https://*.northeasternsga.com\` (it would trust dangling subdomains).`, + acceptance: [ + "Adding a product on dev makes cross-origin POSTs from its origin pass Better Auth's origin check within 60 s; removing it makes them fail.", + "Unit tests for origin normalization (trailing slash, port, case).", + ], + }, + { + id: "AUTH-T57", epic: "sdk", phase: 2, priority: "High", estimate: 3, labels: ["sdk", "infra"], deps: ["AUTH-T28"], + title: "Scaffold the @sgaoperations/sgauth package and publish pipeline (public npm)", + description: `New repo \`SGAOperations/sgauth-sdk\` (TypeScript, tsup, vitest, ESM+CJS, Node 20+). Publish to the **public npm registry** under the \`@sgaoperations\` org scope (claim the scope on npmjs.com; the SDK contains no secrets, only calls to public SGAuth endpoints with the user's cookie). GitHub Packages was rejected because it requires a classic personal access token to install even public packages. Use npm **trusted publishing** (OIDC from GitHub Actions) so no long-lived npm token exists; publish on \`v*\` tags; semantic versioning; \`CHANGELOG.md\`; provenance attestations enabled.`, + acceptance: [ + "`npm install @sgaoperations/sgauth` works from any product repo with no `.npmrc` changes.", + "CI publishes on `v*` tags via trusted publishing and fails on version collisions; the package page shows provenance.", + ], + }, + { + id: "AUTH-T58", epic: "sdk", phase: 2, priority: "High", estimate: 3, labels: ["sdk", "sso"], deps: ["AUTH-T57"], + title: "SDK: getSession() with cookie forwarding and a 60-second cache", + description: `\`getSession({ headers | cookieHeader })\`: extracts the SGAuth cookie (name from \`SGAUTH_COOKIE_NAME\`, default \`__Secure-sgauth.session_token\`), calls \`{SGAUTH_URL}/api/sgauth/session\` with the \`Cookie\` header, returns a typed \`SgaSession | null\`. In-memory LRU cache keyed by SHA-256 of the token, TTL 60 s (configurable, max 300 s), negative cache 10 s. **Availability:** SGAuth is a single point of failure for every product, so on network error or 5xx the SDK serves a previously cached session for that token for up to \`staleIfErrorSeconds\` (default 300, max 900) and calls \`onError\`; with no cached entry it returns null (fail closed). Timeout 3 s. Never caches 5xx as a session. Products must never log the forwarded Cookie header (documented). Ships the JSON schema types from AUTH-T28.`, + acceptance: [ + "Unit tests with a mocked fetch: cache hit/miss, TTL expiry, 401 → null, timeout with cached entry → stale session + onError, timeout without cache → null + onError.", + "Revocation observed within 60 s in an integration test against dev.", + ], + }, + { + id: "AUTH-T59", epic: "sdk", phase: 2, priority: "High", estimate: 3, labels: ["sdk", "sso"], deps: ["AUTH-T58"], + title: "SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)", + description: `Exports: \`createSgaProxy({ publicPaths })\` for Next.js \`proxy.ts\`/\`middleware.ts\` that **only checks for the presence of the SGAuth cookie** (no network call, no per-request latency) and redirects to \`loginUrl(currentUrl)\` when absent; real validation happens in server code via \`requireSession()\` (throws/redirects on an invalid or revoked cookie). \`hasPosition(session, key)\`, \`hasAnyPosition(session, keys)\`, \`hasAllPositions\`, \`isAdmin(session)\`; \`loginUrl(redirect)\`, \`logoutUrl(redirect)\`, \`accountUrl()\`. React cache() wrapper for server components so one request resolves the session once. Document why the proxy must not call SGAuth: middleware runs per request and per instance, so a network round trip there adds latency to every page and defeats the cache.`, + acceptance: [ + "Example app in the repo demonstrates a protected page, a position-gated action, and logout.", + "Helpers never run in the browser (server-only guard) except the URL builders.", + ], + }, + { + id: "AUTH-T60", epic: "sdk", phase: 3, priority: "Medium", estimate: 2, labels: ["sdk", "supabase", "jwt"], deps: ["AUTH-T58", "AUTH-T29"], + title: "SDK: getAccessToken() for Supabase clients", + description: `\`getAccessToken({ headers })\` calls \`/api/auth/token\` with the forwarded cookie and caches the JWT until \`exp - 60 s\` keyed by session token hash. Provide \`createSupabaseAccessTokenProvider()\` returning the \`accessToken\` callback shape supabase-js expects, and a browser-safe variant that calls a product-side route which proxies to SGAuth (so the token never requires exposing SGAuth cookies to client JS).`, + acceptance: ["Token refresh happens before expiry in a long-running test; a revoked session stops yielding tokens at the next refresh."], + }, + { + id: "AUTH-T61", epic: "sdk", phase: 2, priority: "Medium", estimate: 2, labels: ["sdk", "docs"], deps: ["AUTH-T59"], + title: "SDK documentation, example app, and versioning policy", + description: `README with install (public npm, no registry config), env vars (\`SGAUTH_URL\`, \`SGAUTH_COOKIE_NAME\`), quick start, API reference (typedoc), dev/preview topology (AUTH-T34), the stale-if-error behavior and its bound, upgrade notes, and a support policy (latest two minors).`, + acceptance: ["A new product can integrate using only the README (validated by the VaultZ integration)."], + }, + { + id: "AUTH-T62", epic: "sdk", phase: 3, priority: "Medium", estimate: 2, labels: ["backend", "security", "sso"], deps: ["AUTH-T56", "AUTH-T28"], + title: "CORS for browser-side calls from registered products", + description: `Allow client components on registered product origins to call \`/api/sgauth/session\`, \`/api/auth/token\`, and \`/api/auth/sign-out\` with \`credentials: 'include'\`: dynamic \`Access-Control-Allow-Origin\` echoing only registry origins, \`Allow-Credentials: true\`, \`Vary: Origin\`, preflight handling, no wildcard. Server-side calls remain the recommended path.`, + acceptance: ["Preflight from an unregistered origin gets no CORS headers; from a registered one it succeeds with credentials."], + }, + + // ───────────────────────── E9 Security Hardening ───────────────────────── + { + id: "AUTH-T63", epic: "security", phase: 1, priority: "High", estimate: 3, labels: ["backend", "security", "infra"], deps: ["AUTH-T03", "AUTH-T07"], + title: "Upstash Redis rate limiting on auth and token endpoints", + description: `Use \`@upstash/ratelimit\` (sliding window) inside the handlers for **mutating and expensive endpoints only**: sign-in 10/min and 50/hour per IP; sign-up 5/hour per IP; forgot-password 3/10 min per IP and per email; verification resend 3/hour per email; token endpoint 60/min per session; re-auth 5/min per user; admin mutations 100/min per user. The **session endpoint is not Redis-limited** (the Upstash free tier is 500K commands/month and the session endpoint is the hot path); it relies on the SDK cache, Vercel's platform protections, and a cheap in-process token bucket. Disable Better Auth's built-in limiter. **On Upstash outage: fail open everywhere with a logged alert** (decision: an Upstash outage must never become an org-wide login outage); the DB-backed account lockout (AUTH-T64) remains the brute-force backstop. Return 429 with \`Retry-After\`. Budget: estimate monthly Redis commands from expected logins and document the alert threshold at 80% of the free quota.`, + acceptance: [ + "Automated tests hit each limit and observe 429 + `Retry-After`; limits reset after the window.", + "With Upstash unreachable (fake), sign-in still succeeds and an alert-level log line is emitted.", + "Upstash keys are namespaced per environment (`sgauth:prod:`, `sgauth:dev:`).", + ], + }, + { + id: "AUTH-T64", epic: "security", phase: 1, priority: "High", estimate: 5, labels: ["backend", "security", "email"], deps: ["AUTH-T15", "AUTH-T17", "AUTH-T20", "AUTH-T103"], + title: "Escalating account lockout with emailed unlock and known-device exemption", + description: `Track failed password attempts per account (AccountLock). 5 failures within 15 minutes → lock 15 min; each subsequent lock doubles (30 min, 1 h, 2 h, … capped at 24 h); never permanent. **Known-device exemption (red-team):** a plain lockout lets anyone who knows an admin's email lock them out indefinitely. After every successful login SGAuth sets a signed, HttpOnly \`__Secure-sgauth.device\` cookie (host-only on auth.northeasternsga.com, 1-year, hashed in KnownDevice). Sign-in attempts that carry a valid known-device cookie for that account are exempt from the account lock (they remain subject to IP rate limits and their own separate 5-per-15-min counter); attempts without one count toward and are blocked by the lock. Attackers cannot obtain the cookie without a successful login. While locked, sign-in returns the same generic error as wrong password; the locked-account email tells the real owner what happened. On lock: email the user an unlock link (page + POST, AUTH-T103; single-use, 1 h) and a security notice; audit ACCOUNT_LOCKED / ACCOUNT_UNLOCKED. Successful login or password reset resets counters; lock level decays after 24 h clean. Applies to the Primary Admin. Admins can unlock from the UI.`, + acceptance: [ + "Sixth attempt within the window from an unknown device is refused even with the correct password; the unlock link restores access.", + "The same account signing in from a browser holding a valid known-device cookie succeeds while the account is locked for unknown devices.", + "Lock durations escalate and cap at 24 h (time-travel test).", + "Unknown emails do not produce different responses or timing.", + ], + }, + { + id: "AUTH-T65", epic: "security", phase: 1, priority: "High", estimate: 2, labels: ["backend", "security", "sso"], deps: ["AUTH-T56", "AUTH-T26"], + title: "CSRF and origin enforcement across subdomains", + description: `Because the cookie is shared with every subdomain and SameSite=Lax still sends it on same-site POSTs from sibling subdomains, SGAuth must verify the \`Origin\` (fallback \`Referer\`) header on every state-changing request against the registry-derived trusted origins (Better Auth does this for its routes; extend the check to all \`/api/sgauth/*\` mutations). Reject missing Origin on non-GET. Add \`Sec-Fetch-Site\` checks as defense in depth.`, + acceptance: [ + "A POST to `/api/sgauth/admin/users` with `Origin: https://unregistered.northeasternsga.com` and a valid cookie is rejected 403.", + "Same POST from a registered product origin succeeds.", + ], + }, + { + id: "AUTH-T66", epic: "security", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "security"], deps: ["AUTH-T05"], + title: "Security headers (CSP, HSTS, frame, referrer)", + description: `Set via \`next.config.ts\` headers: strict CSP (self + PostHog host, nonce for inline scripts), \`X-Frame-Options: DENY\`, \`Referrer-Policy: strict-origin-when-cross-origin\`, \`X-Content-Type-Options: nosniff\`, \`Permissions-Policy\` minimal. HSTS: \`max-age=31536000\` on auth.northeasternsga.com **without** \`includeSubDomains\`/\`preload\` unless every SGA subdomain is confirmed HTTPS-only (flag for the team; enabling it at the apex affects all products).`, + acceptance: ["securityheaders.com grade A on production; CSP violations reported to PostHog or a report-only endpoint first for one week."], + }, + { + id: "AUTH-T67", epic: "security", phase: 2, priority: "High", estimate: 5, labels: ["backend", "security", "better-auth"], deps: ["AUTH-T15", "AUTH-T17"], + title: "TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)", + description: `Add the Better Auth \`twoFactor\` plugin (TOTP + backup codes; no SMS/email OTP). Login flow: after password, if enrolled, prompt for a 6-digit code or backup code; 'trust this device' is NOT enabled (keep it simple and consistent with sessions). Enforcement: users with \`isAdmin\` must have MFA to access admin endpoints/pages (403 \`MFA_REQUIRED\` → enrollment redirect); granting admin to a user without MFA is allowed but they are locked out of admin functions until enrolled; the PA transfer recipient must be enrolled. Backup codes hashed; regeneration invalidates old ones; audit MFA_ENROLLED / MFA_DISABLED / MFA_BACKUP_USED.`, + acceptance: [ + "Enrolled user must present a valid TOTP; replayed codes within the same step are rejected.", + "Admin without MFA cannot call any admin endpoint; after enrollment the same call succeeds.", + "Disabling MFA requires re-auth and is refused for admins/PA.", + ], + }, + { + id: "AUTH-T68", epic: "security", phase: 2, priority: "Medium", estimate: 2, labels: ["infra", "security", "docs"], deps: ["AUTH-T26"], + title: "Subdomain hygiene: DNS inventory, dangling-record removal, and policy", + description: `Because the session cookie is readable by every \`*.northeasternsga.com\` host, inventory all DNS records for the domain, remove or reclaim records that point at unowned Vercel/other targets (subdomain takeover), and write \`docs/SUBDOMAIN_POLICY.md\`: only products in the SGAuth registry may receive a subdomain; wildcard records are prohibited; third-party services get a separate domain. Add a quarterly checklist item.`, + acceptance: ["Inventory spreadsheet/link attached; zero dangling records; policy merged and linked from the architecture doc."], + }, + { + id: "AUTH-T69", epic: "security", phase: 2, priority: "Medium", estimate: 1, labels: ["infra", "security", "docs"], deps: ["AUTH-T05", "AUTH-T29"], + title: "Secrets management and rotation procedures", + description: `Document and script rotation for \`BETTER_AUTH_SECRET\` (invalidates cookie signatures → all users re-login; schedule in a low-usage window), Resend key, Upstash token, Neon passwords, \`CRON_SECRET\`, and the **manual JWKS rotation** (AUTH-T29): (1) generate the new ES256 key pair in the Jwks table without switching signing; (2) run \`scripts/supabase-push-jwks.ts\` to PUT the combined JWKS into each Supabase project's third-party integration via \`custom_jwks\` (Management API) and confirm \`resolved_jwks\`; (3) switch signing to the new key; (4) after 7 days remove the old key and push again. Ensure secrets are scoped per Vercel environment and never printed in logs or preview builds.`, + acceptance: ["`docs/runbooks/rotate-secrets.md` exists and a dry run of the JWKS rotation on dev against a throwaway Supabase project produces zero token rejections during the switch."], + }, + { + id: "AUTH-T70", epic: "security", phase: 1, priority: "Medium", estimate: 2, labels: ["backend", "security", "testing"], deps: ["AUTH-T17", "AUTH-T19"], + title: "Account-enumeration resistance and timing uniformity", + description: `Ensure sign-in, forgot-password, sign-up, and verification-resend responses do not reveal whether an email exists (identical bodies and status codes). Better Auth already returns success for a duplicate sign-up when \`requireEmailVerification\` is on; verify that path and add the unverified-account overwrite from AUTH-T17. Add a dummy hash comparison on unknown-email sign-in to equalize timing.`, + acceptance: ["Tests assert identical response bodies and status codes across known/unknown emails for each endpoint; a non-gating benchmark script reports timing deltas (a hard 50 ms CI assertion was rejected as flaky)."], + }, + { + id: "AUTH-T71", epic: "security", phase: 3, priority: "Low", estimate: 1, labels: ["ci", "security"], deps: ["AUTH-T06"], + title: "Dependency and code scanning", + description: `Enable Dependabot (npm, weekly, grouped), \`npm audit --audit-level=high\` in CI, and GitHub CodeQL for JavaScript/TypeScript on PRs.`, + acceptance: ["All three run on the repo; a seeded vulnerable dependency fails CI in a test PR."], + }, + { + id: "AUTH-T72", epic: "security", phase: 2, priority: "Medium", estimate: 3, labels: ["docs", "security"], deps: ["AUTH-T26", "AUTH-T29", "AUTH-T35"], + title: "Threat model and pre-launch security review checklist", + description: `Write \`docs/THREAT_MODEL.md\` (STRIDE-lite) covering: shared-cookie exposure and subdomain takeover, session fixation/replay, JWT misuse by Supabase products, admin abuse and self-escalation, PA transfer hijack, break-glass misuse, email link phishing, rate-limit bypass, Neon credential leakage. For each: mitigation and residual risk. Derive a pre-launch checklist executed in AUTH-T91.`, + acceptance: ["Document reviewed by at least two team members; every residual risk has an owner or an accepted-risk note."], + }, + + // ───────────────────────── E10 Observability & Audit ───────────────────────── + { + id: "AUTH-T73", epic: "observability", phase: 1, priority: "High", estimate: 3, labels: ["backend", "audit"], deps: ["AUTH-T13"], + title: "Audit event catalog, emitter, and coverage test", + description: `\`src/lib/audit.ts\`: a typed catalog (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, SESSION_REVOKED, SESSIONS_REVOKED, PASSWORD_RESET_REQUESTED, PASSWORD_RESET, PASSWORD_CHANGED, PASSWORD_REHASHED, EMAIL_VERIFIED, USER_CREATED, USER_INVITED, USER_DEACTIVATED, USER_REACTIVATED, USER_DELETED, ADMIN_GRANTED, ADMIN_REVOKED, POSITION_CREATED/UPDATED/DELETED/ASSIGNED/UNASSIGNED, PRODUCT_CREATED/UPDATED/DELETED, PA_TRANSFER_*, BREAK_GLASS_PA_RECOVERY, ACCOUNT_LOCKED/UNLOCKED, MFA_*, BULK_IMPORT, JWKS_ROTATED) and \`audit(event)\` that captures actor, IP, UA from request context and writes inside the caller's transaction when one is open. Add a test that every mutating endpoint emits at least one audit event (route table cross-checked against catalog usage).`, + acceptance: [ + "Catalog is the single source for the DB CHECK constraint (generated migration).", + "Coverage test fails when a new mutating route is added without an audit call.", + ], + }, + { + id: "AUTH-T74", epic: "observability", phase: 1, priority: "High", estimate: 2, labels: ["backend", "observability"], deps: ["AUTH-T03"], + title: "Structured JSON logging with request IDs and redaction", + description: `Use \`pino\` (or a thin console JSON logger compatible with Vercel log drains): request id (from \`x-vercel-id\` or generated), route, user id (never email), latency, outcome. Redact tokens, cookies, passwords, and email bodies. Log levels by environment.`, + acceptance: ["Sample production log line validated against a schema; a test asserts secrets are redacted."], + }, + { + id: "AUTH-T75", epic: "observability", phase: 2, priority: "Medium", estimate: 2, labels: ["observability", "posthog"], deps: ["AUTH-T74"], + title: "PostHog: server-side auth funnel events and error tracking", + description: `Integrate PostHog (free tier): server-side capture of login_succeeded/login_failed (reason category only), signup_started/completed, reset_requested/completed, mfa_enrolled, session_endpoint_error; identify by SGAuth user id only (no email/name properties). Enable PostHog error tracking on the server; on the client, load the PostHog script **only on authenticated /admin and /account pages, never on login, sign-up, reset, or verification pages** (a third-party script on a credential form is a supply-chain risk; those pages report errors via a first-party endpoint). Respect a \`POSTHOG_DISABLED\` flag for local/test. Add a dashboard for daily logins, failure rate, lockouts.`, + acceptance: ["Events appear in PostHog from dev with no PII properties (verified by inspecting event payloads); an induced server error shows in error tracking."], + }, + { + id: "AUTH-T76", epic: "observability", phase: 1, priority: "Medium", estimate: 1, labels: ["backend", "infra"], deps: ["AUTH-T04", "AUTH-T29"], + title: "Health endpoint and uptime monitor", + description: `\`GET /api/health\` returns 200 with \`{ db: 'ok', jwks: 'ok', version }\` after a cheap \`SELECT 1\` and a JWKS presence check; 503 otherwise. Configure a free external uptime monitor hitting it every 5 minutes with email alerts to the admins.`, + acceptance: ["Monitor is live and alerted correctly during a deliberate 10-minute dev outage test."], + }, + { + id: "AUTH-T77", epic: "observability", phase: 3, priority: "Low", estimate: 2, labels: ["backend", "observability", "email"], deps: ["AUTH-T73", "AUTH-T20", "AUTH-T38"], + title: "Threshold alerts for security events", + description: `A scheduled job (every 15 min via AUTH-T38) queries AuditEvent for spikes: >50 LOGIN_FAILED in 15 min, >5 ACCOUNT_LOCKED in an hour, any BREAK_GLASS_PA_RECOVERY, any PA_TRANSFER_INITIATED, JWKS rotation, job failures, Upstash fail-open events; emails all admins with a summary (deduplicated per hour).`, + acceptance: ["Simulated spike triggers exactly one alert email; the same condition an hour later triggers again."], + }, + { + id: "AUTH-T78", epic: "observability", phase: 3, priority: "Medium", estimate: 3, labels: ["backend", "privacy", "infra"], deps: ["AUTH-T13", "AUTH-T36", "AUTH-T20", "AUTH-T38"], + title: "Retention jobs: tombstone deactivated (30 d) and inactive (12 mo) users, purge sessions and PII", + description: `Daily job (via AUTH-T38): (1) **tombstone** users deactivated ≥30 days ago: status DELETED, email replaced by \`deleted+@invalid\`, name 'Deleted user', preferredName null, password/accounts/MFA/known devices/sessions/positions removed, audit PII anonymized via the gated path; the row and id are kept so product foreign keys stay valid and products render 'Deleted user' (never the PA); (2) flag users with no login for 11 months and email an inactivity notice; tombstone at 12 months if still inactive (admins and the PA are exempt and listed for manual review; holding positions does not exempt); (3) purge expired sessions, **sessions whose \`createdAt\` is older than 90 days (absolute cap enforcement, AUTH-T27)**, verifications, unlock tokens, and unverified sign-ups older than 7 days; (4) null IP/UA on audit rows and sessions older than 90 days. Everything logged with counts; \`--dry-run\` support; admin UI shows upcoming tombstones. A tombstoned email may be re-registered later as a brand-new account (new id).`, + acceptance: [ + "Time-travel tests for each rule; PA and admins are never auto-tombstoned.", + "A tombstoned user's id still resolves (status DELETED) with no PII; audit rows keep `actorUserId` but lose IP/UA/metadata PII.", + "Sessions older than 90 days are gone after the job even if recently refreshed.", + ], + }, + + // ───────────────────────── E11 Integration Guides & Docs ───────────────────────── + { + id: "AUTH-T79", epic: "docs", phase: 1, priority: "High", estimate: 3, labels: ["docs"], deps: ["AUTH-T26", "AUTH-T28", "AUTH-T29"], + title: "ARCHITECTURE.md: Neon mandate, components, session and token flows", + description: `Write the canonical architecture document: the Neon mandate in the first paragraph ('SGAuth runs on Neon serverless Postgres and does not use Supabase for any purpose'), component diagram (Vercel app, Neon, Upstash, Resend, PostHog), request flows (login, product session lookup, Supabase token flow, logout propagation, PA transfer), data model overview, environment topology, and links to runbooks and guides. Keep it current as a living document (owner: SGAuth lead).`, + acceptance: ["Doc merged at `docs/ARCHITECTURE.md` with Mermaid diagrams that render on GitHub; reviewed by the team lead."], + }, + { + id: "AUTH-T80", epic: "docs", phase: 2, priority: "High", estimate: 3, labels: ["docs", "sdk"], deps: ["AUTH-T61", "AUTH-T34"], + title: "Integration guide for Neon-based products (Next.js)", + description: `\`docs/integration/neon-products.md\`: prerequisites (subdomain registered in the product registry), install SDK, env vars, add \`proxy.ts\`, read the session in server components/actions/route handlers, gate features by position keys (with a recommended per-product permission map file), key product tables by SGAuth user id (create-on-first-login pattern), link to the account page, logout, dev/preview topology, propagation/staleness, migration checklist for products with existing users, troubleshooting (cookie missing, 401 loops, origin rejected).`, + acceptance: ["VaultZ integration completed by following the guide with no undocumented steps (feedback folded back in)."], + }, + { + id: "AUTH-T81", epic: "docs", phase: 3, priority: "High", estimate: 5, labels: ["docs", "supabase", "jwt"], deps: ["AUTH-T29", "AUTH-T60"], + title: "Integration guide for Supabase-backed products (third-party auth) plus the move-to-Neon alternative", + description: `\`docs/integration/supabase-products.md\` covering, with tested snippets: +1. **Register SGAuth as a third-party auth provider** using the Supabase Management API (\`POST /v1/projects/{ref}/config/auth/third-party-auth\` with \`oidc_issuer_url: https://auth.northeasternsga.com\`, or \`jwks_url\`), a script \`scripts/supabase-register-tpa.ts\`, and how to verify (\`GET\` the integration, check \`resolved_jwks\`). Note the dashboard may not expose a generic provider; the API does. +2. **Token requirements** SGAuth satisfies: ES256, \`kid\`, \`role: 'authenticated'\`, uuid \`sub\`, \`iss\`, \`exp\` ≤ 10 min. +3. **Client setup**: \`createClient(url, key, { accessToken: () => getAccessToken() })\` server-side, and the browser proxy pattern from the SDK. +4. **RLS**: \`auth.uid()\` = SGAuth user id; positions via \`(select auth.jwt() -> 'positions')\`; helper function \`has_position(text)\`; examples for select/insert policies; performance wrapping. +5. **Decoupling from auth.users**: third-party users have no \`auth.users\` row; replace FKs/triggers with a product \`users\` table keyed by SGAuth id, created on first request. +6. **Limitations**: no Supabase sessions/refresh/MFA/password features for these users; JWT staleness ≤10 min; Supabase JWKS refresh ≤30 min (why rotation has a 7-day grace); billing at $0.00325 per third-party MAU beyond quota; \`custom_jwks\` fallback if discovery fails. +7. **Alternative**: server-side verification with \`jose\` against SGAuth's JWKS for products that do not need RLS. +8. **Move to Neon instead**: checklist for migrating a Supabase product's Postgres to Neon and using the standard Neon guide, recommended for any product that needs auth and does not depend on Supabase-only features (Storage, Realtime).`, + acceptance: [ + "A throwaway Supabase project registered via the script accepts an SGAuth token and an RLS policy using `auth.uid()` and a position claim behaves as documented (recorded in the guide with a verification date).", + "Guide includes the explicit limitations list and the MAU cost line.", + ], + }, + { + id: "AUTH-T82", epic: "docs", phase: 2, priority: "Medium", estimate: 3, labels: ["docs", "admin"], deps: ["AUTH-T37", "AUTH-T39", "AUTH-T64", "AUTH-T41"], + title: "Admin runbooks", + description: `\`docs/runbooks/\`: Primary Admin transfer (step-by-step with screenshots), break-glass recovery, unlocking a user, bulk import, position lifecycle (create/rename/retire), key and secret rotation, incident response (revoke all sessions, rotate secret, notify), onboarding a new product (registry + subdomain + SDK), semester turnover checklist.`, + acceptance: ["Each runbook has been executed once on dev by someone other than its author and corrected accordingly."], + }, + { + id: "AUTH-T83", epic: "docs", phase: 3, priority: "Medium", estimate: 2, labels: ["docs", "privacy"], deps: ["AUTH-T78"], + title: "Privacy notice and data-handling document", + description: `Public page \`/privacy\` and \`docs/DATA_HANDLING.md\`: what SGAuth stores (name, preferred name, northeastern.edu email, positions, security metadata such as IP/user agent for 90 days, audit events), why, who can see it (admins), retention (deactivated 30 days, inactive 12 months), how to request deletion, and that SGAuth stores no NUID, grades, or academic records. Note that names and positions are directory-level information and that SGA, as a student organization, is not the university's FERPA steward; keep the data set that way.`, + acceptance: ["Page live and linked from the login footer; reviewed by the Primary Admin."], + }, + { + id: "AUTH-T84", epic: "docs", phase: 3, priority: "Low", estimate: 1, labels: ["docs", "sdk"], deps: ["AUTH-T61"], + title: "Generated SDK API reference and changelog discipline", + description: `Typedoc site published to GitHub Pages from the SDK repo on release; enforce changelog entries via a CI check on PRs.`, + acceptance: ["Reference site live; a PR without a changelog entry fails CI."], + }, + { + id: "AUTH-T85", epic: "docs", phase: 1, priority: "Low", estimate: 1, labels: ["docs", "chore"], deps: ["AUTH-T02", "AUTH-T06"], + title: "CLAUDE.md and CONTRIBUTING.md for agents and humans", + description: `Document conventions for coding agents and contributors: Neon-only (no Supabase), commands (dev, test, migrate, seed), folder layout, authz and audit requirements for any new mutating route, testing expectations, commit/PR conventions (labels), and links to the design docs. Update the repo's agent definitions that reference Supabase (prisma-migration-agent, security-reviewer) to Neon.`, + acceptance: ["CLAUDE.md merged; agent definitions no longer mention Supabase."], + }, + + // ───────────────────────── E12 User Migration & Rollout ───────────────────────── + { + id: "AUTH-T86", epic: "rollout", phase: 3, priority: "High", estimate: 2, labels: ["migration", "chambers"], deps: [], + title: "Receive the Chambers auth.users export and define the import file format", + description: `**The export itself is a manual action item owned by Eli, outside Linear** (Chambers has no Linear team): export the Chambers Supabase \`auth.users\` table (\`id\`, \`email\`, \`encrypted_password\`, \`email_confirmed_at\`, \`last_sign_in_at\`, \`banned_until\`) joined to \`public.users\` (name fields, \`admin_role\`, \`iems_role\`, \`is_active\`) and board memberships **before the Supabase project is deleted**, into a JSON file kept out of git. This ticket: publish the expected JSON schema and a validation script (\`scripts/validate-export.ts\`) that checks the file, reports row counts and a checksum, and confirms hashes look like bcrypt (\`$2a$\`/\`$2b$\`). The file is stored in the team secrets vault and deleted after import.`, + acceptance: ["Schema and validator merged; the received export validates with counts matching what Eli reports from Supabase."], + }, + { + id: "AUTH-T87", epic: "rollout", phase: 3, priority: "High", estimate: 3, labels: ["migration", "backend", "chambers"], deps: ["AUTH-T86", "AUTH-T21", "AUTH-T44", "AUTH-T13"], + title: "Import script: Chambers users with bcrypt hashes and position mapping", + description: `\`scripts/import-users.ts --source chambers.json --mapping chambers-positions.json --dry-run\`: for each row, upsert User by lower-cased email (merge if it already exists from another import), store the bcrypt hash as \`bcrypt$\` in Account (only when no scrypt password exists), set \`emailVerified\` from \`email_confirmed_at\`, mark \`is_active = false\` users as DEACTIVATED, assign positions from an approved mapping file (Chambers roles → curated position keys), skip banned users, and write a report (created/merged/skipped with reasons) plus an id-mapping file (Supabase id → SGAuth id) for the Chambers team. Audit BULK_IMPORT. Never log hashes.`, + acceptance: [ + "Dry run against dev reports counts; real run is idempotent (second run: 0 created).", + "Sample imported user logs in with their Chambers password on dev (AUTH-T21) and holds the mapped positions.", + "Mapping file approved by the Primary Admin before the production run.", + ], + }, + { + id: "AUTH-T88", epic: "rollout", phase: 4, priority: "High", estimate: 2, labels: ["migration", "aplio"], deps: ["AUTH-T87", "AUTH-T22"], + title: "Aplio user import (emails and names, no passwords) with invites and id mapping", + description: `Aplio users authenticated with email OTP and have no passwords. Extend the import script with \`--source aplio.json\` (id, email, name, isAdmin, deletedAt): upsert by email, mark as invited (set-password link) unless they already exist with a password, do not grant SGAuth admin from Aplio's \`isAdmin\` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with \`legacyEmail = true\`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Invites are sent in batches under the mailer caps; stagger over days if needed.`, + acceptance: ["Report shows created/merged counts; a sample invited user sets a password and logs in; id-mapping file delivered to the Aplio team."], + }, + { + id: "AUTH-T89", epic: "rollout", phase: 4, priority: "Medium", estimate: 2, labels: ["migration"], deps: ["AUTH-T88"], + title: "SenatePath and Attendance Manager user import", + description: `Same script with \`--source senatepath.json\` (admin users only) and \`--source attendance.json\` (email, first/last, role) — NUID is NOT imported. Positions mapping files approved per product. Invites batched.`, + acceptance: ["Both imports run on dev with reports; id-mapping files delivered to each team."], + }, + { + id: "AUTH-T90", epic: "rollout", phase: 3, priority: "Medium", estimate: 2, labels: ["docs", "rollout"], deps: ["AUTH-T80", "AUTH-T87"], + title: "Rollout plan and user communications", + description: `Write \`docs/ROLLOUT.md\`: order SGAuth MVP → VaultZ → Chambers → Aplio (hard cutover) → SenatePath → Attendance Manager; per-product cutover checklist (pre-import users, registry entry, subdomain, env, SDK version, smoke test, announcement, rollback flag, support window of 3 days with a named contact); email templates announcing 'one login for all SGA tools' and, for Chambers users, that their existing password keeps working.`, + acceptance: ["Plan reviewed with each product owner; dates recorded; announcement emails drafted."], + }, + { + id: "AUTH-T91", epic: "rollout", phase: 2, priority: "High", estimate: 2, labels: ["rollout", "infra", "security"], deps: ["AUTH-T72", "AUTH-T66", "AUTH-T76", "AUTH-T39", "AUTH-T64"], + title: "Production launch checklist and Primary Admin bootstrap", + description: `Execute before the first product goes live: DNS + DMARC verified; production env vars set; Neon PITR confirmed; Upstash and Resend production keys; PostHog project; uptime monitor; threat-model checklist items closed; bootstrap the first Primary Admin via a one-time script (\`scripts/bootstrap-primary-admin.ts\`, refuses to run if any PA exists) followed by MFA enrollment; at least two people hold break-glass credentials; backups of the curated positions; smoke test of login/logout/session endpoint from a product stub on a real subdomain.`, + acceptance: ["Checklist completed and signed off in the ticket by the Primary Admin; bootstrap script left disabled afterwards."], + }, + { + id: "AUTH-T92", epic: "rollout", phase: 4, priority: "Low", estimate: 1, labels: ["rollout", "docs"], deps: ["AUTH-T90"], + title: "Post-launch review and legacy cleanup tracking", + description: `Two weeks after each product cutover: review PostHog funnel and audit metrics (failed logins, lockouts, support requests), remove the bcrypt verify path once no \`bcrypt$\` hashes remain, and confirm each product deleted its legacy auth code and secrets. Record findings in \`docs/ROLLOUT.md\`.`, + acceptance: ["Review notes recorded for every product; legacy-cleanup subtasks closed."], + }, + + // ───────────────────────── E13 Testing & QA ───────────────────────── + { + id: "AUTH-T93", epic: "testing", phase: 0, priority: "High", estimate: 3, labels: ["testing", "infra"], deps: ["AUTH-T01", "AUTH-T04"], + title: "Test harness: vitest, Neon test branch, factories, and test mailer", + description: `Configure vitest (node environment) with a global setup that points Prisma at the Neon \`test\` branch (reset from \`dev\` before each CI run; runs serialized by a GitHub Actions concurrency group because the 10-branch cap rules out per-run branches), applies migrations, and truncates tables between test files. Provide factories (\`createUser\`, \`createAdmin\`, \`createPrimaryAdmin\`, \`createSession\`, \`createPosition\`), a fake clock helper, a capturing mailer, and a fake Upstash (in-memory) limiter. Add \`npm test\` and coverage thresholds (80% lines on \`src/lib\`).`, + acceptance: ["`npm test` runs locally against a personal branch and in CI against the test branch in under 5 minutes.", "Factories and fakes documented in CONTRIBUTING.md."], + }, + { + id: "AUTH-T94", epic: "testing", phase: 1, priority: "High", estimate: 5, labels: ["testing", "auth-core"], deps: ["AUTH-T93", "AUTH-T17", "AUTH-T18", "AUTH-T19", "AUTH-T27", "AUTH-T64"], + title: "Integration tests for authentication flows", + description: `End-to-end (HTTP-level) tests: sign-up + verify + login; domain rejection; wrong password; lockout and unlock; forgot/reset including session revocation; invite acceptance; deactivated user; session sliding and absolute expiry; logout and sign-out-everywhere; re-auth freshness; rate-limit responses with the fake limiter.`, + acceptance: ["All flows green in CI; each test asserts the expected audit events."], + }, + { + id: "AUTH-T95", epic: "testing", phase: 2, priority: "High", estimate: 3, labels: ["testing", "admin"], deps: ["AUTH-T93", "AUTH-T36", "AUTH-T37", "AUTH-T42", "AUTH-T43"], + title: "Authorization, admin, positions, and transfer integration tests", + description: `HTTP-level tests exercising the admin endpoints with the full actor/target matrix, the position lifecycle (create/rename/assign/delete/retired-key reuse), and the complete PA transfer state machine including cron execution and mid-flight recipient deactivation.`, + acceptance: ["Matrix and state-machine tests pass; coverage of `authz.ts` and the transfer service ≥ 90%."], + }, + { + id: "AUTH-T96", epic: "testing", phase: 2, priority: "High", estimate: 5, labels: ["testing", "sso", "e2e"], deps: ["AUTH-T34", "AUTH-T59", "AUTH-T30"], + title: "Playwright end-to-end SSO test across subdomains", + description: `Spin up SGAuth and two minimal product stubs (using the SDK) on \`auth.sga.localhost\`, \`a.sga.localhost\`, \`b.sga.localhost\` in CI. Scenarios: login at auth → both products see the session; position assigned by an admin → product B gates a page accordingly; logout on product A → product B is logged out; expired session → redirect to login with the correct redirect param; open-redirect attempts rejected.`, + acceptance: ["Playwright job green in CI with traces on failure; run time under 4 minutes."], + }, + { + id: "AUTH-T97", epic: "testing", phase: 2, priority: "Medium", estimate: 2, labels: ["testing", "jwt", "supabase"], deps: ["AUTH-T29"], + title: "JWT and JWKS conformance tests for Supabase requirements", + description: `Tests: token verifies with \`jose\` via remote JWKS; header has \`alg: ES256\` and \`kid\`; claims include uuid \`sub\`, \`role: 'authenticated'\`, \`iss\`, \`aud\`, \`exp - iat ≤ 600\`; discovery document is valid; rotation keeps the old key in the JWKS for the grace period; token endpoint rejects missing/revoked sessions.`, + acceptance: ["Conformance suite green; a checklist in the Supabase guide references these tests."], + }, + { + id: "AUTH-T98", epic: "testing", phase: 3, priority: "Low", estimate: 2, labels: ["testing", "performance"], deps: ["AUTH-T28", "AUTH-T05"], + title: "Load sanity for the session endpoint on Neon", + description: `Run a k6/autocannon script against the dev deployment: 100 concurrent virtual users hitting \`/api/sgauth/session\` with valid cookies for 2 minutes. Record p50/p95, Neon connection count, and any pooler saturation; tune pool size and Neon compute settings; document results.`, + acceptance: ["p95 < 250 ms and zero connection errors at 100 VUs; results in `docs/PERFORMANCE.md`."], + }, + + // ───────────────────────── Backlog / spikes ───────────────────────── + { + id: "AUTH-T99", epic: "authcore", phase: 5, priority: "Low", estimate: 3, labels: ["spike", "sso"], deps: ["AUTH-T91"], + title: "Spike: Northeastern Microsoft Entra ID sign-in feasibility", + description: `Not a launch dependency. Investigate whether SGA can (a) get an app registered in Northeastern's Entra tenant via ITS, or (b) register a multi-tenant app in an SGA-owned tenant that Northeastern's tenant permits users to consent to, restricting sign-in to Northeastern's tenant id. If viable, prototype Better Auth's \`microsoft\` social provider behind a feature flag, linking to existing accounts by verified email. Report blockers, MFA inheritance (Duo), and the account-linking policy.`, + acceptance: ["Written findings with a go/no-go recommendation and, if go, a follow-up ticket set."], + }, + { + id: "AUTH-T100", epic: "authcore", phase: 5, priority: "Low", estimate: 2, labels: ["backlog", "email"], deps: ["AUTH-T91"], + title: "Backlog: optional email OTP login (deferred due to email volume)", + description: `Deferred by decision: password is the launch method because Resend free-tier volume is constrained. Revisit after launch: Better Auth \`emailOTP\` plugin as an alternative sign-in for users who forget passwords, with strict per-user caps. Requires a volume estimate against the org-wide Resend budget first.`, + acceptance: ["Decision recorded after reviewing 60 days of email volume metrics."], + }, + + // ───────────────────────── Red-team additions ───────────────────────── + { + id: "AUTH-T101", epic: "foundation", phase: 0, priority: "High", estimate: 2, labels: ["infra", "neon", "observability"], deps: ["AUTH-T01"], + title: "Neon Free-plan quota monitoring, alerts, and upgrade runbook", + description: `Accepted risk from the red-team review: SGAuth runs on Neon Free, which suspends the compute for the remainder of the month once the project uses 100 CU-hours. A compute that stays awake most of the day at the 0.25 CU minimum uses about 180 CU-hours/month, so exhaustion is plausible once several products are live, and it would take every SGA login down at once. +Mitigations: (1) a scheduled job (AUTH-T38, daily) reads consumption via the Neon API and emails all admins at 50%, 70%, and 85% of the monthly CU-hour quota, with a projection of the exhaustion date; (2) keep scale-to-zero at 5 minutes and rely on the SDK cache so idle periods suspend the compute; (3) \`docs/runbooks/neon-upgrade.md\`: one-click upgrade to Launch (pay-as-you-go, $0.106/CU-hour), what changes (7-day restore window, scale-to-zero configurable), and who is authorized to approve the spend; (4) record the 6-hour restore window as a known limitation in the architecture doc; (5) never add keep-warm pings (they burn the quota).`, + acceptance: [ + "Alert emails fire at the thresholds (tested by lowering the threshold on dev).", + "Runbook merged and the upgrade trigger (85% or any suspension) is written into the on-call notes.", + "ARCHITECTURE.md states the Free-plan risk and the restore window explicitly.", + ], + }, + { + id: "AUTH-T103", epic: "authcore", phase: 1, priority: "Urgent", estimate: 2, labels: ["backend", "email", "security"], deps: ["AUTH-T20"], + title: "Scanner-safe email links: land on a page, consume the token on POST", + description: `Northeastern mail is Microsoft 365, and Defender Safe Links pre-fetches every link in incoming mail. A link that acts on GET (verify, reset, invite/set-password, unlock, PA transfer accept/cancel) would be consumed by the scanner before the user clicks. Rule for every emailed link in SGAuth: the URL opens a page that shows what is about to happen and a button; the token is validated for display on GET (never consumed, never marks anything) and consumed only on the button's POST (same-origin, CSRF-protected). Better Auth's built-in verify-email link acts on GET, so send our own URL (\`/verify-email?token=\`) that renders the confirmation page and calls the Better Auth verification endpoint on submit. HEAD requests and known scanner user agents get a 200 with no side effects. Provide one shared \`TokenActionPage\` component and a helper used by AUTH-T18, T19, T22, T24, T37, T64.`, + acceptance: [ + "A HEAD or GET request to any emailed link does not consume the token (integration test); the subsequent POST does, exactly once.", + "Every email template's link points at a page implementing the pattern (test enumerates templates).", + ], + }, + { + id: "AUTH-T104", epic: "sessions", phase: 1, priority: "Urgent", estimate: 2, labels: ["spike", "sso", "better-auth"], deps: ["AUTH-T05", "AUTH-T26"], + title: "Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them", + description: `Community reports (better-auth issues #5611, #3938) describe cross-subdomain cookies being set and then dropped in some configurations. Before any product integration work, deploy the minimal Better Auth config to \`auth-dev.northeasternsga.com\` and a static stub on \`stub-dev.northeasternsga.com\`, then verify in Chrome, Firefox, and Safari: (1) login sets exactly one \`__Secure-sgauth-dev.session_token\` cookie with \`Domain=northeasternsga.com\`; (2) the stub's server receives it and the session endpoint resolves it; (3) sign-out clears it on both hosts; (4) the \`SGAUTH_ENV=preview\` host-only mode works on a \`*.vercel.app\` preview; (5) the local \`*.sga.localhost\` scheme works in all three browsers or the fallback (hosts-file \`sga.test\`) is adopted; (6) a stale cookie with the same name set by the stub host (cookie tossing) is observed and its effect documented. Record findings in \`docs/ENVIRONMENTS.md\` and adjust AUTH-T26/T34 accordingly.`, + acceptance: [ + "Findings documented per browser with screenshots or HAR excerpts; any Better Auth version pin or workaround recorded.", + "Go/no-go on the parent-domain cookie approach signed off by the SGAuth lead.", + ], + }, +]; diff --git a/docs/sgauth-design/tickets-products.mjs b/docs/sgauth-design/tickets-products.mjs new file mode 100644 index 0000000..558f902 --- /dev/null +++ b/docs/sgauth-design/tickets-products.mjs @@ -0,0 +1,170 @@ +// Product-side tickets, one Linear team per product. Same field shape as tickets-auth.mjs. +// Each team's epic is a single Linear Project: "SGAuth integration". + +export const PRODUCT_TEAMS = { + VAULTZ: { name: "VaultZ", epic: "SGAuth integration — VaultZ", label: "epic:sgauth-integration", phase: 2, + description: "Replace the shared-passphrase gate with SGAuth sessions via the SDK. First product integrated; proves the SDK and the Neon-product guide." }, + CHAMBERS: { name: "Chambers", epic: "SGAuth integration — Chambers", label: "epic:sgauth-integration", phase: 3, noLinear: true, + description: "Chambers has no Linear team; these items are a Markdown checklist only (no CSV). Chambers is a Neon product (migration completes before integration). Replace Supabase Auth, live-role checks, and session revocation with SGAuth sessions and positions; users and password hashes are imported into SGAuth. The auth.users export is Eli's manual action item, not a ticket." }, + APLIO: { name: "Aplio", epic: "SGAuth integration — Aplio", label: "epic:sgauth-integration", phase: 4, + description: "Hard cutover from Aplio's local Better Auth (email OTP) to SGAuth via the SDK; users pre-imported and invited to set passwords." }, + SENATEPATH: { name: "SenatePath", epic: "SGAuth integration — SenatePath", label: "epic:sgauth-integration", phase: 4, + description: "Migrate SenatePath's database from Supabase to Neon and gate the admin area with SGAuth positions." }, + ATTENDANCE: { name: "Attendance Manager", epic: "SGAuth integration — Attendance Manager", label: "epic:sgauth-integration", phase: 4, + description: "Decision pending from the team: either stay on Supabase and consume SGAuth via third-party auth (JWT trust), or move to Neon and use the SDK. Both paths are ticketed; only one will be executed." }, +}; + +export const PRODUCT_TICKETS = [ + // ───────────────────────── VaultZ ───────────────────────── + { team: "VAULTZ", id: "VAULTZ-V01", priority: "High", estimate: 3, labels: ["sgauth", "backend"], deps: ["AUTH-T59", "AUTH-T80", "AUTH-T34"], + title: "Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy", + description: `Add the SDK from public npm (no registry config), env vars (\`SGAUTH_URL\`, \`SGAUTH_COOKIE_NAME\`), and \`proxy.ts\` using \`createSgaProxy({ publicPaths: ['/api/uploadthing', '/health'] })\` (cookie-presence check only; real validation in server code via \`requireSession()\`). Unauthenticated requests redirect to \`https://auth.northeasternsga.com/login?redirect=\`. Register \`https://vaultz.northeasternsga.com\` (and \`vaultz-dev.northeasternsga.com\`) in the SGAuth product registry first.`, + acceptance: [ + "Visiting any VaultZ page without an SGAuth session redirects to SGAuth login and back to the original page after login.", + "UploadThing callback route remains reachable without a session.", + ] }, + { team: "VAULTZ", id: "VAULTZ-V02", priority: "High", estimate: 2, labels: ["sgauth", "backend"], deps: ["VAULTZ-V01"], + title: "Remove the shared passphrase gate", + description: `Delete \`lib/access-gate.ts\`, \`lib/actions/access.ts\`, the passphrase page, and the \`VAULTZ_ACCESS_CODE\`/\`VAULTZ_ACCESS_SECRET\` env vars after the cutover (keep behind a \`SGAUTH_ENABLED\` flag for one release so rollback is a flag flip, then delete).`, + acceptance: ["No references to the passphrase remain after the flag is removed; the `vaultz_access` cookie is no longer set."] }, + { team: "VAULTZ", id: "VAULTZ-V03", priority: "Medium", estimate: 3, labels: ["sgauth", "data"], deps: ["VAULTZ-V01"], + title: "Link VaultZ purchaser records to SGAuth user ids", + description: `VaultZ's \`User\` table records purchasers (first/last), not logins. Add \`sgauthUserId String? @unique\` and \`email String?\`. On first authenticated request, find-or-create the purchaser row for the session user (name from SGAuth split into first/last as a best effort, editable). Admin UI to link legacy purchaser rows to SGAuth users by email. Purchases created by the current user default \`userId\` to their linked row.`, + acceptance: ["A first-time SGAuth user gets exactly one purchaser row; re-login does not duplicate.", "Legacy rows can be linked once and the link is unique."] }, + { team: "VAULTZ", id: "VAULTZ-V04", priority: "High", estimate: 3, labels: ["sgauth", "permissions"], deps: ["VAULTZ-V01", "AUTH-T44"], + title: "Position-based permissions map for VaultZ", + description: `Create \`lib/permissions.ts\` mapping SGAuth position keys to VaultZ capabilities (e.g. \`vp-finance\`, \`treasurer\` → manage designations/budgets/transfers; \`finance-committee\` → create purchases; everyone else → read-only or no access). Gate server actions and pages with \`hasAnyPosition\`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list.`, + acceptance: ["Every mutating server action checks a capability; a user without positions cannot create or edit anything (tests).", "The map is the only place position keys appear in VaultZ code."] }, + { team: "VAULTZ", id: "VAULTZ-V05", priority: "Low", estimate: 1, labels: ["sgauth", "ui"], deps: ["VAULTZ-V01"], + title: "Account and sign-out links in the VaultZ header", + description: `Show the session user's name, a link to \`accountUrl()\`, and a sign-out action using \`logoutUrl('https://vaultz.northeasternsga.com')\`.`, + acceptance: ["Sign out from VaultZ logs the user out of every SGA product (verified on dev)."] }, + { team: "VAULTZ", id: "VAULTZ-V06", priority: "Medium", estimate: 2, labels: ["sgauth", "infra"], deps: ["VAULTZ-V01", "AUTH-T34"], + title: "Dev and preview topology for VaultZ", + description: `Configure the Vercel branch domain \`vaultz-dev.northeasternsga.com\` pointing at the dev branch with \`SGAUTH_URL=https://auth-dev.northeasternsga.com\` and the dev cookie name; local dev on \`vaultz.sga.localhost:3001\` against local SGAuth. Document in the VaultZ README.`, + acceptance: ["Preview on the branch domain shares the dev SGAuth session; ad-hoc vercel.app previews show a documented 'SSO unavailable on this host' page instead of a redirect loop."] }, + { team: "VAULTZ", id: "VAULTZ-V07", priority: "Medium", estimate: 1, labels: ["sgauth", "rollout"], deps: ["VAULTZ-V02", "VAULTZ-V04", "AUTH-T91"], + title: "VaultZ cutover checklist and rollback", + description: `Execute the per-product checklist from the rollout plan: registry entry, DNS, env vars, SDK version pinned, smoke test, announcement to finance users, \`SGAUTH_ENABLED\` rollback flag verified, 3-day support window.`, + acceptance: ["Checklist completed; rollback rehearsed on dev by flipping the flag."] }, + { team: "VAULTZ", id: "VAULTZ-V08", priority: "Medium", estimate: 2, labels: ["sgauth", "testing"], deps: ["VAULTZ-V04"], + title: "Tests for SGAuth guards and permissions in VaultZ", + description: `Unit tests for the permissions map and integration tests (mocked SDK) for a protected page, a gated server action, and the purchaser link-on-first-login behavior.`, + acceptance: ["Tests run in VaultZ CI and cover allow/deny for each capability."] }, + + // ───────────────────────── Chambers ───────────────────────── + { team: "CHAMBERS", id: "CHAMBERS-C01", priority: "High", estimate: 3, labels: ["sgauth", "audit"], deps: [], + title: "Inventory every auth and authorization touchpoint in Chambers", + description: `List all uses of \`getAuthedUser\`, \`getAuthedUserWithLiveRoles\`, \`hasLiveAdmin\`, \`app_metadata\`, \`is_admin()\`, \`is_iems()\`, \`my_body_ids()\`, \`revoke_user_sessions\`, and any remaining Supabase Auth client calls (login, reset, password change) after the Neon migration. Produce a table: location → replacement (SGAuth session, position check, or Chambers-internal membership).`, + acceptance: ["Inventory document merged in the Chambers repo and reviewed by the Chambers lead."] }, + { team: "CHAMBERS", id: "CHAMBERS-C02", priority: "High", estimate: 5, labels: ["sgauth", "backend"], deps: ["CHAMBERS-C01", "AUTH-T59", "AUTH-T80"], + title: "Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal)", + description: `Install the SDK; add \`proxy.ts\` with public paths for kiosk/display pages (protected by \`DISPLAY_KEY\`), cron routes (\`CRON_SECRET\`), and the service worker assets; replace \`lib/auth.ts\`/\`lib/authorization.ts\` with thin wrappers over \`getSession()\`; delete \`LoginCard\`, forgot-password UI, and all \`supabase.auth.*\` calls; \`/login\` redirects to SGAuth with the current URL. Keep offline/network-retry UX for the session fetch failure case (show the existing offline message).`, + acceptance: ["No `@supabase/*` auth imports remain; every previously protected route is protected by the proxy.", "Kiosk and cron routes work without a user session."] }, + { team: "CHAMBERS", id: "CHAMBERS-C03", priority: "High", estimate: 3, labels: ["sgauth", "data"], deps: ["CHAMBERS-C02", "AUTH-T87"], + title: "Key Chambers users by SGAuth user id", + description: `Using the id-mapping file from the SGAuth import, migrate \`users.id\` (or add \`sgauth_user_id\` and repoint FKs) so the product's user row is keyed by SGAuth id; drop auth-only columns (\`sessions_revoked_at\`, password-related fields) and the dependency on \`auth.users\`. Create-on-first-login for users that exist in SGAuth but not in Chambers (e.g. self-registered students) with no memberships.`, + acceptance: ["Every pre-existing Chambers user resolves to the same SGAuth user by email (verification script), and bookings history is intact."] }, + { team: "CHAMBERS", id: "CHAMBERS-C04", priority: "High", estimate: 5, labels: ["sgauth", "permissions"], deps: ["CHAMBERS-C02", "AUTH-T44"], + title: "Map admin_role / iems_role to SGAuth positions; keep body memberships internal", + description: `Define \`lib/permissions.ts\`: Chambers admin capabilities derive from position keys (e.g. \`chambers-admin\`, \`iems\`, and the relevant exec positions from the curated list); board/body memberships stay in Chambers tables keyed by SGAuth user id and continue to drive booking scopes. Replace \`hasLiveAdmin\`/\`is_admin()\` checks in app code with position checks; since Chambers is on Neon, RLS helpers are replaced by app-level checks in the data layer.`, + acceptance: ["Every admin-only route and action checks positions; a user whose position is removed in SGAuth loses admin ability within 60 s (SDK cache) without any Chambers-side change.", "Body-scoped booking rules unchanged (existing tests pass)."] }, + { team: "CHAMBERS", id: "CHAMBERS-C05", priority: "Medium", estimate: 3, labels: ["sgauth", "cleanup"], deps: ["CHAMBERS-C04"], + title: "Remove live-role-check and session-revocation mechanisms", + description: `Delete \`getAuthedUserWithLiveRoles\`, \`sessions_revoked_at\`, \`revoke_user_sessions\`, and related migrations/tests; revocation and deactivation are now SGAuth's job (admin revoke in SGAuth → 401 from the SDK). Update the user-management admin pages in Chambers to link to the SGAuth admin UI for deactivation/positions instead of local toggles.`, + acceptance: ["Chambers admin UI no longer offers role toggles that SGAuth owns; links open the SGAuth user page."] }, + { team: "CHAMBERS", id: "CHAMBERS-C06", priority: "High", estimate: 1, labels: ["sgauth", "migration"], deps: [], + title: "Approve the Chambers role-to-position mapping file", + description: `(The auth.users export is Eli's separate manual action item and is not tracked here.) Review and approve the mapping from \`admin_role\` / \`iems_role\` / board memberships to curated position keys used by the SGAuth import (AUTH-T87).`, + acceptance: ["Mapping file approved by the Chambers lead and the Primary Admin."] }, + { team: "CHAMBERS", id: "CHAMBERS-C07", priority: "High", estimate: 2, labels: ["sgauth", "rollout"], deps: ["CHAMBERS-C03", "CHAMBERS-C04", "CHAMBERS-C05", "AUTH-T91"], + title: "Chambers cutover, comms, and rollback plan", + description: `Per-product checklist: registry entry for chambers.northeasternsga.com, env, SDK pin, import verified (sample users log in with existing passwords), announcement that passwords carry over, rollback plan (previous deployment + Supabase auth still intact for 7 days), 3-day support window.`, + acceptance: ["Cutover completed with zero password resets required for imported users beyond expected stragglers; rollback rehearsed on dev."] }, + { team: "CHAMBERS", id: "CHAMBERS-C08", priority: "Low", estimate: 1, labels: ["sgauth", "verification"], deps: ["CHAMBERS-C02"], + title: "Verify kiosk display key, Slack reminders, and cron routes are unaffected", + description: `Confirm the display pages, Slack bot posting, Resend flows, and GitHub-Actions-driven cron routes still work with the SGAuth proxy in place (they must be on the public path list).`, + acceptance: ["Manual verification checklist completed on dev and production."] }, + { team: "CHAMBERS", id: "CHAMBERS-C09", priority: "Medium", estimate: 3, labels: ["sgauth", "testing"], deps: ["CHAMBERS-C04"], + title: "Tests for SGAuth-based authorization in Chambers", + description: `Replace tests that mocked Supabase JWT claims with tests that mock the SDK session; cover admin/iems/body-member/none for the main booking and admin routes.`, + acceptance: ["Chambers CI green with the new tests; old Supabase auth tests removed."] }, + + // ───────────────────────── Aplio ───────────────────────── + { team: "APLIO", id: "APLIO-P01", priority: "High", estimate: 2, labels: ["sgauth", "migration"], deps: ["AUTH-T88"], + title: "Export Aplio users for the SGAuth import and receive the id mapping", + description: `Export \`User\` rows (id, email, name, isAdmin, deletedAt) to JSON for the SGAuth import; receive the SGAuth id-mapping file; verify every active Aplio user maps to an SGAuth user.`, + acceptance: ["Mapping verified with a script; discrepancies resolved before cutover."] }, + { team: "APLIO", id: "APLIO-P02", priority: "High", estimate: 5, labels: ["sgauth", "backend"], deps: ["APLIO-P01", "AUTH-T59", "AUTH-T80"], + title: "Replace local Better Auth with the SGAuth SDK", + description: `Remove \`lib/auth/config.ts\` (emailOTP, rate limits), \`app/api/auth/[...path]\`, the OTP email template and Resend usage for OTP, the login/OTP pages, and \`resolveUser\`'s Better Auth branch; implement \`getCurrentUser\`/\`getOptionalUser\`/\`getDeactivatedSessionUser\` over the SDK. Keep Aplio's deactivation concept (\`deletedAt\`) as a product-level state. Drop the \`Session\`, \`Account\`, and \`Verification\` tables in a follow-up migration after cutover.`, + acceptance: ["No `better-auth` dependency remains in Aplio; all `(auth)` routes are protected by the SGAuth proxy.", "Deactivated Aplio users still see the explanatory screen rather than a login loop."] }, + { team: "APLIO", id: "APLIO-P03", priority: "High", estimate: 5, labels: ["sgauth", "data"], deps: ["APLIO-P02"], + title: "Key Aplio users by SGAuth user id", + description: `Add \`sgauthUserId String @unique\` to \`User\`, backfill from the id-mapping file, and make all lookups go through it; create-on-first-login for new applicants (name/email from the session). Keep Aplio's own \`User.id\` as the FK target for the many audit relations to avoid rewriting every table. Remove \`neonAuthId\`.`, + acceptance: ["Every existing application, answer, and email log still resolves to the right person after backfill (verification query)."] }, + { team: "APLIO", id: "APLIO-P04", priority: "High", estimate: 2, labels: ["sgauth", "permissions"], deps: ["APLIO-P02", "AUTH-T44"], + title: "Derive Aplio admin from an SGAuth position; managers stay product-level", + description: `Replace \`User.isAdmin\` with a position check (e.g. \`aplio-admin\` or the relevant exec positions) in \`requireAdmin\`/\`requireAdminOr404\` and \`buildReviewablePositionWhere\`; position managers remain an Aplio relation. Provide a one-time script that assigns the \`aplio-admin\` position in SGAuth to current Aplio admins (via the SGAuth bulk assign API or CSV).`, + acceptance: ["Admin pages gate on positions; the `isAdmin` column is removed after cutover."] }, + { team: "APLIO", id: "APLIO-P05", priority: "High", estimate: 3, labels: ["sgauth", "product"], deps: ["APLIO-P02"], + title: "Applicant flow on SGAuth accounts (northeastern.edu required)", + description: `Applicants now create SGAuth accounts via northeastern.edu self-sign-up with verification. Decision: every student has a northeastern.edu address, so no domain exception exists; applicants who previously used another address must use their Northeastern one. Update the apply entry points to redirect to SGAuth sign-up with a redirect back to the position, and add copy explaining the Northeastern-email requirement. Existing Aplio users with non-NU addresses are imported as legacy accounts (AUTH-T88) and keep working until an admin moves them to their NU address.`, + acceptance: ["A new student can apply end-to-end: SGAuth sign-up → verify → land on the application form.", "Apply pages state the northeastern.edu requirement before the redirect."] }, + { team: "APLIO", id: "APLIO-P06", priority: "Low", estimate: 1, labels: ["sgauth", "dev"], deps: ["APLIO-P02", "AUTH-T34"], + title: "Local dev bypass and preview topology", + description: `Keep the dev-bypass user cookie for local only (never on any deployed host), and configure \`apply-dev.northeasternsga.com\` with the dev SGAuth URL and cookie name.`, + acceptance: ["Bypass is a no-op on all Vercel deployments; preview branch domain shares the dev SSO session."] }, + { team: "APLIO", id: "APLIO-P07", priority: "High", estimate: 2, labels: ["sgauth", "rollout"], deps: ["APLIO-P03", "APLIO-P04", "APLIO-P05", "AUTH-T91"], + title: "Aplio hard cutover outside an application window", + description: `Schedule the cutover when no application cycle is open; users were pre-imported and invited; announce; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window.`, + acceptance: ["Cutover completed; no applicant lost draft access (verified with a sample of drafts)."] }, + { team: "APLIO", id: "APLIO-P08", priority: "Medium", estimate: 3, labels: ["sgauth", "testing"], deps: ["APLIO-P04"], + title: "Update Aplio tests for SDK-based auth", + description: `Replace Better Auth session mocks with SDK session mocks; cover admin, manager, applicant, deactivated, and anonymous paths for guards and scopes.`, + acceptance: ["Aplio CI green; guard coverage unchanged or higher."] }, + + // ───────────────────────── SenatePath ───────────────────────── + { team: "SENATEPATH", id: "SENATEPATH-S01", priority: "High", estimate: 5, labels: ["neon", "migration"], deps: [], + title: "Migrate SenatePath's database from Supabase to Neon", + description: `Create a Neon project/branches, dump and restore the Postgres schema and data (pg_dump from Supabase → psql into Neon), switch \`DATABASE_URL\`/\`DIRECT_URL\`, optionally upgrade Prisma 6 → 7 with the driver adapter, remove \`@supabase/*\` packages and Supabase Storage usage if any (or keep Storage only if truly needed and document it). SGAuth is Neon-only; SenatePath becomes a standard Neon product.`, + acceptance: ["App runs against Neon in dev and prod; row counts match the Supabase source; no Supabase Auth code remains after S02."] }, + { team: "SENATEPATH", id: "SENATEPATH-S02", priority: "High", estimate: 3, labels: ["sgauth", "backend"], deps: ["SENATEPATH-S01", "AUTH-T59", "AUTH-T80"], + title: "Gate the SenatePath admin area with SGAuth positions", + description: `Install the SDK; protect \`/admin/**\` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on a position (e.g. \`elections-chair\` / \`senate-admin\`). Public application/nomination forms remain unauthenticated as today.`, + acceptance: ["Admin pages require an SGAuth session with the configured position; public forms unaffected."] }, + { team: "SENATEPATH", id: "SENATEPATH-S03", priority: "Medium", estimate: 2, labels: ["sgauth", "rollout"], deps: ["SENATEPATH-S02", "AUTH-T89"], + title: "Import SenatePath admins into SGAuth and cut over", + description: `Export admin emails for the SGAuth import; confirm they hold the gating position; run the per-product cutover checklist; delete Supabase project after a 14-day retention window.`, + acceptance: ["All previous admins can log in via SGAuth; Supabase project decommissioned."] }, + { team: "SENATEPATH", id: "SENATEPATH-S04", priority: "Low", estimate: 2, labels: ["sgauth", "testing"], deps: ["SENATEPATH-S02"], + title: "Tests for the SGAuth admin gate", + description: `Integration tests (mocked SDK) for admin access with/without the position and for public routes.`, + acceptance: ["Tests in CI cover allow/deny and public access."] }, + + // ───────────────────────── Attendance Manager ───────────────────────── + { team: "ATTENDANCE", id: "ATTENDANCE-M01", priority: "High", estimate: 1, labels: ["sgauth", "decision"], deps: [], + title: "Decision: stay on Supabase (third-party auth) or move to Neon (SDK)", + description: `Record the team's directive. Criteria: does Attendance Manager use Supabase-only features (Storage, Realtime, Edge Functions)? If not, moving to Neon is recommended (simpler, no JWT staleness, standard SDK). If yes, use the third-party auth path. Exactly one of M02 or M03 proceeds.`, + acceptance: ["Decision documented in the repo README with rationale; the unused path's tickets are cancelled."] }, + { team: "ATTENDANCE", id: "ATTENDANCE-M02", priority: "High", estimate: 5, labels: ["sgauth", "supabase"], deps: ["ATTENDANCE-M01", "AUTH-T81", "AUTH-T60"], + title: "(Supabase path) Consume SGAuth via Supabase third-party auth", + description: `Follow the Supabase integration guide: register SGAuth as a third-party auth provider on the Attendance Supabase project via the Management API script; configure supabase-js with the SDK's access-token provider (server) and the proxy route (browser); replace \`supabaseAuthId\` with \`sgauthUserId\` on \`User\` and remove any \`auth.users\` FKs/triggers; rewrite RLS to \`auth.uid()\` and position claims (\`has_position('...')\`); remove Supabase login/signup pages and the custom middleware in favor of the SGAuth proxy; remove the unused \`password\` column. Note the ≤10-minute JWT staleness and the MAU billing line.`, + acceptance: ["A user with the required position can read/write per RLS using an SGAuth token; a user without it is denied at the database.", "No `auth.users` dependency remains; Supabase login pages removed."] }, + { team: "ATTENDANCE", id: "ATTENDANCE-M03", priority: "High", estimate: 5, labels: ["sgauth", "neon"], deps: ["ATTENDANCE-M01", "AUTH-T59", "AUTH-T80"], + title: "(Neon path) Migrate to Neon and integrate with the SDK", + description: `Dump/restore Postgres to Neon, switch Prisma URLs, remove \`@supabase/*\`, install the SDK, protect routes with the proxy, key \`User\` by \`sgauthUserId\`, and move authorization from RLS to app-level checks using positions.`, + acceptance: ["App runs on Neon with SGAuth sessions; row counts match; no Supabase code remains."] }, + { team: "ATTENDANCE", id: "ATTENDANCE-M04", priority: "Medium", estimate: 2, labels: ["sgauth", "permissions"], deps: ["ATTENDANCE-M01", "AUTH-T44"], + title: "Map roles to SGAuth positions; keep NUID product-side", + description: `Define the permissions map from position keys to Attendance roles (MEMBER/OFFICER/etc.); NUID stays in Attendance's \`User\` table keyed by SGAuth user id and is never sent to SGAuth.`, + acceptance: ["Role checks derive from positions; NUID handling unchanged and documented."] }, + { team: "ATTENDANCE", id: "ATTENDANCE-M05", priority: "Medium", estimate: 2, labels: ["sgauth", "rollout"], deps: ["ATTENDANCE-M04", "AUTH-T89"], + title: "User import and cutover", + description: `Export users (email, names, role) for the SGAuth import, receive the id mapping, backfill \`sgauthUserId\`, run the per-product cutover checklist.`, + acceptance: ["All active members map to SGAuth users; cutover completed with rollback rehearsed."] }, + { team: "ATTENDANCE", id: "ATTENDANCE-M06", priority: "Low", estimate: 2, labels: ["sgauth", "testing"], deps: ["ATTENDANCE-M04"], + title: "Update auth and middleware tests", + description: `Replace Supabase session mocks with SDK (or token) mocks in the existing auth-flow, middleware, and api-auth tests.`, + acceptance: ["CI green; old Supabase auth tests removed."] }, +]; diff --git a/prisma/seed/positions.json b/prisma/seed/positions.json new file mode 100644 index 0000000..247c12e --- /dev/null +++ b/prisma/seed/positions.json @@ -0,0 +1,415 @@ +{ + "$comment": "Curated SGA position list supplied by SGA on 2026-09-18. key = immutable slug used by products; name = editable display name; category = admin-UI grouping. Upserted by key by the seed; never deletes.", + "positions": [ + { + "key": "student-body-president", + "name": "Student Body President", + "category": "Office of the President" + }, + { + "key": "executive-vice-president", + "name": "Executive Vice President", + "category": "Office of the President" + }, + { + "key": "parliamentarian", + "name": "Parliamentarian", + "category": "Office of the President" + }, + { + "key": "appeals-board-justice", + "name": "Appeals Board Justice", + "category": "Office of the President" + }, + { + "key": "director-of-elections", + "name": "Director of Elections", + "category": "Office of the President" + }, + { + "key": "vice-chair-of-elections", + "name": "Vice-Chair of Elections", + "category": "Office of the President" + }, + { + "key": "elections-board-member", + "name": "Elections Board Member", + "category": "Office of the President" + }, + { + "key": "t-shirt-team-chair", + "name": "T-Shirt Team Chair", + "category": "Office of the President" + }, + { + "key": "t-shirt-team-member", + "name": "T-Shirt Team Member", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-chair", + "name": "Governmental Relations Team Chair", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-vice-chair", + "name": "Governmental Relations Team Vice-Chair", + "category": "Office of the President" + }, + { + "key": "governmental-relations-team-member", + "name": "Governmental Relations Team Member", + "category": "Office of the President" + }, + { + "key": "vice-president-of-academic-affairs", + "name": "Vice President of Academic Affairs", + "category": "Academic Affairs" + }, + { + "key": "director-of-academic-services", + "name": "Director of Academic Services", + "category": "Academic Affairs" + }, + { + "key": "director-of-co-op", + "name": "Director of Co-op", + "category": "Academic Affairs" + }, + { + "key": "chair-of-ai-advisory-board", + "name": "Chair of AI Advisory Board", + "category": "Academic Affairs" + }, + { + "key": "faculty-senate-student-representative", + "name": "Faculty Senate Student Representative", + "category": "Academic Affairs" + }, + { + "key": "university-undergraduate-curriculum-committee-representative", + "name": "University Undergraduate Curriculum Committee Representative", + "category": "Academic Affairs" + }, + { + "key": "college-liaison", + "name": "College Liaison", + "category": "Academic Affairs" + }, + { + "key": "academic-services-committee-member", + "name": "Academic Services Committee Member", + "category": "Academic Affairs" + }, + { + "key": "ai-advisory-board-member", + "name": "AI Advisory Board Member", + "category": "Academic Affairs" + }, + { + "key": "co-op-working-group-member", + "name": "Co-op Working Group Member", + "category": "Academic Affairs" + }, + { + "key": "vice-president-of-campus-affairs", + "name": "Vice President of Campus Affairs", + "category": "Campus Affairs" + }, + { + "key": "director-of-campus-services", + "name": "Director of Campus Services", + "category": "Campus Affairs" + }, + { + "key": "director-of-sustainability", + "name": "Director of Sustainability", + "category": "Campus Affairs" + }, + { + "key": "director-of-green-initiatives", + "name": "Director of Green Initiatives", + "category": "Campus Affairs" + }, + { + "key": "chair-of-campus-planning-advisory-board", + "name": "Chair of Campus Planning Advisory Board", + "category": "Campus Affairs" + }, + { + "key": "chair-of-dining-advisory-board", + "name": "Chair of Dining Advisory Board", + "category": "Campus Affairs" + }, + { + "key": "campus-services-committee-member", + "name": "Campus Services Committee Member", + "category": "Campus Affairs" + }, + { + "key": "sustainability-committee-member", + "name": "Sustainability Committee Member", + "category": "Campus Affairs" + }, + { + "key": "campus-planning-advisory-board-member", + "name": "Campus Planning Advisory Board Member", + "category": "Campus Affairs" + }, + { + "key": "dining-advisory-board-member", + "name": "Dining Advisory Board Member", + "category": "Campus Affairs" + }, + { + "key": "green-initiatives-board-member", + "name": "Green Initiatives Board Member", + "category": "Campus Affairs" + }, + { + "key": "vice-president-of-diversity-equity-and-inclusion", + "name": "Vice President of Diversity, Equity, and Inclusion", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "director-of-diversity-equity-and-inclusion", + "name": "Director of Diversity, Equity, and Inclusion", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "director-of-internal-advancement", + "name": "Director of Internal Advancement", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "diversity-equity-and-inclusion-committee-member", + "name": "Diversity, Equity, and Inclusion Committee Member", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "internal-advancement-board-member", + "name": "Internal Advancement Board Member", + "category": "Diversity, Equity, and Inclusion" + }, + { + "key": "vice-president-of-external-affairs", + "name": "Vice President of External Affairs", + "category": "External Affairs" + }, + { + "key": "assistant-vice-president-of-external-affairs", + "name": "Assistant Vice President of External Affairs", + "category": "External Affairs" + }, + { + "key": "director-of-communications", + "name": "Director of Communications", + "category": "External Affairs" + }, + { + "key": "director-of-events", + "name": "Director of Events", + "category": "External Affairs" + }, + { + "key": "assistant-director-of-events", + "name": "Assistant Director of Events", + "category": "External Affairs" + }, + { + "key": "graphic-design-manager", + "name": "Graphic Design Manager", + "category": "External Affairs" + }, + { + "key": "finance-manager", + "name": "Finance Manager", + "category": "External Affairs" + }, + { + "key": "boston-intercollegiate-government-representative", + "name": "Boston Intercollegiate Government Representative", + "category": "External Affairs" + }, + { + "key": "communications-and-events-committee-member", + "name": "Communications and Events Committee Member", + "category": "External Affairs" + }, + { + "key": "vice-president-of-student-involvement", + "name": "Vice President of Student Involvement", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-approvals", + "name": "Director of Student Organization Approvals", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-compliance", + "name": "Director of Student Organization Compliance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-governance", + "name": "Director of Student Organization Governance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-finance", + "name": "Director of Student Organization Finance", + "category": "Student Involvement" + }, + { + "key": "director-of-student-organization-operations", + "name": "Director of Student Organization Operations", + "category": "Student Involvement" + }, + { + "key": "student-organization-approvals-board-member", + "name": "Student Organization Approvals Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-compliance-board-member", + "name": "Student Organization Compliance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-governance-board-member", + "name": "Student Organization Governance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-finance-board-member", + "name": "Student Organization Finance Board Member", + "category": "Student Involvement" + }, + { + "key": "student-organization-operations-committee-member", + "name": "Student Organization Operations Committee Member", + "category": "Student Involvement" + }, + { + "key": "vice-president-of-student-success", + "name": "Vice President of Student Success", + "category": "Student Success" + }, + { + "key": "director-of-student-engagement", + "name": "Director of Student Engagement", + "category": "Student Success" + }, + { + "key": "director-of-global-experience", + "name": "Director of Global Experience", + "category": "Student Success" + }, + { + "key": "director-of-wellness", + "name": "Director of Wellness", + "category": "Student Success" + }, + { + "key": "student-engagement-committee-member", + "name": "Student Engagement Committee Member", + "category": "Student Success" + }, + { + "key": "global-experience-committee-member", + "name": "Global Experience Committee Member", + "category": "Student Success" + }, + { + "key": "wellness-committee-member", + "name": "Wellness Committee Member", + "category": "Student Success" + }, + { + "key": "global-experience-office-working-group-member", + "name": "Global Experience Office Working Group Member", + "category": "Student Success" + }, + { + "key": "holistic-wellness-working-group-member", + "name": "Holistic Wellness Working Group Member", + "category": "Student Success" + }, + { + "key": "vice-president-of-operational-affairs", + "name": "Vice President of Operational Affairs", + "category": "Operational Affairs" + }, + { + "key": "comptroller", + "name": "Comptroller", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-manager", + "name": "Digital Innovation Manager", + "category": "Operational Affairs" + }, + { + "key": "information-manager", + "name": "Information Manager", + "category": "Operational Affairs" + }, + { + "key": "webmaster", + "name": "Webmaster", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-team-lead", + "name": "Digital Innovation Team Lead", + "category": "Operational Affairs" + }, + { + "key": "vice-chair-of-website-management-team", + "name": "Vice-Chair of Website Management Team", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-senateportal-team-member", + "name": "Digital Innovation SenatePortal Team Member", + "category": "Operational Affairs" + }, + { + "key": "digital-innovation-website-creation-team-member", + "name": "Digital Innovation Website Creation Team Member", + "category": "Operational Affairs" + }, + { + "key": "website-management-team-member", + "name": "Website Management Team Member", + "category": "Operational Affairs" + }, + { + "key": "speaker-of-the-senate", + "name": "Speaker of the Senate", + "category": "Senate" + }, + { + "key": "senate-operations-coordinator", + "name": "Senate Operations Coordinator", + "category": "Senate" + }, + { + "key": "legislative-affairs-coordinator", + "name": "Legislative Affairs Coordinator", + "category": "Senate" + }, + { + "key": "senate-communications-coordinator", + "name": "Senate Communications Coordinator", + "category": "Senate" + }, + { + "key": "senator", + "name": "Senator", + "category": "Senate" + } + ] +} From 05e3d436424ba598cc1ae9ca64bbecb74f6275d7 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Fri, 18 Sep 2026 14:54:11 -0400 Subject: [PATCH 2/4] Record GitHub issue numbers for all 132 SGAuth tickets and add relabel command Co-Authored-By: Claude Fable 5.1 --- docs/sgauth-design/gh-issues.mjs | 21 ++ docs/sgauth-design/issue-map.json | 515 ++++++++++++++++++++++++++++++ 2 files changed, 536 insertions(+) diff --git a/docs/sgauth-design/gh-issues.mjs b/docs/sgauth-design/gh-issues.mjs index af50c3a..e19721c 100644 --- a/docs/sgauth-design/gh-issues.mjs +++ b/docs/sgauth-design/gh-issues.mjs @@ -137,8 +137,29 @@ async function link() { console.log(`done link: ${n} updated`); } +async function relabel(team) { + let n = 0; + for (const t of all.filter((x) => x.team === team)) { + const m = map[t.id]; + if (!m) continue; + const file = join(TMP, `${t.id}.labels.json`); + writeFileSync(file, JSON.stringify({ labels: labelsFor(t) })); + try { + gh(["api", `repos/${m.repo}/issues/${m.number}/labels`, "--method", "PUT", "--input", file]); + console.log(`relabeled: ${t.id} (#${m.number})`); + n++; + } catch (e) { + console.error(`FAILED relabel ${t.id}: ${String(e.stderr || e.message).trim()}`); + process.exit(1); + } + await sleep(1500); + } + console.log(`done relabel ${team}: ${n}`); +} + const [cmd, team, flag] = process.argv.slice(2); if (cmd === "labels") { if (!REPOS[team]) throw new Error("unknown team"); await createLabels(team); } +else if (cmd === "relabel") { if (!REPOS[team]) throw new Error("unknown team"); await relabel(team); } else if (cmd === "create") { if (!REPOS[team]) throw new Error("unknown team"); await create(team, { withLabels: flag !== "--no-labels" }); } else if (cmd === "link") await link(); else console.log("usage: node gh-issues.mjs labels TEAM | create TEAM [--no-labels] | link"); diff --git a/docs/sgauth-design/issue-map.json b/docs/sgauth-design/issue-map.json index fd2e4ab..735b86a 100644 --- a/docs/sgauth-design/issue-map.json +++ b/docs/sgauth-design/issue-map.json @@ -143,5 +143,520 @@ "repo": "SGAOperations/senate-path", "number": 91, "url": "https://github.com/SGAOperations/senate-path/issues/91" + }, + "AUTH-T01": { + "repo": "SGAOperations/auth", + "number": 12, + "url": "https://github.com/SGAOperations/auth/issues/12" + }, + "AUTH-T02": { + "repo": "SGAOperations/auth", + "number": 13, + "url": "https://github.com/SGAOperations/auth/issues/13" + }, + "AUTH-T03": { + "repo": "SGAOperations/auth", + "number": 14, + "url": "https://github.com/SGAOperations/auth/issues/14" + }, + "AUTH-T04": { + "repo": "SGAOperations/auth", + "number": 15, + "url": "https://github.com/SGAOperations/auth/issues/15" + }, + "AUTH-T05": { + "repo": "SGAOperations/auth", + "number": 16, + "url": "https://github.com/SGAOperations/auth/issues/16" + }, + "AUTH-T06": { + "repo": "SGAOperations/auth", + "number": 17, + "url": "https://github.com/SGAOperations/auth/issues/17" + }, + "AUTH-T07": { + "repo": "SGAOperations/auth", + "number": 18, + "url": "https://github.com/SGAOperations/auth/issues/18" + }, + "AUTH-T08": { + "repo": "SGAOperations/auth", + "number": 19, + "url": "https://github.com/SGAOperations/auth/issues/19" + }, + "AUTH-T09": { + "repo": "SGAOperations/auth", + "number": 20, + "url": "https://github.com/SGAOperations/auth/issues/20" + }, + "AUTH-T10": { + "repo": "SGAOperations/auth", + "number": 21, + "url": "https://github.com/SGAOperations/auth/issues/21" + }, + "AUTH-T11": { + "repo": "SGAOperations/auth", + "number": 22, + "url": "https://github.com/SGAOperations/auth/issues/22" + }, + "AUTH-T12": { + "repo": "SGAOperations/auth", + "number": 24, + "url": "https://github.com/SGAOperations/auth/issues/24" + }, + "AUTH-T13": { + "repo": "SGAOperations/auth", + "number": 25, + "url": "https://github.com/SGAOperations/auth/issues/25" + }, + "AUTH-T14": { + "repo": "SGAOperations/auth", + "number": 26, + "url": "https://github.com/SGAOperations/auth/issues/26" + }, + "AUTH-T15": { + "repo": "SGAOperations/auth", + "number": 27, + "url": "https://github.com/SGAOperations/auth/issues/27" + }, + "AUTH-T16": { + "repo": "SGAOperations/auth", + "number": 28, + "url": "https://github.com/SGAOperations/auth/issues/28" + }, + "AUTH-T17": { + "repo": "SGAOperations/auth", + "number": 29, + "url": "https://github.com/SGAOperations/auth/issues/29" + }, + "AUTH-T18": { + "repo": "SGAOperations/auth", + "number": 30, + "url": "https://github.com/SGAOperations/auth/issues/30" + }, + "AUTH-T19": { + "repo": "SGAOperations/auth", + "number": 31, + "url": "https://github.com/SGAOperations/auth/issues/31" + }, + "AUTH-T20": { + "repo": "SGAOperations/auth", + "number": 32, + "url": "https://github.com/SGAOperations/auth/issues/32" + }, + "AUTH-T21": { + "repo": "SGAOperations/auth", + "number": 33, + "url": "https://github.com/SGAOperations/auth/issues/33" + }, + "AUTH-T22": { + "repo": "SGAOperations/auth", + "number": 34, + "url": "https://github.com/SGAOperations/auth/issues/34" + }, + "AUTH-T23": { + "repo": "SGAOperations/auth", + "number": 35, + "url": "https://github.com/SGAOperations/auth/issues/35" + }, + "AUTH-T24": { + "repo": "SGAOperations/auth", + "number": 36, + "url": "https://github.com/SGAOperations/auth/issues/36" + }, + "AUTH-T25": { + "repo": "SGAOperations/auth", + "number": 37, + "url": "https://github.com/SGAOperations/auth/issues/37" + }, + "AUTH-T26": { + "repo": "SGAOperations/auth", + "number": 38, + "url": "https://github.com/SGAOperations/auth/issues/38" + }, + "AUTH-T27": { + "repo": "SGAOperations/auth", + "number": 39, + "url": "https://github.com/SGAOperations/auth/issues/39" + }, + "AUTH-T28": { + "repo": "SGAOperations/auth", + "number": 40, + "url": "https://github.com/SGAOperations/auth/issues/40" + }, + "AUTH-T29": { + "repo": "SGAOperations/auth", + "number": 41, + "url": "https://github.com/SGAOperations/auth/issues/41" + }, + "AUTH-T30": { + "repo": "SGAOperations/auth", + "number": 42, + "url": "https://github.com/SGAOperations/auth/issues/42" + }, + "AUTH-T31": { + "repo": "SGAOperations/auth", + "number": 43, + "url": "https://github.com/SGAOperations/auth/issues/43" + }, + "AUTH-T32": { + "repo": "SGAOperations/auth", + "number": 44, + "url": "https://github.com/SGAOperations/auth/issues/44" + }, + "AUTH-T33": { + "repo": "SGAOperations/auth", + "number": 45, + "url": "https://github.com/SGAOperations/auth/issues/45" + }, + "AUTH-T34": { + "repo": "SGAOperations/auth", + "number": 46, + "url": "https://github.com/SGAOperations/auth/issues/46" + }, + "AUTH-T35": { + "repo": "SGAOperations/auth", + "number": 47, + "url": "https://github.com/SGAOperations/auth/issues/47" + }, + "AUTH-T36": { + "repo": "SGAOperations/auth", + "number": 48, + "url": "https://github.com/SGAOperations/auth/issues/48" + }, + "AUTH-T37": { + "repo": "SGAOperations/auth", + "number": 49, + "url": "https://github.com/SGAOperations/auth/issues/49" + }, + "AUTH-T38": { + "repo": "SGAOperations/auth", + "number": 50, + "url": "https://github.com/SGAOperations/auth/issues/50" + }, + "AUTH-T39": { + "repo": "SGAOperations/auth", + "number": 51, + "url": "https://github.com/SGAOperations/auth/issues/51" + }, + "AUTH-T40": { + "repo": "SGAOperations/auth", + "number": 52, + "url": "https://github.com/SGAOperations/auth/issues/52" + }, + "AUTH-T41": { + "repo": "SGAOperations/auth", + "number": 53, + "url": "https://github.com/SGAOperations/auth/issues/53" + }, + "AUTH-T42": { + "repo": "SGAOperations/auth", + "number": 54, + "url": "https://github.com/SGAOperations/auth/issues/54" + }, + "AUTH-T43": { + "repo": "SGAOperations/auth", + "number": 55, + "url": "https://github.com/SGAOperations/auth/issues/55" + }, + "AUTH-T44": { + "repo": "SGAOperations/auth", + "number": 56, + "url": "https://github.com/SGAOperations/auth/issues/56" + }, + "AUTH-T45": { + "repo": "SGAOperations/auth", + "number": 57, + "url": "https://github.com/SGAOperations/auth/issues/57" + }, + "AUTH-T46": { + "repo": "SGAOperations/auth", + "number": 58, + "url": "https://github.com/SGAOperations/auth/issues/58" + }, + "AUTH-T47": { + "repo": "SGAOperations/auth", + "number": 59, + "url": "https://github.com/SGAOperations/auth/issues/59" + }, + "AUTH-T48": { + "repo": "SGAOperations/auth", + "number": 60, + "url": "https://github.com/SGAOperations/auth/issues/60" + }, + "AUTH-T49": { + "repo": "SGAOperations/auth", + "number": 61, + "url": "https://github.com/SGAOperations/auth/issues/61" + }, + "AUTH-T50": { + "repo": "SGAOperations/auth", + "number": 62, + "url": "https://github.com/SGAOperations/auth/issues/62" + }, + "AUTH-T51": { + "repo": "SGAOperations/auth", + "number": 63, + "url": "https://github.com/SGAOperations/auth/issues/63" + }, + "AUTH-T52": { + "repo": "SGAOperations/auth", + "number": 64, + "url": "https://github.com/SGAOperations/auth/issues/64" + }, + "AUTH-T53": { + "repo": "SGAOperations/auth", + "number": 65, + "url": "https://github.com/SGAOperations/auth/issues/65" + }, + "AUTH-T54": { + "repo": "SGAOperations/auth", + "number": 66, + "url": "https://github.com/SGAOperations/auth/issues/66" + }, + "AUTH-T55": { + "repo": "SGAOperations/auth", + "number": 67, + "url": "https://github.com/SGAOperations/auth/issues/67" + }, + "AUTH-T56": { + "repo": "SGAOperations/auth", + "number": 68, + "url": "https://github.com/SGAOperations/auth/issues/68" + }, + "AUTH-T57": { + "repo": "SGAOperations/auth", + "number": 69, + "url": "https://github.com/SGAOperations/auth/issues/69" + }, + "AUTH-T58": { + "repo": "SGAOperations/auth", + "number": 70, + "url": "https://github.com/SGAOperations/auth/issues/70" + }, + "AUTH-T59": { + "repo": "SGAOperations/auth", + "number": 71, + "url": "https://github.com/SGAOperations/auth/issues/71" + }, + "AUTH-T60": { + "repo": "SGAOperations/auth", + "number": 72, + "url": "https://github.com/SGAOperations/auth/issues/72" + }, + "AUTH-T61": { + "repo": "SGAOperations/auth", + "number": 73, + "url": "https://github.com/SGAOperations/auth/issues/73" + }, + "AUTH-T62": { + "repo": "SGAOperations/auth", + "number": 74, + "url": "https://github.com/SGAOperations/auth/issues/74" + }, + "AUTH-T63": { + "repo": "SGAOperations/auth", + "number": 75, + "url": "https://github.com/SGAOperations/auth/issues/75" + }, + "AUTH-T64": { + "repo": "SGAOperations/auth", + "number": 76, + "url": "https://github.com/SGAOperations/auth/issues/76" + }, + "AUTH-T65": { + "repo": "SGAOperations/auth", + "number": 77, + "url": "https://github.com/SGAOperations/auth/issues/77" + }, + "AUTH-T66": { + "repo": "SGAOperations/auth", + "number": 78, + "url": "https://github.com/SGAOperations/auth/issues/78" + }, + "AUTH-T67": { + "repo": "SGAOperations/auth", + "number": 79, + "url": "https://github.com/SGAOperations/auth/issues/79" + }, + "AUTH-T68": { + "repo": "SGAOperations/auth", + "number": 80, + "url": "https://github.com/SGAOperations/auth/issues/80" + }, + "AUTH-T69": { + "repo": "SGAOperations/auth", + "number": 81, + "url": "https://github.com/SGAOperations/auth/issues/81" + }, + "AUTH-T70": { + "repo": "SGAOperations/auth", + "number": 82, + "url": "https://github.com/SGAOperations/auth/issues/82" + }, + "AUTH-T71": { + "repo": "SGAOperations/auth", + "number": 83, + "url": "https://github.com/SGAOperations/auth/issues/83" + }, + "AUTH-T72": { + "repo": "SGAOperations/auth", + "number": 84, + "url": "https://github.com/SGAOperations/auth/issues/84" + }, + "AUTH-T73": { + "repo": "SGAOperations/auth", + "number": 85, + "url": "https://github.com/SGAOperations/auth/issues/85" + }, + "AUTH-T74": { + "repo": "SGAOperations/auth", + "number": 86, + "url": "https://github.com/SGAOperations/auth/issues/86" + }, + "AUTH-T75": { + "repo": "SGAOperations/auth", + "number": 87, + "url": "https://github.com/SGAOperations/auth/issues/87" + }, + "AUTH-T76": { + "repo": "SGAOperations/auth", + "number": 88, + "url": "https://github.com/SGAOperations/auth/issues/88" + }, + "AUTH-T77": { + "repo": "SGAOperations/auth", + "number": 89, + "url": "https://github.com/SGAOperations/auth/issues/89" + }, + "AUTH-T78": { + "repo": "SGAOperations/auth", + "number": 90, + "url": "https://github.com/SGAOperations/auth/issues/90" + }, + "AUTH-T79": { + "repo": "SGAOperations/auth", + "number": 91, + "url": "https://github.com/SGAOperations/auth/issues/91" + }, + "AUTH-T80": { + "repo": "SGAOperations/auth", + "number": 92, + "url": "https://github.com/SGAOperations/auth/issues/92" + }, + "AUTH-T81": { + "repo": "SGAOperations/auth", + "number": 93, + "url": "https://github.com/SGAOperations/auth/issues/93" + }, + "AUTH-T82": { + "repo": "SGAOperations/auth", + "number": 94, + "url": "https://github.com/SGAOperations/auth/issues/94" + }, + "AUTH-T83": { + "repo": "SGAOperations/auth", + "number": 95, + "url": "https://github.com/SGAOperations/auth/issues/95" + }, + "AUTH-T84": { + "repo": "SGAOperations/auth", + "number": 96, + "url": "https://github.com/SGAOperations/auth/issues/96" + }, + "AUTH-T85": { + "repo": "SGAOperations/auth", + "number": 97, + "url": "https://github.com/SGAOperations/auth/issues/97" + }, + "AUTH-T86": { + "repo": "SGAOperations/auth", + "number": 98, + "url": "https://github.com/SGAOperations/auth/issues/98" + }, + "AUTH-T87": { + "repo": "SGAOperations/auth", + "number": 99, + "url": "https://github.com/SGAOperations/auth/issues/99" + }, + "AUTH-T88": { + "repo": "SGAOperations/auth", + "number": 100, + "url": "https://github.com/SGAOperations/auth/issues/100" + }, + "AUTH-T89": { + "repo": "SGAOperations/auth", + "number": 101, + "url": "https://github.com/SGAOperations/auth/issues/101" + }, + "AUTH-T90": { + "repo": "SGAOperations/auth", + "number": 102, + "url": "https://github.com/SGAOperations/auth/issues/102" + }, + "AUTH-T91": { + "repo": "SGAOperations/auth", + "number": 103, + "url": "https://github.com/SGAOperations/auth/issues/103" + }, + "AUTH-T92": { + "repo": "SGAOperations/auth", + "number": 104, + "url": "https://github.com/SGAOperations/auth/issues/104" + }, + "AUTH-T93": { + "repo": "SGAOperations/auth", + "number": 105, + "url": "https://github.com/SGAOperations/auth/issues/105" + }, + "AUTH-T94": { + "repo": "SGAOperations/auth", + "number": 106, + "url": "https://github.com/SGAOperations/auth/issues/106" + }, + "AUTH-T95": { + "repo": "SGAOperations/auth", + "number": 107, + "url": "https://github.com/SGAOperations/auth/issues/107" + }, + "AUTH-T96": { + "repo": "SGAOperations/auth", + "number": 108, + "url": "https://github.com/SGAOperations/auth/issues/108" + }, + "AUTH-T97": { + "repo": "SGAOperations/auth", + "number": 109, + "url": "https://github.com/SGAOperations/auth/issues/109" + }, + "AUTH-T98": { + "repo": "SGAOperations/auth", + "number": 110, + "url": "https://github.com/SGAOperations/auth/issues/110" + }, + "AUTH-T99": { + "repo": "SGAOperations/auth", + "number": 111, + "url": "https://github.com/SGAOperations/auth/issues/111" + }, + "AUTH-T100": { + "repo": "SGAOperations/auth", + "number": 112, + "url": "https://github.com/SGAOperations/auth/issues/112" + }, + "AUTH-T101": { + "repo": "SGAOperations/auth", + "number": 113, + "url": "https://github.com/SGAOperations/auth/issues/113" + }, + "AUTH-T103": { + "repo": "SGAOperations/auth", + "number": 114, + "url": "https://github.com/SGAOperations/auth/issues/114" + }, + "AUTH-T104": { + "repo": "SGAOperations/auth", + "number": 115, + "url": "https://github.com/SGAOperations/auth/issues/115" } } From fa157393cceb536db70ee1a9f020c78d64f6d737 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Fri, 18 Sep 2026 21:36:19 -0400 Subject: [PATCH 3/4] Apply Benedikt's review: lazy set-password, separate Resend account, passkeys - Imported password-less accounts set a password on first sign-in; no import-day invite blast (AUTH-T22, T41, T88, T89, APLIO-P07) - SGAuth sends from its own Resend account (AUTH-T20) - New AUTH-T105: optional passkey sign-in in Phase 4 - Product permission tickets now reference curated position keys - gh-issues.mjs gains an update command; issue map records AUTH-T105 Co-Authored-By: Claude Opus 5 --- docs/sgauth-design/SGAuth-design-summary.md | 9 +- docs/sgauth-design/gh-issues.mjs | 23 +++++ docs/sgauth-design/issue-map.json | 5 ++ .../out/SGAuth-design-summary.md | 9 +- docs/sgauth-design/out/SGAuth-tickets.md | 85 +++++++++++++------ .../sgauth-design/out/linear-import-APLIO.csv | 4 +- .../out/linear-import-ATTENDANCE.csv | 2 +- docs/sgauth-design/out/linear-import-AUTH.csv | 57 +++++++++---- .../out/linear-import-SENATEPATH.csv | 4 +- .../out/linear-import-VAULTZ.csv | 2 +- docs/sgauth-design/tickets-auth.mjs | 50 ++++++++--- docs/sgauth-design/tickets-products.mjs | 10 +-- 12 files changed, 188 insertions(+), 72 deletions(-) diff --git a/docs/sgauth-design/SGAuth-design-summary.md b/docs/sgauth-design/SGAuth-design-summary.md index 6d942ca..d860625 100644 --- a/docs/sgauth-design/SGAuth-design-summary.md +++ b/docs/sgauth-design/SGAuth-design-summary.md @@ -2,6 +2,8 @@ **Date:** 2026-09-18 · **Status:** Design settled, red-teamed, tickets regenerated · **Owner:** SGA Digital Innovation +> Revision 2026-09-18 (Benedikt's review): lazy set-password instead of an import-day invite blast; separate Resend account for SGAuth; optional passkeys in Phase 4. +> > Revision after the red-team review (see `SGAuth-red-team.md`): Neon stays on Free as an accepted, monitored risk; scheduled jobs run from GitHub Actions instead of Vercel cron; user deletion is a tombstone; the rate limiter fails open; the SDK is published to public npm; lockout has a known-device exemption; JWKS rotation is manual with a Management API push; emailed links consume tokens on POST (Safe Links); sign-up overwrites unverified accounts (pre-hijack); Chambers items are a checklist, not a Linear import; the Chambers `auth.users` export is Eli's manual action item. SGAuth is the centralized authentication and identity service for Northeastern SGA. One login at `auth.northeasternsga.com` yields one session shared by every product on `*.northeasternsga.com`. **SGAuth is built on Neon serverless Postgres and does not use Supabase for any purpose.** SGAuth is the sole source of truth for credentials; no product keeps its own password store. @@ -20,6 +22,7 @@ The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linea | Hosting | Vercel (Node runtime), `auth.northeasternsga.com` for production, `auth-dev.northeasternsga.com` for the dev deployment (DEV banner, synthetic users, separate secrets). **No Vercel cron**: scheduled jobs run from GitHub Actions calling secret-protected routes | Matches Aplio and Chambers; Neon integration; custom domains. Vercel Hobby limits cron to daily, so the scheduler is plan-independent | | Login method | Email + password (12+ chars, no composition rules, no breached-password check), scrypt hashing; self-sign-up restricted to `@northeastern.edu` with verification (every student has one; no exceptions); admins can create/invite any address; a later sign-up overwrites an unverified account (pre-hijack defense); every emailed link consumes its token on POST (Microsoft Safe Links) | Resend free tier is nearly exhausted org-wide, so passwordless OTP would be fragile; passwords make SGAuth the credential store the requirement demands. Email OTP is a deferred backlog item | | Northeastern SSO | Not a launch dependency; Phase 5 spike | Registering an app in Northeastern's Entra ID tenant needs ITS approval; a multi-tenant app in an SGA tenant may require Northeastern admin consent. Unknown timeline | +| Passkeys | Optional per-user sign-in method (Better Auth passkey plugin), Phase 4; password stays as fallback; does not satisfy the admin TOTP requirement in v1 | Suggested by Benedikt: no email cost, phishing-resistant, preferred by some users | | SSO mechanism | Parent-domain cookie `Domain=northeasternsga.com`, `__Secure-` prefix, HttpOnly, Secure, SameSite=Lax, no cookie cache | Simplest true single session; logout is instant everywhere. `__Host-` is impossible with a Domain attribute. Any compromised subdomain can read the cookie, so subdomain hygiene is a security control | | Session lookup | Products call `GET /api/sgauth/session` server-side with the forwarded cookie (SDK caches 60 s, serves a stale cached session for up to 5 min if SGAuth is unreachable); the Next.js proxy only checks cookie presence; Supabase products additionally mint a 10-minute ES256 JWT from `/api/auth/token` | DB-backed lookup keeps revocation and position changes immediate; stale-if-error keeps a SGAuth blip from logging every product out; JWTs only where Supabase needs a bearer token | | Session lifetime | 30-day sliding (`updateAge` 1 day), 90-day absolute cap enforced by a daily purge on `createdAt` (so Better Auth's own endpoints cannot bypass it), 10-minute re-auth window via `lastReauthAt` (Better Auth `freshAge` left at default) | Matches semester usage while bounding stolen-cookie exposure | @@ -35,12 +38,12 @@ The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linea | User removal | Admin deactivate (soft, sessions revoked instantly, reactivatable); **tombstone** (status DELETED, PII scrubbed, id kept) 30 days after deactivation or on request; inactive 12 months → notice → tombstone (admins exempt for manual review); audit PII anonymized, events kept | Meets the retention policy without losing the audit trail or orphaning product rows keyed by SGAuth user id | | Profile data | `name`, optional `preferredName`, `email`; no NUID, phone, pronouns, photo | Data minimization; products keep their own fields keyed by SGAuth id | | User UI | Minimal account page: profile, positions (read-only), links to each product from the registry, sessions with revoke, sign out everywhere, password change, MFA enrollment | Users need a hub and self-service basics; nothing product-specific | -| Account creation | Admin-created/invited (set-password link) or bulk CSV import, plus `@northeastern.edu` self-sign-up with zero positions | Avoids admin bottlenecks at turnover; positions gate everything | -| Migration | Chambers: bcrypt hashes imported with a `bcrypt$` marker and lazily re-hashed to scrypt on first login (the `auth.users` export is Eli's manual action item, before the Supabase project is deleted). Aplio/SenatePath/Attendance: emails imported, set-password invites; non-NU Aplio addresses imported as `legacyEmail` accounts. Merge by lower-cased email | Chambers users keep their passwords; OTP users never had one | +| Account creation | Admin-created or bulk CSV import as password-less accounts, plus `@northeastern.edu` self-sign-up with zero positions. Password-less accounts set a password lazily: entering the email on the login page sends a one-time set-password link; admins can still push an invite to one person | Avoids admin bottlenecks at turnover and avoids an import-day email blast; positions gate everything | +| Migration | Chambers: bcrypt hashes imported with a `bcrypt$` marker and lazily re-hashed to scrypt on first login (the `auth.users` export is Eli's manual action item, before the Supabase project is deleted). Aplio/SenatePath/SenatePortal: emails imported as password-less accounts, **no emails sent at import**, set-password on first sign-in; Aplio duplicates merge into the Chambers account; the few non-NU Aplio addresses are imported as `legacyEmail` and corrected by hand. Merge by lower-cased email | Chambers users keep their passwords; OTP users never had one; lazy set-password spreads email volume over time | | MFA | TOTP + backup codes; optional for users, required for admins and the PA (enforced at admin-action time); required for the transfer recipient | No SMS/email cost; protects privileged accounts | | Rate limiting | Upstash Redis sliding windows on sign-in, sign-up, reset, token, re-auth, and admin endpoints only (session endpoint excluded to stay within 500K commands/month); **fails open with an alert** on Upstash outage; Better Auth's DB limiter disabled | Team already runs Upstash (Chambers); a vendor outage must never become an org-wide login outage | | Lockout | 5 failures/15 min → 15-minute lock, doubling to a 24-hour cap, never permanent; emailed unlock link (POST-consumed); applies to the PA; **known-device cookie exempts the owner's usual browser** so the lock cannot be used to deny admins service | Mitigates credential stuffing without permanent self-DoS or a lockout DoS lever | -| Email | Resend on a dedicated SGAuth sending domain and API key, provider-agnostic mailer interface, per-recipient and global caps | Keeps SGAuth off Aplio's and Chambers' quota; volume is low with password login | +| Email | **Separate Resend account** owned by an SGA shared mailbox, sending from `mail.northeasternsga.com`, provider-agnostic mailer interface, per-recipient and global caps | Resend's free quota is per account and Chambers sends almost all SGA email today, so only a separate account isolates SGAuth's small volume | | Product registry | Admin-managed `Product` table (slug, name, base URL restricted to `https://*.northeasternsga.com`, visibility by positions); drives trusted origins, redirect allowlist, CORS, and account-page links | No deploy to add a product; one source for security allowlists | | SDK | `@sgaoperations/sgauth` on **public npm** with trusted publishing: `getSession` (60 s cache, stale-if-error), cookie-presence proxy helper, position guards, URL builders, `getAccessToken` for Supabase | Five products, one place to fix bugs; GitHub Packages was rejected because it needs a personal token to install even public packages | | Environments | Dev SGAuth on the real parent domain with a distinct cookie prefix; product previews on stable `-dev.northeasternsga.com` branch domains; local `*.sga.localhost` hostnames; `*.vercel.app` previews cannot use SSO | A parent-domain cookie cannot reach `vercel.app` or bare `localhost`; the topology must be explicit | diff --git a/docs/sgauth-design/gh-issues.mjs b/docs/sgauth-design/gh-issues.mjs index e19721c..27ee1ad 100644 --- a/docs/sgauth-design/gh-issues.mjs +++ b/docs/sgauth-design/gh-issues.mjs @@ -157,7 +157,30 @@ async function relabel(team) { console.log(`done relabel ${team}: ${n}`); } +// Re-renders title + body for the given ticket ids (or all created tickets) from the current sources. +async function update(ids) { + const targets = ids.length ? ids : all.map((t) => t.id); + let n = 0; + for (const id of targets) { + const t = byId.get(id), m = map[id]; + if (!t || !m) { console.log(`skip (not created): ${id}`); continue; } + const file = join(TMP, `${id}.update.json`); + writeFileSync(file, JSON.stringify({ title: t.title, body: body(t) })); + try { + gh(["api", `repos/${m.repo}/issues/${m.number}`, "--method", "PATCH", "--input", file]); + console.log(`updated: ${id} (#${m.number})`); + n++; + } catch (e) { + console.error(`FAILED update ${id}: ${String(e.stderr || e.message).trim()}`); + process.exit(1); + } + await sleep(1500); + } + console.log(`done update: ${n}`); +} + const [cmd, team, flag] = process.argv.slice(2); +if (cmd === "update") { await update(process.argv.slice(3)); process.exit(0); } if (cmd === "labels") { if (!REPOS[team]) throw new Error("unknown team"); await createLabels(team); } else if (cmd === "relabel") { if (!REPOS[team]) throw new Error("unknown team"); await relabel(team); } else if (cmd === "create") { if (!REPOS[team]) throw new Error("unknown team"); await create(team, { withLabels: flag !== "--no-labels" }); } diff --git a/docs/sgauth-design/issue-map.json b/docs/sgauth-design/issue-map.json index 735b86a..a4b3ed5 100644 --- a/docs/sgauth-design/issue-map.json +++ b/docs/sgauth-design/issue-map.json @@ -658,5 +658,10 @@ "repo": "SGAOperations/auth", "number": 115, "url": "https://github.com/SGAOperations/auth/issues/115" + }, + "AUTH-T105": { + "repo": "SGAOperations/auth", + "number": 116, + "url": "https://github.com/SGAOperations/auth/issues/116" } } diff --git a/docs/sgauth-design/out/SGAuth-design-summary.md b/docs/sgauth-design/out/SGAuth-design-summary.md index 6d942ca..d860625 100644 --- a/docs/sgauth-design/out/SGAuth-design-summary.md +++ b/docs/sgauth-design/out/SGAuth-design-summary.md @@ -2,6 +2,8 @@ **Date:** 2026-09-18 · **Status:** Design settled, red-teamed, tickets regenerated · **Owner:** SGA Digital Innovation +> Revision 2026-09-18 (Benedikt's review): lazy set-password instead of an import-day invite blast; separate Resend account for SGAuth; optional passkeys in Phase 4. +> > Revision after the red-team review (see `SGAuth-red-team.md`): Neon stays on Free as an accepted, monitored risk; scheduled jobs run from GitHub Actions instead of Vercel cron; user deletion is a tombstone; the rate limiter fails open; the SDK is published to public npm; lockout has a known-device exemption; JWKS rotation is manual with a Management API push; emailed links consume tokens on POST (Safe Links); sign-up overwrites unverified accounts (pre-hijack); Chambers items are a checklist, not a Linear import; the Chambers `auth.users` export is Eli's manual action item. SGAuth is the centralized authentication and identity service for Northeastern SGA. One login at `auth.northeasternsga.com` yields one session shared by every product on `*.northeasternsga.com`. **SGAuth is built on Neon serverless Postgres and does not use Supabase for any purpose.** SGAuth is the sole source of truth for credentials; no product keeps its own password store. @@ -20,6 +22,7 @@ The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linea | Hosting | Vercel (Node runtime), `auth.northeasternsga.com` for production, `auth-dev.northeasternsga.com` for the dev deployment (DEV banner, synthetic users, separate secrets). **No Vercel cron**: scheduled jobs run from GitHub Actions calling secret-protected routes | Matches Aplio and Chambers; Neon integration; custom domains. Vercel Hobby limits cron to daily, so the scheduler is plan-independent | | Login method | Email + password (12+ chars, no composition rules, no breached-password check), scrypt hashing; self-sign-up restricted to `@northeastern.edu` with verification (every student has one; no exceptions); admins can create/invite any address; a later sign-up overwrites an unverified account (pre-hijack defense); every emailed link consumes its token on POST (Microsoft Safe Links) | Resend free tier is nearly exhausted org-wide, so passwordless OTP would be fragile; passwords make SGAuth the credential store the requirement demands. Email OTP is a deferred backlog item | | Northeastern SSO | Not a launch dependency; Phase 5 spike | Registering an app in Northeastern's Entra ID tenant needs ITS approval; a multi-tenant app in an SGA tenant may require Northeastern admin consent. Unknown timeline | +| Passkeys | Optional per-user sign-in method (Better Auth passkey plugin), Phase 4; password stays as fallback; does not satisfy the admin TOTP requirement in v1 | Suggested by Benedikt: no email cost, phishing-resistant, preferred by some users | | SSO mechanism | Parent-domain cookie `Domain=northeasternsga.com`, `__Secure-` prefix, HttpOnly, Secure, SameSite=Lax, no cookie cache | Simplest true single session; logout is instant everywhere. `__Host-` is impossible with a Domain attribute. Any compromised subdomain can read the cookie, so subdomain hygiene is a security control | | Session lookup | Products call `GET /api/sgauth/session` server-side with the forwarded cookie (SDK caches 60 s, serves a stale cached session for up to 5 min if SGAuth is unreachable); the Next.js proxy only checks cookie presence; Supabase products additionally mint a 10-minute ES256 JWT from `/api/auth/token` | DB-backed lookup keeps revocation and position changes immediate; stale-if-error keeps a SGAuth blip from logging every product out; JWTs only where Supabase needs a bearer token | | Session lifetime | 30-day sliding (`updateAge` 1 day), 90-day absolute cap enforced by a daily purge on `createdAt` (so Better Auth's own endpoints cannot bypass it), 10-minute re-auth window via `lastReauthAt` (Better Auth `freshAge` left at default) | Matches semester usage while bounding stolen-cookie exposure | @@ -35,12 +38,12 @@ The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linea | User removal | Admin deactivate (soft, sessions revoked instantly, reactivatable); **tombstone** (status DELETED, PII scrubbed, id kept) 30 days after deactivation or on request; inactive 12 months → notice → tombstone (admins exempt for manual review); audit PII anonymized, events kept | Meets the retention policy without losing the audit trail or orphaning product rows keyed by SGAuth user id | | Profile data | `name`, optional `preferredName`, `email`; no NUID, phone, pronouns, photo | Data minimization; products keep their own fields keyed by SGAuth id | | User UI | Minimal account page: profile, positions (read-only), links to each product from the registry, sessions with revoke, sign out everywhere, password change, MFA enrollment | Users need a hub and self-service basics; nothing product-specific | -| Account creation | Admin-created/invited (set-password link) or bulk CSV import, plus `@northeastern.edu` self-sign-up with zero positions | Avoids admin bottlenecks at turnover; positions gate everything | -| Migration | Chambers: bcrypt hashes imported with a `bcrypt$` marker and lazily re-hashed to scrypt on first login (the `auth.users` export is Eli's manual action item, before the Supabase project is deleted). Aplio/SenatePath/Attendance: emails imported, set-password invites; non-NU Aplio addresses imported as `legacyEmail` accounts. Merge by lower-cased email | Chambers users keep their passwords; OTP users never had one | +| Account creation | Admin-created or bulk CSV import as password-less accounts, plus `@northeastern.edu` self-sign-up with zero positions. Password-less accounts set a password lazily: entering the email on the login page sends a one-time set-password link; admins can still push an invite to one person | Avoids admin bottlenecks at turnover and avoids an import-day email blast; positions gate everything | +| Migration | Chambers: bcrypt hashes imported with a `bcrypt$` marker and lazily re-hashed to scrypt on first login (the `auth.users` export is Eli's manual action item, before the Supabase project is deleted). Aplio/SenatePath/SenatePortal: emails imported as password-less accounts, **no emails sent at import**, set-password on first sign-in; Aplio duplicates merge into the Chambers account; the few non-NU Aplio addresses are imported as `legacyEmail` and corrected by hand. Merge by lower-cased email | Chambers users keep their passwords; OTP users never had one; lazy set-password spreads email volume over time | | MFA | TOTP + backup codes; optional for users, required for admins and the PA (enforced at admin-action time); required for the transfer recipient | No SMS/email cost; protects privileged accounts | | Rate limiting | Upstash Redis sliding windows on sign-in, sign-up, reset, token, re-auth, and admin endpoints only (session endpoint excluded to stay within 500K commands/month); **fails open with an alert** on Upstash outage; Better Auth's DB limiter disabled | Team already runs Upstash (Chambers); a vendor outage must never become an org-wide login outage | | Lockout | 5 failures/15 min → 15-minute lock, doubling to a 24-hour cap, never permanent; emailed unlock link (POST-consumed); applies to the PA; **known-device cookie exempts the owner's usual browser** so the lock cannot be used to deny admins service | Mitigates credential stuffing without permanent self-DoS or a lockout DoS lever | -| Email | Resend on a dedicated SGAuth sending domain and API key, provider-agnostic mailer interface, per-recipient and global caps | Keeps SGAuth off Aplio's and Chambers' quota; volume is low with password login | +| Email | **Separate Resend account** owned by an SGA shared mailbox, sending from `mail.northeasternsga.com`, provider-agnostic mailer interface, per-recipient and global caps | Resend's free quota is per account and Chambers sends almost all SGA email today, so only a separate account isolates SGAuth's small volume | | Product registry | Admin-managed `Product` table (slug, name, base URL restricted to `https://*.northeasternsga.com`, visibility by positions); drives trusted origins, redirect allowlist, CORS, and account-page links | No deploy to add a product; one source for security allowlists | | SDK | `@sgaoperations/sgauth` on **public npm** with trusted publishing: `getSession` (60 s cache, stale-if-error), cookie-presence proxy helper, position guards, URL builders, `getAccessToken` for Supabase | Five products, one place to fix bugs; GitHub Packages was rejected because it needs a personal token to install even public packages | | Environments | Dev SGAuth on the real parent domain with a distinct cookie prefix; product previews on stable `-dev.northeasternsga.com` branch domains; local `*.sga.localhost` hostnames; `*.vercel.app` previews cannot use SSO | A parent-domain cookie cannot reach `vercel.app` or bare `localhost`; the topology must be explicit | diff --git a/docs/sgauth-design/out/SGAuth-tickets.md b/docs/sgauth-design/out/SGAuth-tickets.md index e99b7c2..d71547e 100644 --- a/docs/sgauth-design/out/SGAuth-tickets.md +++ b/docs/sgauth-design/out/SGAuth-tickets.md @@ -1,6 +1,6 @@ # SGAuth ticket set -Generated from `tickets-auth.mjs` and `tickets-products.mjs`. 138 tickets across 6 teams (5 with Linear CSVs; Chambers has no Linear team and is a checklist only). CSVs in `out/` use Linear's importer columns (Title, Description, Priority, Estimate, Status, Labels); each ticket's description carries its epic, phase, and dependencies because the importer does not create projects or parent links. Red-team revisions are folded in; see `SGAuth-red-team.md`. +Generated from `tickets-auth.mjs` and `tickets-products.mjs`. 139 tickets across 6 teams (5 with Linear CSVs; Chambers has no Linear team and is a checklist only). CSVs in `out/` use Linear's importer columns (Title, Description, Priority, Estimate, Status, Labels); each ticket's description carries its epic, phase, and dependencies because the importer does not create projects or parent links. Red-team revisions are folded in; see `SGAuth-red-team.md`. **Manual action item (not a ticket):** Eli exports the Chambers Supabase `auth.users` table (with `encrypted_password`) joined to `public.users` before that Supabase project is deleted, and stores the file in the team secrets vault. AUTH-T86 validates the file; AUTH-T87 imports it. @@ -26,7 +26,7 @@ Generated from `tickets-auth.mjs` and `tickets-products.mjs`. 138 tickets across - **E1 Foundation & Neon Migration** (label `epic:foundation`, 10 tickets, 23 points): Replace the Supabase foundation with Neon serverless Postgres, Better Auth, Vercel hosting, CI, and environment tooling. SGAuth MUST run on Neon and MUST NOT use Supabase for auth or data. - **E2 Data Model & Migrations** (label `epic:data-model`, 7 tickets, 16 points): Prisma 7 schema on Neon: users, Better Auth tables, positions, admin/Primary Admin invariants, audit log, product registry, security tables, and the migration workflow. -- **E3 Authentication Core** (label `epic:auth-core`, 12 tickets, 29 points): Email + password authentication with Better Auth: sign-up restricted to northeastern.edu, verification, reset, invites, imported bcrypt hashes, and the login UI. +- **E3 Authentication Core** (label `epic:auth-core`, 13 tickets, 34 points): Email + password authentication with Better Auth: sign-up restricted to northeastern.edu, verification, reset, invites, imported bcrypt hashes, and the login UI. - **E4 Sessions & SSO** (label `epic:sessions`, 10 tickets, 24 points): One session across *.northeasternsga.com via a parent-domain cookie, the session endpoint products call, ES256 JWTs + JWKS for Supabase products, logout propagation, redirects, re-authentication, and the non-production topology. - **E5 Admin & Primary Admin** (label `epic:admin`, 7 tickets, 23 points): Server-enforced admin rules, the single transferable Primary Admin with a guarded transfer flow, break-glass recovery, and bulk user administration. - **E6 Positions** (label `epic:positions`, 5 tickets, 11 points): Flat, admin-managed positions (stable key + display name) carried in every session; assignment, soft delete, retirement, propagation, and history. @@ -42,7 +42,7 @@ Generated from `tickets-auth.mjs` and `tickets-products.mjs`. 138 tickets across - **SGAuth integration — VaultZ** (team VaultZ, 8 tickets, 17 points, Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ): Replace the shared-passphrase gate with SGAuth sessions via the SDK. First product integrated; proves the SDK and the Neon-product guide. - **SGAuth integration — Chambers** (team Chambers, 9 tickets, 26 points, Phase 3 — Hardening, observability, Chambers): Chambers has no Linear team; these items are a Markdown checklist only (no CSV). Chambers is a Neon product (migration completes before integration). Replace Supabase Auth, live-role checks, and session revocation with SGAuth sessions and positions; users and password hashes are imported into SGAuth. The auth.users export is Eli's manual action item, not a ticket. -- **SGAuth integration — Aplio** (team Aplio, 8 tickets, 23 points, Phase 4 — Aplio, SenatePath, Attendance Manager, retention): Hard cutover from Aplio's local Better Auth (email OTP) to SGAuth via the SDK; users pre-imported and invited to set passwords. +- **SGAuth integration — Aplio** (team Aplio, 8 tickets, 23 points, Phase 4 — Aplio, SenatePath, Attendance Manager, retention): Hard cutover from Aplio's local Better Auth (email OTP) to SGAuth via the SDK; users pre-imported as password-less accounts that set a password on first sign-in. - **SGAuth integration — SenatePath** (team SenatePath, 4 tickets, 12 points, Phase 4 — Aplio, SenatePath, Attendance Manager, retention): Migrate SenatePath's database from Supabase to Neon and gate the admin area with SGAuth positions. - **SGAuth integration — Attendance Manager** (team Attendance Manager, 6 tickets, 17 points, Phase 4 — Aplio, SenatePath, Attendance Manager, retention): Decision pending from the team: either stay on Supabase and consume SGAuth via third-party auth (JWT trust), or move to Neon and use the SDK. Both paths are ticketed; only one will be executed. @@ -71,7 +71,7 @@ Generated from `tickets-auth.mjs` and `tickets-products.mjs`. 138 tickets across | AUTH-T19 | Password reset flow | AUTH | 1 | High | 3 | AUTH-T17, AUTH-T20, AUTH-T103 | | AUTH-T20 | Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps | AUTH | 1 | High | 3 | AUTH-T07 | | AUTH-T21 | Accept imported Chambers bcrypt hashes with lazy re-hash to scrypt | AUTH | 3 | High | 2 | AUTH-T17 | -| AUTH-T22 | Invite flow: admin-created users receive a set-password link | AUTH | 2 | Medium | 2 | AUTH-T20, AUTH-T17, AUTH-T103 | +| AUTH-T22 | Password-less accounts: lazy set-password on first sign-in, plus admin invites | AUTH | 2 | Medium | 2 | AUTH-T20, AUTH-T17, AUTH-T103 | | AUTH-T23 | Change password (current password + re-auth), revoke other sessions | AUTH | 2 | Medium | 2 | AUTH-T17, AUTH-T32 | | AUTH-T24 | Admin-initiated email change with re-verification and privilege rules | AUTH | 3 | Low | 2 | AUTH-T36, AUTH-T20 | | AUTH-T25 | Login, sign-up, forgot/reset, and verify pages | AUTH | 1 | High | 3 | AUTH-T17, AUTH-T18, AUTH-T19, AUTH-T31 | @@ -137,8 +137,8 @@ Generated from `tickets-auth.mjs` and `tickets-products.mjs`. 138 tickets across | AUTH-T85 | CLAUDE.md and CONTRIBUTING.md for agents and humans | AUTH | 1 | Low | 1 | AUTH-T02, AUTH-T06 | | AUTH-T86 | Receive the Chambers auth.users export and define the import file format | AUTH | 3 | High | 2 | — | | AUTH-T87 | Import script: Chambers users with bcrypt hashes and position mapping | AUTH | 3 | High | 3 | AUTH-T86, AUTH-T21, AUTH-T44, AUTH-T13 | -| AUTH-T88 | Aplio user import (emails and names, no passwords) with invites and id mapping | AUTH | 4 | High | 2 | AUTH-T87, AUTH-T22 | -| AUTH-T89 | SenatePath and Attendance Manager user import | AUTH | 4 | Medium | 2 | AUTH-T88 | +| AUTH-T88 | Aplio user import (emails and names, no passwords) with id mapping | AUTH | 4 | High | 2 | AUTH-T87, AUTH-T22 | +| AUTH-T89 | SenatePath and SenatePortal user import | AUTH | 4 | Medium | 2 | AUTH-T88 | | AUTH-T90 | Rollout plan and user communications | AUTH | 3 | Medium | 2 | AUTH-T80, AUTH-T87 | | AUTH-T91 | Production launch checklist and Primary Admin bootstrap | AUTH | 2 | High | 2 | AUTH-T72, AUTH-T66, AUTH-T76, AUTH-T39, AUTH-T64 | | AUTH-T92 | Post-launch review and legacy cleanup tracking | AUTH | 4 | Low | 1 | AUTH-T90 | @@ -152,6 +152,7 @@ Generated from `tickets-auth.mjs` and `tickets-products.mjs`. 138 tickets across | AUTH-T100 | Backlog: optional email OTP login (deferred due to email volume) | AUTH | 5 | Low | 2 | AUTH-T91 | | AUTH-T101 | Neon Free-plan quota monitoring, alerts, and upgrade runbook | AUTH | 0 | High | 2 | AUTH-T01 | | AUTH-T103 | Scanner-safe email links: land on a page, consume the token on POST | AUTH | 1 | Urgent | 2 | AUTH-T20 | +| AUTH-T105 | Passkeys as an optional sign-in method | AUTH | 4 | Medium | 5 | AUTH-T17, AUTH-T32, AUTH-T54, AUTH-T67 | | AUTH-T104 | Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them | AUTH | 1 | Urgent | 2 | AUTH-T05, AUTH-T26 | | VAULTZ-V01 | Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy | VAULTZ | 2 | High | 3 | AUTH-T59, AUTH-T80, AUTH-T34 | | VAULTZ-V02 | Remove the shared passphrase gate | VAULTZ | 2 | High | 2 | VAULTZ-V01 | @@ -516,13 +517,15 @@ Forgot-password request (email only; uniform response regardless of existence), **Priority:** High · **Estimate:** 3 · **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) · **Labels:** epic:auth-core, phase:1, backend, email, infra **Depends on:** AUTH-T07 (Validate environment variables at startup with zod) -Password login keeps email volume low (verification, reset, invite, lock/unlock, security notices, PA transfer steps). To avoid sharing Aplio's and Chambers' Resend quota: -- Separate Resend API key and a dedicated sending domain `mail.northeasternsga.com` with SPF, DKIM, and DMARC (p=quarantine) records; `from` = `SGA Auth `. +Password login keeps email volume low (verification, reset, set-password, lock/unlock, security notices, PA transfer steps). Chambers sends almost all of SGA's current email, and Resend's free quota is **per account**, so a separate API key inside the shared account would not isolate anything. Therefore: +- **Separate Resend account** owned by an SGA shared mailbox (not a student's personal login), credentials in the team vault. Confirm once that Resend's terms permit a distinct account for a distinct sender and record the answer here. +- Dedicated sending subdomain `mail.northeasternsga.com` verified only in that account, with SPF, DKIM, and DMARC (p=quarantine) records; `from` = `SGA Auth `. - `src/lib/email/mailer.ts` provider-agnostic interface (`sendEmail({ to, template, data })`) with a Resend implementation and a console/preview transport for local/test. - Templates (React Email or plain HTML+text): verify, reset, invite/set-password, account locked, unlock, security notice (password changed / new admin grant), PA transfer initiated / accepted / cancelled / completed, inactivity notice. - Caps: per-recipient 10 emails per hour and 30 per day; global daily cap (env, default 500) with an alert at 80%. Log every send with template and recipient hash to the log stream. **Acceptance criteria** +- [ ] SGAuth sends from its own Resend account, owned by an SGA shared mailbox; its usage does not appear in the Chambers/Aplio account. - [ ] DKIM/SPF/DMARC verified in the Resend dashboard; a test email to Gmail and Outlook lands in the inbox with aligned DMARC. - [ ] Exceeding the per-recipient cap is refused with a logged warning, not an exception to the user. - [ ] All templates render in both HTML and text and are snapshot-tested. @@ -539,16 +542,22 @@ Supabase Auth (GoTrue) stores bcrypt hashes (`$2a$`/`$2b$`). Configure Better Au - [ ] Wrong password against a bcrypt hash fails without re-hashing. - [ ] A metric/query reports how many `bcrypt$` hashes remain. -### AUTH-T22 — Invite flow: admin-created users receive a set-password link +### AUTH-T22 — Password-less accounts: lazy set-password on first sign-in, plus admin invites **Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:auth-core, phase:2, backend, email, admin **Depends on:** AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) -When an admin creates a user (or the bulk import runs), create the User with `emailVerified = true` (admin vouches for the address), no password, and send an invite email with a set-password token valid 7 days (consumed on form submit, AUTH-T103). Setting the password marks the account ready and logs the user in. Admins can resend an invite (rate-limited 3/day per user). Users who never accept are listed in the admin UI as 'Invited'. If an invited (password-less) user tries to self-sign-up with the same email, the login page copy points them to 'Forgot password / set password' rather than creating a second account. +Imported accounts (Aplio OTP users, SenatePath, SenatePortal) and admin-created accounts exist with `emailVerified = true` (the source product or admin vouches for the address) and **no password**. Decision: no invite blast at import, so email volume spreads out and users who never return cost nothing. +- **Lazy set-password:** the login page asks for email first. If the account exists and has no password, SGAuth sends a one-time set-password link (valid 1 hour, consumed on form submit per AUTH-T103, rate-limited 3/hour per account) and shows the same 'Check your email' message it would show for an unknown address, so the flow reveals nothing about account existence. Setting the password logs the user in; the account keeps its UUID, so all product data stays attached. +- **Admin invite (optional):** admins can still push a set-password email to a specific user (for example a newly appointed officer who needs access before an event). Rate-limited 3/day per user; token valid 7 days. +- Self-sign-up with the email of a password-less account does not create a second account; it triggers the same set-password email. +- Admin UI shows these accounts as 'Password not set'. **Acceptance criteria** -- [ ] An invited user cannot log in with any password until they set one via the link. -- [ ] Expired invite shows a message and the admin sees a 'Resend invite' action. +- [ ] A password-less account cannot log in with any password until one is set via the emailed link. +- [ ] Entering a password-less account's email on the login page sends exactly one set-password email and shows the same message as for an unknown email (enumeration test). +- [ ] Importing 500 password-less users sends zero emails. +- [ ] An admin can send an invite to one user; an expired link shows a request-new-link option. ### AUTH-T23 — Change password (current password + re-auth), revoke other sessions @@ -616,6 +625,26 @@ Northeastern mail is Microsoft 365, and Defender Safe Links pre-fetches every li - [ ] A HEAD or GET request to any emailed link does not consume the token (integration test); the subsequent POST does, exactly once. - [ ] Every email template's link points at a page implementing the pattern (test enumerates templates). +### AUTH-T105 — Passkeys as an optional sign-in method + +**Priority:** Medium · **Estimate:** 5 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:auth-core, phase:4, backend, frontend, security, better-auth +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T54 (Account security page: change password, MFA enrollment, backup codes); AUTH-T67 (TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)) + +Suggested by Benedikt and accepted: add the Better Auth `passkey` plugin (WebAuthn) as an optional, per-user sign-in method. Passkeys cost no email, resist phishing, and suit students who prefer not to manage passwords. +- **Enrollment** from `/account/security` after fresh re-auth (AUTH-T32); users may register several passkeys and name/remove them. Relying-party ID is `auth.northeasternsga.com` (dev: `auth-dev.northeasternsga.com`), so passkeys are only usable on SGAuth's own login page; products never see WebAuthn. +- **Sign-in:** the login page offers 'Sign in with a passkey' alongside email + password, including conditional UI (browser autofill) where supported. A successful passkey sign-in creates the same parent-domain session as a password sign-in. +- **Password stays** as the account's fallback and for recovery; passkeys do not replace the password in v1. +- **MFA:** a passkey does **not** satisfy the admin TOTP requirement in v1 (admins still enroll TOTP); revisit later. +- Lockout counters (AUTH-T64) are unaffected by passkey attempts; failed WebAuthn ceremonies are rate-limited (AUTH-T63). +- Schema: plugin's `Passkey` table via Prisma; audit PASSKEY_ADDED / PASSKEY_REMOVED / LOGIN_SUCCESS with method=passkey. +- Tombstoning (AUTH-T78) and admin deactivation remove passkeys. + +**Acceptance criteria** +- [ ] A user can enroll a passkey after re-auth, sign out, and sign back in with the passkey in Chrome, Safari, and Firefox (platform authenticator and a security key). +- [ ] A passkey sign-in yields a session that products resolve exactly like a password sign-in (session endpoint contract test). +- [ ] Removing a passkey or deactivating the user prevents its further use; audit events are emitted. +- [ ] An admin who signs in with a passkey is still required to have TOTP enrolled for admin actions. + ## E4 Sessions & SSO ### AUTH-T26 — Parent-domain session cookie for *.northeasternsga.com @@ -786,7 +815,7 @@ Return structured denials (`{ allowed: false, reason: 'SELF_MODIFICATION' | 'PRI ### AUTH-T36 — Admin user-management endpoints **Priority:** High · **Estimate:** 5 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:admin, phase:2, backend, admin, api -**Depends on:** AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T13 (Append-only AuditEvent table); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T22 (Invite flow: admin-created users receive a set-password link) +**Depends on:** AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T13 (Append-only AuditEvent table); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T22 (Password-less accounts: lazy set-password on first sign-in, plus admin invites) Server actions or route handlers under `/api/sgauth/admin/users`: list/search (by email, name, position, status, admin flag; paginated), get, create (invite), update name/preferredName, deactivate (revokes all sessions immediately), reactivate, delete (= tombstone: status DELETED, PII scrubbed, sessions/accounts/MFA/positions removed, id retained), grant admin, revoke admin, revoke all sessions, unlock account, reset MFA (per the authz rules; emails the user), resend invite, force re-login. Every call passes `can()`, requires fresh re-auth for grant/revoke admin, deactivate, delete, and MFA reset, and emits an audit event with actor, target, and diff. Deactivation/deletion of the PA and self-modification are refused with the authz reason. @@ -858,7 +887,7 @@ Integration tests proving the PA cannot be deleted, deactivated, banned, strippe **Priority:** Medium · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:admin, phase:2, backend, admin, migration **Depends on:** AUTH-T36 (Admin user-management endpoints); AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)) -Admin endpoint + UI to upload a CSV (`email,name,positions` where positions is a `|`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as invited (AUTH-T22), assigns positions, and queues invite emails respecting mailer caps (AUTH-T20) in batches. Produces a downloadable report. Audit BULK_IMPORT with counts. +Admin endpoint + UI to upload a CSV (`email,name,positions` where positions is a `|`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as password-less accounts (AUTH-T22), assigns positions, and sends no email by default; an optional 'send set-password emails now' checkbox queues invites in batches under the mailer caps (AUTH-T20). Produces a downloadable report. Audit BULK_IMPORT with counts. **Acceptance criteria** - [ ] A 200-row CSV with 5 invalid rows shows the 5 errors and imports nothing until fixed (all-or-nothing) or with an explicit 'skip invalid' toggle. @@ -1377,22 +1406,22 @@ Document conventions for coding agents and contributors: Neon-only (no Supabase) - [ ] Sample imported user logs in with their Chambers password on dev (AUTH-T21) and holds the mapped positions. - [ ] Mapping file approved by the Primary Admin before the production run. -### AUTH-T88 — Aplio user import (emails and names, no passwords) with invites and id mapping +### AUTH-T88 — Aplio user import (emails and names, no passwords) with id mapping **Priority:** High · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:rollout, phase:4, migration, aplio -**Depends on:** AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping); AUTH-T22 (Invite flow: admin-created users receive a set-password link) +**Depends on:** AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping); AUTH-T22 (Password-less accounts: lazy set-password on first sign-in, plus admin invites) -Aplio users authenticated with email OTP and have no passwords. Extend the import script with `--source aplio.json` (id, email, name, isAdmin, deletedAt): upsert by email, mark as invited (set-password link) unless they already exist with a password, do not grant SGAuth admin from Aplio's `isAdmin` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with `legacyEmail = true`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Invites are sent in batches under the mailer caps; stagger over days if needed. +Aplio users authenticated with email OTP and have no passwords. Extend the import script with `--source aplio.json` (id, email, name, isAdmin, deletedAt): upsert by email (duplicates of Chambers accounts merge into the existing account and keep its password), create the rest as password-less accounts that set a password lazily on first sign-in (AUTH-T22; **no emails are sent at import**), do not grant SGAuth admin from Aplio's `isAdmin` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with `legacyEmail = true`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Eli plans to look up and correct these few addresses by hand; the import report lists them. **Acceptance criteria** -- [ ] Report shows created/merged counts; a sample invited user sets a password and logs in; id-mapping file delivered to the Aplio team. +- [ ] Report shows created/merged counts and lists every non-northeastern.edu account; the import sends no email; a sample imported user sets a password on first sign-in and sees their Aplio data; id-mapping file delivered to the Aplio team. -### AUTH-T89 — SenatePath and Attendance Manager user import +### AUTH-T89 — SenatePath and SenatePortal user import **Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:rollout, phase:4, migration -**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with invites and id mapping) +**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with id mapping) -Same script with `--source senatepath.json` (admin users only) and `--source attendance.json` (email, first/last, role) — NUID is NOT imported. Positions mapping files approved per product. Invites batched. +Same script with `--source senatepath.json` (admin users only) and `--source senateportal.json` (email, first/last, role; SenatePortal is the new name for Attendance Manager) — NUID is NOT imported. Positions mapping files approved per product. Accounts are created password-less and set passwords lazily on first sign-in (AUTH-T22); no emails at import. **Acceptance criteria** - [ ] Both imports run on dev with reports; id-mapping files delivered to each team. @@ -1533,7 +1562,7 @@ VaultZ's `User` table records purchasers (first/last), not logins. Add `sgauthUs **Priority:** High · **Estimate:** 3 · **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ · **Labels:** epic:sgauth-integration, phase:2, sgauth, permissions **Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy); AUTH-T44 (Seed the curated SGA position list) -Create `lib/permissions.ts` mapping SGAuth position keys to VaultZ capabilities (e.g. `vp-finance`, `treasurer` → manage designations/budgets/transfers; `finance-committee` → create purchases; everyone else → read-only or no access). Gate server actions and pages with `hasAnyPosition`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list. +Create `lib/permissions.ts` mapping SGAuth position keys to VaultZ capabilities, using keys from the curated seed (`prisma/seed/positions.json` in SGAOperations/auth). Starting proposal for the VaultZ owner to confirm: `vice-president-of-operational-affairs`, `comptroller`, `finance-manager` → manage designations/budgets/transfers; `student-body-president`, `executive-vice-president` → read everything; any other position holder → create their own purchases; no positions → no access. Add a product role (e.g. `vaultz-admin`) in the SGAuth admin UI only if the curated offices do not cover a need. Gate server actions and pages with `hasAnyPosition`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list. **Acceptance criteria** - [ ] Every mutating server action checks a capability; a user without positions cannot create or edit anything (tests). @@ -1621,7 +1650,7 @@ Using the id-mapping file from the SGAuth import, migrate `users.id` (or add `sg **Priority:** High · **Estimate:** 5 · **Phase:** Phase 3 — Hardening, observability, Chambers · **Labels:** epic:sgauth-integration, phase:3, sgauth, permissions **Depends on:** CHAMBERS-C02 (Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal)); AUTH-T44 (Seed the curated SGA position list) -Define `lib/permissions.ts`: Chambers admin capabilities derive from position keys (e.g. `chambers-admin`, `iems`, and the relevant exec positions from the curated list); board/body memberships stay in Chambers tables keyed by SGAuth user id and continue to drive booking scopes. Replace `hasLiveAdmin`/`is_admin()` checks in app code with position checks; since Chambers is on Neon, RLS helpers are replaced by app-level checks in the data layer. +Define `lib/permissions.ts`: Chambers admin capabilities derive from position keys: curated offices (proposal: `vice-president-of-operational-affairs`, `speaker-of-the-senate`, `senate-operations-coordinator`) plus product roles created in the SGAuth admin UI for roles that are not SGA offices (`chambers-admin`, `chambers-iems`); board/body memberships stay in Chambers tables keyed by SGAuth user id and continue to drive booking scopes. Replace `hasLiveAdmin`/`is_admin()` checks in app code with position checks; since Chambers is on Neon, RLS helpers are replaced by app-level checks in the data layer. **Acceptance criteria** - [ ] Every admin-only route and action checks positions; a user whose position is removed in SGAuth loses admin ability within 60 s (SDK cache) without any Chambers-side change. @@ -1686,7 +1715,7 @@ Replace tests that mocked Supabase JWT claims with tests that mock the SDK sessi ### APLIO-P01 — Export Aplio users for the SGAuth import and receive the id mapping **Priority:** High · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, migration -**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with invites and id mapping) +**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with id mapping) Export `User` rows (id, email, name, isAdmin, deletedAt) to JSON for the SGAuth import; receive the SGAuth id-mapping file; verify every active Aplio user maps to an SGAuth user. @@ -1750,7 +1779,7 @@ Keep the dev-bypass user cookie for local only (never on any deployed host), and **Priority:** High · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, rollout **Depends on:** APLIO-P03 (Key Aplio users by SGAuth user id); APLIO-P04 (Derive Aplio admin from an SGAuth position; managers stay product-level); APLIO-P05 (Applicant flow on SGAuth accounts (northeastern.edu required)); AUTH-T91 (Production launch checklist and Primary Admin bootstrap) -Schedule the cutover when no application cycle is open; users were pre-imported and invited; announce; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window. +Schedule the cutover when no application cycle is open; users were pre-imported as password-less accounts (no invite blast); announce the change, explaining that the first sign-in asks for a new password by email; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window. **Acceptance criteria** - [ ] Cutover completed; no applicant lost draft access (verified with a sample of drafts). @@ -1786,7 +1815,7 @@ Create a Neon project/branches, dump and restore the Postgres schema and data (p **Priority:** High · **Estimate:** 3 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, backend **Depends on:** SENATEPATH-S01 (Migrate SenatePath's database from Supabase to Neon); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) -Install the SDK; protect `/admin/**` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on a position (e.g. `elections-chair` / `senate-admin`). Public application/nomination forms remain unauthenticated as today. +Install the SDK; protect `/admin/**` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on curated positions (proposal for the SenatePath owner to confirm: `director-of-elections`, `vice-chair-of-elections`, `elections-board-member`, `speaker-of-the-senate`). Public application/nomination forms remain unauthenticated as today. **Acceptance criteria** - [ ] Admin pages require an SGAuth session with the configured position; public forms unaffected. @@ -1794,7 +1823,7 @@ Install the SDK; protect `/admin/**` with the proxy; replace the Supabase login ### SENATEPATH-S03 — Import SenatePath admins into SGAuth and cut over **Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, rollout -**Depends on:** SENATEPATH-S02 (Gate the SenatePath admin area with SGAuth positions); AUTH-T89 (SenatePath and Attendance Manager user import) +**Depends on:** SENATEPATH-S02 (Gate the SenatePath admin area with SGAuth positions); AUTH-T89 (SenatePath and SenatePortal user import) Export admin emails for the SGAuth import; confirm they hold the gating position; run the per-product cutover checklist; delete Supabase project after a 14-day retention window. @@ -1861,7 +1890,7 @@ Define the permissions map from position keys to Attendance roles (MEMBER/OFFICE ### ATTENDANCE-M05 — User import and cutover **Priority:** Medium · **Estimate:** 2 · **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention · **Labels:** epic:sgauth-integration, phase:4, sgauth, rollout -**Depends on:** ATTENDANCE-M04 (Map roles to SGAuth positions; keep NUID product-side); AUTH-T89 (SenatePath and Attendance Manager user import) +**Depends on:** ATTENDANCE-M04 (Map roles to SGAuth positions; keep NUID product-side); AUTH-T89 (SenatePath and SenatePortal user import) Export users (email, names, role) for the SGAuth import, receive the id mapping, backfill `sgauthUserId`, run the per-product cutover checklist. diff --git a/docs/sgauth-design/out/linear-import-APLIO.csv b/docs/sgauth-design/out/linear-import-APLIO.csv index 9050c57..43b8d4c 100644 --- a/docs/sgauth-design/out/linear-import-APLIO.csv +++ b/docs/sgauth-design/out/linear-import-APLIO.csv @@ -2,7 +2,7 @@ "Export Aplio users for the SGAuth import and receive the id mapping","**Ticket:** APLIO-P01 **Epic:** SGAuth integration — Aplio **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention -**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with invites and id mapping) +**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with id mapping) Export `User` rows (id, email, name, isAdmin, deletedAt) to JSON for the SGAuth import; receive the SGAuth id-mapping file; verify every active Aplio user maps to an SGAuth user. @@ -60,7 +60,7 @@ Keep the dev-bypass user cookie for local only (never on any deployed host), and **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention **Depends on:** APLIO-P03 (Key Aplio users by SGAuth user id); APLIO-P04 (Derive Aplio admin from an SGAuth position; managers stay product-level); APLIO-P05 (Applicant flow on SGAuth accounts (northeastern.edu required)); AUTH-T91 (Production launch checklist and Primary Admin bootstrap) -Schedule the cutover when no application cycle is open; users were pre-imported and invited; announce; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window. +Schedule the cutover when no application cycle is open; users were pre-imported as password-less accounts (no invite blast); announce the change, explaining that the first sign-in asks for a new password by email; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window. **Acceptance criteria** - [ ] Cutover completed; no applicant lost draft access (verified with a sample of drafts).","High","2","Backlog","epic:sgauth-integration, phase:4, sgauth, rollout" diff --git a/docs/sgauth-design/out/linear-import-ATTENDANCE.csv b/docs/sgauth-design/out/linear-import-ATTENDANCE.csv index 0e49589..a33719c 100644 --- a/docs/sgauth-design/out/linear-import-ATTENDANCE.csv +++ b/docs/sgauth-design/out/linear-import-ATTENDANCE.csv @@ -39,7 +39,7 @@ Define the permissions map from position keys to Attendance roles (MEMBER/OFFICE "User import and cutover","**Ticket:** ATTENDANCE-M05 **Epic:** SGAuth integration — Attendance Manager **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention -**Depends on:** ATTENDANCE-M04 (Map roles to SGAuth positions; keep NUID product-side); AUTH-T89 (SenatePath and Attendance Manager user import) +**Depends on:** ATTENDANCE-M04 (Map roles to SGAuth positions; keep NUID product-side); AUTH-T89 (SenatePath and SenatePortal user import) Export users (email, names, role) for the SGAuth import, receive the id mapping, backfill `sgauthUserId`, run the per-product cutover checklist. diff --git a/docs/sgauth-design/out/linear-import-AUTH.csv b/docs/sgauth-design/out/linear-import-AUTH.csv index 665f342..51a053b 100644 --- a/docs/sgauth-design/out/linear-import-AUTH.csv +++ b/docs/sgauth-design/out/linear-import-AUTH.csv @@ -284,13 +284,15 @@ Forgot-password request (email only; uniform response regardless of existence), **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) **Depends on:** AUTH-T07 (Validate environment variables at startup with zod) -Password login keeps email volume low (verification, reset, invite, lock/unlock, security notices, PA transfer steps). To avoid sharing Aplio's and Chambers' Resend quota: -- Separate Resend API key and a dedicated sending domain `mail.northeasternsga.com` with SPF, DKIM, and DMARC (p=quarantine) records; `from` = `SGA Auth `. +Password login keeps email volume low (verification, reset, set-password, lock/unlock, security notices, PA transfer steps). Chambers sends almost all of SGA's current email, and Resend's free quota is **per account**, so a separate API key inside the shared account would not isolate anything. Therefore: +- **Separate Resend account** owned by an SGA shared mailbox (not a student's personal login), credentials in the team vault. Confirm once that Resend's terms permit a distinct account for a distinct sender and record the answer here. +- Dedicated sending subdomain `mail.northeasternsga.com` verified only in that account, with SPF, DKIM, and DMARC (p=quarantine) records; `from` = `SGA Auth `. - `src/lib/email/mailer.ts` provider-agnostic interface (`sendEmail({ to, template, data })`) with a Resend implementation and a console/preview transport for local/test. - Templates (React Email or plain HTML+text): verify, reset, invite/set-password, account locked, unlock, security notice (password changed / new admin grant), PA transfer initiated / accepted / cancelled / completed, inactivity notice. - Caps: per-recipient 10 emails per hour and 30 per day; global daily cap (env, default 500) with an alert at 80%. Log every send with template and recipient hash to the log stream. **Acceptance criteria** +- [ ] SGAuth sends from its own Resend account, owned by an SGA shared mailbox; its usage does not appear in the Chambers/Aplio account. - [ ] DKIM/SPF/DMARC verified in the Resend dashboard; a test email to Gmail and Outlook lands in the inbox with aligned DMARC. - [ ] Exceeding the per-recipient cap is refused with a logged warning, not an exception to the user. - [ ] All templates render in both HTML and text and are snapshot-tested.","High","3","Backlog","epic:auth-core, phase:1, backend, email, infra" @@ -305,16 +307,22 @@ Supabase Auth (GoTrue) stores bcrypt hashes (`$2a$`/`$2b$`). Configure Better Au - [ ] A user imported with a bcrypt hash can log in with their Chambers password on the first try and their Account row is scrypt afterwards. - [ ] Wrong password against a bcrypt hash fails without re-hashing. - [ ] A metric/query reports how many `bcrypt$` hashes remain.","High","2","Backlog","epic:auth-core, phase:3, backend, migration, security" -"Invite flow: admin-created users receive a set-password link","**Ticket:** AUTH-T22 +"Password-less accounts: lazy set-password on first sign-in, plus admin invites","**Ticket:** AUTH-T22 **Epic:** E3 Authentication Core **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ **Depends on:** AUTH-T20 (Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps); AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T103 (Scanner-safe email links: land on a page, consume the token on POST) -When an admin creates a user (or the bulk import runs), create the User with `emailVerified = true` (admin vouches for the address), no password, and send an invite email with a set-password token valid 7 days (consumed on form submit, AUTH-T103). Setting the password marks the account ready and logs the user in. Admins can resend an invite (rate-limited 3/day per user). Users who never accept are listed in the admin UI as 'Invited'. If an invited (password-less) user tries to self-sign-up with the same email, the login page copy points them to 'Forgot password / set password' rather than creating a second account. +Imported accounts (Aplio OTP users, SenatePath, SenatePortal) and admin-created accounts exist with `emailVerified = true` (the source product or admin vouches for the address) and **no password**. Decision: no invite blast at import, so email volume spreads out and users who never return cost nothing. +- **Lazy set-password:** the login page asks for email first. If the account exists and has no password, SGAuth sends a one-time set-password link (valid 1 hour, consumed on form submit per AUTH-T103, rate-limited 3/hour per account) and shows the same 'Check your email' message it would show for an unknown address, so the flow reveals nothing about account existence. Setting the password logs the user in; the account keeps its UUID, so all product data stays attached. +- **Admin invite (optional):** admins can still push a set-password email to a specific user (for example a newly appointed officer who needs access before an event). Rate-limited 3/day per user; token valid 7 days. +- Self-sign-up with the email of a password-less account does not create a second account; it triggers the same set-password email. +- Admin UI shows these accounts as 'Password not set'. **Acceptance criteria** -- [ ] An invited user cannot log in with any password until they set one via the link. -- [ ] Expired invite shows a message and the admin sees a 'Resend invite' action.","Medium","2","Backlog","epic:auth-core, phase:2, backend, email, admin" +- [ ] A password-less account cannot log in with any password until one is set via the emailed link. +- [ ] Entering a password-less account's email on the login page sends exactly one set-password email and shows the same message as for an unknown email (enumeration test). +- [ ] Importing 500 password-less users sends zero emails. +- [ ] An admin can send an invite to one user; an expired link shows a request-new-link option.","Medium","2","Backlog","epic:auth-core, phase:2, backend, email, admin" "Change password (current password + re-auth), revoke other sessions","**Ticket:** AUTH-T23 **Epic:** E3 Authentication Core **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ @@ -492,7 +500,7 @@ Return structured denials (`{ allowed: false, reason: 'SELF_MODIFICATION' | 'PRI "Admin user-management endpoints","**Ticket:** AUTH-T36 **Epic:** E5 Admin & Primary Admin **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ -**Depends on:** AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T13 (Append-only AuditEvent table); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T22 (Invite flow: admin-created users receive a set-password link) +**Depends on:** AUTH-T35 (Authorization module with the admin/Primary Admin rule matrix); AUTH-T13 (Append-only AuditEvent table); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T22 (Password-less accounts: lazy set-password on first sign-in, plus admin invites) Server actions or route handlers under `/api/sgauth/admin/users`: list/search (by email, name, position, status, admin flag; paginated), get, create (invite), update name/preferredName, deactivate (revokes all sessions immediately), reactivate, delete (= tombstone: status DELETED, PII scrubbed, sessions/accounts/MFA/positions removed, id retained), grant admin, revoke admin, revoke all sessions, unlock account, reset MFA (per the authz rules; emails the user), resend invite, force re-login. Every call passes `can()`, requires fresh re-auth for grant/revoke admin, deactivate, delete, and MFA reset, and emits an audit event with actor, target, and diff. Deactivation/deletion of the PA and self-modification are refused with the authz reason. @@ -559,7 +567,7 @@ Integration tests proving the PA cannot be deleted, deactivated, banned, strippe **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ **Depends on:** AUTH-T36 (Admin user-management endpoints); AUTH-T43 (Position assignment endpoints (assign, unassign, bulk)) -Admin endpoint + UI to upload a CSV (`email,name,positions` where positions is a `|`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as invited (AUTH-T22), assigns positions, and queues invite emails respecting mailer caps (AUTH-T20) in batches. Produces a downloadable report. Audit BULK_IMPORT with counts. +Admin endpoint + UI to upload a CSV (`email,name,positions` where positions is a `|`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as password-less accounts (AUTH-T22), assigns positions, and sends no email by default; an optional 'send set-password emails now' checkbox queues invites in batches under the mailer caps (AUTH-T20). Produces a downloadable report. Audit BULK_IMPORT with counts. **Acceptance criteria** - [ ] A 200-row CSV with 5 invalid rows shows the 5 errors and imports nothing until fixed (all-or-nothing) or with an explicit 'skip invalid' toggle. @@ -1017,21 +1025,21 @@ Document conventions for coding agents and contributors: Neon-only (no Supabase) - [ ] Dry run against dev reports counts; real run is idempotent (second run: 0 created). - [ ] Sample imported user logs in with their Chambers password on dev (AUTH-T21) and holds the mapped positions. - [ ] Mapping file approved by the Primary Admin before the production run.","High","3","Backlog","epic:rollout, phase:3, migration, backend, chambers" -"Aplio user import (emails and names, no passwords) with invites and id mapping","**Ticket:** AUTH-T88 +"Aplio user import (emails and names, no passwords) with id mapping","**Ticket:** AUTH-T88 **Epic:** E12 User Migration & Rollout **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention -**Depends on:** AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping); AUTH-T22 (Invite flow: admin-created users receive a set-password link) +**Depends on:** AUTH-T87 (Import script: Chambers users with bcrypt hashes and position mapping); AUTH-T22 (Password-less accounts: lazy set-password on first sign-in, plus admin invites) -Aplio users authenticated with email OTP and have no passwords. Extend the import script with `--source aplio.json` (id, email, name, isAdmin, deletedAt): upsert by email, mark as invited (set-password link) unless they already exist with a password, do not grant SGAuth admin from Aplio's `isAdmin` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with `legacyEmail = true`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Invites are sent in batches under the mailer caps; stagger over days if needed. +Aplio users authenticated with email OTP and have no passwords. Extend the import script with `--source aplio.json` (id, email, name, isAdmin, deletedAt): upsert by email (duplicates of Chambers accounts merge into the existing account and keep its password), create the rest as password-less accounts that set a password lazily on first sign-in (AUTH-T22; **no emails are sent at import**), do not grant SGAuth admin from Aplio's `isAdmin` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with `legacyEmail = true`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Eli plans to look up and correct these few addresses by hand; the import report lists them. **Acceptance criteria** -- [ ] Report shows created/merged counts; a sample invited user sets a password and logs in; id-mapping file delivered to the Aplio team.","High","2","Backlog","epic:rollout, phase:4, migration, aplio" -"SenatePath and Attendance Manager user import","**Ticket:** AUTH-T89 +- [ ] Report shows created/merged counts and lists every non-northeastern.edu account; the import sends no email; a sample imported user sets a password on first sign-in and sees their Aplio data; id-mapping file delivered to the Aplio team.","High","2","Backlog","epic:rollout, phase:4, migration, aplio" +"SenatePath and SenatePortal user import","**Ticket:** AUTH-T89 **Epic:** E12 User Migration & Rollout **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention -**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with invites and id mapping) +**Depends on:** AUTH-T88 (Aplio user import (emails and names, no passwords) with id mapping) -Same script with `--source senatepath.json` (admin users only) and `--source attendance.json` (email, first/last, role) — NUID is NOT imported. Positions mapping files approved per product. Invites batched. +Same script with `--source senatepath.json` (admin users only) and `--source senateportal.json` (email, first/last, role; SenatePortal is the new name for Attendance Manager) — NUID is NOT imported. Positions mapping files approved per product. Accounts are created password-less and set passwords lazily on first sign-in (AUTH-T22); no emails at import. **Acceptance criteria** - [ ] Both imports run on dev with reports; id-mapping files delivered to each team.","Medium","2","Backlog","epic:rollout, phase:4, migration" @@ -1157,6 +1165,25 @@ Northeastern mail is Microsoft 365, and Defender Safe Links pre-fetches every li **Acceptance criteria** - [ ] A HEAD or GET request to any emailed link does not consume the token (integration test); the subsequent POST does, exactly once. - [ ] Every email template's link points at a page implementing the pattern (test enumerates templates).","Urgent","2","Backlog","epic:auth-core, phase:1, backend, email, security" +"Passkeys as an optional sign-in method","**Ticket:** AUTH-T105 +**Epic:** E3 Authentication Core +**Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention +**Depends on:** AUTH-T17 (Email + password sign-in and self-sign-up restricted to northeastern.edu); AUTH-T32 (Re-authentication (sudo mode) for sensitive actions); AUTH-T54 (Account security page: change password, MFA enrollment, backup codes); AUTH-T67 (TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)) + +Suggested by Benedikt and accepted: add the Better Auth `passkey` plugin (WebAuthn) as an optional, per-user sign-in method. Passkeys cost no email, resist phishing, and suit students who prefer not to manage passwords. +- **Enrollment** from `/account/security` after fresh re-auth (AUTH-T32); users may register several passkeys and name/remove them. Relying-party ID is `auth.northeasternsga.com` (dev: `auth-dev.northeasternsga.com`), so passkeys are only usable on SGAuth's own login page; products never see WebAuthn. +- **Sign-in:** the login page offers 'Sign in with a passkey' alongside email + password, including conditional UI (browser autofill) where supported. A successful passkey sign-in creates the same parent-domain session as a password sign-in. +- **Password stays** as the account's fallback and for recovery; passkeys do not replace the password in v1. +- **MFA:** a passkey does **not** satisfy the admin TOTP requirement in v1 (admins still enroll TOTP); revisit later. +- Lockout counters (AUTH-T64) are unaffected by passkey attempts; failed WebAuthn ceremonies are rate-limited (AUTH-T63). +- Schema: plugin's `Passkey` table via Prisma; audit PASSKEY_ADDED / PASSKEY_REMOVED / LOGIN_SUCCESS with method=passkey. +- Tombstoning (AUTH-T78) and admin deactivation remove passkeys. + +**Acceptance criteria** +- [ ] A user can enroll a passkey after re-auth, sign out, and sign back in with the passkey in Chrome, Safari, and Firefox (platform authenticator and a security key). +- [ ] A passkey sign-in yields a session that products resolve exactly like a password sign-in (session endpoint contract test). +- [ ] Removing a passkey or deactivating the user prevents its further use; audit events are emitted. +- [ ] An admin who signs in with a passkey is still required to have TOTP enrolled for admin actions.","Medium","5","Backlog","epic:auth-core, phase:4, backend, frontend, security, better-auth" "Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them","**Ticket:** AUTH-T104 **Epic:** E4 Sessions & SSO **Phase:** Phase 1 — Core auth, sessions, SSO (MVP) diff --git a/docs/sgauth-design/out/linear-import-SENATEPATH.csv b/docs/sgauth-design/out/linear-import-SENATEPATH.csv index ab7fb4a..1a600ca 100644 --- a/docs/sgauth-design/out/linear-import-SENATEPATH.csv +++ b/docs/sgauth-design/out/linear-import-SENATEPATH.csv @@ -13,14 +13,14 @@ Create a Neon project/branches, dump and restore the Postgres schema and data (p **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention **Depends on:** SENATEPATH-S01 (Migrate SenatePath's database from Supabase to Neon); AUTH-T59 (SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)); AUTH-T80 (Integration guide for Neon-based products (Next.js)) -Install the SDK; protect `/admin/**` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on a position (e.g. `elections-chair` / `senate-admin`). Public application/nomination forms remain unauthenticated as today. +Install the SDK; protect `/admin/**` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on curated positions (proposal for the SenatePath owner to confirm: `director-of-elections`, `vice-chair-of-elections`, `elections-board-member`, `speaker-of-the-senate`). Public application/nomination forms remain unauthenticated as today. **Acceptance criteria** - [ ] Admin pages require an SGAuth session with the configured position; public forms unaffected.","High","3","Backlog","epic:sgauth-integration, phase:4, sgauth, backend" "Import SenatePath admins into SGAuth and cut over","**Ticket:** SENATEPATH-S03 **Epic:** SGAuth integration — SenatePath **Phase:** Phase 4 — Aplio, SenatePath, Attendance Manager, retention -**Depends on:** SENATEPATH-S02 (Gate the SenatePath admin area with SGAuth positions); AUTH-T89 (SenatePath and Attendance Manager user import) +**Depends on:** SENATEPATH-S02 (Gate the SenatePath admin area with SGAuth positions); AUTH-T89 (SenatePath and SenatePortal user import) Export admin emails for the SGAuth import; confirm they hold the gating position; run the per-product cutover checklist; delete Supabase project after a 14-day retention window. diff --git a/docs/sgauth-design/out/linear-import-VAULTZ.csv b/docs/sgauth-design/out/linear-import-VAULTZ.csv index 82ca924..0682d23 100644 --- a/docs/sgauth-design/out/linear-import-VAULTZ.csv +++ b/docs/sgauth-design/out/linear-import-VAULTZ.csv @@ -33,7 +33,7 @@ VaultZ's `User` table records purchasers (first/last), not logins. Add `sgauthUs **Phase:** Phase 2 — Admin, Primary Admin, positions, UI, SDK, VaultZ **Depends on:** VAULTZ-V01 (Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy); AUTH-T44 (Seed the curated SGA position list) -Create `lib/permissions.ts` mapping SGAuth position keys to VaultZ capabilities (e.g. `vp-finance`, `treasurer` → manage designations/budgets/transfers; `finance-committee` → create purchases; everyone else → read-only or no access). Gate server actions and pages with `hasAnyPosition`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list. +Create `lib/permissions.ts` mapping SGAuth position keys to VaultZ capabilities, using keys from the curated seed (`prisma/seed/positions.json` in SGAOperations/auth). Starting proposal for the VaultZ owner to confirm: `vice-president-of-operational-affairs`, `comptroller`, `finance-manager` → manage designations/budgets/transfers; `student-body-president`, `executive-vice-president` → read everything; any other position holder → create their own purchases; no positions → no access. Add a product role (e.g. `vaultz-admin`) in the SGAuth admin UI only if the curated offices do not cover a need. Gate server actions and pages with `hasAnyPosition`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list. **Acceptance criteria** - [ ] Every mutating server action checks a capability; a user without positions cannot create or edit anything (tests). diff --git a/docs/sgauth-design/tickets-auth.mjs b/docs/sgauth-design/tickets-auth.mjs index f36fb57..8478c1d 100644 --- a/docs/sgauth-design/tickets-auth.mjs +++ b/docs/sgauth-design/tickets-auth.mjs @@ -288,12 +288,14 @@ No breached-password (HIBP) check, per decision.`, { id: "AUTH-T20", epic: "authcore", phase: 1, priority: "High", estimate: 3, labels: ["backend", "email", "infra"], deps: ["AUTH-T07"], title: "Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps", - description: `Password login keeps email volume low (verification, reset, invite, lock/unlock, security notices, PA transfer steps). To avoid sharing Aplio's and Chambers' Resend quota: -- Separate Resend API key and a dedicated sending domain \`mail.northeasternsga.com\` with SPF, DKIM, and DMARC (p=quarantine) records; \`from\` = \`SGA Auth \`. + description: `Password login keeps email volume low (verification, reset, set-password, lock/unlock, security notices, PA transfer steps). Chambers sends almost all of SGA's current email, and Resend's free quota is **per account**, so a separate API key inside the shared account would not isolate anything. Therefore: +- **Separate Resend account** owned by an SGA shared mailbox (not a student's personal login), credentials in the team vault. Confirm once that Resend's terms permit a distinct account for a distinct sender and record the answer here. +- Dedicated sending subdomain \`mail.northeasternsga.com\` verified only in that account, with SPF, DKIM, and DMARC (p=quarantine) records; \`from\` = \`SGA Auth \`. - \`src/lib/email/mailer.ts\` provider-agnostic interface (\`sendEmail({ to, template, data })\`) with a Resend implementation and a console/preview transport for local/test. - Templates (React Email or plain HTML+text): verify, reset, invite/set-password, account locked, unlock, security notice (password changed / new admin grant), PA transfer initiated / accepted / cancelled / completed, inactivity notice. - Caps: per-recipient 10 emails per hour and 30 per day; global daily cap (env, default 500) with an alert at 80%. Log every send with template and recipient hash to the log stream.`, acceptance: [ + "SGAuth sends from its own Resend account, owned by an SGA shared mailbox; its usage does not appear in the Chambers/Aplio account.", "DKIM/SPF/DMARC verified in the Resend dashboard; a test email to Gmail and Outlook lands in the inbox with aligned DMARC.", "Exceeding the per-recipient cap is refused with a logged warning, not an exception to the user.", "All templates render in both HTML and text and are snapshot-tested.", @@ -311,11 +313,17 @@ No breached-password (HIBP) check, per decision.`, }, { id: "AUTH-T22", epic: "authcore", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "email", "admin"], deps: ["AUTH-T20", "AUTH-T17", "AUTH-T103"], - title: "Invite flow: admin-created users receive a set-password link", - description: `When an admin creates a user (or the bulk import runs), create the User with \`emailVerified = true\` (admin vouches for the address), no password, and send an invite email with a set-password token valid 7 days (consumed on form submit, AUTH-T103). Setting the password marks the account ready and logs the user in. Admins can resend an invite (rate-limited 3/day per user). Users who never accept are listed in the admin UI as 'Invited'. If an invited (password-less) user tries to self-sign-up with the same email, the login page copy points them to 'Forgot password / set password' rather than creating a second account.`, + title: "Password-less accounts: lazy set-password on first sign-in, plus admin invites", + description: `Imported accounts (Aplio OTP users, SenatePath, SenatePortal) and admin-created accounts exist with \`emailVerified = true\` (the source product or admin vouches for the address) and **no password**. Decision: no invite blast at import, so email volume spreads out and users who never return cost nothing. +- **Lazy set-password:** the login page asks for email first. If the account exists and has no password, SGAuth sends a one-time set-password link (valid 1 hour, consumed on form submit per AUTH-T103, rate-limited 3/hour per account) and shows the same 'Check your email' message it would show for an unknown address, so the flow reveals nothing about account existence. Setting the password logs the user in; the account keeps its UUID, so all product data stays attached. +- **Admin invite (optional):** admins can still push a set-password email to a specific user (for example a newly appointed officer who needs access before an event). Rate-limited 3/day per user; token valid 7 days. +- Self-sign-up with the email of a password-less account does not create a second account; it triggers the same set-password email. +- Admin UI shows these accounts as 'Password not set'.`, acceptance: [ - "An invited user cannot log in with any password until they set one via the link.", - "Expired invite shows a message and the admin sees a 'Resend invite' action.", + "A password-less account cannot log in with any password until one is set via the emailed link.", + "Entering a password-less account's email on the login page sends exactly one set-password email and shows the same message as for an unknown email (enumeration test).", + "Importing 500 password-less users sends zero emails.", + "An admin can send an invite to one user; an expired link shows a request-new-link option.", ], }, { @@ -546,7 +554,7 @@ Each route is idempotent, logs outcomes, and returns quickly (under the function { id: "AUTH-T41", epic: "admin", phase: 2, priority: "Medium", estimate: 3, labels: ["backend", "admin", "migration"], deps: ["AUTH-T36", "AUTH-T43"], title: "Bulk user import (CSV) with position assignment and batched invites", - description: `Admin endpoint + UI to upload a CSV (\`email,name,positions\` where positions is a \`|\`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as invited (AUTH-T22), assigns positions, and queues invite emails respecting mailer caps (AUTH-T20) in batches. Produces a downloadable report. Audit BULK_IMPORT with counts.`, + description: `Admin endpoint + UI to upload a CSV (\`email,name,positions\` where positions is a \`|\`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as password-less accounts (AUTH-T22), assigns positions, and sends no email by default; an optional 'send set-password emails now' checkbox queues invites in batches under the mailer caps (AUTH-T20). Produces a downloadable report. Audit BULK_IMPORT with counts.`, acceptance: [ "A 200-row CSV with 5 invalid rows shows the 5 errors and imports nothing until fixed (all-or-nothing) or with an explicit 'skip invalid' toggle.", "Re-importing the same CSV is idempotent (no duplicate users or assignments).", @@ -926,14 +934,14 @@ Key naming convention (document in the admin UI help and the integration guides) }, { id: "AUTH-T88", epic: "rollout", phase: 4, priority: "High", estimate: 2, labels: ["migration", "aplio"], deps: ["AUTH-T87", "AUTH-T22"], - title: "Aplio user import (emails and names, no passwords) with invites and id mapping", - description: `Aplio users authenticated with email OTP and have no passwords. Extend the import script with \`--source aplio.json\` (id, email, name, isAdmin, deletedAt): upsert by email, mark as invited (set-password link) unless they already exist with a password, do not grant SGAuth admin from Aplio's \`isAdmin\` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with \`legacyEmail = true\`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Invites are sent in batches under the mailer caps; stagger over days if needed.`, - acceptance: ["Report shows created/merged counts; a sample invited user sets a password and logs in; id-mapping file delivered to the Aplio team."], + title: "Aplio user import (emails and names, no passwords) with id mapping", + description: `Aplio users authenticated with email OTP and have no passwords. Extend the import script with \`--source aplio.json\` (id, email, name, isAdmin, deletedAt): upsert by email (duplicates of Chambers accounts merge into the existing account and keep its password), create the rest as password-less accounts that set a password lazily on first sign-in (AUTH-T22; **no emails are sent at import**), do not grant SGAuth admin from Aplio's \`isAdmin\` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with \`legacyEmail = true\`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Eli plans to look up and correct these few addresses by hand; the import report lists them.`, + acceptance: ["Report shows created/merged counts and lists every non-northeastern.edu account; the import sends no email; a sample imported user sets a password on first sign-in and sees their Aplio data; id-mapping file delivered to the Aplio team."], }, { id: "AUTH-T89", epic: "rollout", phase: 4, priority: "Medium", estimate: 2, labels: ["migration"], deps: ["AUTH-T88"], - title: "SenatePath and Attendance Manager user import", - description: `Same script with \`--source senatepath.json\` (admin users only) and \`--source attendance.json\` (email, first/last, role) — NUID is NOT imported. Positions mapping files approved per product. Invites batched.`, + title: "SenatePath and SenatePortal user import", + description: `Same script with \`--source senatepath.json\` (admin users only) and \`--source senateportal.json\` (email, first/last, role; SenatePortal is the new name for Attendance Manager) — NUID is NOT imported. Positions mapping files approved per product. Accounts are created password-less and set passwords lazily on first sign-in (AUTH-T22); no emails at import.`, acceptance: ["Both imports run on dev with reports; id-mapping files delivered to each team."], }, { @@ -1028,6 +1036,24 @@ Mitigations: (1) a scheduled job (AUTH-T38, daily) reads consumption via the Neo "Every email template's link points at a page implementing the pattern (test enumerates templates).", ], }, + { + id: "AUTH-T105", epic: "authcore", phase: 4, priority: "Medium", estimate: 5, labels: ["backend", "frontend", "security", "better-auth"], deps: ["AUTH-T17", "AUTH-T32", "AUTH-T54", "AUTH-T67"], + title: "Passkeys as an optional sign-in method", + description: `Suggested by Benedikt and accepted: add the Better Auth \`passkey\` plugin (WebAuthn) as an optional, per-user sign-in method. Passkeys cost no email, resist phishing, and suit students who prefer not to manage passwords. +- **Enrollment** from \`/account/security\` after fresh re-auth (AUTH-T32); users may register several passkeys and name/remove them. Relying-party ID is \`auth.northeasternsga.com\` (dev: \`auth-dev.northeasternsga.com\`), so passkeys are only usable on SGAuth's own login page; products never see WebAuthn. +- **Sign-in:** the login page offers 'Sign in with a passkey' alongside email + password, including conditional UI (browser autofill) where supported. A successful passkey sign-in creates the same parent-domain session as a password sign-in. +- **Password stays** as the account's fallback and for recovery; passkeys do not replace the password in v1. +- **MFA:** a passkey does **not** satisfy the admin TOTP requirement in v1 (admins still enroll TOTP); revisit later. +- Lockout counters (AUTH-T64) are unaffected by passkey attempts; failed WebAuthn ceremonies are rate-limited (AUTH-T63). +- Schema: plugin's \`Passkey\` table via Prisma; audit PASSKEY_ADDED / PASSKEY_REMOVED / LOGIN_SUCCESS with method=passkey. +- Tombstoning (AUTH-T78) and admin deactivation remove passkeys.`, + acceptance: [ + "A user can enroll a passkey after re-auth, sign out, and sign back in with the passkey in Chrome, Safari, and Firefox (platform authenticator and a security key).", + "A passkey sign-in yields a session that products resolve exactly like a password sign-in (session endpoint contract test).", + "Removing a passkey or deactivating the user prevents its further use; audit events are emitted.", + "An admin who signs in with a passkey is still required to have TOTP enrolled for admin actions.", + ], + }, { id: "AUTH-T104", epic: "sessions", phase: 1, priority: "Urgent", estimate: 2, labels: ["spike", "sso", "better-auth"], deps: ["AUTH-T05", "AUTH-T26"], title: "Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them", diff --git a/docs/sgauth-design/tickets-products.mjs b/docs/sgauth-design/tickets-products.mjs index 558f902..5e48860 100644 --- a/docs/sgauth-design/tickets-products.mjs +++ b/docs/sgauth-design/tickets-products.mjs @@ -7,7 +7,7 @@ export const PRODUCT_TEAMS = { CHAMBERS: { name: "Chambers", epic: "SGAuth integration — Chambers", label: "epic:sgauth-integration", phase: 3, noLinear: true, description: "Chambers has no Linear team; these items are a Markdown checklist only (no CSV). Chambers is a Neon product (migration completes before integration). Replace Supabase Auth, live-role checks, and session revocation with SGAuth sessions and positions; users and password hashes are imported into SGAuth. The auth.users export is Eli's manual action item, not a ticket." }, APLIO: { name: "Aplio", epic: "SGAuth integration — Aplio", label: "epic:sgauth-integration", phase: 4, - description: "Hard cutover from Aplio's local Better Auth (email OTP) to SGAuth via the SDK; users pre-imported and invited to set passwords." }, + description: "Hard cutover from Aplio's local Better Auth (email OTP) to SGAuth via the SDK; users pre-imported as password-less accounts that set a password on first sign-in." }, SENATEPATH: { name: "SenatePath", epic: "SGAuth integration — SenatePath", label: "epic:sgauth-integration", phase: 4, description: "Migrate SenatePath's database from Supabase to Neon and gate the admin area with SGAuth positions." }, ATTENDANCE: { name: "Attendance Manager", epic: "SGAuth integration — Attendance Manager", label: "epic:sgauth-integration", phase: 4, @@ -33,7 +33,7 @@ export const PRODUCT_TICKETS = [ acceptance: ["A first-time SGAuth user gets exactly one purchaser row; re-login does not duplicate.", "Legacy rows can be linked once and the link is unique."] }, { team: "VAULTZ", id: "VAULTZ-V04", priority: "High", estimate: 3, labels: ["sgauth", "permissions"], deps: ["VAULTZ-V01", "AUTH-T44"], title: "Position-based permissions map for VaultZ", - description: `Create \`lib/permissions.ts\` mapping SGAuth position keys to VaultZ capabilities (e.g. \`vp-finance\`, \`treasurer\` → manage designations/budgets/transfers; \`finance-committee\` → create purchases; everyone else → read-only or no access). Gate server actions and pages with \`hasAnyPosition\`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list.`, + description: `Create \`lib/permissions.ts\` mapping SGAuth position keys to VaultZ capabilities, using keys from the curated seed (\`prisma/seed/positions.json\` in SGAOperations/auth). Starting proposal for the VaultZ owner to confirm: \`vice-president-of-operational-affairs\`, \`comptroller\`, \`finance-manager\` → manage designations/budgets/transfers; \`student-body-president\`, \`executive-vice-president\` → read everything; any other position holder → create their own purchases; no positions → no access. Add a product role (e.g. \`vaultz-admin\`) in the SGAuth admin UI only if the curated offices do not cover a need. Gate server actions and pages with \`hasAnyPosition\`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list.`, acceptance: ["Every mutating server action checks a capability; a user without positions cannot create or edit anything (tests).", "The map is the only place position keys appear in VaultZ code."] }, { team: "VAULTZ", id: "VAULTZ-V05", priority: "Low", estimate: 1, labels: ["sgauth", "ui"], deps: ["VAULTZ-V01"], title: "Account and sign-out links in the VaultZ header", @@ -67,7 +67,7 @@ export const PRODUCT_TICKETS = [ acceptance: ["Every pre-existing Chambers user resolves to the same SGAuth user by email (verification script), and bookings history is intact."] }, { team: "CHAMBERS", id: "CHAMBERS-C04", priority: "High", estimate: 5, labels: ["sgauth", "permissions"], deps: ["CHAMBERS-C02", "AUTH-T44"], title: "Map admin_role / iems_role to SGAuth positions; keep body memberships internal", - description: `Define \`lib/permissions.ts\`: Chambers admin capabilities derive from position keys (e.g. \`chambers-admin\`, \`iems\`, and the relevant exec positions from the curated list); board/body memberships stay in Chambers tables keyed by SGAuth user id and continue to drive booking scopes. Replace \`hasLiveAdmin\`/\`is_admin()\` checks in app code with position checks; since Chambers is on Neon, RLS helpers are replaced by app-level checks in the data layer.`, + description: `Define \`lib/permissions.ts\`: Chambers admin capabilities derive from position keys: curated offices (proposal: \`vice-president-of-operational-affairs\`, \`speaker-of-the-senate\`, \`senate-operations-coordinator\`) plus product roles created in the SGAuth admin UI for roles that are not SGA offices (\`chambers-admin\`, \`chambers-iems\`); board/body memberships stay in Chambers tables keyed by SGAuth user id and continue to drive booking scopes. Replace \`hasLiveAdmin\`/\`is_admin()\` checks in app code with position checks; since Chambers is on Neon, RLS helpers are replaced by app-level checks in the data layer.`, acceptance: ["Every admin-only route and action checks positions; a user whose position is removed in SGAuth loses admin ability within 60 s (SDK cache) without any Chambers-side change.", "Body-scoped booking rules unchanged (existing tests pass)."] }, { team: "CHAMBERS", id: "CHAMBERS-C05", priority: "Medium", estimate: 3, labels: ["sgauth", "cleanup"], deps: ["CHAMBERS-C04"], title: "Remove live-role-check and session-revocation mechanisms", @@ -117,7 +117,7 @@ export const PRODUCT_TICKETS = [ acceptance: ["Bypass is a no-op on all Vercel deployments; preview branch domain shares the dev SSO session."] }, { team: "APLIO", id: "APLIO-P07", priority: "High", estimate: 2, labels: ["sgauth", "rollout"], deps: ["APLIO-P03", "APLIO-P04", "APLIO-P05", "AUTH-T91"], title: "Aplio hard cutover outside an application window", - description: `Schedule the cutover when no application cycle is open; users were pre-imported and invited; announce; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window.`, + description: `Schedule the cutover when no application cycle is open; users were pre-imported as password-less accounts (no invite blast); announce the change, explaining that the first sign-in asks for a new password by email; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window.`, acceptance: ["Cutover completed; no applicant lost draft access (verified with a sample of drafts)."] }, { team: "APLIO", id: "APLIO-P08", priority: "Medium", estimate: 3, labels: ["sgauth", "testing"], deps: ["APLIO-P04"], title: "Update Aplio tests for SDK-based auth", @@ -131,7 +131,7 @@ export const PRODUCT_TICKETS = [ acceptance: ["App runs against Neon in dev and prod; row counts match the Supabase source; no Supabase Auth code remains after S02."] }, { team: "SENATEPATH", id: "SENATEPATH-S02", priority: "High", estimate: 3, labels: ["sgauth", "backend"], deps: ["SENATEPATH-S01", "AUTH-T59", "AUTH-T80"], title: "Gate the SenatePath admin area with SGAuth positions", - description: `Install the SDK; protect \`/admin/**\` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on a position (e.g. \`elections-chair\` / \`senate-admin\`). Public application/nomination forms remain unauthenticated as today.`, + description: `Install the SDK; protect \`/admin/**\` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on curated positions (proposal for the SenatePath owner to confirm: \`director-of-elections\`, \`vice-chair-of-elections\`, \`elections-board-member\`, \`speaker-of-the-senate\`). Public application/nomination forms remain unauthenticated as today.`, acceptance: ["Admin pages require an SGAuth session with the configured position; public forms unaffected."] }, { team: "SENATEPATH", id: "SENATEPATH-S03", priority: "Medium", estimate: 2, labels: ["sgauth", "rollout"], deps: ["SENATEPATH-S02", "AUTH-T89"], title: "Import SenatePath admins into SGAuth and cut over", From 069bba54b300157343b61dc62f8b873dd13495f7 Mon Sep 17 00:00:00 2001 From: pataniaeli Date: Fri, 18 Sep 2026 21:49:47 -0400 Subject: [PATCH 4/4] Fix Prettier check: format design sources and ignore generated outputs Co-Authored-By: Claude Opus 5 --- .prettierignore | 1 + docs/sgauth-design/SGAuth-design-summary.md | 82 +- docs/sgauth-design/SGAuth-red-team.md | 96 +- docs/sgauth-design/build.mjs | 130 +- docs/sgauth-design/gh-issues.mjs | 137 ++- docs/sgauth-design/tickets-auth.mjs | 1187 ++++++++++++++++--- docs/sgauth-design/tickets-products.mjs | 526 ++++++-- 7 files changed, 1743 insertions(+), 416 deletions(-) diff --git a/.prettierignore b/.prettierignore index f74c781..e89e99d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,3 @@ .next node_modules +docs/sgauth-design/out diff --git a/docs/sgauth-design/SGAuth-design-summary.md b/docs/sgauth-design/SGAuth-design-summary.md index d860625..da38b6e 100644 --- a/docs/sgauth-design/SGAuth-design-summary.md +++ b/docs/sgauth-design/SGAuth-design-summary.md @@ -14,44 +14,44 @@ The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linea ## 1. Decisions and rationale -| Area | Decision | Rationale | -|---|---|---| -| Repo | In-place migration of the existing `auth` repo: strip Supabase, add Neon + Better Auth; close or rebase the open Supabase branches | Keeps CI, shadcn, Prisma 7 setup, and Linear history; the existing code is a thin scaffold with no auth logic worth preserving | -| Database | Neon serverless Postgres: `main` (prod), `dev` (shared), `test` (CI), per-PR preview branches via the Neon–Vercel integration; pooled URL at runtime, direct URL for migrations. **Free plan, scale-to-zero, quota monitored (accepted risk)** | Hard requirement; branching gives isolated previews without Docker. Free suspends compute when 100 CU-hours are used, which would take every product's login down; alerts at 50/70/85% and an upgrade runbook to Launch ($0.106/CU-hour) mitigate. Recommendation on record: upgrade before the Chambers cutover | -| Auth framework | Self-managed Better Auth 1.7.x with `@better-auth/prisma-adapter` | Aplio already uses it on Neon; provides cross-subdomain cookies, JWT/JWKS plugin, two-factor, admin plugin, session APIs. Neon's Managed Better Auth is beta and pins an older version; hand-rolled auth is unjustified for a small team | -| Hosting | Vercel (Node runtime), `auth.northeasternsga.com` for production, `auth-dev.northeasternsga.com` for the dev deployment (DEV banner, synthetic users, separate secrets). **No Vercel cron**: scheduled jobs run from GitHub Actions calling secret-protected routes | Matches Aplio and Chambers; Neon integration; custom domains. Vercel Hobby limits cron to daily, so the scheduler is plan-independent | -| Login method | Email + password (12+ chars, no composition rules, no breached-password check), scrypt hashing; self-sign-up restricted to `@northeastern.edu` with verification (every student has one; no exceptions); admins can create/invite any address; a later sign-up overwrites an unverified account (pre-hijack defense); every emailed link consumes its token on POST (Microsoft Safe Links) | Resend free tier is nearly exhausted org-wide, so passwordless OTP would be fragile; passwords make SGAuth the credential store the requirement demands. Email OTP is a deferred backlog item | -| Northeastern SSO | Not a launch dependency; Phase 5 spike | Registering an app in Northeastern's Entra ID tenant needs ITS approval; a multi-tenant app in an SGA tenant may require Northeastern admin consent. Unknown timeline | -| Passkeys | Optional per-user sign-in method (Better Auth passkey plugin), Phase 4; password stays as fallback; does not satisfy the admin TOTP requirement in v1 | Suggested by Benedikt: no email cost, phishing-resistant, preferred by some users | -| SSO mechanism | Parent-domain cookie `Domain=northeasternsga.com`, `__Secure-` prefix, HttpOnly, Secure, SameSite=Lax, no cookie cache | Simplest true single session; logout is instant everywhere. `__Host-` is impossible with a Domain attribute. Any compromised subdomain can read the cookie, so subdomain hygiene is a security control | -| Session lookup | Products call `GET /api/sgauth/session` server-side with the forwarded cookie (SDK caches 60 s, serves a stale cached session for up to 5 min if SGAuth is unreachable); the Next.js proxy only checks cookie presence; Supabase products additionally mint a 10-minute ES256 JWT from `/api/auth/token` | DB-backed lookup keeps revocation and position changes immediate; stale-if-error keeps a SGAuth blip from logging every product out; JWTs only where Supabase needs a bearer token | -| Session lifetime | 30-day sliding (`updateAge` 1 day), 90-day absolute cap enforced by a daily purge on `createdAt` (so Better Auth's own endpoints cannot bypass it), 10-minute re-auth window via `lastReauthAt` (Better Auth `freshAge` left at default) | Matches semester usage while bounding stolen-cookie exposure | -| Logout | Global: any sign-out clears the shared cookie and deletes the session row; "sign out everywhere" for users; admin revoke; `/logout?redirect=` validated against the registry | One session means one logout | -| Supabase products | Supabase **third-party auth** (JWT trust) is the standard path; server-side JWT verification documented as a fallback; moving to Neon recommended where possible | See §2 | -| Identity key | Products store the SGAuth user id (UUID v7) as their foreign key; email/name come from the session | Stable across email changes; Supabase `auth.uid()` requires a UUID `sub` | -| Admin rules | Enforced in a pure `authz.ts` module, mirrored by Postgres constraints/triggers, and audited. Admins grant/revoke admin for others only; never self; never the Primary Admin | Server-side enforcement with a DB backstop; UI only hides controls | -| Primary Admin | Exactly one (partial unique index). Transfer: PA re-auth + typed recipient email → recipient (active admin with MFA) accepts with re-auth → 24-hour cooling window with an emailed cancel link → scheduled execution revokes both parties' sessions and emails all admins. PA cannot be deleted, deactivated, or de-admined; lockout applies but self-unlock by email exists | Protects the most powerful account against both mistakes and hijacking while still allowing graduation handoffs | -| Break-glass | Offline script run with production Neon credentials, confirmation phrase, `BREAK_GLASS` audit row, emails to all admins; no HTTP path; runbook with at least two credential holders | Infra access is the real control; no web backdoor | -| Positions | Flat list; immutable slug `key` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`, 2–64) plus editable display `name` and optional `category`; max 50 per user; rename keeps holders; delete is soft with typed confirmation, keeps history, retires the key for 365 days | Products check keys so renames never break permissions; retirement prevents accidental privilege revival | -| Propagation | Immediate for Neon products (live DB read); ≤10 minutes for Supabase JWTs; "force re-login" admin action | Bounded staleness without forcing logouts on every change | -| Seed data | Curated SGA position list supplied by SGA on 2026-09-18: 81 offices in 9 categories plus `senator`; keys slugified from official names; product roles (`-`) created on demand by product owners, not seeded | Explicit choice over inferring from Chambers roles | -| User removal | Admin deactivate (soft, sessions revoked instantly, reactivatable); **tombstone** (status DELETED, PII scrubbed, id kept) 30 days after deactivation or on request; inactive 12 months → notice → tombstone (admins exempt for manual review); audit PII anonymized, events kept | Meets the retention policy without losing the audit trail or orphaning product rows keyed by SGAuth user id | -| Profile data | `name`, optional `preferredName`, `email`; no NUID, phone, pronouns, photo | Data minimization; products keep their own fields keyed by SGAuth id | -| User UI | Minimal account page: profile, positions (read-only), links to each product from the registry, sessions with revoke, sign out everywhere, password change, MFA enrollment | Users need a hub and self-service basics; nothing product-specific | -| Account creation | Admin-created or bulk CSV import as password-less accounts, plus `@northeastern.edu` self-sign-up with zero positions. Password-less accounts set a password lazily: entering the email on the login page sends a one-time set-password link; admins can still push an invite to one person | Avoids admin bottlenecks at turnover and avoids an import-day email blast; positions gate everything | -| Migration | Chambers: bcrypt hashes imported with a `bcrypt$` marker and lazily re-hashed to scrypt on first login (the `auth.users` export is Eli's manual action item, before the Supabase project is deleted). Aplio/SenatePath/SenatePortal: emails imported as password-less accounts, **no emails sent at import**, set-password on first sign-in; Aplio duplicates merge into the Chambers account; the few non-NU Aplio addresses are imported as `legacyEmail` and corrected by hand. Merge by lower-cased email | Chambers users keep their passwords; OTP users never had one; lazy set-password spreads email volume over time | -| MFA | TOTP + backup codes; optional for users, required for admins and the PA (enforced at admin-action time); required for the transfer recipient | No SMS/email cost; protects privileged accounts | -| Rate limiting | Upstash Redis sliding windows on sign-in, sign-up, reset, token, re-auth, and admin endpoints only (session endpoint excluded to stay within 500K commands/month); **fails open with an alert** on Upstash outage; Better Auth's DB limiter disabled | Team already runs Upstash (Chambers); a vendor outage must never become an org-wide login outage | -| Lockout | 5 failures/15 min → 15-minute lock, doubling to a 24-hour cap, never permanent; emailed unlock link (POST-consumed); applies to the PA; **known-device cookie exempts the owner's usual browser** so the lock cannot be used to deny admins service | Mitigates credential stuffing without permanent self-DoS or a lockout DoS lever | -| Email | **Separate Resend account** owned by an SGA shared mailbox, sending from `mail.northeasternsga.com`, provider-agnostic mailer interface, per-recipient and global caps | Resend's free quota is per account and Chambers sends almost all SGA email today, so only a separate account isolates SGAuth's small volume | -| Product registry | Admin-managed `Product` table (slug, name, base URL restricted to `https://*.northeasternsga.com`, visibility by positions); drives trusted origins, redirect allowlist, CORS, and account-page links | No deploy to add a product; one source for security allowlists | -| SDK | `@sgaoperations/sgauth` on **public npm** with trusted publishing: `getSession` (60 s cache, stale-if-error), cookie-presence proxy helper, position guards, URL builders, `getAccessToken` for Supabase | Five products, one place to fix bugs; GitHub Packages was rejected because it needs a personal token to install even public packages | -| Environments | Dev SGAuth on the real parent domain with a distinct cookie prefix; product previews on stable `-dev.northeasternsga.com` branch domains; local `*.sga.localhost` hostnames; `*.vercel.app` previews cannot use SSO | A parent-domain cookie cannot reach `vercel.app` or bare `localhost`; the topology must be explicit | -| Observability | Append-only `AuditEvent` table with a typed catalog and coverage test; structured JSON logs; PostHog (funnel events without PII, error tracking); health endpoint + uptime monitor; threshold alerts; retention jobs | Audit is a product requirement; PostHog free tier chosen over Sentry | -| Tokens | ES256, **manual** JWKS rotation (annual or incident) with the new key pushed to each Supabase project via `custom_jwks` before signing switches, 7-day overlap, `kid` header, issuer `https://auth.northeasternsga.com`, audience `authenticated`, 10-minute TTL, claims `sub` (uuid), `email`, `name`, `role: "authenticated"`, `positions`, `is_admin` | ES256 is universally supported (Supabase, jose); automatic rotation would reject fresh tokens for up to 30 minutes until Supabase re-fetches keys | -| Security extras | Origin/Referer enforcement on all mutations (SameSite=Lax still sends the cookie on sibling-subdomain POSTs), security headers (HSTS without `includeSubDomains` until every subdomain is confirmed HTTPS), subdomain DNS hygiene policy, enumeration-resistant responses, secrets rotation runbook, Dependabot/CodeQL, STRIDE-lite threat model | Consequences of the shared-cookie design | -| Rollout | SGAuth MVP → VaultZ → Chambers (already a Neon product) → Aplio (hard cutover outside an application window) → SenatePath (migrated to Neon by its owner) → Attendance Manager (Supabase or Neon path pending their decision) | VaultZ has no auth to unwind and proves the SDK; Chambers has the most users and pre-imported credentials | -| Ticketing | Fibonacci estimates; one Linear CSV per team (AUTH, VaultZ, Aplio, SenatePath, Attendance Manager); Chambers has no Linear team so its items are a Markdown checklist; epics as Linear Projects with `epic:*` labels and Epic/Phase/Depends-on lines in every description | Linear's importer does not create projects or parent links | +| Area | Decision | Rationale | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Repo | In-place migration of the existing `auth` repo: strip Supabase, add Neon + Better Auth; close or rebase the open Supabase branches | Keeps CI, shadcn, Prisma 7 setup, and Linear history; the existing code is a thin scaffold with no auth logic worth preserving | +| Database | Neon serverless Postgres: `main` (prod), `dev` (shared), `test` (CI), per-PR preview branches via the Neon–Vercel integration; pooled URL at runtime, direct URL for migrations. **Free plan, scale-to-zero, quota monitored (accepted risk)** | Hard requirement; branching gives isolated previews without Docker. Free suspends compute when 100 CU-hours are used, which would take every product's login down; alerts at 50/70/85% and an upgrade runbook to Launch ($0.106/CU-hour) mitigate. Recommendation on record: upgrade before the Chambers cutover | +| Auth framework | Self-managed Better Auth 1.7.x with `@better-auth/prisma-adapter` | Aplio already uses it on Neon; provides cross-subdomain cookies, JWT/JWKS plugin, two-factor, admin plugin, session APIs. Neon's Managed Better Auth is beta and pins an older version; hand-rolled auth is unjustified for a small team | +| Hosting | Vercel (Node runtime), `auth.northeasternsga.com` for production, `auth-dev.northeasternsga.com` for the dev deployment (DEV banner, synthetic users, separate secrets). **No Vercel cron**: scheduled jobs run from GitHub Actions calling secret-protected routes | Matches Aplio and Chambers; Neon integration; custom domains. Vercel Hobby limits cron to daily, so the scheduler is plan-independent | +| Login method | Email + password (12+ chars, no composition rules, no breached-password check), scrypt hashing; self-sign-up restricted to `@northeastern.edu` with verification (every student has one; no exceptions); admins can create/invite any address; a later sign-up overwrites an unverified account (pre-hijack defense); every emailed link consumes its token on POST (Microsoft Safe Links) | Resend free tier is nearly exhausted org-wide, so passwordless OTP would be fragile; passwords make SGAuth the credential store the requirement demands. Email OTP is a deferred backlog item | +| Northeastern SSO | Not a launch dependency; Phase 5 spike | Registering an app in Northeastern's Entra ID tenant needs ITS approval; a multi-tenant app in an SGA tenant may require Northeastern admin consent. Unknown timeline | +| Passkeys | Optional per-user sign-in method (Better Auth passkey plugin), Phase 4; password stays as fallback; does not satisfy the admin TOTP requirement in v1 | Suggested by Benedikt: no email cost, phishing-resistant, preferred by some users | +| SSO mechanism | Parent-domain cookie `Domain=northeasternsga.com`, `__Secure-` prefix, HttpOnly, Secure, SameSite=Lax, no cookie cache | Simplest true single session; logout is instant everywhere. `__Host-` is impossible with a Domain attribute. Any compromised subdomain can read the cookie, so subdomain hygiene is a security control | +| Session lookup | Products call `GET /api/sgauth/session` server-side with the forwarded cookie (SDK caches 60 s, serves a stale cached session for up to 5 min if SGAuth is unreachable); the Next.js proxy only checks cookie presence; Supabase products additionally mint a 10-minute ES256 JWT from `/api/auth/token` | DB-backed lookup keeps revocation and position changes immediate; stale-if-error keeps a SGAuth blip from logging every product out; JWTs only where Supabase needs a bearer token | +| Session lifetime | 30-day sliding (`updateAge` 1 day), 90-day absolute cap enforced by a daily purge on `createdAt` (so Better Auth's own endpoints cannot bypass it), 10-minute re-auth window via `lastReauthAt` (Better Auth `freshAge` left at default) | Matches semester usage while bounding stolen-cookie exposure | +| Logout | Global: any sign-out clears the shared cookie and deletes the session row; "sign out everywhere" for users; admin revoke; `/logout?redirect=` validated against the registry | One session means one logout | +| Supabase products | Supabase **third-party auth** (JWT trust) is the standard path; server-side JWT verification documented as a fallback; moving to Neon recommended where possible | See §2 | +| Identity key | Products store the SGAuth user id (UUID v7) as their foreign key; email/name come from the session | Stable across email changes; Supabase `auth.uid()` requires a UUID `sub` | +| Admin rules | Enforced in a pure `authz.ts` module, mirrored by Postgres constraints/triggers, and audited. Admins grant/revoke admin for others only; never self; never the Primary Admin | Server-side enforcement with a DB backstop; UI only hides controls | +| Primary Admin | Exactly one (partial unique index). Transfer: PA re-auth + typed recipient email → recipient (active admin with MFA) accepts with re-auth → 24-hour cooling window with an emailed cancel link → scheduled execution revokes both parties' sessions and emails all admins. PA cannot be deleted, deactivated, or de-admined; lockout applies but self-unlock by email exists | Protects the most powerful account against both mistakes and hijacking while still allowing graduation handoffs | +| Break-glass | Offline script run with production Neon credentials, confirmation phrase, `BREAK_GLASS` audit row, emails to all admins; no HTTP path; runbook with at least two credential holders | Infra access is the real control; no web backdoor | +| Positions | Flat list; immutable slug `key` (`^[a-z0-9]+(?:-[a-z0-9]+)*$`, 2–64) plus editable display `name` and optional `category`; max 50 per user; rename keeps holders; delete is soft with typed confirmation, keeps history, retires the key for 365 days | Products check keys so renames never break permissions; retirement prevents accidental privilege revival | +| Propagation | Immediate for Neon products (live DB read); ≤10 minutes for Supabase JWTs; "force re-login" admin action | Bounded staleness without forcing logouts on every change | +| Seed data | Curated SGA position list supplied by SGA on 2026-09-18: 81 offices in 9 categories plus `senator`; keys slugified from official names; product roles (`-`) created on demand by product owners, not seeded | Explicit choice over inferring from Chambers roles | +| User removal | Admin deactivate (soft, sessions revoked instantly, reactivatable); **tombstone** (status DELETED, PII scrubbed, id kept) 30 days after deactivation or on request; inactive 12 months → notice → tombstone (admins exempt for manual review); audit PII anonymized, events kept | Meets the retention policy without losing the audit trail or orphaning product rows keyed by SGAuth user id | +| Profile data | `name`, optional `preferredName`, `email`; no NUID, phone, pronouns, photo | Data minimization; products keep their own fields keyed by SGAuth id | +| User UI | Minimal account page: profile, positions (read-only), links to each product from the registry, sessions with revoke, sign out everywhere, password change, MFA enrollment | Users need a hub and self-service basics; nothing product-specific | +| Account creation | Admin-created or bulk CSV import as password-less accounts, plus `@northeastern.edu` self-sign-up with zero positions. Password-less accounts set a password lazily: entering the email on the login page sends a one-time set-password link; admins can still push an invite to one person | Avoids admin bottlenecks at turnover and avoids an import-day email blast; positions gate everything | +| Migration | Chambers: bcrypt hashes imported with a `bcrypt$` marker and lazily re-hashed to scrypt on first login (the `auth.users` export is Eli's manual action item, before the Supabase project is deleted). Aplio/SenatePath/SenatePortal: emails imported as password-less accounts, **no emails sent at import**, set-password on first sign-in; Aplio duplicates merge into the Chambers account; the few non-NU Aplio addresses are imported as `legacyEmail` and corrected by hand. Merge by lower-cased email | Chambers users keep their passwords; OTP users never had one; lazy set-password spreads email volume over time | +| MFA | TOTP + backup codes; optional for users, required for admins and the PA (enforced at admin-action time); required for the transfer recipient | No SMS/email cost; protects privileged accounts | +| Rate limiting | Upstash Redis sliding windows on sign-in, sign-up, reset, token, re-auth, and admin endpoints only (session endpoint excluded to stay within 500K commands/month); **fails open with an alert** on Upstash outage; Better Auth's DB limiter disabled | Team already runs Upstash (Chambers); a vendor outage must never become an org-wide login outage | +| Lockout | 5 failures/15 min → 15-minute lock, doubling to a 24-hour cap, never permanent; emailed unlock link (POST-consumed); applies to the PA; **known-device cookie exempts the owner's usual browser** so the lock cannot be used to deny admins service | Mitigates credential stuffing without permanent self-DoS or a lockout DoS lever | +| Email | **Separate Resend account** owned by an SGA shared mailbox, sending from `mail.northeasternsga.com`, provider-agnostic mailer interface, per-recipient and global caps | Resend's free quota is per account and Chambers sends almost all SGA email today, so only a separate account isolates SGAuth's small volume | +| Product registry | Admin-managed `Product` table (slug, name, base URL restricted to `https://*.northeasternsga.com`, visibility by positions); drives trusted origins, redirect allowlist, CORS, and account-page links | No deploy to add a product; one source for security allowlists | +| SDK | `@sgaoperations/sgauth` on **public npm** with trusted publishing: `getSession` (60 s cache, stale-if-error), cookie-presence proxy helper, position guards, URL builders, `getAccessToken` for Supabase | Five products, one place to fix bugs; GitHub Packages was rejected because it needs a personal token to install even public packages | +| Environments | Dev SGAuth on the real parent domain with a distinct cookie prefix; product previews on stable `-dev.northeasternsga.com` branch domains; local `*.sga.localhost` hostnames; `*.vercel.app` previews cannot use SSO | A parent-domain cookie cannot reach `vercel.app` or bare `localhost`; the topology must be explicit | +| Observability | Append-only `AuditEvent` table with a typed catalog and coverage test; structured JSON logs; PostHog (funnel events without PII, error tracking); health endpoint + uptime monitor; threshold alerts; retention jobs | Audit is a product requirement; PostHog free tier chosen over Sentry | +| Tokens | ES256, **manual** JWKS rotation (annual or incident) with the new key pushed to each Supabase project via `custom_jwks` before signing switches, 7-day overlap, `kid` header, issuer `https://auth.northeasternsga.com`, audience `authenticated`, 10-minute TTL, claims `sub` (uuid), `email`, `name`, `role: "authenticated"`, `positions`, `is_admin` | ES256 is universally supported (Supabase, jose); automatic rotation would reject fresh tokens for up to 30 minutes until Supabase re-fetches keys | +| Security extras | Origin/Referer enforcement on all mutations (SameSite=Lax still sends the cookie on sibling-subdomain POSTs), security headers (HSTS without `includeSubDomains` until every subdomain is confirmed HTTPS), subdomain DNS hygiene policy, enumeration-resistant responses, secrets rotation runbook, Dependabot/CodeQL, STRIDE-lite threat model | Consequences of the shared-cookie design | +| Rollout | SGAuth MVP → VaultZ → Chambers (already a Neon product) → Aplio (hard cutover outside an application window) → SenatePath (migrated to Neon by its owner) → Attendance Manager (Supabase or Neon path pending their decision) | VaultZ has no auth to unwind and proves the SDK; Chambers has the most users and pre-imported credentials | +| Ticketing | Fibonacci estimates; one Linear CSV per team (AUTH, VaultZ, Aplio, SenatePath, Attendance Manager); Chambers has no Linear team so its items are a Markdown checklist; epics as Linear Projects with `epic:*` labels and Epic/Phase/Depends-on lines in every description | Linear's importer does not create projects or parent links | ## 2. Supabase compatibility findings @@ -60,6 +60,7 @@ The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linea **Path chosen: Supabase third-party auth (JWT trust).** Supabase's Management API endpoint `POST /v1/projects/{ref}/config/auth/third-party-auth` accepts a generic `oidc_issuer_url`, `jwks_url`, or inline `custom_jwks`, so a project can trust SGAuth-issued JWTs even though the dashboard advertises only Clerk, Firebase, Auth0, Cognito, and WorkOS. Requirements SGAuth meets: asymmetric signing (ES256), a `kid` header, a `role: "authenticated"` claim (maps the request to the `authenticated` Postgres role), a UUID `sub` (read by `auth.uid()`), and standard `iss`/`exp`. Positions ride in the token and are readable in RLS via `auth.jwt() -> 'positions'`. supabase-js takes an `accessToken` callback; the SDK supplies it. **Limitations to plan around:** + 1. Third-party users have **no `auth.users` row**. Foreign keys, triggers, or joins on `auth.users` break. Chambers had these (now moot, since Chambers is a Neon product); Attendance Manager has a `supabaseAuthId` column that must become `sgauthUserId`. 2. Supabase Auth features are unavailable to these users: no Supabase sessions/refresh, password reset, or MFA. All of that is SGAuth's job, by design. 3. JWT staleness of up to 10 minutes for position changes and revocations; "force re-login" in SGAuth bounds it. Supabase refreshes JWKS within ~30 minutes, so SGAuth keeps rotated keys valid for 7 days. @@ -67,8 +68,9 @@ The companion files are `SGAuth-tickets.md` (readable ticket set) and `out/linea 5. Generic-issuer configuration is an API operation, not a dashboard toggle; the guide ships a script. **Alternatives evaluated and rejected as the default:** -- *Supabase custom OIDC provider* (Supabase Auth as a relying party to SGAuth acting as an OpenID provider via Better Auth's OAuth 2.1 provider package). Keeps `auth.users` but creates a second session SGAuth cannot terminate, positions must be copied into `app_metadata` by a hook, and free projects are capped at three custom providers. Documented as an escape hatch only. -- *Server-side verification only* (verify SGAuth JWTs with `jose`, use the service-role key). Works everywhere but loses per-user RLS. Documented as a fallback for products that do not need RLS. + +- _Supabase custom OIDC provider_ (Supabase Auth as a relying party to SGAuth acting as an OpenID provider via Better Auth's OAuth 2.1 provider package). Keeps `auth.users` but creates a second session SGAuth cannot terminate, positions must be copied into `app_metadata` by a hook, and free projects are capped at three custom providers. Documented as an escape hatch only. +- _Server-side verification only_ (verify SGAuth JWTs with `jose`, use the service-role key). Works everywhere but loses per-user RLS. Documented as a fallback for products that do not need RLS. **Product-by-product:** Website Creation has no user login today and will be hosted on Neon if it ever needs auth. SenatePath is migrated to Neon by its owner. Attendance Manager gets both paths ticketed; exactly one proceeds after the team's decision. diff --git a/docs/sgauth-design/SGAuth-red-team.md b/docs/sgauth-design/SGAuth-red-team.md index 7a5b557..76a249d 100644 --- a/docs/sgauth-design/SGAuth-red-team.md +++ b/docs/sgauth-design/SGAuth-red-team.md @@ -6,60 +6,60 @@ Severity: **S1** would take SGAuth or all products down, or hand over an account ## A. Findings resolved by decision -| # | Sev | Finding | Disposition | -|---|---|---|---| -| A1 | S1 | **Neon Free suspends compute for the rest of the month at 100 CU-hours.** An auth database awake most of the day at 0.25 CU uses ~180 CU-hours/month. Suspension = every SGA login fails. Restore window on Free is 6 hours. | **Decision: stay on Free, scale-to-zero on, monitor quota.** Accepted risk. AUTH-T101 adds 50/70/85% alerts with an exhaustion projection, forbids keep-warm pings, and documents the one-click Launch upgrade ($0.106/CU-hour, roughly $10–25/month). Recommend upgrading before the Chambers cutover. | -| A2 | S1 | **Vercel Hobby cron: 2 jobs, once per day, enforced at deploy.** The 15-minute transfer executor and alert job would fail every deploy. | **Decision: GitHub Actions schedules calling secret-protected routes** (Chambers' existing pattern). AUTH-T38 rewritten; `vercel.json` must contain no `crons`. | -| A3 | S2 | **Physical deletion orphans product data.** VaultZ purchases, Aplio applications, and Chambers bookings are keyed by SGAuth user id. | **Decision: tombstone.** Status `DELETED`, PII scrubbed, id kept. AUTH-T10, T36, T78 updated. | -| A4 | S1 | **Upstash fail-closed on sign-in** turns a Redis vendor outage into an org-wide login outage. | **Decision: fail open with alert**; DB-backed lockout is the brute-force backstop. AUTH-T63 updated. | -| A5 | S3 | **Upstash free tier is 500K commands/month.** Rate-limiting the session endpoint (the hot path) would exhaust it in weeks. | Session endpoint is no longer Redis-limited; only mutations and the token endpoint are. Budget and 80% alert added. AUTH-T63. | -| A6 | S2 | **GitHub Packages requires a classic PAT to install even public packages.** A graduating student's token would sit in every product's Vercel env. | **Decision: public npm under `@sgaoperations`** with trusted publishing (no long-lived token). AUTH-T57, T61, VAULTZ-V01. | -| A7 | S2 | **Lockout as a denial-of-service lever.** Anyone knowing an admin's email locks them out for up to 24 h, repeatedly, including the Primary Admin during a transfer. | **Decision: known-device exemption.** A signed device cookie set after successful login bypasses the account lock; unknown devices are still locked. AUTH-T64 (estimate raised to 5). | -| A8 | S2 | **Automatic JWKS rotation vs Supabase's ≤30-minute key refresh.** New key is used immediately; Supabase rejects fresh tokens until it re-fetches. | **Decision: manual rotation** (annual or incident) with the combined JWKS pushed to each Supabase project via `custom_jwks` before switching. AUTH-T29, T69. Audience set to `authenticated` to match Supabase convention. | -| A9 | S2 | **Aplio users with non-northeastern.edu emails.** Every student has an NU address, but existing Aplio accounts are keyed by other addresses. | **Decision: import as-is with `legacyEmail = true`**; admins move them to the NU address later (AUTH-T24) preserving the SGAuth id and Aplio history. New applicants must use NU email (APLIO-P05). | -| A10 | S1 | **Chambers password hashes are lost if the Supabase project is deleted before export.** | **Eli exports `auth.users` manually; not a ticket.** Chambers has no Linear team, so Chambers items are a Markdown checklist only (no CSV). AUTH-T86 now validates the received file. | -| A11 | S3 | **Dev SGAuth on the real parent domain** is a production-looking login page with weaker controls. | **Decision: open dev with a persistent DEV banner, `noindex`, synthetic users only, separate secrets.** AUTH-T34. | +| # | Sev | Finding | Disposition | +| --- | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A1 | S1 | **Neon Free suspends compute for the rest of the month at 100 CU-hours.** An auth database awake most of the day at 0.25 CU uses ~180 CU-hours/month. Suspension = every SGA login fails. Restore window on Free is 6 hours. | **Decision: stay on Free, scale-to-zero on, monitor quota.** Accepted risk. AUTH-T101 adds 50/70/85% alerts with an exhaustion projection, forbids keep-warm pings, and documents the one-click Launch upgrade ($0.106/CU-hour, roughly $10–25/month). Recommend upgrading before the Chambers cutover. | +| A2 | S1 | **Vercel Hobby cron: 2 jobs, once per day, enforced at deploy.** The 15-minute transfer executor and alert job would fail every deploy. | **Decision: GitHub Actions schedules calling secret-protected routes** (Chambers' existing pattern). AUTH-T38 rewritten; `vercel.json` must contain no `crons`. | +| A3 | S2 | **Physical deletion orphans product data.** VaultZ purchases, Aplio applications, and Chambers bookings are keyed by SGAuth user id. | **Decision: tombstone.** Status `DELETED`, PII scrubbed, id kept. AUTH-T10, T36, T78 updated. | +| A4 | S1 | **Upstash fail-closed on sign-in** turns a Redis vendor outage into an org-wide login outage. | **Decision: fail open with alert**; DB-backed lockout is the brute-force backstop. AUTH-T63 updated. | +| A5 | S3 | **Upstash free tier is 500K commands/month.** Rate-limiting the session endpoint (the hot path) would exhaust it in weeks. | Session endpoint is no longer Redis-limited; only mutations and the token endpoint are. Budget and 80% alert added. AUTH-T63. | +| A6 | S2 | **GitHub Packages requires a classic PAT to install even public packages.** A graduating student's token would sit in every product's Vercel env. | **Decision: public npm under `@sgaoperations`** with trusted publishing (no long-lived token). AUTH-T57, T61, VAULTZ-V01. | +| A7 | S2 | **Lockout as a denial-of-service lever.** Anyone knowing an admin's email locks them out for up to 24 h, repeatedly, including the Primary Admin during a transfer. | **Decision: known-device exemption.** A signed device cookie set after successful login bypasses the account lock; unknown devices are still locked. AUTH-T64 (estimate raised to 5). | +| A8 | S2 | **Automatic JWKS rotation vs Supabase's ≤30-minute key refresh.** New key is used immediately; Supabase rejects fresh tokens until it re-fetches. | **Decision: manual rotation** (annual or incident) with the combined JWKS pushed to each Supabase project via `custom_jwks` before switching. AUTH-T29, T69. Audience set to `authenticated` to match Supabase convention. | +| A9 | S2 | **Aplio users with non-northeastern.edu emails.** Every student has an NU address, but existing Aplio accounts are keyed by other addresses. | **Decision: import as-is with `legacyEmail = true`**; admins move them to the NU address later (AUTH-T24) preserving the SGAuth id and Aplio history. New applicants must use NU email (APLIO-P05). | +| A10 | S1 | **Chambers password hashes are lost if the Supabase project is deleted before export.** | **Eli exports `auth.users` manually; not a ticket.** Chambers has no Linear team, so Chambers items are a Markdown checklist only (no CSV). AUTH-T86 now validates the received file. | +| A11 | S3 | **Dev SGAuth on the real parent domain** is a production-looking login page with weaker controls. | **Decision: open dev with a persistent DEV banner, `noindex`, synthetic users only, separate secrets.** AUTH-T34. | ## B. Corrections applied to tickets (no decision needed) -| # | Sev | Finding | Fix | -|---|---|---|---| -| B1 | S1 | **Microsoft Safe Links consumes single-use links.** Northeastern mail is M365; Defender pre-fetches links, so GET-acting verify/reset/invite/unlock/transfer links die before the user clicks. | New AUTH-T103: every emailed link lands on a page and consumes the token only on a POST; HEAD/scanner requests are side-effect free. T18, T19, T22, T24, T37, T64 depend on it. | -| B2 | S1 | **Account pre-hijack via sign-up.** With `requireEmailVerification`, Better Auth answers a duplicate sign-up with success. An attacker pre-registers a victim's NU address; the victim "signs up", verifies, and the attacker's password is live. | AUTH-T17: a later sign-up overwrites an *unverified* account's password and name; verification revokes all sessions. Test added. AUTH-T70 aligned. | -| B3 | S2 | **90-day absolute cap enforced only in the session endpoint.** Better Auth's own `/token` and plugin endpoints never ran the custom check, so a 90-day-old session could still mint Supabase JWTs. | AUTH-T27/T78: the daily job deletes sessions by `createdAt` > 90 days; the endpoint check stays as defense in depth. | -| B4 | S2 | **`freshAge = 10 min` misuse.** Better Auth's `freshAge` gates its own fresh-session endpoints; setting it to 10 minutes would have broken those for any session older than 10 minutes and was not a re-auth mechanism. | Leave `freshAge` default; re-auth uses `lastReauthAt`. AUTH-T27, T32. | -| B5 | S2 | **SDK proxy calling SGAuth on every request.** Middleware runs per request and per instance; a network call there adds latency to every page and the per-instance cache rarely hits. | AUTH-T59: proxy checks cookie presence only; validation happens in server code via `requireSession()`. | -| B6 | S1 | **SGAuth is a single point of failure for every product**, and the SDK failed closed on any error. A short Neon or Vercel blip logged everyone out of everything. | AUTH-T58: stale-if-error — serve a previously cached session for that token for up to 5 minutes (max 15) when SGAuth is unreachable; fail closed only with no cache. | -| B7 | S2 | **SGAuth's own `*.vercel.app` previews cannot set a `northeasternsga.com` cookie**; previews would be un-loginable. | `SGAUTH_ENV=preview` host-only cookie mode. AUTH-T05, T26. | -| B8 | S2 | **Hijacked Primary Admin session could redirect the transfer-cancel email** by changing the PA's email, and any admin could change any admin's email then reset their password. | AUTH-T24/T35: email change requires re-auth; admins change only non-admin emails; only the PA changes an admin's email; PA's own email only by the PA. | -| B9 | S2 | **PA MFA lockout is unrecoverable** (lost phone and codes; admins cannot touch PA MFA). | AUTH-T39: break-glass `--reset-mfa` mode. AUTH-T36: admin MFA reset for non-PA users under authz rules. | -| B10 | S2 | **Cross-subdomain cookie behavior has unresolved community bug reports** (better-auth #5611, #3938) and local `*.localhost` cookie handling varies by browser. | New AUTH-T104 spike, Phase 1, before any product work: verify on the real domain in three browsers, plus preview mode, local scheme, and cookie tossing. | -| B11 | S3 | **Third-party analytics script on credential pages** (PostHog client SDK on login/reset) is a supply-chain risk. | AUTH-T75: client script only on authenticated pages; auth pages report errors first-party. | -| B12 | S3 | **`/logout` on GET is CSRF-able** (an external page logs users out via an image tag). | AUTH-T30: reject `Sec-Fetch-Site: cross-site`; sibling subdomains remain same-site. | -| B13 | S3 | **Registry outage breaks origin checks** and a wildcard `*.northeasternsga.com` would trust dangling hosts. | AUTH-T56: static fallback of SGAuth's own origin; wildcard explicitly forbidden. | -| B14 | S3 | **Migrations run during the build while the old deployment still serves.** A destructive migration breaks the live release. | AUTH-T16: expand/contract rule documented and required. | -| B15 | S3 | **Shared Neon test branch with parallel CI runs** collides; per-run branches hit the 10-branch cap. | AUTH-T06/T93: Actions concurrency group serializes runs; branch reset before each. | -| B16 | S3 | **Audit `type` CHECK constraint from the catalog** forces a migration for every new event. | TS-level validation only. AUTH-T13, T73. | -| B17 | S3 | **50 ms timing assertion in CI** would be flaky. | Non-gating benchmark; bodies and status codes asserted. AUTH-T70. | -| B18 | S3 | **Verification re-send on every sign-in attempt** by an unverified user (Better Auth behavior) is an email-flood lever. | Documented in AUTH-T17; bounded by mailer per-recipient caps and sign-in rate limits. | -| B19 | S3 | **Position key collisions** between org roles and product roles. | Naming convention in AUTH-T44: bare keys for org roles, `-` prefix for product roles. | -| B20 | S3 | **Better Auth admin plugin fields** (`impersonatedBy`) were listed but the plugin is not used. | Removed; custom authz only. AUTH-T10. | -| B21 | S3 | **Invited (password-less) user tries to self-sign-up** and gets a confusing no-op. | AUTH-T22: copy routes them to set-password/forgot. | +| # | Sev | Finding | Fix | +| --- | --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| B1 | S1 | **Microsoft Safe Links consumes single-use links.** Northeastern mail is M365; Defender pre-fetches links, so GET-acting verify/reset/invite/unlock/transfer links die before the user clicks. | New AUTH-T103: every emailed link lands on a page and consumes the token only on a POST; HEAD/scanner requests are side-effect free. T18, T19, T22, T24, T37, T64 depend on it. | +| B2 | S1 | **Account pre-hijack via sign-up.** With `requireEmailVerification`, Better Auth answers a duplicate sign-up with success. An attacker pre-registers a victim's NU address; the victim "signs up", verifies, and the attacker's password is live. | AUTH-T17: a later sign-up overwrites an _unverified_ account's password and name; verification revokes all sessions. Test added. AUTH-T70 aligned. | +| B3 | S2 | **90-day absolute cap enforced only in the session endpoint.** Better Auth's own `/token` and plugin endpoints never ran the custom check, so a 90-day-old session could still mint Supabase JWTs. | AUTH-T27/T78: the daily job deletes sessions by `createdAt` > 90 days; the endpoint check stays as defense in depth. | +| B4 | S2 | **`freshAge = 10 min` misuse.** Better Auth's `freshAge` gates its own fresh-session endpoints; setting it to 10 minutes would have broken those for any session older than 10 minutes and was not a re-auth mechanism. | Leave `freshAge` default; re-auth uses `lastReauthAt`. AUTH-T27, T32. | +| B5 | S2 | **SDK proxy calling SGAuth on every request.** Middleware runs per request and per instance; a network call there adds latency to every page and the per-instance cache rarely hits. | AUTH-T59: proxy checks cookie presence only; validation happens in server code via `requireSession()`. | +| B6 | S1 | **SGAuth is a single point of failure for every product**, and the SDK failed closed on any error. A short Neon or Vercel blip logged everyone out of everything. | AUTH-T58: stale-if-error — serve a previously cached session for that token for up to 5 minutes (max 15) when SGAuth is unreachable; fail closed only with no cache. | +| B7 | S2 | **SGAuth's own `*.vercel.app` previews cannot set a `northeasternsga.com` cookie**; previews would be un-loginable. | `SGAUTH_ENV=preview` host-only cookie mode. AUTH-T05, T26. | +| B8 | S2 | **Hijacked Primary Admin session could redirect the transfer-cancel email** by changing the PA's email, and any admin could change any admin's email then reset their password. | AUTH-T24/T35: email change requires re-auth; admins change only non-admin emails; only the PA changes an admin's email; PA's own email only by the PA. | +| B9 | S2 | **PA MFA lockout is unrecoverable** (lost phone and codes; admins cannot touch PA MFA). | AUTH-T39: break-glass `--reset-mfa` mode. AUTH-T36: admin MFA reset for non-PA users under authz rules. | +| B10 | S2 | **Cross-subdomain cookie behavior has unresolved community bug reports** (better-auth #5611, #3938) and local `*.localhost` cookie handling varies by browser. | New AUTH-T104 spike, Phase 1, before any product work: verify on the real domain in three browsers, plus preview mode, local scheme, and cookie tossing. | +| B11 | S3 | **Third-party analytics script on credential pages** (PostHog client SDK on login/reset) is a supply-chain risk. | AUTH-T75: client script only on authenticated pages; auth pages report errors first-party. | +| B12 | S3 | **`/logout` on GET is CSRF-able** (an external page logs users out via an image tag). | AUTH-T30: reject `Sec-Fetch-Site: cross-site`; sibling subdomains remain same-site. | +| B13 | S3 | **Registry outage breaks origin checks** and a wildcard `*.northeasternsga.com` would trust dangling hosts. | AUTH-T56: static fallback of SGAuth's own origin; wildcard explicitly forbidden. | +| B14 | S3 | **Migrations run during the build while the old deployment still serves.** A destructive migration breaks the live release. | AUTH-T16: expand/contract rule documented and required. | +| B15 | S3 | **Shared Neon test branch with parallel CI runs** collides; per-run branches hit the 10-branch cap. | AUTH-T06/T93: Actions concurrency group serializes runs; branch reset before each. | +| B16 | S3 | **Audit `type` CHECK constraint from the catalog** forces a migration for every new event. | TS-level validation only. AUTH-T13, T73. | +| B17 | S3 | **50 ms timing assertion in CI** would be flaky. | Non-gating benchmark; bodies and status codes asserted. AUTH-T70. | +| B18 | S3 | **Verification re-send on every sign-in attempt** by an unverified user (Better Auth behavior) is an email-flood lever. | Documented in AUTH-T17; bounded by mailer per-recipient caps and sign-in rate limits. | +| B19 | S3 | **Position key collisions** between org roles and product roles. | Naming convention in AUTH-T44: bare keys for org roles, `-` prefix for product roles. | +| B20 | S3 | **Better Auth admin plugin fields** (`impersonatedBy`) were listed but the plugin is not used. | Removed; custom authz only. AUTH-T10. | +| B21 | S3 | **Invited (password-less) user tries to self-sign-up** and gets a confusing no-op. | AUTH-T22: copy routes them to set-password/forgot. | ## C. Accepted risks (documented, no change) -| # | Sev | Risk | Why accepted / residual control | -|---|---|---|---| -| C1 | S2 | **Cookie tossing / login CSRF from any `*.northeasternsga.com` host.** A compromised or dangling subdomain can set a same-named cookie carrying an attacker's *valid* session, logging the victim into the attacker's account. `__Host-` cannot be used with a Domain attribute. | Inherent to the parent-domain cookie decision. Cookie values are signed (no forgery). Controls: AUTH-T68 DNS hygiene and subdomain policy, AUTH-T104 observes the behavior, threat model records it. Alternative (OIDC per product) was rejected for complexity. | -| C2 | S2 | **Neon Free cold starts** (1–3 s after 5 idle minutes) on the first login of a quiet period, and the A1 quota cliff. | Eli's decision; monitored by AUTH-T101. | -| C3 | S3 | **JWT staleness ≤10 minutes** for Supabase products after revocation or position changes. | Bounded by TTL; "force re-login" exists; documented in the Supabase guide. | -| C4 | S3 | **SDK stale-if-error window** (up to 5 minutes by default) delays revocation only while SGAuth is down. | Availability trade-off chosen deliberately; bounded and configurable. | -| C5 | S3 | **Any admin can strip the Primary Admin's positions.** Affects product access only, never SGAuth authority; audited. | Simplicity; PA can restore instantly. | -| C6 | S3 | **12-month inactivity tombstone vs co-op or leave.** A student away 12 months loses positions and must re-register. | With tombstones the data cost is low; admins are exempt; notice at 11 months. Could extend to 24 months later without code changes. | -| C7 | S3 | **Northeastern mailbox loss after graduation** blocks password reset for alumni. | Admin email change (AUTH-T24) or account naturally tombstones. | -| C8 | S3 | **10-branch cap** (Free and Launch) with personal, preview, and fixed branches. | AUTH-T09 cleanup, warning at 8; personal branches short-lived. | -| C9 | S3 | **Audit anonymization gate is not a security boundary** against the DB role. | Stated in AUTH-T13; DB credentials are the boundary. | -| C10 | S3 | **Chambers PWA offline login page** becomes a redirect to SGAuth when offline. | Chambers-C02 keeps the offline message; SGAuth cannot work offline by design. | +| # | Sev | Risk | Why accepted / residual control | +| --- | --- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| C1 | S2 | **Cookie tossing / login CSRF from any `*.northeasternsga.com` host.** A compromised or dangling subdomain can set a same-named cookie carrying an attacker's _valid_ session, logging the victim into the attacker's account. `__Host-` cannot be used with a Domain attribute. | Inherent to the parent-domain cookie decision. Cookie values are signed (no forgery). Controls: AUTH-T68 DNS hygiene and subdomain policy, AUTH-T104 observes the behavior, threat model records it. Alternative (OIDC per product) was rejected for complexity. | +| C2 | S2 | **Neon Free cold starts** (1–3 s after 5 idle minutes) on the first login of a quiet period, and the A1 quota cliff. | Eli's decision; monitored by AUTH-T101. | +| C3 | S3 | **JWT staleness ≤10 minutes** for Supabase products after revocation or position changes. | Bounded by TTL; "force re-login" exists; documented in the Supabase guide. | +| C4 | S3 | **SDK stale-if-error window** (up to 5 minutes by default) delays revocation only while SGAuth is down. | Availability trade-off chosen deliberately; bounded and configurable. | +| C5 | S3 | **Any admin can strip the Primary Admin's positions.** Affects product access only, never SGAuth authority; audited. | Simplicity; PA can restore instantly. | +| C6 | S3 | **12-month inactivity tombstone vs co-op or leave.** A student away 12 months loses positions and must re-register. | With tombstones the data cost is low; admins are exempt; notice at 11 months. Could extend to 24 months later without code changes. | +| C7 | S3 | **Northeastern mailbox loss after graduation** blocks password reset for alumni. | Admin email change (AUTH-T24) or account naturally tombstones. | +| C8 | S3 | **10-branch cap** (Free and Launch) with personal, preview, and fixed branches. | AUTH-T09 cleanup, warning at 8; personal branches short-lived. | +| C9 | S3 | **Audit anonymization gate is not a security boundary** against the DB role. | Stated in AUTH-T13; DB credentials are the boundary. | +| C10 | S3 | **Chambers PWA offline login page** becomes a redirect to SGAuth when offline. | Chambers-C02 keeps the offline message; SGAuth cannot work offline by design. | ## D. Items verified as sound diff --git a/docs/sgauth-design/build.mjs b/docs/sgauth-design/build.mjs index 5db5d09..71b82f8 100644 --- a/docs/sgauth-design/build.mjs +++ b/docs/sgauth-design/build.mjs @@ -21,36 +21,62 @@ const PHASES = { // ---------- validation ---------- const all = [ - ...TICKETS.map((t) => ({ ...t, team: "AUTH", epicName: EPICS[t.epic].name, epicLabel: EPICS[t.epic].label })), - ...PRODUCT_TICKETS.map((t) => ({ ...t, epicName: PRODUCT_TEAMS[t.team].epic, epicLabel: PRODUCT_TEAMS[t.team].label, phase: PRODUCT_TEAMS[t.team].phase })), + ...TICKETS.map((t) => ({ + ...t, + team: "AUTH", + epicName: EPICS[t.epic].name, + epicLabel: EPICS[t.epic].label, + })), + ...PRODUCT_TICKETS.map((t) => ({ + ...t, + epicName: PRODUCT_TEAMS[t.team].epic, + epicLabel: PRODUCT_TEAMS[t.team].label, + phase: PRODUCT_TEAMS[t.team].phase, + })), ]; const byId = new Map(all.map((t) => [t.id, t])); const errors = []; for (const t of all) { if (byId.get(t.id) !== t) errors.push(`duplicate id ${t.id}`); - if (!["Urgent", "High", "Medium", "Low"].includes(t.priority)) errors.push(`${t.id}: bad priority`); - if (![1, 2, 3, 5, 8].includes(t.estimate)) errors.push(`${t.id}: bad estimate`); + if (!["Urgent", "High", "Medium", "Low"].includes(t.priority)) + errors.push(`${t.id}: bad priority`); + if (![1, 2, 3, 5, 8].includes(t.estimate)) + errors.push(`${t.id}: bad estimate`); if (!t.acceptance?.length) errors.push(`${t.id}: no acceptance criteria`); - if (t.description.trim().startsWith("'")) errors.push(`${t.id}: description starts with an apostrophe (Linear strips it)`); - for (const d of t.deps) if (!byId.has(d)) errors.push(`${t.id}: unknown dep ${d}`); + if (t.description.trim().startsWith("'")) + errors.push( + `${t.id}: description starts with an apostrophe (Linear strips it)`, + ); + for (const d of t.deps) + if (!byId.has(d)) errors.push(`${t.id}: unknown dep ${d}`); } // cycle check -const visiting = new Set(), done = new Set(); +const visiting = new Set(), + done = new Set(); function visit(id, path) { if (done.has(id)) return; - if (visiting.has(id)) { errors.push(`dependency cycle: ${[...path, id].join(" -> ")}`); return; } + if (visiting.has(id)) { + errors.push(`dependency cycle: ${[...path, id].join(" -> ")}`); + return; + } visiting.add(id); for (const d of byId.get(id).deps) visit(d, [...path, id]); - visiting.delete(id); done.add(id); + visiting.delete(id); + done.add(id); } for (const t of all) visit(t.id, []); -if (errors.length) { console.error(errors.join("\n")); process.exit(1); } +if (errors.length) { + console.error(errors.join("\n")); + process.exit(1); +} // ---------- helpers ---------- const csvCell = (v) => `"${String(v ?? "").replace(/"/g, '""')}"`; const titleOf = (id) => byId.get(id)?.title ?? ""; function descriptionFor(t) { - const deps = t.deps.length ? t.deps.map((d) => `${d} (${titleOf(d)})`).join("; ") : "none"; + const deps = t.deps.length + ? t.deps.map((d) => `${d} (${titleOf(d)})`).join("; ") + : "none"; const lines = [ `**Ticket:** ${t.id} `, `**Epic:** ${t.epicName} `, @@ -71,15 +97,36 @@ function labelsFor(t) { // ---------- CSV per team ---------- // Columns honored by Linear's CSV importer (case-sensitive): Title, Description, Priority, Estimate, Status, Labels. // Priority values must be the words Urgent/High/Medium/Low. Labels are ", "-separated. Status must match a workflow state name. -const HEADER = ["Title", "Description", "Priority", "Estimate", "Status", "Labels"]; +const HEADER = [ + "Title", + "Description", + "Priority", + "Estimate", + "Status", + "Labels", +]; const teams = ["AUTH", ...Object.keys(PRODUCT_TEAMS)]; const csvTeams = teams.filter((t) => !PRODUCT_TEAMS[t]?.noLinear); const files = []; for (const team of csvTeams) { const rows = all.filter((t) => t.team === team); - const csv = [HEADER.map(csvCell).join(",")] - .concat(rows.map((t) => [t.title, descriptionFor(t), t.priority, t.estimate, "Backlog", labelsFor(t)].map(csvCell).join(","))) - .join("\r\n") + "\r\n"; + const csv = + [HEADER.map(csvCell).join(",")] + .concat( + rows.map((t) => + [ + t.title, + descriptionFor(t), + t.priority, + t.estimate, + "Backlog", + labelsFor(t), + ] + .map(csvCell) + .join(","), + ), + ) + .join("\r\n") + "\r\n"; const name = `linear-import-${team}.csv`; writeFileSync(join(out, name), "" + csv, "utf8"); // BOM helps Excel; Linear's parser tolerates it files.push({ name, count: rows.length }); @@ -89,16 +136,28 @@ for (const team of csvTeams) { const md = []; md.push(`# SGAuth ticket set`); md.push(``); -md.push(`Generated from \`tickets-auth.mjs\` and \`tickets-products.mjs\`. ${all.length} tickets across ${teams.length} teams (${csvTeams.length} with Linear CSVs; Chambers has no Linear team and is a checklist only). CSVs in \`out/\` use Linear's importer columns (Title, Description, Priority, Estimate, Status, Labels); each ticket's description carries its epic, phase, and dependencies because the importer does not create projects or parent links. Red-team revisions are folded in; see \`SGAuth-red-team.md\`.`); +md.push( + `Generated from \`tickets-auth.mjs\` and \`tickets-products.mjs\`. ${all.length} tickets across ${teams.length} teams (${csvTeams.length} with Linear CSVs; Chambers has no Linear team and is a checklist only). CSVs in \`out/\` use Linear's importer columns (Title, Description, Priority, Estimate, Status, Labels); each ticket's description carries its epic, phase, and dependencies because the importer does not create projects or parent links. Red-team revisions are folded in; see \`SGAuth-red-team.md\`.`, +); md.push(``); -md.push(`**Manual action item (not a ticket):** Eli exports the Chambers Supabase \`auth.users\` table (with \`encrypted_password\`) joined to \`public.users\` before that Supabase project is deleted, and stores the file in the team secrets vault. AUTH-T86 validates the file; AUTH-T87 imports it.`); +md.push( + `**Manual action item (not a ticket):** Eli exports the Chambers Supabase \`auth.users\` table (with \`encrypted_password\`) joined to \`public.users\` before that Supabase project is deleted, and stores the file in the team secrets vault. AUTH-T86 validates the file; AUTH-T87 imports it.`, +); md.push(``); md.push(`## Import instructions`); md.push(``); -md.push(`1. In Linear, create the projects listed under **Epics** below (one per epic) in the AUTH team, and one "SGAuth integration" project in each product team.`); -md.push(`2. Settings → Import/Export → Import → CSV. Import \`linear-import-AUTH.csv\` into team AUTH; import each product file into that product's team. Map columns as-is (headers match Linear's expected names).`); -md.push(`3. After import, filter by label \`epic:*\` and bulk-move issues into the matching project. Dependencies are written as text (\`Depends on: AUTH-T03 (...)\`); add "blocked by" relations while triaging.`); -md.push(`4. Estimates are Fibonacci points (1/2/3/5/8). Set each team's estimate scale to Fibonacci before importing so values map cleanly.`); +md.push( + `1. In Linear, create the projects listed under **Epics** below (one per epic) in the AUTH team, and one "SGAuth integration" project in each product team.`, +); +md.push( + `2. Settings → Import/Export → Import → CSV. Import \`linear-import-AUTH.csv\` into team AUTH; import each product file into that product's team. Map columns as-is (headers match Linear's expected names).`, +); +md.push( + `3. After import, filter by label \`epic:*\` and bulk-move issues into the matching project. Dependencies are written as text (\`Depends on: AUTH-T03 (...)\`); add "blocked by" relations while triaging.`, +); +md.push( + `4. Estimates are Fibonacci points (1/2/3/5/8). Set each team's estimate scale to Fibonacci before importing so values map cleanly.`, +); md.push(``); md.push(`## Phases`); md.push(``); @@ -111,7 +170,9 @@ md.push(``); for (const [k, e] of Object.entries(EPICS)) { const n = TICKETS.filter((t) => t.epic === k); const pts = n.reduce((s, t) => s + t.estimate, 0); - md.push(`- **${e.name}** (label \`${e.label}\`, ${n.length} tickets, ${pts} points): ${e.description}`); + md.push( + `- **${e.name}** (label \`${e.label}\`, ${n.length} tickets, ${pts} points): ${e.description}`, + ); } md.push(``); md.push(`### Product teams`); @@ -119,14 +180,19 @@ md.push(``); for (const [k, e] of Object.entries(PRODUCT_TEAMS)) { const n = PRODUCT_TICKETS.filter((t) => t.team === k); const pts = n.reduce((s, t) => s + t.estimate, 0); - md.push(`- **${e.epic}** (team ${e.name}, ${n.length} tickets, ${pts} points, ${PHASES[e.phase]}): ${e.description}`); + md.push( + `- **${e.epic}** (team ${e.name}, ${n.length} tickets, ${pts} points, ${PHASES[e.phase]}): ${e.description}`, + ); } md.push(``); md.push(`## Summary table`); md.push(``); md.push(`| ID | Title | Team | Phase | Priority | Est. | Depends on |`); md.push(`|---|---|---|---|---|---|---|`); -for (const t of all) md.push(`| ${t.id} | ${t.title} | ${t.team} | ${t.phase} | ${t.priority} | ${t.estimate} | ${t.deps.join(", ") || "—"} |`); +for (const t of all) + md.push( + `| ${t.id} | ${t.title} | ${t.team} | ${t.phase} | ${t.priority} | ${t.estimate} | ${t.deps.join(", ") || "—"} |`, + ); md.push(``); for (const team of teams) { @@ -136,15 +202,22 @@ for (const team of teams) { md.push(`# Team ${team}`); md.push(``); const groups = new Map(); - for (const t of rows) { if (!groups.has(t.epicName)) groups.set(t.epicName, []); groups.get(t.epicName).push(t); } + for (const t of rows) { + if (!groups.has(t.epicName)) groups.set(t.epicName, []); + groups.get(t.epicName).push(t); + } for (const [epic, list] of groups) { md.push(`## ${epic}`); md.push(``); for (const t of list) { md.push(`### ${t.id} — ${t.title}`); md.push(``); - md.push(`**Priority:** ${t.priority} · **Estimate:** ${t.estimate} · **Phase:** ${PHASES[t.phase]} · **Labels:** ${labelsFor(t)} `); - md.push(`**Depends on:** ${t.deps.length ? t.deps.map((d) => `${d} (${titleOf(d)})`).join("; ") : "none"}`); + md.push( + `**Priority:** ${t.priority} · **Estimate:** ${t.estimate} · **Phase:** ${PHASES[t.phase]} · **Labels:** ${labelsFor(t)} `, + ); + md.push( + `**Depends on:** ${t.deps.length ? t.deps.map((d) => `${d} (${titleOf(d)})`).join("; ") : "none"}`, + ); md.push(``); md.push(t.description.trim()); md.push(``); @@ -160,7 +233,8 @@ writeFileSync(join(out, "SGAuth-tickets.md"), md.join("\n"), "utf8"); const stats = {}; for (const t of all) { stats[t.team] ??= { tickets: 0, points: 0, byPhase: {} }; - stats[t.team].tickets++; stats[t.team].points += t.estimate; + stats[t.team].tickets++; + stats[t.team].points += t.estimate; stats[t.team].byPhase[t.phase] = (stats[t.team].byPhase[t.phase] ?? 0) + 1; } console.log(JSON.stringify({ files, stats, total: all.length }, null, 2)); diff --git a/docs/sgauth-design/gh-issues.mjs b/docs/sgauth-design/gh-issues.mjs index 27ee1ad..0f4c67f 100644 --- a/docs/sgauth-design/gh-issues.mjs +++ b/docs/sgauth-design/gh-issues.mjs @@ -16,7 +16,13 @@ const MAP_FILE = join(here, "issue-map.json"); const TMP = join(here, "out", "tmp"); mkdirSync(TMP, { recursive: true }); -const REPOS = { AUTH: "auth", VAULTZ: "vaultz", CHAMBERS: "chambers", APLIO: "aplio", SENATEPATH: "senate-path" }; // ATTENDANCE (SenatePortal) skipped +const REPOS = { + AUTH: "auth", + VAULTZ: "vaultz", + CHAMBERS: "chambers", + APLIO: "aplio", + SENATEPATH: "senate-path", +}; // ATTENDANCE (SenatePortal) skipped const ORG = "SGAOperations"; const PHASES = { 0: "Phase 0 — Foundation", @@ -29,13 +35,26 @@ const PHASES = { const LABEL_COLORS = { epic: "5319e7", phase: "0e8a16", default: "c5def5" }; const all = [ - ...TICKETS.map((t) => ({ ...t, team: "AUTH", epicName: EPICS[t.epic].name, epicLabel: EPICS[t.epic].label })), - ...PRODUCT_TICKETS.map((t) => ({ ...t, epicName: PRODUCT_TEAMS[t.team].epic, epicLabel: PRODUCT_TEAMS[t.team].label, phase: PRODUCT_TEAMS[t.team].phase })), + ...TICKETS.map((t) => ({ + ...t, + team: "AUTH", + epicName: EPICS[t.epic].name, + epicLabel: EPICS[t.epic].label, + })), + ...PRODUCT_TICKETS.map((t) => ({ + ...t, + epicName: PRODUCT_TEAMS[t.team].epic, + epicLabel: PRODUCT_TEAMS[t.team].label, + phase: PRODUCT_TEAMS[t.team].phase, + })), ].filter((t) => REPOS[t.team]); const byId = new Map(all.map((t) => [t.id, t])); -const map = existsSync(MAP_FILE) ? JSON.parse(readFileSync(MAP_FILE, "utf8")) : {}; -const saveMap = () => writeFileSync(MAP_FILE, JSON.stringify(map, null, 2) + "\n"); +const map = existsSync(MAP_FILE) + ? JSON.parse(readFileSync(MAP_FILE, "utf8")) + : {}; +const saveMap = () => + writeFileSync(MAP_FILE, JSON.stringify(map, null, 2) + "\n"); function gh(args, input) { const opts = { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }; @@ -51,12 +70,16 @@ function depRef(id, fromTeam) { if (!t) return id; if (!m) return `${id} (${t.title})`; const sameRepo = REPOS[t.team] === REPOS[fromTeam]; - const link = sameRepo ? `#${m.number}` : `${ORG}/${REPOS[t.team]}#${m.number}`; + const link = sameRepo + ? `#${m.number}` + : `${ORG}/${REPOS[t.team]}#${m.number}`; return `${link} — ${id} ${t.title}`; } function body(t) { - const deps = t.deps.length ? t.deps.map((d) => `- ${depRef(d, t.team)}`).join("\n") : "- none"; + const deps = t.deps.length + ? t.deps.map((d) => `- ${depRef(d, t.team)}`).join("\n") + : "- none"; return [ `**Ticket:** ${t.id} · **Epic:** ${t.epicName} · **Phase:** ${PHASES[t.phase]}`, `**Priority:** ${t.priority} · **Estimate:** ${t.estimate} points · **Labels:** ${labelsFor(t).join(", ")}`, @@ -76,14 +99,21 @@ function body(t) { async function createLabels(team) { const repo = `${ORG}/${REPOS[team]}`; const names = new Set(); - for (const t of all.filter((x) => x.team === team)) labelsFor(t).forEach((l) => names.add(l)); + for (const t of all.filter((x) => x.team === team)) + labelsFor(t).forEach((l) => names.add(l)); for (const name of names) { - const color = name.startsWith("epic:") ? LABEL_COLORS.epic : name.startsWith("phase:") ? LABEL_COLORS.phase : LABEL_COLORS.default; + const color = name.startsWith("epic:") + ? LABEL_COLORS.epic + : name.startsWith("phase:") + ? LABEL_COLORS.phase + : LABEL_COLORS.default; try { gh(["label", "create", name, "-R", repo, "--color", color, "--force"]); console.log(`label ok: ${repo} ${name}`); } catch (e) { - console.error(`label FAILED: ${repo} ${name}: ${String(e.stderr || e.message).trim()}`); + console.error( + `label FAILED: ${repo} ${name}: ${String(e.stderr || e.message).trim()}`, + ); return false; } } @@ -95,13 +125,25 @@ async function create(team, { withLabels }) { const rows = all.filter((t) => t.team === team); let n = 0; for (const t of rows) { - if (map[t.id]) { console.log(`skip (exists): ${t.id} -> #${map[t.id].number}`); continue; } + if (map[t.id]) { + console.log(`skip (exists): ${t.id} -> #${map[t.id].number}`); + continue; + } const payload = { title: t.title, body: body(t) }; if (withLabels) payload.labels = labelsFor(t); const file = join(TMP, `${t.id}.json`); writeFileSync(file, JSON.stringify(payload)); try { - const res = JSON.parse(gh(["api", `repos/${repo}/issues`, "--method", "POST", "--input", file])); + const res = JSON.parse( + gh([ + "api", + `repos/${repo}/issues`, + "--method", + "POST", + "--input", + file, + ]), + ); map[t.id] = { repo, number: res.number, url: res.html_url }; saveMap(); console.log(`created: ${t.id} -> ${res.html_url}`); @@ -125,11 +167,20 @@ async function link() { const file = join(TMP, `${t.id}.patch.json`); writeFileSync(file, JSON.stringify(payload)); try { - gh(["api", `repos/${m.repo}/issues/${m.number}`, "--method", "PATCH", "--input", file]); + gh([ + "api", + `repos/${m.repo}/issues/${m.number}`, + "--method", + "PATCH", + "--input", + file, + ]); console.log(`linked: ${t.id} (#${m.number})`); n++; } catch (e) { - console.error(`FAILED link ${t.id}: ${String(e.stderr || e.message).trim()}`); + console.error( + `FAILED link ${t.id}: ${String(e.stderr || e.message).trim()}`, + ); process.exit(1); } await sleep(1500); @@ -145,11 +196,20 @@ async function relabel(team) { const file = join(TMP, `${t.id}.labels.json`); writeFileSync(file, JSON.stringify({ labels: labelsFor(t) })); try { - gh(["api", `repos/${m.repo}/issues/${m.number}/labels`, "--method", "PUT", "--input", file]); + gh([ + "api", + `repos/${m.repo}/issues/${m.number}/labels`, + "--method", + "PUT", + "--input", + file, + ]); console.log(`relabeled: ${t.id} (#${m.number})`); n++; } catch (e) { - console.error(`FAILED relabel ${t.id}: ${String(e.stderr || e.message).trim()}`); + console.error( + `FAILED relabel ${t.id}: ${String(e.stderr || e.message).trim()}`, + ); process.exit(1); } await sleep(1500); @@ -162,16 +222,29 @@ async function update(ids) { const targets = ids.length ? ids : all.map((t) => t.id); let n = 0; for (const id of targets) { - const t = byId.get(id), m = map[id]; - if (!t || !m) { console.log(`skip (not created): ${id}`); continue; } + const t = byId.get(id), + m = map[id]; + if (!t || !m) { + console.log(`skip (not created): ${id}`); + continue; + } const file = join(TMP, `${id}.update.json`); writeFileSync(file, JSON.stringify({ title: t.title, body: body(t) })); try { - gh(["api", `repos/${m.repo}/issues/${m.number}`, "--method", "PATCH", "--input", file]); + gh([ + "api", + `repos/${m.repo}/issues/${m.number}`, + "--method", + "PATCH", + "--input", + file, + ]); console.log(`updated: ${id} (#${m.number})`); n++; } catch (e) { - console.error(`FAILED update ${id}: ${String(e.stderr || e.message).trim()}`); + console.error( + `FAILED update ${id}: ${String(e.stderr || e.message).trim()}`, + ); process.exit(1); } await sleep(1500); @@ -180,9 +253,21 @@ async function update(ids) { } const [cmd, team, flag] = process.argv.slice(2); -if (cmd === "update") { await update(process.argv.slice(3)); process.exit(0); } -if (cmd === "labels") { if (!REPOS[team]) throw new Error("unknown team"); await createLabels(team); } -else if (cmd === "relabel") { if (!REPOS[team]) throw new Error("unknown team"); await relabel(team); } -else if (cmd === "create") { if (!REPOS[team]) throw new Error("unknown team"); await create(team, { withLabels: flag !== "--no-labels" }); } -else if (cmd === "link") await link(); -else console.log("usage: node gh-issues.mjs labels TEAM | create TEAM [--no-labels] | link"); +if (cmd === "update") { + await update(process.argv.slice(3)); + process.exit(0); +} +if (cmd === "labels") { + if (!REPOS[team]) throw new Error("unknown team"); + await createLabels(team); +} else if (cmd === "relabel") { + if (!REPOS[team]) throw new Error("unknown team"); + await relabel(team); +} else if (cmd === "create") { + if (!REPOS[team]) throw new Error("unknown team"); + await create(team, { withLabels: flag !== "--no-labels" }); +} else if (cmd === "link") await link(); +else + console.log( + "usage: node gh-issues.mjs labels TEAM | create TEAM [--no-labels] | link", + ); diff --git a/docs/sgauth-design/tickets-auth.mjs b/docs/sgauth-design/tickets-auth.mjs index 8478c1d..a795c99 100644 --- a/docs/sgauth-design/tickets-auth.mjs +++ b/docs/sgauth-design/tickets-auth.mjs @@ -3,25 +3,96 @@ // labels (array), deps (array of ids), description (markdown), acceptance (array of strings). export const EPICS = { - foundation: { name: "E1 Foundation & Neon Migration", label: "epic:foundation", description: "Replace the Supabase foundation with Neon serverless Postgres, Better Auth, Vercel hosting, CI, and environment tooling. SGAuth MUST run on Neon and MUST NOT use Supabase for auth or data." }, - datamodel: { name: "E2 Data Model & Migrations", label: "epic:data-model", description: "Prisma 7 schema on Neon: users, Better Auth tables, positions, admin/Primary Admin invariants, audit log, product registry, security tables, and the migration workflow." }, - authcore: { name: "E3 Authentication Core", label: "epic:auth-core", description: "Email + password authentication with Better Auth: sign-up restricted to northeastern.edu, verification, reset, invites, imported bcrypt hashes, and the login UI." }, - sessions: { name: "E4 Sessions & SSO", label: "epic:sessions", description: "One session across *.northeasternsga.com via a parent-domain cookie, the session endpoint products call, ES256 JWTs + JWKS for Supabase products, logout propagation, redirects, re-authentication, and the non-production topology." }, - admin: { name: "E5 Admin & Primary Admin", label: "epic:admin", description: "Server-enforced admin rules, the single transferable Primary Admin with a guarded transfer flow, break-glass recovery, and bulk user administration." }, - positions: { name: "E6 Positions", label: "epic:positions", description: "Flat, admin-managed positions (stable key + display name) carried in every session; assignment, soft delete, retirement, propagation, and history." }, - ui: { name: "E7 Admin UI & Account UI", label: "epic:ui", description: "Light admin UI (users, positions, products, audit, PA transfer) and a minimal account page for every user (profile, positions, product links, sessions, password, MFA)." }, - sdk: { name: "E8 Product Registry & SDK", label: "epic:sdk", description: "Admin-managed product registry that drives trusted origins and redirects, and the published @sgaoperations/sgauth package products use to read sessions and tokens." }, - security: { name: "E9 Security Hardening", label: "epic:security", description: "Upstash rate limiting, escalating account lockout, CSRF and origin enforcement, headers, TOTP MFA (required for admins), subdomain hygiene, secrets, enumeration resistance, scanning, and a threat model." }, - observability: { name: "E10 Observability & Audit", label: "epic:observability", description: "Append-only audit log with full coverage, structured logs, PostHog analytics and error tracking, health and uptime, alerts, and retention/deletion jobs." }, - docs: { name: "E11 Integration Guides & Docs", label: "epic:docs", description: "Architecture doc (Neon mandate), Neon-product and Supabase-product integration guides, admin runbooks, privacy notice, SDK reference, contributor guide." }, - rollout: { name: "E12 User Migration & Rollout", label: "epic:rollout", description: "Export/import of existing product users (Chambers hashes, Aplio/SenatePath/Attendance emails), launch checklist, rollout comms, cutover and rollback plans." }, - testing: { name: "E13 Testing & QA", label: "epic:testing", description: "Test harness on a Neon test branch, integration tests for every auth flow, authorization matrix, Playwright SSO end-to-end, JWT/JWKS conformance, and load sanity." }, + foundation: { + name: "E1 Foundation & Neon Migration", + label: "epic:foundation", + description: + "Replace the Supabase foundation with Neon serverless Postgres, Better Auth, Vercel hosting, CI, and environment tooling. SGAuth MUST run on Neon and MUST NOT use Supabase for auth or data.", + }, + datamodel: { + name: "E2 Data Model & Migrations", + label: "epic:data-model", + description: + "Prisma 7 schema on Neon: users, Better Auth tables, positions, admin/Primary Admin invariants, audit log, product registry, security tables, and the migration workflow.", + }, + authcore: { + name: "E3 Authentication Core", + label: "epic:auth-core", + description: + "Email + password authentication with Better Auth: sign-up restricted to northeastern.edu, verification, reset, invites, imported bcrypt hashes, and the login UI.", + }, + sessions: { + name: "E4 Sessions & SSO", + label: "epic:sessions", + description: + "One session across *.northeasternsga.com via a parent-domain cookie, the session endpoint products call, ES256 JWTs + JWKS for Supabase products, logout propagation, redirects, re-authentication, and the non-production topology.", + }, + admin: { + name: "E5 Admin & Primary Admin", + label: "epic:admin", + description: + "Server-enforced admin rules, the single transferable Primary Admin with a guarded transfer flow, break-glass recovery, and bulk user administration.", + }, + positions: { + name: "E6 Positions", + label: "epic:positions", + description: + "Flat, admin-managed positions (stable key + display name) carried in every session; assignment, soft delete, retirement, propagation, and history.", + }, + ui: { + name: "E7 Admin UI & Account UI", + label: "epic:ui", + description: + "Light admin UI (users, positions, products, audit, PA transfer) and a minimal account page for every user (profile, positions, product links, sessions, password, MFA).", + }, + sdk: { + name: "E8 Product Registry & SDK", + label: "epic:sdk", + description: + "Admin-managed product registry that drives trusted origins and redirects, and the published @sgaoperations/sgauth package products use to read sessions and tokens.", + }, + security: { + name: "E9 Security Hardening", + label: "epic:security", + description: + "Upstash rate limiting, escalating account lockout, CSRF and origin enforcement, headers, TOTP MFA (required for admins), subdomain hygiene, secrets, enumeration resistance, scanning, and a threat model.", + }, + observability: { + name: "E10 Observability & Audit", + label: "epic:observability", + description: + "Append-only audit log with full coverage, structured logs, PostHog analytics and error tracking, health and uptime, alerts, and retention/deletion jobs.", + }, + docs: { + name: "E11 Integration Guides & Docs", + label: "epic:docs", + description: + "Architecture doc (Neon mandate), Neon-product and Supabase-product integration guides, admin runbooks, privacy notice, SDK reference, contributor guide.", + }, + rollout: { + name: "E12 User Migration & Rollout", + label: "epic:rollout", + description: + "Export/import of existing product users (Chambers hashes, Aplio/SenatePath/Attendance emails), launch checklist, rollout comms, cutover and rollback plans.", + }, + testing: { + name: "E13 Testing & QA", + label: "epic:testing", + description: + "Test harness on a Neon test branch, integration tests for every auth flow, authorization matrix, Playwright SSO end-to-end, JWT/JWKS conformance, and load sanity.", + }, }; export const TICKETS = [ // ───────────────────────── E1 Foundation & Neon Migration ───────────────────────── { - id: "AUTH-T01", epic: "foundation", phase: 0, priority: "Urgent", estimate: 2, labels: ["infra", "neon"], deps: [], + id: "AUTH-T01", + epic: "foundation", + phase: 0, + priority: "Urgent", + estimate: 2, + labels: ["infra", "neon"], + deps: [], title: "Provision the Neon project, branches, and roles for SGAuth", description: `Create the production Neon project that SGAuth is built on. **SGAuth is built on Neon serverless Postgres and MUST NOT use Supabase in any form** (no Supabase Auth, no Supabase Postgres, no Supabase client libraries). @@ -43,7 +114,13 @@ Reference: Neon Prisma guide (pooled vs direct URLs), Neon free plan limits (10 ], }, { - id: "AUTH-T02", epic: "foundation", phase: 0, priority: "Urgent", estimate: 3, labels: ["neon", "chore"], deps: [], + id: "AUTH-T02", + epic: "foundation", + phase: 0, + priority: "Urgent", + estimate: 3, + labels: ["neon", "chore"], + deps: [], title: "Remove Supabase from the auth repo (in-place migration to Neon)", description: `The current repo scaffolds Supabase auth and DB. Strip it entirely so the codebase reflects the Neon mandate. @@ -61,8 +138,15 @@ Also triage open branches that build on Supabase (AUTH-7, AUTH-8, auth-9, auth-1 ], }, { - id: "AUTH-T03", epic: "foundation", phase: 0, priority: "Urgent", estimate: 3, labels: ["backend", "better-auth"], deps: ["AUTH-T02", "AUTH-T04"], - title: "Install Better Auth 1.7 with the Prisma adapter and mount the handler", + id: "AUTH-T03", + epic: "foundation", + phase: 0, + priority: "Urgent", + estimate: 3, + labels: ["backend", "better-auth"], + deps: ["AUTH-T02", "AUTH-T04"], + title: + "Install Better Auth 1.7 with the Prisma adapter and mount the handler", description: `Add self-managed Better Auth (latest 1.7.x) and \`@better-auth/prisma-adapter\` on Neon, mirroring the pattern Aplio already uses. - \`src/lib/auth/config.ts\`: \`betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql' }), baseURL, secret, trustedOrigins, advanced: { database: { generateId: false } }, plugins: [nextCookies()] })\`. Prisma generates \`uuid(7)\` ids. @@ -80,7 +164,13 @@ Plugins (jwt, admin, twoFactor, customSession) are added in their own tickets.`, ], }, { - id: "AUTH-T04", epic: "foundation", phase: 0, priority: "High", estimate: 2, labels: ["backend", "neon", "prisma"], deps: ["AUTH-T01"], + id: "AUTH-T04", + epic: "foundation", + phase: 0, + priority: "High", + estimate: 2, + labels: ["backend", "neon", "prisma"], + deps: ["AUTH-T01"], title: "Configure Prisma 7 for Neon (pooled runtime, direct migrations)", description: `Wire Prisma 7 to Neon following Neon's Prisma guide. @@ -95,8 +185,15 @@ Plugins (jwt, admin, twoFactor, customSession) are added in their own tickets.`, ], }, { - id: "AUTH-T05", epic: "foundation", phase: 0, priority: "High", estimate: 3, labels: ["infra", "vercel", "neon"], deps: ["AUTH-T01", "AUTH-T03"], - title: "Create the Vercel project, custom domains, environments, and Neon preview branching", + id: "AUTH-T05", + epic: "foundation", + phase: 0, + priority: "High", + estimate: 3, + labels: ["infra", "vercel", "neon"], + deps: ["AUTH-T01", "AUTH-T03"], + title: + "Create the Vercel project, custom domains, environments, and Neon preview branching", description: `Host SGAuth on Vercel (Node runtime, not Edge, because of Prisma). - Vercel project \`sgauth\` linked to the repo; production branch \`main\` → \`auth.northeasternsga.com\`; a protected \`dev\` branch → \`auth-dev.northeasternsga.com\` (see AUTH-T34 for why a dev SGAuth deployment on the real parent domain is required for product previews). @@ -113,8 +210,15 @@ Plugins (jwt, admin, twoFactor, customSession) are added in their own tickets.`, ], }, { - id: "AUTH-T06", epic: "foundation", phase: 0, priority: "High", estimate: 3, labels: ["ci", "chore"], deps: ["AUTH-T03", "AUTH-T93"], - title: "Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch", + id: "AUTH-T06", + epic: "foundation", + phase: 0, + priority: "High", + estimate: 3, + labels: ["ci", "chore"], + deps: ["AUTH-T03", "AUTH-T93"], + title: + "Update CI: typecheck, lint, format, migrations check, and tests on a Neon test branch", description: `Extend \`.github/workflows/ci.yml\` for the new stack. Jobs: typecheck, lint, format:check (existing), plus: @@ -129,7 +233,13 @@ Jobs: typecheck, lint, format:check (existing), plus: ], }, { - id: "AUTH-T07", epic: "foundation", phase: 0, priority: "Medium", estimate: 1, labels: ["backend", "chore"], deps: ["AUTH-T02"], + id: "AUTH-T07", + epic: "foundation", + phase: 0, + priority: "Medium", + estimate: 1, + labels: ["backend", "chore"], + deps: ["AUTH-T02"], title: "Validate environment variables at startup with zod", description: `Add \`src/lib/env.ts\` that parses \`process.env\` with zod (server and public schemas separated) and fails fast with a readable error listing missing/invalid vars. Rewrite \`.env.example\` to the final variable list: \`DATABASE_URL\`, \`DIRECT_URL\`, \`BETTER_AUTH_SECRET\`, \`BETTER_AUTH_URL\`, \`SGAUTH_COOKIE_DOMAIN\`, \`SGAUTH_COOKIE_PREFIX\`, \`SGAUTH_ENV\` (production|dev|preview|local), \`RESEND_API_KEY\`, \`RESEND_FROM_EMAIL\`, \`UPSTASH_REDIS_REST_URL\`, \`UPSTASH_REDIS_REST_TOKEN\`, \`POSTHOG_KEY\`, \`POSTHOG_HOST\`, \`CRON_SECRET\`, \`NEON_API_KEY\` (CI only).`, acceptance: [ @@ -139,7 +249,13 @@ Jobs: typecheck, lint, format:check (existing), plus: ], }, { - id: "AUTH-T08", epic: "foundation", phase: 0, priority: "Medium", estimate: 2, labels: ["docs", "chore"], deps: ["AUTH-T10", "AUTH-T44"], + id: "AUTH-T08", + epic: "foundation", + phase: 0, + priority: "Medium", + estimate: 2, + labels: ["docs", "chore"], + deps: ["AUTH-T10", "AUTH-T44"], title: "Rewrite README and developer bootstrap for Neon branches", description: `Replace the Docker/Supabase setup with: create a personal Neon branch from \`dev\` (\`neonctl branches create --parent dev --name \`), copy its URLs into \`.env\`, run \`npx prisma migrate dev\`, run \`npm run seed\` (curated positions + a local Primary Admin from \`SEED_PRIMARY_ADMIN_EMAIL\`), then \`npm run dev\`. Include the local SSO hostname setup from AUTH-T34 and the scripts table.`, acceptance: [ @@ -148,7 +264,13 @@ Jobs: typecheck, lint, format:check (existing), plus: ], }, { - id: "AUTH-T09", epic: "foundation", phase: 0, priority: "Medium", estimate: 2, labels: ["infra", "neon"], deps: ["AUTH-T05"], + id: "AUTH-T09", + epic: "foundation", + phase: 0, + priority: "Medium", + estimate: 2, + labels: ["infra", "neon"], + deps: ["AUTH-T05"], title: "Automate Neon branch hygiene to stay under plan limits", description: `The free plan allows 10 branches per project. Add a scheduled GitHub Action (daily) that lists branches via the Neon API and deletes preview branches whose PR is closed/merged or that are older than 7 days, excluding \`main\`, \`dev\`, \`test\`, and branches matching \`dev-*\` (personal). Post a summary to the workflow log.`, acceptance: [ @@ -159,8 +281,15 @@ Jobs: typecheck, lint, format:check (existing), plus: // ───────────────────────── E2 Data Model & Migrations ───────────────────────── { - id: "AUTH-T10", epic: "datamodel", phase: 0, priority: "High", estimate: 3, labels: ["backend", "prisma"], deps: ["AUTH-T03", "AUTH-T04"], - title: "Core schema: User plus Better Auth Session, Account, and Verification tables", + id: "AUTH-T10", + epic: "datamodel", + phase: 0, + priority: "High", + estimate: 3, + labels: ["backend", "prisma"], + deps: ["AUTH-T03", "AUTH-T04"], + title: + "Core schema: User plus Better Auth Session, Account, and Verification tables", description: `Define the identity model in \`prisma/schema.prisma\` (Neon). Better Auth core tables plus SGAuth fields: **User**: \`id\` (uuid v7, PK), \`email\` (unique, stored lower-cased; use \`citext\` or a lower-case check), \`emailVerified\`, \`name\` (required display name), \`preferredName\` (optional), \`isAdmin\` (bool), \`isPrimaryAdmin\` (bool), \`status\` (enum ACTIVE | DEACTIVATED | DELETED — DELETED is a tombstone: the row and id survive with PII scrubbed so product foreign keys stay valid), \`deactivatedAt\`, \`deletedAt\`, \`legacyEmail\` (bool; true for imported accounts whose address is not northeastern.edu), \`lastLoginAt\`, \`passwordChangedAt\`, \`twoFactorEnabled\` (plugin), \`createdAt\`, \`updatedAt\`. @@ -177,7 +306,13 @@ No NUID, phone, pronouns, or photo. Products keep extra fields keyed by SGAuth u ], }, { - id: "AUTH-T11", epic: "datamodel", phase: 1, priority: "High", estimate: 3, labels: ["backend", "prisma", "positions"], deps: ["AUTH-T10"], + id: "AUTH-T11", + epic: "datamodel", + phase: 1, + priority: "High", + estimate: 3, + labels: ["backend", "prisma", "positions"], + deps: ["AUTH-T10"], title: "Positions schema: Position, UserPosition, and retired keys", description: `**Position**: \`id\` (uuid v7), \`key\` (unique, immutable, regex \`^[a-z0-9]+(?:-[a-z0-9]+)*$\`, 2–64 chars), \`name\` (display, 2–100 chars, editable), \`category\` (optional text for admin grouping), \`description\` (optional), \`deletedAt\` (soft delete), \`createdById\`, \`updatedById\`, timestamps. **UserPosition**: composite PK (\`userId\`, \`positionId\`), \`assignedById\`, \`assignedAt\`. Unassignment deletes the row; history lives in the audit log. Soft-deleting a position keeps rows for history but they are excluded from sessions. @@ -190,8 +325,15 @@ DB-level: partial unique index on \`Position(key) WHERE deletedAt IS NULL\`; CHE ], }, { - id: "AUTH-T12", epic: "datamodel", phase: 1, priority: "High", estimate: 3, labels: ["backend", "prisma", "admin", "security"], deps: ["AUTH-T10"], - title: "Primary Admin invariants at the database level and the PrimaryAdminTransfer table", + id: "AUTH-T12", + epic: "datamodel", + phase: 1, + priority: "High", + estimate: 3, + labels: ["backend", "prisma", "admin", "security"], + deps: ["AUTH-T10"], + title: + "Primary Admin invariants at the database level and the PrimaryAdminTransfer table", description: `Enforce in Postgres, independent of application code: - Partial unique index: exactly one row may have \`isPrimaryAdmin = true\` (\`CREATE UNIQUE INDEX one_primary_admin ON "User" ((true)) WHERE "isPrimaryAdmin"\`). - Trigger \`protect_primary_admin\` BEFORE UPDATE/DELETE on User: reject setting \`isAdmin = false\`, \`status = DEACTIVATED\`, or deleting the row while \`isPrimaryAdmin = true\`; reject clearing \`isPrimaryAdmin\` unless the transaction has set the session variable \`sgauth.pa_transfer = 'on'\` (set only by the transfer service and the break-glass script). @@ -203,7 +345,13 @@ DB-level: partial unique index on \`Position(key) WHERE deletedAt IS NULL\`; CHE ], }, { - id: "AUTH-T13", epic: "datamodel", phase: 1, priority: "High", estimate: 2, labels: ["backend", "prisma", "audit"], deps: ["AUTH-T10"], + id: "AUTH-T13", + epic: "datamodel", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "prisma", "audit"], + deps: ["AUTH-T10"], title: "Append-only AuditEvent table", description: `**AuditEvent**: \`id\` (uuid v7), \`type\` (string enum from the event catalog in AUTH-T73), \`actorUserId\` (nullable; null for system/break-glass), \`actorType\` (USER | SYSTEM | BREAK_GLASS), \`targetType\` (USER | POSITION | PRODUCT | SESSION | TRANSFER), \`targetId\`, \`metadata\` (jsonb, no secrets), \`ip\`, \`userAgent\`, \`createdAt\`. Index on (\`type\`, \`createdAt\`), (\`actorUserId\`, \`createdAt\`), (\`targetType\`, \`targetId\`, \`createdAt\`). Append-only: a trigger rejects UPDATE and DELETE except for the anonymization job (AUTH-T78), which may null \`ip\`/\`userAgent\` and replace user-identifying metadata when a user is tombstoned, gated by the same session-variable pattern as AUTH-T12. The session-variable gate protects against accidental writes from application code; it is not a security boundary against anyone holding the database role. @@ -214,7 +362,13 @@ Append-only: a trigger rejects UPDATE and DELETE except for the anonymization jo ], }, { - id: "AUTH-T14", epic: "datamodel", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "prisma", "registry"], deps: ["AUTH-T10"], + id: "AUTH-T14", + epic: "datamodel", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["backend", "prisma", "registry"], + deps: ["AUTH-T10"], title: "Product registry schema", description: `**Product**: \`id\`, \`slug\` (unique, kebab-case), \`name\`, \`description\`, \`baseUrl\` (must be \`https://\` and end with \`.northeasternsga.com\`; validated in service and by CHECK), \`iconUrl\` (optional, https only), \`isActive\`, \`sortOrder\`, \`visibleToPositionKeys\` (text[]; empty = visible to everyone), \`loginRedirectPath\` (optional path appended after login), \`createdById\`, timestamps. Seed the dev branch with SGAuth itself plus VaultZ, Chambers, Aplio placeholders.`, acceptance: [ @@ -223,7 +377,13 @@ Append-only: a trigger rejects UPDATE and DELETE except for the anonymization jo ], }, { - id: "AUTH-T15", epic: "datamodel", phase: 1, priority: "Medium", estimate: 2, labels: ["backend", "prisma", "security"], deps: ["AUTH-T10"], + id: "AUTH-T15", + epic: "datamodel", + phase: 1, + priority: "Medium", + estimate: 2, + labels: ["backend", "prisma", "security"], + deps: ["AUTH-T10"], title: "Security tables: AccountLock, UnlockToken, Jwks, TwoFactor", description: `- **AccountLock**: \`userId\` (PK), \`failedCount\`, \`windowStartedAt\`, \`lockedUntil\`, \`lockLevel\` (0..N for escalation), \`lastFailedAt\`, \`lastFailedIp\`. - **UnlockToken**: \`id\`, \`userId\`, \`tokenHash\`, \`expiresAt\`, \`usedAt\`. @@ -236,8 +396,15 @@ Append-only: a trigger rejects UPDATE and DELETE except for the anonymization jo ], }, { - id: "AUTH-T16", epic: "datamodel", phase: 0, priority: "Medium", estimate: 1, labels: ["backend", "prisma", "docs"], deps: ["AUTH-T04", "AUTH-T05"], - title: "Migration workflow: deploy in build, never migrate dev against production", + id: "AUTH-T16", + epic: "datamodel", + phase: 0, + priority: "Medium", + estimate: 1, + labels: ["backend", "prisma", "docs"], + deps: ["AUTH-T04", "AUTH-T05"], + title: + "Migration workflow: deploy in build, never migrate dev against production", description: `Document and enforce: developers run \`prisma migrate dev\` only against personal/preview Neon branches; production and dev deployments run \`prisma migrate deploy\` during the Vercel build using \`DIRECT_URL\`. Add a guard script that aborts \`migrate dev\`/\`db push\`/\`migrate reset\` when \`SGAUTH_ENV=production\` or when \`DIRECT_URL\` points at the \`main\` branch host. Write \`docs/MIGRATIONS.md\` including how to hand-edit generated SQL for triggers/indexes Prisma cannot express, and the **expand/contract rule**: migrations run during the build while the previous deployment is still serving traffic, so every migration must be backward-compatible with the currently deployed code (add columns nullable first, drop in a later release).`, acceptance: [ "Running `npm run prisma:migrate-dev` with a production `DIRECT_URL` exits non-zero before touching the database (test with a fake host).", @@ -247,8 +414,15 @@ Append-only: a trigger rejects UPDATE and DELETE except for the anonymization jo // ───────────────────────── E3 Authentication Core ───────────────────────── { - id: "AUTH-T17", epic: "authcore", phase: 1, priority: "Urgent", estimate: 3, labels: ["backend", "better-auth"], deps: ["AUTH-T03", "AUTH-T10"], - title: "Email + password sign-in and self-sign-up restricted to northeastern.edu", + id: "AUTH-T17", + epic: "authcore", + phase: 1, + priority: "Urgent", + estimate: 3, + labels: ["backend", "better-auth"], + deps: ["AUTH-T03", "AUTH-T10"], + title: + "Email + password sign-in and self-sign-up restricted to northeastern.edu", description: `Enable \`emailAndPassword\` in Better Auth: \`minPasswordLength: 12\`, \`maxPasswordLength: 128\`, default scrypt hashing, \`requireEmailVerification: true\`, \`autoSignIn: false\` after sign-up. - Self-sign-up: a \`databaseHooks.user.create.before\` hook rejects emails whose domain is not \`northeastern.edu\` (exact match, lower-cased) unless the creation is admin/import initiated (flag passed via context). Admin-created users may have any domain. - Normalize email (trim, lower-case) before lookup and storage. @@ -267,7 +441,13 @@ No breached-password (HIBP) check, per decision.`, ], }, { - id: "AUTH-T18", epic: "authcore", phase: 1, priority: "High", estimate: 2, labels: ["backend", "email"], deps: ["AUTH-T17", "AUTH-T20", "AUTH-T103"], + id: "AUTH-T18", + epic: "authcore", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "email"], + deps: ["AUTH-T17", "AUTH-T20", "AUTH-T103"], title: "Email verification for self-sign-up with resend limits", description: `Send a verification email on sign-up via the mailer (AUTH-T20) with a link valid for 24 hours; the link lands on a confirmation page and the token is consumed only on the button POST (AUTH-T103, Safe Links). Verifying revokes any pre-existing sessions, logs the user in, and redirects to the validated \`redirect\` target (AUTH-T31) or the account page. Resend is limited to 3 per address per hour (Upstash key \`verify:\`). Unverified accounts older than 7 days are purged by the retention job (AUTH-T78). The UI shows the same message whether or not the address exists.`, acceptance: [ @@ -276,7 +456,13 @@ No breached-password (HIBP) check, per decision.`, ], }, { - id: "AUTH-T19", epic: "authcore", phase: 1, priority: "High", estimate: 3, labels: ["backend", "email", "security"], deps: ["AUTH-T17", "AUTH-T20", "AUTH-T103"], + id: "AUTH-T19", + epic: "authcore", + phase: 1, + priority: "High", + estimate: 3, + labels: ["backend", "email", "security"], + deps: ["AUTH-T17", "AUTH-T20", "AUTH-T103"], title: "Password reset flow", description: `Forgot-password request (email only; uniform response regardless of existence), reset token valid 1 hour, single use, delivered by email; the token is consumed only when the new-password form is submitted, never on link open (AUTH-T103). On successful reset: set \`passwordChangedAt\`, revoke all other sessions, clear any account lock, send a security notice email, emit PASSWORD_RESET audit event. Rate limit: 3 requests per email per 10 minutes and per IP (AUTH-T63). Imported bcrypt accounts (AUTH-T21) reset to scrypt.`, acceptance: [ @@ -286,8 +472,15 @@ No breached-password (HIBP) check, per decision.`, ], }, { - id: "AUTH-T20", epic: "authcore", phase: 1, priority: "High", estimate: 3, labels: ["backend", "email", "infra"], deps: ["AUTH-T07"], - title: "Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps", + id: "AUTH-T20", + epic: "authcore", + phase: 1, + priority: "High", + estimate: 3, + labels: ["backend", "email", "infra"], + deps: ["AUTH-T07"], + title: + "Email infrastructure: Resend on a dedicated SGAuth sending domain with volume caps", description: `Password login keeps email volume low (verification, reset, set-password, lock/unlock, security notices, PA transfer steps). Chambers sends almost all of SGA's current email, and Resend's free quota is **per account**, so a separate API key inside the shared account would not isolate anything. Therefore: - **Separate Resend account** owned by an SGA shared mailbox (not a student's personal login), credentials in the team vault. Confirm once that Resend's terms permit a distinct account for a distinct sender and record the answer here. - Dedicated sending subdomain \`mail.northeasternsga.com\` verified only in that account, with SPF, DKIM, and DMARC (p=quarantine) records; \`from\` = \`SGA Auth \`. @@ -302,7 +495,13 @@ No breached-password (HIBP) check, per decision.`, ], }, { - id: "AUTH-T21", epic: "authcore", phase: 3, priority: "High", estimate: 2, labels: ["backend", "migration", "security"], deps: ["AUTH-T17"], + id: "AUTH-T21", + epic: "authcore", + phase: 3, + priority: "High", + estimate: 2, + labels: ["backend", "migration", "security"], + deps: ["AUTH-T17"], title: "Accept imported Chambers bcrypt hashes with lazy re-hash to scrypt", description: `Supabase Auth (GoTrue) stores bcrypt hashes (\`$2a$\`/\`$2b$\`). Configure Better Auth \`emailAndPassword.password.verify\`: if the stored hash starts with \`bcrypt$\` (marker set by the import, AUTH-T87), verify with \`bcryptjs\`; on success, re-hash the plaintext with scrypt and update the Account row in the same request, then emit PASSWORD_REHASHED. Otherwise use the default scrypt verify. Never log plaintext. Remove the bcrypt path after all imported accounts have re-hashed or been reset (tracked by a metric).`, acceptance: [ @@ -312,8 +511,15 @@ No breached-password (HIBP) check, per decision.`, ], }, { - id: "AUTH-T22", epic: "authcore", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "email", "admin"], deps: ["AUTH-T20", "AUTH-T17", "AUTH-T103"], - title: "Password-less accounts: lazy set-password on first sign-in, plus admin invites", + id: "AUTH-T22", + epic: "authcore", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["backend", "email", "admin"], + deps: ["AUTH-T20", "AUTH-T17", "AUTH-T103"], + title: + "Password-less accounts: lazy set-password on first sign-in, plus admin invites", description: `Imported accounts (Aplio OTP users, SenatePath, SenatePortal) and admin-created accounts exist with \`emailVerified = true\` (the source product or admin vouches for the address) and **no password**. Decision: no invite blast at import, so email volume spreads out and users who never return cost nothing. - **Lazy set-password:** the login page asks for email first. If the account exists and has no password, SGAuth sends a one-time set-password link (valid 1 hour, consumed on form submit per AUTH-T103, rate-limited 3/hour per account) and shows the same 'Check your email' message it would show for an unknown address, so the flow reveals nothing about account existence. Setting the password logs the user in; the account keeps its UUID, so all product data stays attached. - **Admin invite (optional):** admins can still push a set-password email to a specific user (for example a newly appointed officer who needs access before an event). Rate-limited 3/day per user; token valid 7 days. @@ -327,8 +533,15 @@ No breached-password (HIBP) check, per decision.`, ], }, { - id: "AUTH-T23", epic: "authcore", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "security"], deps: ["AUTH-T17", "AUTH-T32"], - title: "Change password (current password + re-auth), revoke other sessions", + id: "AUTH-T23", + epic: "authcore", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["backend", "security"], + deps: ["AUTH-T17", "AUTH-T32"], + title: + "Change password (current password + re-auth), revoke other sessions", description: `Authenticated users change their password by providing the current password; requires a fresh re-authentication (AUTH-T32). On success: update hash, set \`passwordChangedAt\`, revoke every other session, send a security notice, emit PASSWORD_CHANGED.`, acceptance: [ "Wrong current password fails and counts toward account lockout.", @@ -336,8 +549,15 @@ No breached-password (HIBP) check, per decision.`, ], }, { - id: "AUTH-T24", epic: "authcore", phase: 3, priority: "Low", estimate: 2, labels: ["backend", "admin"], deps: ["AUTH-T36", "AUTH-T20"], - title: "Admin-initiated email change with re-verification and privilege rules", + id: "AUTH-T24", + epic: "authcore", + phase: 3, + priority: "Low", + estimate: 2, + labels: ["backend", "admin"], + deps: ["AUTH-T36", "AUTH-T20"], + title: + "Admin-initiated email change with re-verification and privilege rules", description: `Users cannot change their own email in v1 (identity anchoring). Admins can set a new email on a **non-admin** user; only the Primary Admin can change an admin's email; the Primary Admin's own email can be changed only by the Primary Admin. Every email change requires fresh re-authentication (AUTH-T32) so a hijacked session cannot redirect reset or transfer-cancel emails. The account keeps working with the old address until the user verifies the new one via a link sent to the new address (POST-consumed, AUTH-T103); a notice goes to the old address. Changing a \`legacyEmail\` account to a northeastern.edu address clears the flag. Audit EMAIL_CHANGE_REQUESTED / EMAIL_CHANGED.`, acceptance: [ "Old address remains valid until verification; after verification the old address cannot log in.", @@ -346,7 +566,13 @@ No breached-password (HIBP) check, per decision.`, ], }, { - id: "AUTH-T25", epic: "authcore", phase: 1, priority: "High", estimate: 3, labels: ["frontend", "ui"], deps: ["AUTH-T17", "AUTH-T18", "AUTH-T19", "AUTH-T31"], + id: "AUTH-T25", + epic: "authcore", + phase: 1, + priority: "High", + estimate: 3, + labels: ["frontend", "ui"], + deps: ["AUTH-T17", "AUTH-T18", "AUTH-T19", "AUTH-T31"], title: "Login, sign-up, forgot/reset, and verify pages", description: `Build the public auth pages with shadcn/ui and react-hook-form + zod: \`/login\` (email, password, preserves redirect param, links to forgot and sign-up), \`/sign-up\` (northeastern.edu hint, name, password with live length feedback), \`/forgot-password\`, \`/reset-password/[token]\`, \`/verify-email/[token]\`, \`/set-password/[token]\` (invites), \`/locked\` (explains lock and unlock email). Map Better Auth error codes to friendly copy without leaking account existence. Show SGA branding and a one-line explanation that this login works across all SGA tools.`, acceptance: [ @@ -358,7 +584,13 @@ No breached-password (HIBP) check, per decision.`, // ───────────────────────── E4 Sessions & SSO ───────────────────────── { - id: "AUTH-T26", epic: "sessions", phase: 1, priority: "Urgent", estimate: 2, labels: ["backend", "sso", "security"], deps: ["AUTH-T03"], + id: "AUTH-T26", + epic: "sessions", + phase: 1, + priority: "Urgent", + estimate: 2, + labels: ["backend", "sso", "security"], + deps: ["AUTH-T03"], title: "Parent-domain session cookie for *.northeasternsga.com", description: `Configure Better Auth cookies so a single session spans every SGA subdomain: - \`advanced.crossSubDomainCookies = { enabled: true, domain: 'northeasternsga.com' }\` in production and dev; \`advanced.cookiePrefix\` = \`sgauth\` (production) and \`sgauth-dev\` (dev deployment) so the two environments never collide on the shared parent domain. @@ -374,8 +606,15 @@ Document the threat model: any compromised or dangling \`*.northeasternsga.com\` ], }, { - id: "AUTH-T27", epic: "sessions", phase: 1, priority: "High", estimate: 2, labels: ["backend", "sso"], deps: ["AUTH-T10", "AUTH-T26"], - title: "Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache", + id: "AUTH-T27", + epic: "sessions", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "sso"], + deps: ["AUTH-T10", "AUTH-T26"], + title: + "Session lifetime: 30-day sliding, 90-day absolute cap, no cookie cache", description: `\`session.expiresIn = 30 days\`, \`session.updateAge = 1 day\` (sliding). Leave Better Auth's \`freshAge\` at its default: it gates Better Auth's own fresh-session endpoints and is **not** the re-auth mechanism (AUTH-T32 uses \`lastReauthAt\`). Absolute cap: set \`absoluteExpiresAt = createdAt + 90 days\` in \`session.create.before\`, reject sessions past it in the session endpoint, **and** have the daily retention job (AUTH-T78) delete any session whose \`createdAt\` is older than 90 days, because Better Auth's own endpoints (e.g. \`/token\`) do not run the custom check. Capture IP (from \`x-forwarded-for\` first hop on Vercel) and user agent. Sessions and JWTs are DB-backed; no cookie cache.`, acceptance: [ "A session used daily is still valid on day 29 and invalid on day 91 (time-travel test), including at `/api/auth/token`.", @@ -384,7 +623,13 @@ Document the threat model: any compromised or dangling \`*.northeasternsga.com\` ], }, { - id: "AUTH-T28", epic: "sessions", phase: 1, priority: "Urgent", estimate: 3, labels: ["backend", "sso", "api"], deps: ["AUTH-T27", "AUTH-T11"], + id: "AUTH-T28", + epic: "sessions", + phase: 1, + priority: "Urgent", + estimate: 3, + labels: ["backend", "sso", "api"], + deps: ["AUTH-T27", "AUTH-T11"], title: "Session endpoint for products: user, email, positions, admin flags", description: `Products resolve the shared cookie by calling SGAuth server-side. Implement \`GET /api/sgauth/session\` (a thin wrapper over Better Auth \`getSession\` plus the \`customSession\` plugin) returning: \`\`\`json @@ -403,8 +648,15 @@ Document the threat model: any compromised or dangling \`*.northeasternsga.com\` ], }, { - id: "AUTH-T29", epic: "sessions", phase: 2, priority: "High", estimate: 3, labels: ["backend", "sso", "supabase", "jwt"], deps: ["AUTH-T28", "AUTH-T15"], - title: "ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products", + id: "AUTH-T29", + epic: "sessions", + phase: 2, + priority: "High", + estimate: 3, + labels: ["backend", "sso", "supabase", "jwt"], + deps: ["AUTH-T28", "AUTH-T15"], + title: + "ES256 JWTs with JWKS and OIDC discovery for Supabase-backed products", description: `Add the Better Auth \`jwt\` plugin configured for Supabase third-party auth: - \`jwks.keyPairConfig = { alg: 'ES256' }\`; **automatic rotation disabled** (no \`rotationInterval\`). Supabase learns about a new key only when it re-fetches the JWKS (up to ~30 minutes), so an automatic rotation would reject fresh tokens for that window. Rotation is manual (annually or on incident) per the runbook in AUTH-T69: generate the new key, push the combined JWKS to every Supabase project via \`custom_jwks\`, then switch signing; keep the old key published for 7 days. - \`jwt.issuer = 'https://auth.northeasternsga.com'\` (no trailing slash; discovery must resolve at \`{issuer}/.well-known/openid-configuration\`), \`jwt.audience = 'authenticated'\` (matches Supabase's own token convention so any audience check passes), \`jwt.expirationTime = '10m'\`, \`getSubject = user.id\` (UUID, required because Supabase \`auth.uid()\` casts \`sub\` to uuid). Private keys encrypted at rest (default). @@ -419,7 +671,13 @@ Document the threat model: any compromised or dangling \`*.northeasternsga.com\` ], }, { - id: "AUTH-T30", epic: "sessions", phase: 1, priority: "High", estimate: 2, labels: ["backend", "sso"], deps: ["AUTH-T26", "AUTH-T56"], + id: "AUTH-T30", + epic: "sessions", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "sso"], + deps: ["AUTH-T26", "AUTH-T56"], title: "Global logout, sign out everywhere, and admin revocation", description: `- \`POST /api/auth/sign-out\` deletes the current session row and clears the parent-domain cookie (same Domain/Path/prefix, \`Max-Age=0\`); every subdomain is logged out immediately because they share the cookie and the DB row is gone. - \`POST /api/sgauth/sessions/revoke-all\` (user) revokes all sessions including the current one. @@ -433,7 +691,13 @@ Supabase-style JWTs already issued remain valid until their 10-minute expiry; do ], }, { - id: "AUTH-T31", epic: "sessions", phase: 1, priority: "High", estimate: 2, labels: ["backend", "security", "sso"], deps: ["AUTH-T56"], + id: "AUTH-T31", + epic: "sessions", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "security", "sso"], + deps: ["AUTH-T56"], title: "Safe post-login redirects validated against the product registry", description: `\`/login?redirect=\` is the entry point every product uses. Validate: absolute \`https://\` URL whose origin exactly matches an active product's base URL origin (or SGAuth itself), or a relative path starting with \`/\` (not \`//\`). Strip fragments; cap length at 2 KB. Persist the redirect through sign-up, verification, and reset via a short-lived signed cookie rather than hidden form fields. Unknown or malformed targets fall back to \`/account\`.`, acceptance: [ @@ -442,7 +706,13 @@ Supabase-style JWTs already issued remain valid until their 10-minute expiry; do ], }, { - id: "AUTH-T32", epic: "sessions", phase: 2, priority: "High", estimate: 3, labels: ["backend", "security"], deps: ["AUTH-T27", "AUTH-T67"], + id: "AUTH-T32", + epic: "sessions", + phase: 2, + priority: "High", + estimate: 3, + labels: ["backend", "security"], + deps: ["AUTH-T27", "AUTH-T67"], title: "Re-authentication (sudo mode) for sensitive actions", description: `Sensitive actions require proof of presence within the last 10 minutes: Primary Admin transfer steps, granting/revoking admin, changing password, changing any email, enrolling/disabling/resetting MFA, revoking all sessions, deleting/deactivating users. Implement \`POST /api/sgauth/reauth\` accepting password (and TOTP code if enrolled) that stamps \`Session.lastReauthAt\`; a \`requireFreshAuth()\` guard checks the stamp. This is independent of Better Auth's \`freshAge\` (left at default). The UI shows a modal to re-enter credentials when the guard fails (403 \`REAUTH_REQUIRED\`). Failed re-auth attempts count toward lockout.`, acceptance: [ @@ -451,7 +721,13 @@ Supabase-style JWTs already issued remain valid until their 10-minute expiry; do ], }, { - id: "AUTH-T33", epic: "sessions", phase: 2, priority: "Medium", estimate: 2, labels: ["backend"], deps: ["AUTH-T27"], + id: "AUTH-T33", + epic: "sessions", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["backend"], + deps: ["AUTH-T27"], title: "List and revoke the user's own sessions", description: `\`GET /api/sgauth/sessions\` returns the caller's active sessions (id, createdAt, lastActiveAt, ip, userAgent parsed to a friendly device string, isCurrent). \`DELETE /api/sgauth/sessions/:id\` revokes one of the caller's sessions. Audit SESSION_REVOKED.`, acceptance: [ @@ -460,8 +736,15 @@ Supabase-style JWTs already issued remain valid until their 10-minute expiry; do ], }, { - id: "AUTH-T34", epic: "sessions", phase: 1, priority: "High", estimate: 3, labels: ["infra", "sso", "docs"], deps: ["AUTH-T05", "AUTH-T26"], - title: "Non-production SSO topology: dev deployment, local hostnames, product preview domains", + id: "AUTH-T34", + epic: "sessions", + phase: 1, + priority: "High", + estimate: 3, + labels: ["infra", "sso", "docs"], + deps: ["AUTH-T05", "AUTH-T26"], + title: + "Non-production SSO topology: dev deployment, local hostnames, product preview domains", description: `A parent-domain cookie cannot be read by \`*.vercel.app\` previews or plain \`localhost\`, so define the non-production topology once: - **Dev SGAuth**: \`auth-dev.northeasternsga.com\` (branch \`dev\`, Neon \`dev\` branch, cookie prefix \`sgauth-dev\`). Products point their preview and dev environments here. Because it is a production-looking login page on the trusted domain: persistent 'DEVELOPMENT ENVIRONMENT' banner on every page, \`noindex\`, synthetic users only (real user imports are never run against dev), and fully separate secrets (Better Auth secret, Resend key, Upstash namespace). - **Product previews**: each product gets a stable branch domain like \`-dev.northeasternsga.com\` (Vercel branch domain) so the dev cookie is shared; ad-hoc \`*.vercel.app\` previews cannot use SSO (document the limitation). @@ -476,7 +759,13 @@ Supabase-style JWTs already issued remain valid until their 10-minute expiry; do // ───────────────────────── E5 Admin & Primary Admin ───────────────────────── { - id: "AUTH-T35", epic: "admin", phase: 2, priority: "Urgent", estimate: 3, labels: ["backend", "admin", "security"], deps: ["AUTH-T10", "AUTH-T12"], + id: "AUTH-T35", + epic: "admin", + phase: 2, + priority: "Urgent", + estimate: 3, + labels: ["backend", "admin", "security"], + deps: ["AUTH-T10", "AUTH-T12"], title: "Authorization module with the admin/Primary Admin rule matrix", description: `Create \`src/lib/authz.ts\`: pure functions \`can(actor, action, target)\` used by every mutating endpoint. Rules: - Only admins may perform admin actions; deactivated actors can do nothing. @@ -492,7 +781,13 @@ Return structured denials (\`{ allowed: false, reason: 'SELF_MODIFICATION' | 'PR ], }, { - id: "AUTH-T36", epic: "admin", phase: 2, priority: "High", estimate: 5, labels: ["backend", "admin", "api"], deps: ["AUTH-T35", "AUTH-T13", "AUTH-T32", "AUTH-T22"], + id: "AUTH-T36", + epic: "admin", + phase: 2, + priority: "High", + estimate: 5, + labels: ["backend", "admin", "api"], + deps: ["AUTH-T35", "AUTH-T13", "AUTH-T32", "AUTH-T22"], title: "Admin user-management endpoints", description: `Server actions or route handlers under \`/api/sgauth/admin/users\`: list/search (by email, name, position, status, admin flag; paginated), get, create (invite), update name/preferredName, deactivate (revokes all sessions immediately), reactivate, delete (= tombstone: status DELETED, PII scrubbed, sessions/accounts/MFA/positions removed, id retained), grant admin, revoke admin, revoke all sessions, unlock account, reset MFA (per the authz rules; emails the user), resend invite, force re-login. Every call passes \`can()\`, requires fresh re-auth for grant/revoke admin, deactivate, delete, and MFA reset, and emits an audit event with actor, target, and diff. Deactivation/deletion of the PA and self-modification are refused with the authz reason.`, acceptance: [ @@ -502,8 +797,23 @@ Return structured denials (\`{ allowed: false, reason: 'SELF_MODIFICATION' | 'PR ], }, { - id: "AUTH-T37", epic: "admin", phase: 2, priority: "High", estimate: 5, labels: ["backend", "admin", "security", "email"], deps: ["AUTH-T12", "AUTH-T35", "AUTH-T32", "AUTH-T20", "AUTH-T67", "AUTH-T103", "AUTH-T38"], - title: "Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)", + id: "AUTH-T37", + epic: "admin", + phase: 2, + priority: "High", + estimate: 5, + labels: ["backend", "admin", "security", "email"], + deps: [ + "AUTH-T12", + "AUTH-T35", + "AUTH-T32", + "AUTH-T20", + "AUTH-T67", + "AUTH-T103", + "AUTH-T38", + ], + title: + "Primary Admin transfer flow (re-auth, recipient acceptance, 24-hour cancel window)", description: `Implement the guarded transfer as a state machine over \`PrimaryAdminTransfer\`: 1. **Initiate** (PA only, fresh re-auth, recipient must be an active admin with MFA enrolled, PA must type the recipient email exactly): creates PENDING_ACCEPTANCE (expires in 7 days), emails recipient (accept link) and PA (confirmation), audit PA_TRANSFER_INITIATED. 2. **Accept** (recipient only, fresh re-auth): moves to COOLING, sets \`executesAt = now + 24h\`, generates a cancel token emailed to the outgoing PA (and shown in-app), emails both, audit PA_TRANSFER_ACCEPTED. @@ -519,8 +829,15 @@ Exactly one in-flight transfer is allowed. If the recipient loses admin or is de ], }, { - id: "AUTH-T38", epic: "admin", phase: 1, priority: "High", estimate: 2, labels: ["backend", "infra", "ci"], deps: ["AUTH-T05", "AUTH-T07"], - title: "Scheduled-job runner: GitHub Actions schedules calling secret-protected routes", + id: "AUTH-T38", + epic: "admin", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "infra", "ci"], + deps: ["AUTH-T05", "AUTH-T07"], + title: + "Scheduled-job runner: GitHub Actions schedules calling secret-protected routes", description: `Vercel Hobby limits cron to two jobs at once-per-day and rejects finer schedules at deploy time, so **no Vercel cron is used**. Instead, a GitHub Actions workflow in the auth repo (same pattern Chambers uses) runs on schedules and calls \`GET /api/jobs/\` with \`Authorization: Bearer $CRON_SECRET\` (repo secret = Vercel env): - \`pa-transfer\` every 15 minutes: executes due COOLING transfers and expires stale PENDING_ACCEPTANCE ones, idempotently (row-level lock, status re-check inside the transaction). - \`alerts\` every 15 minutes (AUTH-T77). @@ -533,7 +850,13 @@ Each route is idempotent, logs outcomes, and returns quickly (under the function ], }, { - id: "AUTH-T39", epic: "admin", phase: 2, priority: "High", estimate: 3, labels: ["backend", "admin", "security", "docs"], deps: ["AUTH-T12", "AUTH-T13"], + id: "AUTH-T39", + epic: "admin", + phase: 2, + priority: "High", + estimate: 3, + labels: ["backend", "admin", "security", "docs"], + deps: ["AUTH-T12", "AUTH-T13"], title: "Break-glass Primary Admin recovery script and runbook", description: `\`scripts/recover-primary-admin.ts\` run by a human with production Neon credentials (\`npm run recover-primary-admin -- --email new-pa@northeastern.edu --reason "..."\`). It: verifies the target user exists and is active; prompts for the confirmation phrase \`TRANSFER PRIMARY ADMIN\`; inside one transaction with the DB session variable set, moves the PA flag, ensures the new PA is admin, revokes all sessions of the previous PA; writes an AuditEvent with \`actorType = BREAK_GLASS\` and the operator's name/reason; emails every admin and the old PA address. Supports \`--dry-run\`. A second mode, \`--reset-mfa --email \`, clears the current PA's TOTP and backup codes (for the lost-phone-and-lost-codes case, since admins cannot touch PA MFA) and forces re-enrollment on next login. No HTTP endpoint exists for either. Write \`docs/runbooks/break-glass.md\`: when to use it, who holds credentials (at least two people), and how to verify afterwards.`, acceptance: [ @@ -543,7 +866,13 @@ Each route is idempotent, logs outcomes, and returns quickly (under the function ], }, { - id: "AUTH-T40", epic: "admin", phase: 2, priority: "High", estimate: 2, labels: ["testing", "admin", "security"], deps: ["AUTH-T12", "AUTH-T36"], + id: "AUTH-T40", + epic: "admin", + phase: 2, + priority: "High", + estimate: 2, + labels: ["testing", "admin", "security"], + deps: ["AUTH-T12", "AUTH-T36"], title: "Primary Admin protection tests across API and database layers", description: `Integration tests proving the PA cannot be deleted, deactivated, banned, stripped of admin, or have the PA flag removed via any admin endpoint, Better Auth admin plugin endpoint (if mounted), or direct SQL from the runtime role; and that the PA is subject to account lockout but can self-unlock via the emailed link.`, acceptance: [ @@ -552,8 +881,15 @@ Each route is idempotent, logs outcomes, and returns quickly (under the function ], }, { - id: "AUTH-T41", epic: "admin", phase: 2, priority: "Medium", estimate: 3, labels: ["backend", "admin", "migration"], deps: ["AUTH-T36", "AUTH-T43"], - title: "Bulk user import (CSV) with position assignment and batched invites", + id: "AUTH-T41", + epic: "admin", + phase: 2, + priority: "Medium", + estimate: 3, + labels: ["backend", "admin", "migration"], + deps: ["AUTH-T36", "AUTH-T43"], + title: + "Bulk user import (CSV) with position assignment and batched invites", description: `Admin endpoint + UI to upload a CSV (\`email,name,positions\` where positions is a \`|\`-separated list of keys). Validates rows (email format, known keys), previews the diff (new users, existing users, position changes), then applies: creates users as password-less accounts (AUTH-T22), assigns positions, and sends no email by default; an optional 'send set-password emails now' checkbox queues invites in batches under the mailer caps (AUTH-T20). Produces a downloadable report. Audit BULK_IMPORT with counts.`, acceptance: [ "A 200-row CSV with 5 invalid rows shows the 5 errors and imports nothing until fixed (all-or-nothing) or with an explicit 'skip invalid' toggle.", @@ -563,8 +899,15 @@ Each route is idempotent, logs outcomes, and returns quickly (under the function // ───────────────────────── E6 Positions ───────────────────────── { - id: "AUTH-T42", epic: "positions", phase: 2, priority: "High", estimate: 3, labels: ["backend", "positions", "api"], deps: ["AUTH-T11", "AUTH-T35", "AUTH-T13"], - title: "Positions CRUD: create, edit name/category, soft delete with retirement", + id: "AUTH-T42", + epic: "positions", + phase: 2, + priority: "High", + estimate: 3, + labels: ["backend", "positions", "api"], + deps: ["AUTH-T11", "AUTH-T35", "AUTH-T13"], + title: + "Positions CRUD: create, edit name/category, soft delete with retirement", description: `Admin endpoints: create (key + name + optional category/description; key validated, uniqueness checked against active and retired-within-365-days keys), update (name/category/description only; key immutable), delete (soft; returns holder count first via a preflight, requires the admin to send \`confirmKey\` equal to the key; on delete: set \`deletedAt\`, write RetiredPositionKey, keep UserPosition rows, emit POSITION_DELETED with holder ids). Audit every change with before/after. List endpoint supports including deleted for history views.`, acceptance: [ "Renaming changes `name` only; the key and all holders are untouched (test asserts session output before/after).", @@ -573,7 +916,13 @@ Each route is idempotent, logs outcomes, and returns quickly (under the function ], }, { - id: "AUTH-T43", epic: "positions", phase: 2, priority: "High", estimate: 3, labels: ["backend", "positions", "api"], deps: ["AUTH-T42"], + id: "AUTH-T43", + epic: "positions", + phase: 2, + priority: "High", + estimate: 3, + labels: ["backend", "positions", "api"], + deps: ["AUTH-T42"], title: "Position assignment endpoints (assign, unassign, bulk)", description: `Admin endpoints to assign/unassign one or many positions to a user and to assign one position to many users. Enforce the 50-position cap, refuse deleted positions, ignore duplicates, emit POSITION_ASSIGNED / POSITION_UNASSIGNED per pair. Assignments are visible in the session endpoint on the next call (no caching in SGAuth).`, acceptance: [ @@ -582,7 +931,13 @@ Each route is idempotent, logs outcomes, and returns quickly (under the function ], }, { - id: "AUTH-T44", epic: "positions", phase: 1, priority: "Medium", estimate: 1, labels: ["backend", "positions"], deps: ["AUTH-T11"], + id: "AUTH-T44", + epic: "positions", + phase: 1, + priority: "Medium", + estimate: 1, + labels: ["backend", "positions"], + deps: ["AUTH-T11"], title: "Seed the curated SGA position list", description: `**Input received 2026-09-18:** \`prisma/seed/positions.json\` already exists in the repo with 82 positions (81 offices across 9 categories — Office of the President, Academic Affairs, Campus Affairs, Diversity Equity and Inclusion, External Affairs, Student Involvement, Student Success, Operational Affairs, Senate — plus \`senator\`). Keys were generated by slugifying the official names and validated against the key format and length rules. Upsert by key in the seed script (safe to run in every environment; never deletes; name/category updates are applied, keys never change). Include a validation test that every key matches the format and names are unique. Product-specific roles (e.g. \`aplio-admin\`) are **not** seeded; product owners create them in the admin UI when they integrate. Key naming convention (document in the admin UI help and the integration guides): organization roles use bare keys (\`vp-finance\`, \`senator\`); product-specific roles are prefixed with the product slug (\`aplio-admin\`, \`chambers-iems\`) so keys never collide and products can grep their own.`, @@ -592,7 +947,13 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T45", epic: "positions", phase: 2, priority: "Medium", estimate: 2, labels: ["testing", "positions", "sso"], deps: ["AUTH-T43", "AUTH-T28", "AUTH-T29"], + id: "AUTH-T45", + epic: "positions", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["testing", "positions", "sso"], + deps: ["AUTH-T43", "AUTH-T28", "AUTH-T29"], title: "Position propagation tests and forced re-login", description: `Tests: after assign/unassign/delete, the very next \`/api/sgauth/session\` call reflects the change; a JWT minted before the change stays valid until expiry (≤10 min) and a new token reflects it. Add an admin action 'Force re-login' (revoke all sessions of a user) surfaced in the UI as the way to invalidate outstanding JWTs immediately. Document the staleness model in the integration guides.`, acceptance: [ @@ -601,15 +962,29 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T46", epic: "positions", phase: 3, priority: "Low", estimate: 2, labels: ["backend", "positions", "audit"], deps: ["AUTH-T42", "AUTH-T73"], + id: "AUTH-T46", + epic: "positions", + phase: 3, + priority: "Low", + estimate: 2, + labels: ["backend", "positions", "audit"], + deps: ["AUTH-T42", "AUTH-T73"], title: "Position history queries", description: `Endpoints returning the audit trail for a position (who was assigned/unassigned, renames, deletion) and for a user (all position changes), backed by AuditEvent indexes. Used by the admin UI detail pages.`, - acceptance: ["Both queries paginate and return within 200 ms for 10k events on the test branch."], + acceptance: [ + "Both queries paginate and return within 200 ms for 10k events on the test branch.", + ], }, // ───────────────────────── E7 Admin UI & Account UI ───────────────────────── { - id: "AUTH-T47", epic: "ui", phase: 2, priority: "High", estimate: 3, labels: ["frontend", "ui"], deps: ["AUTH-T25", "AUTH-T28"], + id: "AUTH-T47", + epic: "ui", + phase: 2, + priority: "High", + estimate: 3, + labels: ["frontend", "ui"], + deps: ["AUTH-T25", "AUTH-T28"], title: "App shell, navigation, and route guards for /admin and /account", description: `Next.js App Router layouts: \`(auth)\` public pages, \`(app)\` authenticated pages with a header (user menu, sign out, sign out everywhere), \`/account\` for all users, \`/admin\` visible only to admins (server-side guard using \`can()\`; non-admins get 404). shadcn/ui components, light/dark via next-themes, SGA branding tokens. Toasts via sonner. Loading and error boundaries.`, acceptance: [ @@ -618,7 +993,13 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T48", epic: "ui", phase: 2, priority: "High", estimate: 5, labels: ["frontend", "ui", "admin"], deps: ["AUTH-T47", "AUTH-T36", "AUTH-T43"], + id: "AUTH-T48", + epic: "ui", + phase: 2, + priority: "High", + estimate: 5, + labels: ["frontend", "ui", "admin"], + deps: ["AUTH-T47", "AUTH-T36", "AUTH-T43"], title: "Admin: users list and user detail pages", description: `Users list with search, filters (status, admin, position), pagination, and an 'Invited' badge. User detail: profile fields (editable), positions (assign/unassign with a searchable multi-select), admin toggle (disabled for self and PA with tooltip reason from \`can()\`), deactivate/reactivate, unlock, resend invite, sessions list with revoke, force re-login, and the user's audit timeline. Re-auth modal appears when the API returns REAUTH_REQUIRED.`, acceptance: [ @@ -627,7 +1008,13 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T49", epic: "ui", phase: 2, priority: "High", estimate: 3, labels: ["frontend", "ui", "positions"], deps: ["AUTH-T47", "AUTH-T42"], + id: "AUTH-T49", + epic: "ui", + phase: 2, + priority: "High", + estimate: 3, + labels: ["frontend", "ui", "positions"], + deps: ["AUTH-T47", "AUTH-T42"], title: "Admin: positions pages", description: `List (with holder counts and category grouping), create dialog (key auto-suggested from name, editable before save, immutable after), edit page (name, category, description; key shown read-only with an explanation), delete dialog showing holder count and requiring the key to be typed, and a 'deleted positions' tab with history.`, acceptance: [ @@ -636,20 +1023,44 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T50", epic: "ui", phase: 2, priority: "Medium", estimate: 2, labels: ["frontend", "ui", "registry"], deps: ["AUTH-T47", "AUTH-T56"], + id: "AUTH-T50", + epic: "ui", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["frontend", "ui", "registry"], + deps: ["AUTH-T47", "AUTH-T56"], title: "Admin: product registry pages", description: `CRUD pages for products (name, slug, base URL, description, icon URL, active, sort order, visible-to-positions). Show which trusted origins and redirect targets the registry currently yields.`, - acceptance: ["Adding a product makes its origin trusted and its URL appear on account pages without a deploy (verified on dev)."], + acceptance: [ + "Adding a product makes its origin trusted and its URL appear on account pages without a deploy (verified on dev).", + ], }, { - id: "AUTH-T51", epic: "ui", phase: 3, priority: "Medium", estimate: 3, labels: ["frontend", "ui", "audit"], deps: ["AUTH-T47", "AUTH-T73"], + id: "AUTH-T51", + epic: "ui", + phase: 3, + priority: "Medium", + estimate: 3, + labels: ["frontend", "ui", "audit"], + deps: ["AUTH-T47", "AUTH-T73"], title: "Admin: audit log viewer with filters and CSV export", description: `Table of AuditEvents with filters (type, actor, target, date range), detail drawer for metadata, and CSV export of the filtered set (server-streamed, capped at 50k rows).`, - acceptance: ["Filtering by a user shows both events they performed and events targeting them.", "Export matches the on-screen filter."], + acceptance: [ + "Filtering by a user shows both events they performed and events targeting them.", + "Export matches the on-screen filter.", + ], }, { - id: "AUTH-T52", epic: "ui", phase: 2, priority: "High", estimate: 3, labels: ["frontend", "ui", "admin"], deps: ["AUTH-T47", "AUTH-T37"], - title: "Admin: Primary Admin transfer wizard, status, acceptance, and cancel pages", + id: "AUTH-T52", + epic: "ui", + phase: 2, + priority: "High", + estimate: 3, + labels: ["frontend", "ui", "admin"], + deps: ["AUTH-T47", "AUTH-T37"], + title: + "Admin: Primary Admin transfer wizard, status, acceptance, and cancel pages", description: `PA-only wizard: full-screen warning explaining consequences, recipient selection limited to eligible admins (MFA enrolled), typed email confirmation, re-auth step, summary. Status card on the admin dashboard while a transfer is in flight (with cancel). Recipient acceptance page (\`/admin/transfer/accept/[id]\`) with re-auth and decline. Public cancel page for the emailed token. Completion banner for all admins.`, acceptance: [ "Wizard cannot be completed without typing the exact recipient email and passing re-auth.", @@ -657,8 +1068,15 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T53", epic: "ui", phase: 2, priority: "High", estimate: 3, labels: ["frontend", "ui"], deps: ["AUTH-T47", "AUTH-T33", "AUTH-T56"], - title: "Account page: profile, positions, product links, sessions, sign out everywhere", + id: "AUTH-T53", + epic: "ui", + phase: 2, + priority: "High", + estimate: 3, + labels: ["frontend", "ui"], + deps: ["AUTH-T47", "AUTH-T33", "AUTH-T56"], + title: + "Account page: profile, positions, product links, sessions, sign out everywhere", description: `\`/account\`: edit name and preferredName; read-only list of positions (key and name); 'Your SGA tools' grid of active products from the registry filtered by \`visibleToPositionKeys\` with links to each product (this is the hub users land on after login without a redirect); sessions list with per-device revoke and 'Sign out everywhere'; links to security settings.`, acceptance: [ "A user with no positions sees only products visible to everyone.", @@ -666,8 +1084,15 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T54", epic: "ui", phase: 3, priority: "High", estimate: 3, labels: ["frontend", "ui", "security"], deps: ["AUTH-T23", "AUTH-T67"], - title: "Account security page: change password, MFA enrollment, backup codes", + id: "AUTH-T54", + epic: "ui", + phase: 3, + priority: "High", + estimate: 3, + labels: ["frontend", "ui", "security"], + deps: ["AUTH-T23", "AUTH-T67"], + title: + "Account security page: change password, MFA enrollment, backup codes", description: `\`/account/security\`: change password form; TOTP enrollment (QR + manual secret, verify code, download/copy backup codes once), regenerate backup codes, disable MFA (re-auth; disallowed for admins and PA with explanation). Show 'MFA required for admins' banner and block admin pages until enrolled.`, acceptance: [ "An admin without MFA is redirected to enrollment when opening `/admin`.", @@ -675,16 +1100,31 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T55", epic: "ui", phase: 3, priority: "Medium", estimate: 2, labels: ["frontend", "ui", "a11y"], deps: ["AUTH-T48", "AUTH-T49", "AUTH-T53", "AUTH-T54"], + id: "AUTH-T55", + epic: "ui", + phase: 3, + priority: "Medium", + estimate: 2, + labels: ["frontend", "ui", "a11y"], + deps: ["AUTH-T48", "AUTH-T49", "AUTH-T53", "AUTH-T54"], title: "Accessibility, responsive, and empty/error state pass", description: `Audit every page with axe and keyboard-only navigation; add empty states, loading skeletons, and error states; verify focus management in dialogs; confirm color contrast in both themes.`, - acceptance: ["axe: zero serious/critical issues on all pages; documented checklist completed."], + acceptance: [ + "axe: zero serious/critical issues on all pages; documented checklist completed.", + ], }, // ───────────────────────── E8 Product Registry & SDK ───────────────────────── { - id: "AUTH-T56", epic: "sdk", phase: 1, priority: "High", estimate: 2, labels: ["backend", "registry", "security"], deps: ["AUTH-T14"], - title: "Product registry service: trusted origins and redirect allowlist at runtime", + id: "AUTH-T56", + epic: "sdk", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "registry", "security"], + deps: ["AUTH-T14"], + title: + "Product registry service: trusted origins and redirect allowlist at runtime", description: `\`src/lib/products.ts\`: loads active products (cached in-memory for 60 s, invalidated on write), exposes \`getTrustedOrigins()\` (product origins + SGAuth origin + local dev origins when \`SGAUTH_ENV=local\`) fed to Better Auth's \`trustedOrigins\` as a function, and \`isAllowedRedirect(url)\` used by AUTH-T31 and AUTH-T30. If the registry query fails, fall back to a static list containing only SGAuth's own origin (fail closed for products, but SGAuth's own pages keep working). Never trust \`*.vercel.app\` and never use a wildcard \`https://*.northeasternsga.com\` (it would trust dangling subdomains).`, acceptance: [ "Adding a product on dev makes cross-origin POSTs from its origin pass Better Auth's origin check within 60 s; removing it makes them fail.", @@ -692,8 +1132,15 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T57", epic: "sdk", phase: 2, priority: "High", estimate: 3, labels: ["sdk", "infra"], deps: ["AUTH-T28"], - title: "Scaffold the @sgaoperations/sgauth package and publish pipeline (public npm)", + id: "AUTH-T57", + epic: "sdk", + phase: 2, + priority: "High", + estimate: 3, + labels: ["sdk", "infra"], + deps: ["AUTH-T28"], + title: + "Scaffold the @sgaoperations/sgauth package and publish pipeline (public npm)", description: `New repo \`SGAOperations/sgauth-sdk\` (TypeScript, tsup, vitest, ESM+CJS, Node 20+). Publish to the **public npm registry** under the \`@sgaoperations\` org scope (claim the scope on npmjs.com; the SDK contains no secrets, only calls to public SGAuth endpoints with the user's cookie). GitHub Packages was rejected because it requires a classic personal access token to install even public packages. Use npm **trusted publishing** (OIDC from GitHub Actions) so no long-lived npm token exists; publish on \`v*\` tags; semantic versioning; \`CHANGELOG.md\`; provenance attestations enabled.`, acceptance: [ "`npm install @sgaoperations/sgauth` works from any product repo with no `.npmrc` changes.", @@ -701,7 +1148,13 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T58", epic: "sdk", phase: 2, priority: "High", estimate: 3, labels: ["sdk", "sso"], deps: ["AUTH-T57"], + id: "AUTH-T58", + epic: "sdk", + phase: 2, + priority: "High", + estimate: 3, + labels: ["sdk", "sso"], + deps: ["AUTH-T57"], title: "SDK: getSession() with cookie forwarding and a 60-second cache", description: `\`getSession({ headers | cookieHeader })\`: extracts the SGAuth cookie (name from \`SGAUTH_COOKIE_NAME\`, default \`__Secure-sgauth.session_token\`), calls \`{SGAUTH_URL}/api/sgauth/session\` with the \`Cookie\` header, returns a typed \`SgaSession | null\`. In-memory LRU cache keyed by SHA-256 of the token, TTL 60 s (configurable, max 300 s), negative cache 10 s. **Availability:** SGAuth is a single point of failure for every product, so on network error or 5xx the SDK serves a previously cached session for that token for up to \`staleIfErrorSeconds\` (default 300, max 900) and calls \`onError\`; with no cached entry it returns null (fail closed). Timeout 3 s. Never caches 5xx as a session. Products must never log the forwarded Cookie header (documented). Ships the JSON schema types from AUTH-T28.`, acceptance: [ @@ -710,8 +1163,15 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T59", epic: "sdk", phase: 2, priority: "High", estimate: 3, labels: ["sdk", "sso"], deps: ["AUTH-T58"], - title: "SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)", + id: "AUTH-T59", + epic: "sdk", + phase: 2, + priority: "High", + estimate: 3, + labels: ["sdk", "sso"], + deps: ["AUTH-T58"], + title: + "SDK: Next.js helpers (proxy/middleware, requireSession, position guards, URLs)", description: `Exports: \`createSgaProxy({ publicPaths })\` for Next.js \`proxy.ts\`/\`middleware.ts\` that **only checks for the presence of the SGAuth cookie** (no network call, no per-request latency) and redirects to \`loginUrl(currentUrl)\` when absent; real validation happens in server code via \`requireSession()\` (throws/redirects on an invalid or revoked cookie). \`hasPosition(session, key)\`, \`hasAnyPosition(session, keys)\`, \`hasAllPositions\`, \`isAdmin(session)\`; \`loginUrl(redirect)\`, \`logoutUrl(redirect)\`, \`accountUrl()\`. React cache() wrapper for server components so one request resolves the session once. Document why the proxy must not call SGAuth: middleware runs per request and per instance, so a network round trip there adds latency to every page and defeats the cache.`, acceptance: [ "Example app in the repo demonstrates a protected page, a position-gated action, and logout.", @@ -719,27 +1179,57 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T60", epic: "sdk", phase: 3, priority: "Medium", estimate: 2, labels: ["sdk", "supabase", "jwt"], deps: ["AUTH-T58", "AUTH-T29"], + id: "AUTH-T60", + epic: "sdk", + phase: 3, + priority: "Medium", + estimate: 2, + labels: ["sdk", "supabase", "jwt"], + deps: ["AUTH-T58", "AUTH-T29"], title: "SDK: getAccessToken() for Supabase clients", description: `\`getAccessToken({ headers })\` calls \`/api/auth/token\` with the forwarded cookie and caches the JWT until \`exp - 60 s\` keyed by session token hash. Provide \`createSupabaseAccessTokenProvider()\` returning the \`accessToken\` callback shape supabase-js expects, and a browser-safe variant that calls a product-side route which proxies to SGAuth (so the token never requires exposing SGAuth cookies to client JS).`, - acceptance: ["Token refresh happens before expiry in a long-running test; a revoked session stops yielding tokens at the next refresh."], + acceptance: [ + "Token refresh happens before expiry in a long-running test; a revoked session stops yielding tokens at the next refresh.", + ], }, { - id: "AUTH-T61", epic: "sdk", phase: 2, priority: "Medium", estimate: 2, labels: ["sdk", "docs"], deps: ["AUTH-T59"], + id: "AUTH-T61", + epic: "sdk", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["sdk", "docs"], + deps: ["AUTH-T59"], title: "SDK documentation, example app, and versioning policy", description: `README with install (public npm, no registry config), env vars (\`SGAUTH_URL\`, \`SGAUTH_COOKIE_NAME\`), quick start, API reference (typedoc), dev/preview topology (AUTH-T34), the stale-if-error behavior and its bound, upgrade notes, and a support policy (latest two minors).`, - acceptance: ["A new product can integrate using only the README (validated by the VaultZ integration)."], + acceptance: [ + "A new product can integrate using only the README (validated by the VaultZ integration).", + ], }, { - id: "AUTH-T62", epic: "sdk", phase: 3, priority: "Medium", estimate: 2, labels: ["backend", "security", "sso"], deps: ["AUTH-T56", "AUTH-T28"], + id: "AUTH-T62", + epic: "sdk", + phase: 3, + priority: "Medium", + estimate: 2, + labels: ["backend", "security", "sso"], + deps: ["AUTH-T56", "AUTH-T28"], title: "CORS for browser-side calls from registered products", description: `Allow client components on registered product origins to call \`/api/sgauth/session\`, \`/api/auth/token\`, and \`/api/auth/sign-out\` with \`credentials: 'include'\`: dynamic \`Access-Control-Allow-Origin\` echoing only registry origins, \`Allow-Credentials: true\`, \`Vary: Origin\`, preflight handling, no wildcard. Server-side calls remain the recommended path.`, - acceptance: ["Preflight from an unregistered origin gets no CORS headers; from a registered one it succeeds with credentials."], + acceptance: [ + "Preflight from an unregistered origin gets no CORS headers; from a registered one it succeeds with credentials.", + ], }, // ───────────────────────── E9 Security Hardening ───────────────────────── { - id: "AUTH-T63", epic: "security", phase: 1, priority: "High", estimate: 3, labels: ["backend", "security", "infra"], deps: ["AUTH-T03", "AUTH-T07"], + id: "AUTH-T63", + epic: "security", + phase: 1, + priority: "High", + estimate: 3, + labels: ["backend", "security", "infra"], + deps: ["AUTH-T03", "AUTH-T07"], title: "Upstash Redis rate limiting on auth and token endpoints", description: `Use \`@upstash/ratelimit\` (sliding window) inside the handlers for **mutating and expensive endpoints only**: sign-in 10/min and 50/hour per IP; sign-up 5/hour per IP; forgot-password 3/10 min per IP and per email; verification resend 3/hour per email; token endpoint 60/min per session; re-auth 5/min per user; admin mutations 100/min per user. The **session endpoint is not Redis-limited** (the Upstash free tier is 500K commands/month and the session endpoint is the hot path); it relies on the SDK cache, Vercel's platform protections, and a cheap in-process token bucket. Disable Better Auth's built-in limiter. **On Upstash outage: fail open everywhere with a logged alert** (decision: an Upstash outage must never become an org-wide login outage); the DB-backed account lockout (AUTH-T64) remains the brute-force backstop. Return 429 with \`Retry-After\`. Budget: estimate monthly Redis commands from expected logins and document the alert threshold at 80% of the free quota.`, acceptance: [ @@ -749,8 +1239,15 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T64", epic: "security", phase: 1, priority: "High", estimate: 5, labels: ["backend", "security", "email"], deps: ["AUTH-T15", "AUTH-T17", "AUTH-T20", "AUTH-T103"], - title: "Escalating account lockout with emailed unlock and known-device exemption", + id: "AUTH-T64", + epic: "security", + phase: 1, + priority: "High", + estimate: 5, + labels: ["backend", "security", "email"], + deps: ["AUTH-T15", "AUTH-T17", "AUTH-T20", "AUTH-T103"], + title: + "Escalating account lockout with emailed unlock and known-device exemption", description: `Track failed password attempts per account (AccountLock). 5 failures within 15 minutes → lock 15 min; each subsequent lock doubles (30 min, 1 h, 2 h, … capped at 24 h); never permanent. **Known-device exemption (red-team):** a plain lockout lets anyone who knows an admin's email lock them out indefinitely. After every successful login SGAuth sets a signed, HttpOnly \`__Secure-sgauth.device\` cookie (host-only on auth.northeasternsga.com, 1-year, hashed in KnownDevice). Sign-in attempts that carry a valid known-device cookie for that account are exempt from the account lock (they remain subject to IP rate limits and their own separate 5-per-15-min counter); attempts without one count toward and are blocked by the lock. Attackers cannot obtain the cookie without a successful login. While locked, sign-in returns the same generic error as wrong password; the locked-account email tells the real owner what happened. On lock: email the user an unlock link (page + POST, AUTH-T103; single-use, 1 h) and a security notice; audit ACCOUNT_LOCKED / ACCOUNT_UNLOCKED. Successful login or password reset resets counters; lock level decays after 24 h clean. Applies to the Primary Admin. Admins can unlock from the UI.`, acceptance: [ "Sixth attempt within the window from an unknown device is refused even with the correct password; the unlock link restores access.", @@ -760,7 +1257,13 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T65", epic: "security", phase: 1, priority: "High", estimate: 2, labels: ["backend", "security", "sso"], deps: ["AUTH-T56", "AUTH-T26"], + id: "AUTH-T65", + epic: "security", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "security", "sso"], + deps: ["AUTH-T56", "AUTH-T26"], title: "CSRF and origin enforcement across subdomains", description: `Because the cookie is shared with every subdomain and SameSite=Lax still sends it on same-site POSTs from sibling subdomains, SGAuth must verify the \`Origin\` (fallback \`Referer\`) header on every state-changing request against the registry-derived trusted origins (Better Auth does this for its routes; extend the check to all \`/api/sgauth/*\` mutations). Reject missing Origin on non-GET. Add \`Sec-Fetch-Site\` checks as defense in depth.`, acceptance: [ @@ -769,14 +1272,29 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T66", epic: "security", phase: 2, priority: "Medium", estimate: 2, labels: ["backend", "security"], deps: ["AUTH-T05"], + id: "AUTH-T66", + epic: "security", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["backend", "security"], + deps: ["AUTH-T05"], title: "Security headers (CSP, HSTS, frame, referrer)", description: `Set via \`next.config.ts\` headers: strict CSP (self + PostHog host, nonce for inline scripts), \`X-Frame-Options: DENY\`, \`Referrer-Policy: strict-origin-when-cross-origin\`, \`X-Content-Type-Options: nosniff\`, \`Permissions-Policy\` minimal. HSTS: \`max-age=31536000\` on auth.northeasternsga.com **without** \`includeSubDomains\`/\`preload\` unless every SGA subdomain is confirmed HTTPS-only (flag for the team; enabling it at the apex affects all products).`, - acceptance: ["securityheaders.com grade A on production; CSP violations reported to PostHog or a report-only endpoint first for one week."], + acceptance: [ + "securityheaders.com grade A on production; CSP violations reported to PostHog or a report-only endpoint first for one week.", + ], }, { - id: "AUTH-T67", epic: "security", phase: 2, priority: "High", estimate: 5, labels: ["backend", "security", "better-auth"], deps: ["AUTH-T15", "AUTH-T17"], - title: "TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)", + id: "AUTH-T67", + epic: "security", + phase: 2, + priority: "High", + estimate: 5, + labels: ["backend", "security", "better-auth"], + deps: ["AUTH-T15", "AUTH-T17"], + title: + "TOTP multi-factor authentication (optional for users, required for admins and the Primary Admin)", description: `Add the Better Auth \`twoFactor\` plugin (TOTP + backup codes; no SMS/email OTP). Login flow: after password, if enrolled, prompt for a 6-digit code or backup code; 'trust this device' is NOT enabled (keep it simple and consistent with sessions). Enforcement: users with \`isAdmin\` must have MFA to access admin endpoints/pages (403 \`MFA_REQUIRED\` → enrollment redirect); granting admin to a user without MFA is allowed but they are locked out of admin functions until enrolled; the PA transfer recipient must be enrolled. Backup codes hashed; regeneration invalidates old ones; audit MFA_ENROLLED / MFA_DISABLED / MFA_BACKUP_USED.`, acceptance: [ "Enrolled user must present a valid TOTP; replayed codes within the same step are rejected.", @@ -785,39 +1303,86 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T68", epic: "security", phase: 2, priority: "Medium", estimate: 2, labels: ["infra", "security", "docs"], deps: ["AUTH-T26"], - title: "Subdomain hygiene: DNS inventory, dangling-record removal, and policy", + id: "AUTH-T68", + epic: "security", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["infra", "security", "docs"], + deps: ["AUTH-T26"], + title: + "Subdomain hygiene: DNS inventory, dangling-record removal, and policy", description: `Because the session cookie is readable by every \`*.northeasternsga.com\` host, inventory all DNS records for the domain, remove or reclaim records that point at unowned Vercel/other targets (subdomain takeover), and write \`docs/SUBDOMAIN_POLICY.md\`: only products in the SGAuth registry may receive a subdomain; wildcard records are prohibited; third-party services get a separate domain. Add a quarterly checklist item.`, - acceptance: ["Inventory spreadsheet/link attached; zero dangling records; policy merged and linked from the architecture doc."], + acceptance: [ + "Inventory spreadsheet/link attached; zero dangling records; policy merged and linked from the architecture doc.", + ], }, { - id: "AUTH-T69", epic: "security", phase: 2, priority: "Medium", estimate: 1, labels: ["infra", "security", "docs"], deps: ["AUTH-T05", "AUTH-T29"], + id: "AUTH-T69", + epic: "security", + phase: 2, + priority: "Medium", + estimate: 1, + labels: ["infra", "security", "docs"], + deps: ["AUTH-T05", "AUTH-T29"], title: "Secrets management and rotation procedures", description: `Document and script rotation for \`BETTER_AUTH_SECRET\` (invalidates cookie signatures → all users re-login; schedule in a low-usage window), Resend key, Upstash token, Neon passwords, \`CRON_SECRET\`, and the **manual JWKS rotation** (AUTH-T29): (1) generate the new ES256 key pair in the Jwks table without switching signing; (2) run \`scripts/supabase-push-jwks.ts\` to PUT the combined JWKS into each Supabase project's third-party integration via \`custom_jwks\` (Management API) and confirm \`resolved_jwks\`; (3) switch signing to the new key; (4) after 7 days remove the old key and push again. Ensure secrets are scoped per Vercel environment and never printed in logs or preview builds.`, - acceptance: ["`docs/runbooks/rotate-secrets.md` exists and a dry run of the JWKS rotation on dev against a throwaway Supabase project produces zero token rejections during the switch."], + acceptance: [ + "`docs/runbooks/rotate-secrets.md` exists and a dry run of the JWKS rotation on dev against a throwaway Supabase project produces zero token rejections during the switch.", + ], }, { - id: "AUTH-T70", epic: "security", phase: 1, priority: "Medium", estimate: 2, labels: ["backend", "security", "testing"], deps: ["AUTH-T17", "AUTH-T19"], + id: "AUTH-T70", + epic: "security", + phase: 1, + priority: "Medium", + estimate: 2, + labels: ["backend", "security", "testing"], + deps: ["AUTH-T17", "AUTH-T19"], title: "Account-enumeration resistance and timing uniformity", description: `Ensure sign-in, forgot-password, sign-up, and verification-resend responses do not reveal whether an email exists (identical bodies and status codes). Better Auth already returns success for a duplicate sign-up when \`requireEmailVerification\` is on; verify that path and add the unverified-account overwrite from AUTH-T17. Add a dummy hash comparison on unknown-email sign-in to equalize timing.`, - acceptance: ["Tests assert identical response bodies and status codes across known/unknown emails for each endpoint; a non-gating benchmark script reports timing deltas (a hard 50 ms CI assertion was rejected as flaky)."], + acceptance: [ + "Tests assert identical response bodies and status codes across known/unknown emails for each endpoint; a non-gating benchmark script reports timing deltas (a hard 50 ms CI assertion was rejected as flaky).", + ], }, { - id: "AUTH-T71", epic: "security", phase: 3, priority: "Low", estimate: 1, labels: ["ci", "security"], deps: ["AUTH-T06"], + id: "AUTH-T71", + epic: "security", + phase: 3, + priority: "Low", + estimate: 1, + labels: ["ci", "security"], + deps: ["AUTH-T06"], title: "Dependency and code scanning", description: `Enable Dependabot (npm, weekly, grouped), \`npm audit --audit-level=high\` in CI, and GitHub CodeQL for JavaScript/TypeScript on PRs.`, - acceptance: ["All three run on the repo; a seeded vulnerable dependency fails CI in a test PR."], + acceptance: [ + "All three run on the repo; a seeded vulnerable dependency fails CI in a test PR.", + ], }, { - id: "AUTH-T72", epic: "security", phase: 2, priority: "Medium", estimate: 3, labels: ["docs", "security"], deps: ["AUTH-T26", "AUTH-T29", "AUTH-T35"], + id: "AUTH-T72", + epic: "security", + phase: 2, + priority: "Medium", + estimate: 3, + labels: ["docs", "security"], + deps: ["AUTH-T26", "AUTH-T29", "AUTH-T35"], title: "Threat model and pre-launch security review checklist", description: `Write \`docs/THREAT_MODEL.md\` (STRIDE-lite) covering: shared-cookie exposure and subdomain takeover, session fixation/replay, JWT misuse by Supabase products, admin abuse and self-escalation, PA transfer hijack, break-glass misuse, email link phishing, rate-limit bypass, Neon credential leakage. For each: mitigation and residual risk. Derive a pre-launch checklist executed in AUTH-T91.`, - acceptance: ["Document reviewed by at least two team members; every residual risk has an owner or an accepted-risk note."], + acceptance: [ + "Document reviewed by at least two team members; every residual risk has an owner or an accepted-risk note.", + ], }, // ───────────────────────── E10 Observability & Audit ───────────────────────── { - id: "AUTH-T73", epic: "observability", phase: 1, priority: "High", estimate: 3, labels: ["backend", "audit"], deps: ["AUTH-T13"], + id: "AUTH-T73", + epic: "observability", + phase: 1, + priority: "High", + estimate: 3, + labels: ["backend", "audit"], + deps: ["AUTH-T13"], title: "Audit event catalog, emitter, and coverage test", description: `\`src/lib/audit.ts\`: a typed catalog (LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, SESSION_REVOKED, SESSIONS_REVOKED, PASSWORD_RESET_REQUESTED, PASSWORD_RESET, PASSWORD_CHANGED, PASSWORD_REHASHED, EMAIL_VERIFIED, USER_CREATED, USER_INVITED, USER_DEACTIVATED, USER_REACTIVATED, USER_DELETED, ADMIN_GRANTED, ADMIN_REVOKED, POSITION_CREATED/UPDATED/DELETED/ASSIGNED/UNASSIGNED, PRODUCT_CREATED/UPDATED/DELETED, PA_TRANSFER_*, BREAK_GLASS_PA_RECOVERY, ACCOUNT_LOCKED/UNLOCKED, MFA_*, BULK_IMPORT, JWKS_ROTATED) and \`audit(event)\` that captures actor, IP, UA from request context and writes inside the caller's transaction when one is open. Add a test that every mutating endpoint emits at least one audit event (route table cross-checked against catalog usage).`, acceptance: [ @@ -826,32 +1391,71 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T74", epic: "observability", phase: 1, priority: "High", estimate: 2, labels: ["backend", "observability"], deps: ["AUTH-T03"], + id: "AUTH-T74", + epic: "observability", + phase: 1, + priority: "High", + estimate: 2, + labels: ["backend", "observability"], + deps: ["AUTH-T03"], title: "Structured JSON logging with request IDs and redaction", description: `Use \`pino\` (or a thin console JSON logger compatible with Vercel log drains): request id (from \`x-vercel-id\` or generated), route, user id (never email), latency, outcome. Redact tokens, cookies, passwords, and email bodies. Log levels by environment.`, - acceptance: ["Sample production log line validated against a schema; a test asserts secrets are redacted."], + acceptance: [ + "Sample production log line validated against a schema; a test asserts secrets are redacted.", + ], }, { - id: "AUTH-T75", epic: "observability", phase: 2, priority: "Medium", estimate: 2, labels: ["observability", "posthog"], deps: ["AUTH-T74"], + id: "AUTH-T75", + epic: "observability", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["observability", "posthog"], + deps: ["AUTH-T74"], title: "PostHog: server-side auth funnel events and error tracking", description: `Integrate PostHog (free tier): server-side capture of login_succeeded/login_failed (reason category only), signup_started/completed, reset_requested/completed, mfa_enrolled, session_endpoint_error; identify by SGAuth user id only (no email/name properties). Enable PostHog error tracking on the server; on the client, load the PostHog script **only on authenticated /admin and /account pages, never on login, sign-up, reset, or verification pages** (a third-party script on a credential form is a supply-chain risk; those pages report errors via a first-party endpoint). Respect a \`POSTHOG_DISABLED\` flag for local/test. Add a dashboard for daily logins, failure rate, lockouts.`, - acceptance: ["Events appear in PostHog from dev with no PII properties (verified by inspecting event payloads); an induced server error shows in error tracking."], + acceptance: [ + "Events appear in PostHog from dev with no PII properties (verified by inspecting event payloads); an induced server error shows in error tracking.", + ], }, { - id: "AUTH-T76", epic: "observability", phase: 1, priority: "Medium", estimate: 1, labels: ["backend", "infra"], deps: ["AUTH-T04", "AUTH-T29"], + id: "AUTH-T76", + epic: "observability", + phase: 1, + priority: "Medium", + estimate: 1, + labels: ["backend", "infra"], + deps: ["AUTH-T04", "AUTH-T29"], title: "Health endpoint and uptime monitor", description: `\`GET /api/health\` returns 200 with \`{ db: 'ok', jwks: 'ok', version }\` after a cheap \`SELECT 1\` and a JWKS presence check; 503 otherwise. Configure a free external uptime monitor hitting it every 5 minutes with email alerts to the admins.`, - acceptance: ["Monitor is live and alerted correctly during a deliberate 10-minute dev outage test."], + acceptance: [ + "Monitor is live and alerted correctly during a deliberate 10-minute dev outage test.", + ], }, { - id: "AUTH-T77", epic: "observability", phase: 3, priority: "Low", estimate: 2, labels: ["backend", "observability", "email"], deps: ["AUTH-T73", "AUTH-T20", "AUTH-T38"], + id: "AUTH-T77", + epic: "observability", + phase: 3, + priority: "Low", + estimate: 2, + labels: ["backend", "observability", "email"], + deps: ["AUTH-T73", "AUTH-T20", "AUTH-T38"], title: "Threshold alerts for security events", description: `A scheduled job (every 15 min via AUTH-T38) queries AuditEvent for spikes: >50 LOGIN_FAILED in 15 min, >5 ACCOUNT_LOCKED in an hour, any BREAK_GLASS_PA_RECOVERY, any PA_TRANSFER_INITIATED, JWKS rotation, job failures, Upstash fail-open events; emails all admins with a summary (deduplicated per hour).`, - acceptance: ["Simulated spike triggers exactly one alert email; the same condition an hour later triggers again."], + acceptance: [ + "Simulated spike triggers exactly one alert email; the same condition an hour later triggers again.", + ], }, { - id: "AUTH-T78", epic: "observability", phase: 3, priority: "Medium", estimate: 3, labels: ["backend", "privacy", "infra"], deps: ["AUTH-T13", "AUTH-T36", "AUTH-T20", "AUTH-T38"], - title: "Retention jobs: tombstone deactivated (30 d) and inactive (12 mo) users, purge sessions and PII", + id: "AUTH-T78", + epic: "observability", + phase: 3, + priority: "Medium", + estimate: 3, + labels: ["backend", "privacy", "infra"], + deps: ["AUTH-T13", "AUTH-T36", "AUTH-T20", "AUTH-T38"], + title: + "Retention jobs: tombstone deactivated (30 d) and inactive (12 mo) users, purge sessions and PII", description: `Daily job (via AUTH-T38): (1) **tombstone** users deactivated ≥30 days ago: status DELETED, email replaced by \`deleted+@invalid\`, name 'Deleted user', preferredName null, password/accounts/MFA/known devices/sessions/positions removed, audit PII anonymized via the gated path; the row and id are kept so product foreign keys stay valid and products render 'Deleted user' (never the PA); (2) flag users with no login for 11 months and email an inactivity notice; tombstone at 12 months if still inactive (admins and the PA are exempt and listed for manual review; holding positions does not exempt); (3) purge expired sessions, **sessions whose \`createdAt\` is older than 90 days (absolute cap enforcement, AUTH-T27)**, verifications, unlock tokens, and unverified sign-ups older than 7 days; (4) null IP/UA on audit rows and sessions older than 90 days. Everything logged with counts; \`--dry-run\` support; admin UI shows upcoming tombstones. A tombstoned email may be re-registered later as a brand-new account (new id).`, acceptance: [ "Time-travel tests for each rule; PA and admins are never auto-tombstoned.", @@ -862,20 +1466,43 @@ Key naming convention (document in the admin UI help and the integration guides) // ───────────────────────── E11 Integration Guides & Docs ───────────────────────── { - id: "AUTH-T79", epic: "docs", phase: 1, priority: "High", estimate: 3, labels: ["docs"], deps: ["AUTH-T26", "AUTH-T28", "AUTH-T29"], + id: "AUTH-T79", + epic: "docs", + phase: 1, + priority: "High", + estimate: 3, + labels: ["docs"], + deps: ["AUTH-T26", "AUTH-T28", "AUTH-T29"], title: "ARCHITECTURE.md: Neon mandate, components, session and token flows", description: `Write the canonical architecture document: the Neon mandate in the first paragraph ('SGAuth runs on Neon serverless Postgres and does not use Supabase for any purpose'), component diagram (Vercel app, Neon, Upstash, Resend, PostHog), request flows (login, product session lookup, Supabase token flow, logout propagation, PA transfer), data model overview, environment topology, and links to runbooks and guides. Keep it current as a living document (owner: SGAuth lead).`, - acceptance: ["Doc merged at `docs/ARCHITECTURE.md` with Mermaid diagrams that render on GitHub; reviewed by the team lead."], + acceptance: [ + "Doc merged at `docs/ARCHITECTURE.md` with Mermaid diagrams that render on GitHub; reviewed by the team lead.", + ], }, { - id: "AUTH-T80", epic: "docs", phase: 2, priority: "High", estimate: 3, labels: ["docs", "sdk"], deps: ["AUTH-T61", "AUTH-T34"], + id: "AUTH-T80", + epic: "docs", + phase: 2, + priority: "High", + estimate: 3, + labels: ["docs", "sdk"], + deps: ["AUTH-T61", "AUTH-T34"], title: "Integration guide for Neon-based products (Next.js)", description: `\`docs/integration/neon-products.md\`: prerequisites (subdomain registered in the product registry), install SDK, env vars, add \`proxy.ts\`, read the session in server components/actions/route handlers, gate features by position keys (with a recommended per-product permission map file), key product tables by SGAuth user id (create-on-first-login pattern), link to the account page, logout, dev/preview topology, propagation/staleness, migration checklist for products with existing users, troubleshooting (cookie missing, 401 loops, origin rejected).`, - acceptance: ["VaultZ integration completed by following the guide with no undocumented steps (feedback folded back in)."], + acceptance: [ + "VaultZ integration completed by following the guide with no undocumented steps (feedback folded back in).", + ], }, { - id: "AUTH-T81", epic: "docs", phase: 3, priority: "High", estimate: 5, labels: ["docs", "supabase", "jwt"], deps: ["AUTH-T29", "AUTH-T60"], - title: "Integration guide for Supabase-backed products (third-party auth) plus the move-to-Neon alternative", + id: "AUTH-T81", + epic: "docs", + phase: 3, + priority: "High", + estimate: 5, + labels: ["docs", "supabase", "jwt"], + deps: ["AUTH-T29", "AUTH-T60"], + title: + "Integration guide for Supabase-backed products (third-party auth) plus the move-to-Neon alternative", description: `\`docs/integration/supabase-products.md\` covering, with tested snippets: 1. **Register SGAuth as a third-party auth provider** using the Supabase Management API (\`POST /v1/projects/{ref}/config/auth/third-party-auth\` with \`oidc_issuer_url: https://auth.northeasternsga.com\`, or \`jwks_url\`), a script \`scripts/supabase-register-tpa.ts\`, and how to verify (\`GET\` the integration, check \`resolved_jwks\`). Note the dashboard may not expose a generic provider; the API does. 2. **Token requirements** SGAuth satisfies: ES256, \`kid\`, \`role: 'authenticated'\`, uuid \`sub\`, \`iss\`, \`exp\` ≤ 10 min. @@ -891,40 +1518,88 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T82", epic: "docs", phase: 2, priority: "Medium", estimate: 3, labels: ["docs", "admin"], deps: ["AUTH-T37", "AUTH-T39", "AUTH-T64", "AUTH-T41"], + id: "AUTH-T82", + epic: "docs", + phase: 2, + priority: "Medium", + estimate: 3, + labels: ["docs", "admin"], + deps: ["AUTH-T37", "AUTH-T39", "AUTH-T64", "AUTH-T41"], title: "Admin runbooks", description: `\`docs/runbooks/\`: Primary Admin transfer (step-by-step with screenshots), break-glass recovery, unlocking a user, bulk import, position lifecycle (create/rename/retire), key and secret rotation, incident response (revoke all sessions, rotate secret, notify), onboarding a new product (registry + subdomain + SDK), semester turnover checklist.`, - acceptance: ["Each runbook has been executed once on dev by someone other than its author and corrected accordingly."], + acceptance: [ + "Each runbook has been executed once on dev by someone other than its author and corrected accordingly.", + ], }, { - id: "AUTH-T83", epic: "docs", phase: 3, priority: "Medium", estimate: 2, labels: ["docs", "privacy"], deps: ["AUTH-T78"], + id: "AUTH-T83", + epic: "docs", + phase: 3, + priority: "Medium", + estimate: 2, + labels: ["docs", "privacy"], + deps: ["AUTH-T78"], title: "Privacy notice and data-handling document", description: `Public page \`/privacy\` and \`docs/DATA_HANDLING.md\`: what SGAuth stores (name, preferred name, northeastern.edu email, positions, security metadata such as IP/user agent for 90 days, audit events), why, who can see it (admins), retention (deactivated 30 days, inactive 12 months), how to request deletion, and that SGAuth stores no NUID, grades, or academic records. Note that names and positions are directory-level information and that SGA, as a student organization, is not the university's FERPA steward; keep the data set that way.`, - acceptance: ["Page live and linked from the login footer; reviewed by the Primary Admin."], + acceptance: [ + "Page live and linked from the login footer; reviewed by the Primary Admin.", + ], }, { - id: "AUTH-T84", epic: "docs", phase: 3, priority: "Low", estimate: 1, labels: ["docs", "sdk"], deps: ["AUTH-T61"], + id: "AUTH-T84", + epic: "docs", + phase: 3, + priority: "Low", + estimate: 1, + labels: ["docs", "sdk"], + deps: ["AUTH-T61"], title: "Generated SDK API reference and changelog discipline", description: `Typedoc site published to GitHub Pages from the SDK repo on release; enforce changelog entries via a CI check on PRs.`, - acceptance: ["Reference site live; a PR without a changelog entry fails CI."], + acceptance: [ + "Reference site live; a PR without a changelog entry fails CI.", + ], }, { - id: "AUTH-T85", epic: "docs", phase: 1, priority: "Low", estimate: 1, labels: ["docs", "chore"], deps: ["AUTH-T02", "AUTH-T06"], + id: "AUTH-T85", + epic: "docs", + phase: 1, + priority: "Low", + estimate: 1, + labels: ["docs", "chore"], + deps: ["AUTH-T02", "AUTH-T06"], title: "CLAUDE.md and CONTRIBUTING.md for agents and humans", description: `Document conventions for coding agents and contributors: Neon-only (no Supabase), commands (dev, test, migrate, seed), folder layout, authz and audit requirements for any new mutating route, testing expectations, commit/PR conventions (labels), and links to the design docs. Update the repo's agent definitions that reference Supabase (prisma-migration-agent, security-reviewer) to Neon.`, - acceptance: ["CLAUDE.md merged; agent definitions no longer mention Supabase."], + acceptance: [ + "CLAUDE.md merged; agent definitions no longer mention Supabase.", + ], }, // ───────────────────────── E12 User Migration & Rollout ───────────────────────── { - id: "AUTH-T86", epic: "rollout", phase: 3, priority: "High", estimate: 2, labels: ["migration", "chambers"], deps: [], - title: "Receive the Chambers auth.users export and define the import file format", + id: "AUTH-T86", + epic: "rollout", + phase: 3, + priority: "High", + estimate: 2, + labels: ["migration", "chambers"], + deps: [], + title: + "Receive the Chambers auth.users export and define the import file format", description: `**The export itself is a manual action item owned by Eli, outside Linear** (Chambers has no Linear team): export the Chambers Supabase \`auth.users\` table (\`id\`, \`email\`, \`encrypted_password\`, \`email_confirmed_at\`, \`last_sign_in_at\`, \`banned_until\`) joined to \`public.users\` (name fields, \`admin_role\`, \`iems_role\`, \`is_active\`) and board memberships **before the Supabase project is deleted**, into a JSON file kept out of git. This ticket: publish the expected JSON schema and a validation script (\`scripts/validate-export.ts\`) that checks the file, reports row counts and a checksum, and confirms hashes look like bcrypt (\`$2a$\`/\`$2b$\`). The file is stored in the team secrets vault and deleted after import.`, - acceptance: ["Schema and validator merged; the received export validates with counts matching what Eli reports from Supabase."], + acceptance: [ + "Schema and validator merged; the received export validates with counts matching what Eli reports from Supabase.", + ], }, { - id: "AUTH-T87", epic: "rollout", phase: 3, priority: "High", estimate: 3, labels: ["migration", "backend", "chambers"], deps: ["AUTH-T86", "AUTH-T21", "AUTH-T44", "AUTH-T13"], - title: "Import script: Chambers users with bcrypt hashes and position mapping", + id: "AUTH-T87", + epic: "rollout", + phase: 3, + priority: "High", + estimate: 3, + labels: ["migration", "backend", "chambers"], + deps: ["AUTH-T86", "AUTH-T21", "AUTH-T44", "AUTH-T13"], + title: + "Import script: Chambers users with bcrypt hashes and position mapping", description: `\`scripts/import-users.ts --source chambers.json --mapping chambers-positions.json --dry-run\`: for each row, upsert User by lower-cased email (merge if it already exists from another import), store the bcrypt hash as \`bcrypt$\` in Account (only when no scrypt password exists), set \`emailVerified\` from \`email_confirmed_at\`, mark \`is_active = false\` users as DEACTIVATED, assign positions from an approved mapping file (Chambers roles → curated position keys), skip banned users, and write a report (created/merged/skipped with reasons) plus an id-mapping file (Supabase id → SGAuth id) for the Chambers team. Audit BULK_IMPORT. Never log hashes.`, acceptance: [ "Dry run against dev reports counts; real run is idempotent (second run: 0 created).", @@ -933,91 +1608,209 @@ Key naming convention (document in the admin UI help and the integration guides) ], }, { - id: "AUTH-T88", epic: "rollout", phase: 4, priority: "High", estimate: 2, labels: ["migration", "aplio"], deps: ["AUTH-T87", "AUTH-T22"], + id: "AUTH-T88", + epic: "rollout", + phase: 4, + priority: "High", + estimate: 2, + labels: ["migration", "aplio"], + deps: ["AUTH-T87", "AUTH-T22"], title: "Aplio user import (emails and names, no passwords) with id mapping", description: `Aplio users authenticated with email OTP and have no passwords. Extend the import script with \`--source aplio.json\` (id, email, name, isAdmin, deletedAt): upsert by email (duplicates of Chambers accounts merge into the existing account and keep its password), create the rest as password-less accounts that set a password lazily on first sign-in (AUTH-T22; **no emails are sent at import**), do not grant SGAuth admin from Aplio's \`isAdmin\` (product-level; APLIO-P04 maps it to a position), skip soft-deleted users, produce the id-mapping file for APLIO-P03. Accounts whose address is not northeastern.edu are imported as-is (the admin/import path bypasses the domain rule) with \`legacyEmail = true\`; they keep working, and an admin can later move them to the person's northeastern.edu address via AUTH-T24, preserving the SGAuth id and Aplio history. Eli plans to look up and correct these few addresses by hand; the import report lists them.`, - acceptance: ["Report shows created/merged counts and lists every non-northeastern.edu account; the import sends no email; a sample imported user sets a password on first sign-in and sees their Aplio data; id-mapping file delivered to the Aplio team."], + acceptance: [ + "Report shows created/merged counts and lists every non-northeastern.edu account; the import sends no email; a sample imported user sets a password on first sign-in and sees their Aplio data; id-mapping file delivered to the Aplio team.", + ], }, { - id: "AUTH-T89", epic: "rollout", phase: 4, priority: "Medium", estimate: 2, labels: ["migration"], deps: ["AUTH-T88"], + id: "AUTH-T89", + epic: "rollout", + phase: 4, + priority: "Medium", + estimate: 2, + labels: ["migration"], + deps: ["AUTH-T88"], title: "SenatePath and SenatePortal user import", description: `Same script with \`--source senatepath.json\` (admin users only) and \`--source senateportal.json\` (email, first/last, role; SenatePortal is the new name for Attendance Manager) — NUID is NOT imported. Positions mapping files approved per product. Accounts are created password-less and set passwords lazily on first sign-in (AUTH-T22); no emails at import.`, - acceptance: ["Both imports run on dev with reports; id-mapping files delivered to each team."], + acceptance: [ + "Both imports run on dev with reports; id-mapping files delivered to each team.", + ], }, { - id: "AUTH-T90", epic: "rollout", phase: 3, priority: "Medium", estimate: 2, labels: ["docs", "rollout"], deps: ["AUTH-T80", "AUTH-T87"], + id: "AUTH-T90", + epic: "rollout", + phase: 3, + priority: "Medium", + estimate: 2, + labels: ["docs", "rollout"], + deps: ["AUTH-T80", "AUTH-T87"], title: "Rollout plan and user communications", description: `Write \`docs/ROLLOUT.md\`: order SGAuth MVP → VaultZ → Chambers → Aplio (hard cutover) → SenatePath → Attendance Manager; per-product cutover checklist (pre-import users, registry entry, subdomain, env, SDK version, smoke test, announcement, rollback flag, support window of 3 days with a named contact); email templates announcing 'one login for all SGA tools' and, for Chambers users, that their existing password keeps working.`, - acceptance: ["Plan reviewed with each product owner; dates recorded; announcement emails drafted."], + acceptance: [ + "Plan reviewed with each product owner; dates recorded; announcement emails drafted.", + ], }, { - id: "AUTH-T91", epic: "rollout", phase: 2, priority: "High", estimate: 2, labels: ["rollout", "infra", "security"], deps: ["AUTH-T72", "AUTH-T66", "AUTH-T76", "AUTH-T39", "AUTH-T64"], + id: "AUTH-T91", + epic: "rollout", + phase: 2, + priority: "High", + estimate: 2, + labels: ["rollout", "infra", "security"], + deps: ["AUTH-T72", "AUTH-T66", "AUTH-T76", "AUTH-T39", "AUTH-T64"], title: "Production launch checklist and Primary Admin bootstrap", description: `Execute before the first product goes live: DNS + DMARC verified; production env vars set; Neon PITR confirmed; Upstash and Resend production keys; PostHog project; uptime monitor; threat-model checklist items closed; bootstrap the first Primary Admin via a one-time script (\`scripts/bootstrap-primary-admin.ts\`, refuses to run if any PA exists) followed by MFA enrollment; at least two people hold break-glass credentials; backups of the curated positions; smoke test of login/logout/session endpoint from a product stub on a real subdomain.`, - acceptance: ["Checklist completed and signed off in the ticket by the Primary Admin; bootstrap script left disabled afterwards."], + acceptance: [ + "Checklist completed and signed off in the ticket by the Primary Admin; bootstrap script left disabled afterwards.", + ], }, { - id: "AUTH-T92", epic: "rollout", phase: 4, priority: "Low", estimate: 1, labels: ["rollout", "docs"], deps: ["AUTH-T90"], + id: "AUTH-T92", + epic: "rollout", + phase: 4, + priority: "Low", + estimate: 1, + labels: ["rollout", "docs"], + deps: ["AUTH-T90"], title: "Post-launch review and legacy cleanup tracking", description: `Two weeks after each product cutover: review PostHog funnel and audit metrics (failed logins, lockouts, support requests), remove the bcrypt verify path once no \`bcrypt$\` hashes remain, and confirm each product deleted its legacy auth code and secrets. Record findings in \`docs/ROLLOUT.md\`.`, - acceptance: ["Review notes recorded for every product; legacy-cleanup subtasks closed."], + acceptance: [ + "Review notes recorded for every product; legacy-cleanup subtasks closed.", + ], }, // ───────────────────────── E13 Testing & QA ───────────────────────── { - id: "AUTH-T93", epic: "testing", phase: 0, priority: "High", estimate: 3, labels: ["testing", "infra"], deps: ["AUTH-T01", "AUTH-T04"], + id: "AUTH-T93", + epic: "testing", + phase: 0, + priority: "High", + estimate: 3, + labels: ["testing", "infra"], + deps: ["AUTH-T01", "AUTH-T04"], title: "Test harness: vitest, Neon test branch, factories, and test mailer", description: `Configure vitest (node environment) with a global setup that points Prisma at the Neon \`test\` branch (reset from \`dev\` before each CI run; runs serialized by a GitHub Actions concurrency group because the 10-branch cap rules out per-run branches), applies migrations, and truncates tables between test files. Provide factories (\`createUser\`, \`createAdmin\`, \`createPrimaryAdmin\`, \`createSession\`, \`createPosition\`), a fake clock helper, a capturing mailer, and a fake Upstash (in-memory) limiter. Add \`npm test\` and coverage thresholds (80% lines on \`src/lib\`).`, - acceptance: ["`npm test` runs locally against a personal branch and in CI against the test branch in under 5 minutes.", "Factories and fakes documented in CONTRIBUTING.md."], + acceptance: [ + "`npm test` runs locally against a personal branch and in CI against the test branch in under 5 minutes.", + "Factories and fakes documented in CONTRIBUTING.md.", + ], }, { - id: "AUTH-T94", epic: "testing", phase: 1, priority: "High", estimate: 5, labels: ["testing", "auth-core"], deps: ["AUTH-T93", "AUTH-T17", "AUTH-T18", "AUTH-T19", "AUTH-T27", "AUTH-T64"], + id: "AUTH-T94", + epic: "testing", + phase: 1, + priority: "High", + estimate: 5, + labels: ["testing", "auth-core"], + deps: [ + "AUTH-T93", + "AUTH-T17", + "AUTH-T18", + "AUTH-T19", + "AUTH-T27", + "AUTH-T64", + ], title: "Integration tests for authentication flows", description: `End-to-end (HTTP-level) tests: sign-up + verify + login; domain rejection; wrong password; lockout and unlock; forgot/reset including session revocation; invite acceptance; deactivated user; session sliding and absolute expiry; logout and sign-out-everywhere; re-auth freshness; rate-limit responses with the fake limiter.`, - acceptance: ["All flows green in CI; each test asserts the expected audit events."], + acceptance: [ + "All flows green in CI; each test asserts the expected audit events.", + ], }, { - id: "AUTH-T95", epic: "testing", phase: 2, priority: "High", estimate: 3, labels: ["testing", "admin"], deps: ["AUTH-T93", "AUTH-T36", "AUTH-T37", "AUTH-T42", "AUTH-T43"], + id: "AUTH-T95", + epic: "testing", + phase: 2, + priority: "High", + estimate: 3, + labels: ["testing", "admin"], + deps: ["AUTH-T93", "AUTH-T36", "AUTH-T37", "AUTH-T42", "AUTH-T43"], title: "Authorization, admin, positions, and transfer integration tests", description: `HTTP-level tests exercising the admin endpoints with the full actor/target matrix, the position lifecycle (create/rename/assign/delete/retired-key reuse), and the complete PA transfer state machine including cron execution and mid-flight recipient deactivation.`, - acceptance: ["Matrix and state-machine tests pass; coverage of `authz.ts` and the transfer service ≥ 90%."], + acceptance: [ + "Matrix and state-machine tests pass; coverage of `authz.ts` and the transfer service ≥ 90%.", + ], }, { - id: "AUTH-T96", epic: "testing", phase: 2, priority: "High", estimate: 5, labels: ["testing", "sso", "e2e"], deps: ["AUTH-T34", "AUTH-T59", "AUTH-T30"], + id: "AUTH-T96", + epic: "testing", + phase: 2, + priority: "High", + estimate: 5, + labels: ["testing", "sso", "e2e"], + deps: ["AUTH-T34", "AUTH-T59", "AUTH-T30"], title: "Playwright end-to-end SSO test across subdomains", description: `Spin up SGAuth and two minimal product stubs (using the SDK) on \`auth.sga.localhost\`, \`a.sga.localhost\`, \`b.sga.localhost\` in CI. Scenarios: login at auth → both products see the session; position assigned by an admin → product B gates a page accordingly; logout on product A → product B is logged out; expired session → redirect to login with the correct redirect param; open-redirect attempts rejected.`, - acceptance: ["Playwright job green in CI with traces on failure; run time under 4 minutes."], + acceptance: [ + "Playwright job green in CI with traces on failure; run time under 4 minutes.", + ], }, { - id: "AUTH-T97", epic: "testing", phase: 2, priority: "Medium", estimate: 2, labels: ["testing", "jwt", "supabase"], deps: ["AUTH-T29"], + id: "AUTH-T97", + epic: "testing", + phase: 2, + priority: "Medium", + estimate: 2, + labels: ["testing", "jwt", "supabase"], + deps: ["AUTH-T29"], title: "JWT and JWKS conformance tests for Supabase requirements", description: `Tests: token verifies with \`jose\` via remote JWKS; header has \`alg: ES256\` and \`kid\`; claims include uuid \`sub\`, \`role: 'authenticated'\`, \`iss\`, \`aud\`, \`exp - iat ≤ 600\`; discovery document is valid; rotation keeps the old key in the JWKS for the grace period; token endpoint rejects missing/revoked sessions.`, - acceptance: ["Conformance suite green; a checklist in the Supabase guide references these tests."], + acceptance: [ + "Conformance suite green; a checklist in the Supabase guide references these tests.", + ], }, { - id: "AUTH-T98", epic: "testing", phase: 3, priority: "Low", estimate: 2, labels: ["testing", "performance"], deps: ["AUTH-T28", "AUTH-T05"], + id: "AUTH-T98", + epic: "testing", + phase: 3, + priority: "Low", + estimate: 2, + labels: ["testing", "performance"], + deps: ["AUTH-T28", "AUTH-T05"], title: "Load sanity for the session endpoint on Neon", description: `Run a k6/autocannon script against the dev deployment: 100 concurrent virtual users hitting \`/api/sgauth/session\` with valid cookies for 2 minutes. Record p50/p95, Neon connection count, and any pooler saturation; tune pool size and Neon compute settings; document results.`, - acceptance: ["p95 < 250 ms and zero connection errors at 100 VUs; results in `docs/PERFORMANCE.md`."], + acceptance: [ + "p95 < 250 ms and zero connection errors at 100 VUs; results in `docs/PERFORMANCE.md`.", + ], }, // ───────────────────────── Backlog / spikes ───────────────────────── { - id: "AUTH-T99", epic: "authcore", phase: 5, priority: "Low", estimate: 3, labels: ["spike", "sso"], deps: ["AUTH-T91"], + id: "AUTH-T99", + epic: "authcore", + phase: 5, + priority: "Low", + estimate: 3, + labels: ["spike", "sso"], + deps: ["AUTH-T91"], title: "Spike: Northeastern Microsoft Entra ID sign-in feasibility", description: `Not a launch dependency. Investigate whether SGA can (a) get an app registered in Northeastern's Entra tenant via ITS, or (b) register a multi-tenant app in an SGA-owned tenant that Northeastern's tenant permits users to consent to, restricting sign-in to Northeastern's tenant id. If viable, prototype Better Auth's \`microsoft\` social provider behind a feature flag, linking to existing accounts by verified email. Report blockers, MFA inheritance (Duo), and the account-linking policy.`, - acceptance: ["Written findings with a go/no-go recommendation and, if go, a follow-up ticket set."], + acceptance: [ + "Written findings with a go/no-go recommendation and, if go, a follow-up ticket set.", + ], }, { - id: "AUTH-T100", epic: "authcore", phase: 5, priority: "Low", estimate: 2, labels: ["backlog", "email"], deps: ["AUTH-T91"], + id: "AUTH-T100", + epic: "authcore", + phase: 5, + priority: "Low", + estimate: 2, + labels: ["backlog", "email"], + deps: ["AUTH-T91"], title: "Backlog: optional email OTP login (deferred due to email volume)", description: `Deferred by decision: password is the launch method because Resend free-tier volume is constrained. Revisit after launch: Better Auth \`emailOTP\` plugin as an alternative sign-in for users who forget passwords, with strict per-user caps. Requires a volume estimate against the org-wide Resend budget first.`, - acceptance: ["Decision recorded after reviewing 60 days of email volume metrics."], + acceptance: [ + "Decision recorded after reviewing 60 days of email volume metrics.", + ], }, // ───────────────────────── Red-team additions ───────────────────────── { - id: "AUTH-T101", epic: "foundation", phase: 0, priority: "High", estimate: 2, labels: ["infra", "neon", "observability"], deps: ["AUTH-T01"], + id: "AUTH-T101", + epic: "foundation", + phase: 0, + priority: "High", + estimate: 2, + labels: ["infra", "neon", "observability"], + deps: ["AUTH-T01"], title: "Neon Free-plan quota monitoring, alerts, and upgrade runbook", description: `Accepted risk from the red-team review: SGAuth runs on Neon Free, which suspends the compute for the remainder of the month once the project uses 100 CU-hours. A compute that stays awake most of the day at the 0.25 CU minimum uses about 180 CU-hours/month, so exhaustion is plausible once several products are live, and it would take every SGA login down at once. Mitigations: (1) a scheduled job (AUTH-T38, daily) reads consumption via the Neon API and emails all admins at 50%, 70%, and 85% of the monthly CU-hour quota, with a projection of the exhaustion date; (2) keep scale-to-zero at 5 minutes and rely on the SDK cache so idle periods suspend the compute; (3) \`docs/runbooks/neon-upgrade.md\`: one-click upgrade to Launch (pay-as-you-go, $0.106/CU-hour), what changes (7-day restore window, scale-to-zero configurable), and who is authorized to approve the spend; (4) record the 6-hour restore window as a known limitation in the architecture doc; (5) never add keep-warm pings (they burn the quota).`, @@ -1028,8 +1821,15 @@ Mitigations: (1) a scheduled job (AUTH-T38, daily) reads consumption via the Neo ], }, { - id: "AUTH-T103", epic: "authcore", phase: 1, priority: "Urgent", estimate: 2, labels: ["backend", "email", "security"], deps: ["AUTH-T20"], - title: "Scanner-safe email links: land on a page, consume the token on POST", + id: "AUTH-T103", + epic: "authcore", + phase: 1, + priority: "Urgent", + estimate: 2, + labels: ["backend", "email", "security"], + deps: ["AUTH-T20"], + title: + "Scanner-safe email links: land on a page, consume the token on POST", description: `Northeastern mail is Microsoft 365, and Defender Safe Links pre-fetches every link in incoming mail. A link that acts on GET (verify, reset, invite/set-password, unlock, PA transfer accept/cancel) would be consumed by the scanner before the user clicks. Rule for every emailed link in SGAuth: the URL opens a page that shows what is about to happen and a button; the token is validated for display on GET (never consumed, never marks anything) and consumed only on the button's POST (same-origin, CSRF-protected). Better Auth's built-in verify-email link acts on GET, so send our own URL (\`/verify-email?token=\`) that renders the confirmation page and calls the Better Auth verification endpoint on submit. HEAD requests and known scanner user agents get a 200 with no side effects. Provide one shared \`TokenActionPage\` component and a helper used by AUTH-T18, T19, T22, T24, T37, T64.`, acceptance: [ "A HEAD or GET request to any emailed link does not consume the token (integration test); the subsequent POST does, exactly once.", @@ -1037,7 +1837,13 @@ Mitigations: (1) a scheduled job (AUTH-T38, daily) reads consumption via the Neo ], }, { - id: "AUTH-T105", epic: "authcore", phase: 4, priority: "Medium", estimate: 5, labels: ["backend", "frontend", "security", "better-auth"], deps: ["AUTH-T17", "AUTH-T32", "AUTH-T54", "AUTH-T67"], + id: "AUTH-T105", + epic: "authcore", + phase: 4, + priority: "Medium", + estimate: 5, + labels: ["backend", "frontend", "security", "better-auth"], + deps: ["AUTH-T17", "AUTH-T32", "AUTH-T54", "AUTH-T67"], title: "Passkeys as an optional sign-in method", description: `Suggested by Benedikt and accepted: add the Better Auth \`passkey\` plugin (WebAuthn) as an optional, per-user sign-in method. Passkeys cost no email, resist phishing, and suit students who prefer not to manage passwords. - **Enrollment** from \`/account/security\` after fresh re-auth (AUTH-T32); users may register several passkeys and name/remove them. Relying-party ID is \`auth.northeasternsga.com\` (dev: \`auth-dev.northeasternsga.com\`), so passkeys are only usable on SGAuth's own login page; products never see WebAuthn. @@ -1055,8 +1861,15 @@ Mitigations: (1) a scheduled job (AUTH-T38, daily) reads consumption via the Neo ], }, { - id: "AUTH-T104", epic: "sessions", phase: 1, priority: "Urgent", estimate: 2, labels: ["spike", "sso", "better-auth"], deps: ["AUTH-T05", "AUTH-T26"], - title: "Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them", + id: "AUTH-T104", + epic: "sessions", + phase: 1, + priority: "Urgent", + estimate: 2, + labels: ["spike", "sso", "better-auth"], + deps: ["AUTH-T05", "AUTH-T26"], + title: + "Spike: validate cross-subdomain cookies, prefixes, and local hostnames on the real domain before building on them", description: `Community reports (better-auth issues #5611, #3938) describe cross-subdomain cookies being set and then dropped in some configurations. Before any product integration work, deploy the minimal Better Auth config to \`auth-dev.northeasternsga.com\` and a static stub on \`stub-dev.northeasternsga.com\`, then verify in Chrome, Firefox, and Safari: (1) login sets exactly one \`__Secure-sgauth-dev.session_token\` cookie with \`Domain=northeasternsga.com\`; (2) the stub's server receives it and the session endpoint resolves it; (3) sign-out clears it on both hosts; (4) the \`SGAUTH_ENV=preview\` host-only mode works on a \`*.vercel.app\` preview; (5) the local \`*.sga.localhost\` scheme works in all three browsers or the fallback (hosts-file \`sga.test\`) is adopted; (6) a stale cookie with the same name set by the stub host (cookie tossing) is observed and its effect documented. Record findings in \`docs/ENVIRONMENTS.md\` and adjust AUTH-T26/T34 accordingly.`, acceptance: [ "Findings documented per browser with screenshots or HAR excerpts; any Better Auth version pin or workaround recorded.", diff --git a/docs/sgauth-design/tickets-products.mjs b/docs/sgauth-design/tickets-products.mjs index 5e48860..f391258 100644 --- a/docs/sgauth-design/tickets-products.mjs +++ b/docs/sgauth-design/tickets-products.mjs @@ -2,169 +2,521 @@ // Each team's epic is a single Linear Project: "SGAuth integration". export const PRODUCT_TEAMS = { - VAULTZ: { name: "VaultZ", epic: "SGAuth integration — VaultZ", label: "epic:sgauth-integration", phase: 2, - description: "Replace the shared-passphrase gate with SGAuth sessions via the SDK. First product integrated; proves the SDK and the Neon-product guide." }, - CHAMBERS: { name: "Chambers", epic: "SGAuth integration — Chambers", label: "epic:sgauth-integration", phase: 3, noLinear: true, - description: "Chambers has no Linear team; these items are a Markdown checklist only (no CSV). Chambers is a Neon product (migration completes before integration). Replace Supabase Auth, live-role checks, and session revocation with SGAuth sessions and positions; users and password hashes are imported into SGAuth. The auth.users export is Eli's manual action item, not a ticket." }, - APLIO: { name: "Aplio", epic: "SGAuth integration — Aplio", label: "epic:sgauth-integration", phase: 4, - description: "Hard cutover from Aplio's local Better Auth (email OTP) to SGAuth via the SDK; users pre-imported as password-less accounts that set a password on first sign-in." }, - SENATEPATH: { name: "SenatePath", epic: "SGAuth integration — SenatePath", label: "epic:sgauth-integration", phase: 4, - description: "Migrate SenatePath's database from Supabase to Neon and gate the admin area with SGAuth positions." }, - ATTENDANCE: { name: "Attendance Manager", epic: "SGAuth integration — Attendance Manager", label: "epic:sgauth-integration", phase: 4, - description: "Decision pending from the team: either stay on Supabase and consume SGAuth via third-party auth (JWT trust), or move to Neon and use the SDK. Both paths are ticketed; only one will be executed." }, + VAULTZ: { + name: "VaultZ", + epic: "SGAuth integration — VaultZ", + label: "epic:sgauth-integration", + phase: 2, + description: + "Replace the shared-passphrase gate with SGAuth sessions via the SDK. First product integrated; proves the SDK and the Neon-product guide.", + }, + CHAMBERS: { + name: "Chambers", + epic: "SGAuth integration — Chambers", + label: "epic:sgauth-integration", + phase: 3, + noLinear: true, + description: + "Chambers has no Linear team; these items are a Markdown checklist only (no CSV). Chambers is a Neon product (migration completes before integration). Replace Supabase Auth, live-role checks, and session revocation with SGAuth sessions and positions; users and password hashes are imported into SGAuth. The auth.users export is Eli's manual action item, not a ticket.", + }, + APLIO: { + name: "Aplio", + epic: "SGAuth integration — Aplio", + label: "epic:sgauth-integration", + phase: 4, + description: + "Hard cutover from Aplio's local Better Auth (email OTP) to SGAuth via the SDK; users pre-imported as password-less accounts that set a password on first sign-in.", + }, + SENATEPATH: { + name: "SenatePath", + epic: "SGAuth integration — SenatePath", + label: "epic:sgauth-integration", + phase: 4, + description: + "Migrate SenatePath's database from Supabase to Neon and gate the admin area with SGAuth positions.", + }, + ATTENDANCE: { + name: "Attendance Manager", + epic: "SGAuth integration — Attendance Manager", + label: "epic:sgauth-integration", + phase: 4, + description: + "Decision pending from the team: either stay on Supabase and consume SGAuth via third-party auth (JWT trust), or move to Neon and use the SDK. Both paths are ticketed; only one will be executed.", + }, }; export const PRODUCT_TICKETS = [ // ───────────────────────── VaultZ ───────────────────────── - { team: "VAULTZ", id: "VAULTZ-V01", priority: "High", estimate: 3, labels: ["sgauth", "backend"], deps: ["AUTH-T59", "AUTH-T80", "AUTH-T34"], - title: "Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy", + { + team: "VAULTZ", + id: "VAULTZ-V01", + priority: "High", + estimate: 3, + labels: ["sgauth", "backend"], + deps: ["AUTH-T59", "AUTH-T80", "AUTH-T34"], + title: + "Install @sgaoperations/sgauth and protect all routes with the SGAuth proxy", description: `Add the SDK from public npm (no registry config), env vars (\`SGAUTH_URL\`, \`SGAUTH_COOKIE_NAME\`), and \`proxy.ts\` using \`createSgaProxy({ publicPaths: ['/api/uploadthing', '/health'] })\` (cookie-presence check only; real validation in server code via \`requireSession()\`). Unauthenticated requests redirect to \`https://auth.northeasternsga.com/login?redirect=\`. Register \`https://vaultz.northeasternsga.com\` (and \`vaultz-dev.northeasternsga.com\`) in the SGAuth product registry first.`, acceptance: [ "Visiting any VaultZ page without an SGAuth session redirects to SGAuth login and back to the original page after login.", "UploadThing callback route remains reachable without a session.", - ] }, - { team: "VAULTZ", id: "VAULTZ-V02", priority: "High", estimate: 2, labels: ["sgauth", "backend"], deps: ["VAULTZ-V01"], + ], + }, + { + team: "VAULTZ", + id: "VAULTZ-V02", + priority: "High", + estimate: 2, + labels: ["sgauth", "backend"], + deps: ["VAULTZ-V01"], title: "Remove the shared passphrase gate", description: `Delete \`lib/access-gate.ts\`, \`lib/actions/access.ts\`, the passphrase page, and the \`VAULTZ_ACCESS_CODE\`/\`VAULTZ_ACCESS_SECRET\` env vars after the cutover (keep behind a \`SGAUTH_ENABLED\` flag for one release so rollback is a flag flip, then delete).`, - acceptance: ["No references to the passphrase remain after the flag is removed; the `vaultz_access` cookie is no longer set."] }, - { team: "VAULTZ", id: "VAULTZ-V03", priority: "Medium", estimate: 3, labels: ["sgauth", "data"], deps: ["VAULTZ-V01"], + acceptance: [ + "No references to the passphrase remain after the flag is removed; the `vaultz_access` cookie is no longer set.", + ], + }, + { + team: "VAULTZ", + id: "VAULTZ-V03", + priority: "Medium", + estimate: 3, + labels: ["sgauth", "data"], + deps: ["VAULTZ-V01"], title: "Link VaultZ purchaser records to SGAuth user ids", description: `VaultZ's \`User\` table records purchasers (first/last), not logins. Add \`sgauthUserId String? @unique\` and \`email String?\`. On first authenticated request, find-or-create the purchaser row for the session user (name from SGAuth split into first/last as a best effort, editable). Admin UI to link legacy purchaser rows to SGAuth users by email. Purchases created by the current user default \`userId\` to their linked row.`, - acceptance: ["A first-time SGAuth user gets exactly one purchaser row; re-login does not duplicate.", "Legacy rows can be linked once and the link is unique."] }, - { team: "VAULTZ", id: "VAULTZ-V04", priority: "High", estimate: 3, labels: ["sgauth", "permissions"], deps: ["VAULTZ-V01", "AUTH-T44"], + acceptance: [ + "A first-time SGAuth user gets exactly one purchaser row; re-login does not duplicate.", + "Legacy rows can be linked once and the link is unique.", + ], + }, + { + team: "VAULTZ", + id: "VAULTZ-V04", + priority: "High", + estimate: 3, + labels: ["sgauth", "permissions"], + deps: ["VAULTZ-V01", "AUTH-T44"], title: "Position-based permissions map for VaultZ", description: `Create \`lib/permissions.ts\` mapping SGAuth position keys to VaultZ capabilities, using keys from the curated seed (\`prisma/seed/positions.json\` in SGAOperations/auth). Starting proposal for the VaultZ owner to confirm: \`vice-president-of-operational-affairs\`, \`comptroller\`, \`finance-manager\` → manage designations/budgets/transfers; \`student-body-president\`, \`executive-vice-president\` → read everything; any other position holder → create their own purchases; no positions → no access. Add a product role (e.g. \`vaultz-admin\`) in the SGAuth admin UI only if the curated offices do not cover a need. Gate server actions and pages with \`hasAnyPosition\`. Permissions are decided in VaultZ, not in SGAuth; SGAuth only supplies positions. Document the map in the README and agree the keys with the curated position list.`, - acceptance: ["Every mutating server action checks a capability; a user without positions cannot create or edit anything (tests).", "The map is the only place position keys appear in VaultZ code."] }, - { team: "VAULTZ", id: "VAULTZ-V05", priority: "Low", estimate: 1, labels: ["sgauth", "ui"], deps: ["VAULTZ-V01"], + acceptance: [ + "Every mutating server action checks a capability; a user without positions cannot create or edit anything (tests).", + "The map is the only place position keys appear in VaultZ code.", + ], + }, + { + team: "VAULTZ", + id: "VAULTZ-V05", + priority: "Low", + estimate: 1, + labels: ["sgauth", "ui"], + deps: ["VAULTZ-V01"], title: "Account and sign-out links in the VaultZ header", description: `Show the session user's name, a link to \`accountUrl()\`, and a sign-out action using \`logoutUrl('https://vaultz.northeasternsga.com')\`.`, - acceptance: ["Sign out from VaultZ logs the user out of every SGA product (verified on dev)."] }, - { team: "VAULTZ", id: "VAULTZ-V06", priority: "Medium", estimate: 2, labels: ["sgauth", "infra"], deps: ["VAULTZ-V01", "AUTH-T34"], + acceptance: [ + "Sign out from VaultZ logs the user out of every SGA product (verified on dev).", + ], + }, + { + team: "VAULTZ", + id: "VAULTZ-V06", + priority: "Medium", + estimate: 2, + labels: ["sgauth", "infra"], + deps: ["VAULTZ-V01", "AUTH-T34"], title: "Dev and preview topology for VaultZ", description: `Configure the Vercel branch domain \`vaultz-dev.northeasternsga.com\` pointing at the dev branch with \`SGAUTH_URL=https://auth-dev.northeasternsga.com\` and the dev cookie name; local dev on \`vaultz.sga.localhost:3001\` against local SGAuth. Document in the VaultZ README.`, - acceptance: ["Preview on the branch domain shares the dev SGAuth session; ad-hoc vercel.app previews show a documented 'SSO unavailable on this host' page instead of a redirect loop."] }, - { team: "VAULTZ", id: "VAULTZ-V07", priority: "Medium", estimate: 1, labels: ["sgauth", "rollout"], deps: ["VAULTZ-V02", "VAULTZ-V04", "AUTH-T91"], + acceptance: [ + "Preview on the branch domain shares the dev SGAuth session; ad-hoc vercel.app previews show a documented 'SSO unavailable on this host' page instead of a redirect loop.", + ], + }, + { + team: "VAULTZ", + id: "VAULTZ-V07", + priority: "Medium", + estimate: 1, + labels: ["sgauth", "rollout"], + deps: ["VAULTZ-V02", "VAULTZ-V04", "AUTH-T91"], title: "VaultZ cutover checklist and rollback", description: `Execute the per-product checklist from the rollout plan: registry entry, DNS, env vars, SDK version pinned, smoke test, announcement to finance users, \`SGAUTH_ENABLED\` rollback flag verified, 3-day support window.`, - acceptance: ["Checklist completed; rollback rehearsed on dev by flipping the flag."] }, - { team: "VAULTZ", id: "VAULTZ-V08", priority: "Medium", estimate: 2, labels: ["sgauth", "testing"], deps: ["VAULTZ-V04"], + acceptance: [ + "Checklist completed; rollback rehearsed on dev by flipping the flag.", + ], + }, + { + team: "VAULTZ", + id: "VAULTZ-V08", + priority: "Medium", + estimate: 2, + labels: ["sgauth", "testing"], + deps: ["VAULTZ-V04"], title: "Tests for SGAuth guards and permissions in VaultZ", description: `Unit tests for the permissions map and integration tests (mocked SDK) for a protected page, a gated server action, and the purchaser link-on-first-login behavior.`, - acceptance: ["Tests run in VaultZ CI and cover allow/deny for each capability."] }, + acceptance: [ + "Tests run in VaultZ CI and cover allow/deny for each capability.", + ], + }, // ───────────────────────── Chambers ───────────────────────── - { team: "CHAMBERS", id: "CHAMBERS-C01", priority: "High", estimate: 3, labels: ["sgauth", "audit"], deps: [], + { + team: "CHAMBERS", + id: "CHAMBERS-C01", + priority: "High", + estimate: 3, + labels: ["sgauth", "audit"], + deps: [], title: "Inventory every auth and authorization touchpoint in Chambers", description: `List all uses of \`getAuthedUser\`, \`getAuthedUserWithLiveRoles\`, \`hasLiveAdmin\`, \`app_metadata\`, \`is_admin()\`, \`is_iems()\`, \`my_body_ids()\`, \`revoke_user_sessions\`, and any remaining Supabase Auth client calls (login, reset, password change) after the Neon migration. Produce a table: location → replacement (SGAuth session, position check, or Chambers-internal membership).`, - acceptance: ["Inventory document merged in the Chambers repo and reviewed by the Chambers lead."] }, - { team: "CHAMBERS", id: "CHAMBERS-C02", priority: "High", estimate: 5, labels: ["sgauth", "backend"], deps: ["CHAMBERS-C01", "AUTH-T59", "AUTH-T80"], - title: "Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal)", + acceptance: [ + "Inventory document merged in the Chambers repo and reviewed by the Chambers lead.", + ], + }, + { + team: "CHAMBERS", + id: "CHAMBERS-C02", + priority: "High", + estimate: 5, + labels: ["sgauth", "backend"], + deps: ["CHAMBERS-C01", "AUTH-T59", "AUTH-T80"], + title: + "Replace Supabase Auth with the SGAuth SDK (proxy, session helpers, login removal)", description: `Install the SDK; add \`proxy.ts\` with public paths for kiosk/display pages (protected by \`DISPLAY_KEY\`), cron routes (\`CRON_SECRET\`), and the service worker assets; replace \`lib/auth.ts\`/\`lib/authorization.ts\` with thin wrappers over \`getSession()\`; delete \`LoginCard\`, forgot-password UI, and all \`supabase.auth.*\` calls; \`/login\` redirects to SGAuth with the current URL. Keep offline/network-retry UX for the session fetch failure case (show the existing offline message).`, - acceptance: ["No `@supabase/*` auth imports remain; every previously protected route is protected by the proxy.", "Kiosk and cron routes work without a user session."] }, - { team: "CHAMBERS", id: "CHAMBERS-C03", priority: "High", estimate: 3, labels: ["sgauth", "data"], deps: ["CHAMBERS-C02", "AUTH-T87"], + acceptance: [ + "No `@supabase/*` auth imports remain; every previously protected route is protected by the proxy.", + "Kiosk and cron routes work without a user session.", + ], + }, + { + team: "CHAMBERS", + id: "CHAMBERS-C03", + priority: "High", + estimate: 3, + labels: ["sgauth", "data"], + deps: ["CHAMBERS-C02", "AUTH-T87"], title: "Key Chambers users by SGAuth user id", description: `Using the id-mapping file from the SGAuth import, migrate \`users.id\` (or add \`sgauth_user_id\` and repoint FKs) so the product's user row is keyed by SGAuth id; drop auth-only columns (\`sessions_revoked_at\`, password-related fields) and the dependency on \`auth.users\`. Create-on-first-login for users that exist in SGAuth but not in Chambers (e.g. self-registered students) with no memberships.`, - acceptance: ["Every pre-existing Chambers user resolves to the same SGAuth user by email (verification script), and bookings history is intact."] }, - { team: "CHAMBERS", id: "CHAMBERS-C04", priority: "High", estimate: 5, labels: ["sgauth", "permissions"], deps: ["CHAMBERS-C02", "AUTH-T44"], - title: "Map admin_role / iems_role to SGAuth positions; keep body memberships internal", + acceptance: [ + "Every pre-existing Chambers user resolves to the same SGAuth user by email (verification script), and bookings history is intact.", + ], + }, + { + team: "CHAMBERS", + id: "CHAMBERS-C04", + priority: "High", + estimate: 5, + labels: ["sgauth", "permissions"], + deps: ["CHAMBERS-C02", "AUTH-T44"], + title: + "Map admin_role / iems_role to SGAuth positions; keep body memberships internal", description: `Define \`lib/permissions.ts\`: Chambers admin capabilities derive from position keys: curated offices (proposal: \`vice-president-of-operational-affairs\`, \`speaker-of-the-senate\`, \`senate-operations-coordinator\`) plus product roles created in the SGAuth admin UI for roles that are not SGA offices (\`chambers-admin\`, \`chambers-iems\`); board/body memberships stay in Chambers tables keyed by SGAuth user id and continue to drive booking scopes. Replace \`hasLiveAdmin\`/\`is_admin()\` checks in app code with position checks; since Chambers is on Neon, RLS helpers are replaced by app-level checks in the data layer.`, - acceptance: ["Every admin-only route and action checks positions; a user whose position is removed in SGAuth loses admin ability within 60 s (SDK cache) without any Chambers-side change.", "Body-scoped booking rules unchanged (existing tests pass)."] }, - { team: "CHAMBERS", id: "CHAMBERS-C05", priority: "Medium", estimate: 3, labels: ["sgauth", "cleanup"], deps: ["CHAMBERS-C04"], + acceptance: [ + "Every admin-only route and action checks positions; a user whose position is removed in SGAuth loses admin ability within 60 s (SDK cache) without any Chambers-side change.", + "Body-scoped booking rules unchanged (existing tests pass).", + ], + }, + { + team: "CHAMBERS", + id: "CHAMBERS-C05", + priority: "Medium", + estimate: 3, + labels: ["sgauth", "cleanup"], + deps: ["CHAMBERS-C04"], title: "Remove live-role-check and session-revocation mechanisms", description: `Delete \`getAuthedUserWithLiveRoles\`, \`sessions_revoked_at\`, \`revoke_user_sessions\`, and related migrations/tests; revocation and deactivation are now SGAuth's job (admin revoke in SGAuth → 401 from the SDK). Update the user-management admin pages in Chambers to link to the SGAuth admin UI for deactivation/positions instead of local toggles.`, - acceptance: ["Chambers admin UI no longer offers role toggles that SGAuth owns; links open the SGAuth user page."] }, - { team: "CHAMBERS", id: "CHAMBERS-C06", priority: "High", estimate: 1, labels: ["sgauth", "migration"], deps: [], + acceptance: [ + "Chambers admin UI no longer offers role toggles that SGAuth owns; links open the SGAuth user page.", + ], + }, + { + team: "CHAMBERS", + id: "CHAMBERS-C06", + priority: "High", + estimate: 1, + labels: ["sgauth", "migration"], + deps: [], title: "Approve the Chambers role-to-position mapping file", description: `(The auth.users export is Eli's separate manual action item and is not tracked here.) Review and approve the mapping from \`admin_role\` / \`iems_role\` / board memberships to curated position keys used by the SGAuth import (AUTH-T87).`, - acceptance: ["Mapping file approved by the Chambers lead and the Primary Admin."] }, - { team: "CHAMBERS", id: "CHAMBERS-C07", priority: "High", estimate: 2, labels: ["sgauth", "rollout"], deps: ["CHAMBERS-C03", "CHAMBERS-C04", "CHAMBERS-C05", "AUTH-T91"], + acceptance: [ + "Mapping file approved by the Chambers lead and the Primary Admin.", + ], + }, + { + team: "CHAMBERS", + id: "CHAMBERS-C07", + priority: "High", + estimate: 2, + labels: ["sgauth", "rollout"], + deps: ["CHAMBERS-C03", "CHAMBERS-C04", "CHAMBERS-C05", "AUTH-T91"], title: "Chambers cutover, comms, and rollback plan", description: `Per-product checklist: registry entry for chambers.northeasternsga.com, env, SDK pin, import verified (sample users log in with existing passwords), announcement that passwords carry over, rollback plan (previous deployment + Supabase auth still intact for 7 days), 3-day support window.`, - acceptance: ["Cutover completed with zero password resets required for imported users beyond expected stragglers; rollback rehearsed on dev."] }, - { team: "CHAMBERS", id: "CHAMBERS-C08", priority: "Low", estimate: 1, labels: ["sgauth", "verification"], deps: ["CHAMBERS-C02"], - title: "Verify kiosk display key, Slack reminders, and cron routes are unaffected", + acceptance: [ + "Cutover completed with zero password resets required for imported users beyond expected stragglers; rollback rehearsed on dev.", + ], + }, + { + team: "CHAMBERS", + id: "CHAMBERS-C08", + priority: "Low", + estimate: 1, + labels: ["sgauth", "verification"], + deps: ["CHAMBERS-C02"], + title: + "Verify kiosk display key, Slack reminders, and cron routes are unaffected", description: `Confirm the display pages, Slack bot posting, Resend flows, and GitHub-Actions-driven cron routes still work with the SGAuth proxy in place (they must be on the public path list).`, - acceptance: ["Manual verification checklist completed on dev and production."] }, - { team: "CHAMBERS", id: "CHAMBERS-C09", priority: "Medium", estimate: 3, labels: ["sgauth", "testing"], deps: ["CHAMBERS-C04"], + acceptance: [ + "Manual verification checklist completed on dev and production.", + ], + }, + { + team: "CHAMBERS", + id: "CHAMBERS-C09", + priority: "Medium", + estimate: 3, + labels: ["sgauth", "testing"], + deps: ["CHAMBERS-C04"], title: "Tests for SGAuth-based authorization in Chambers", description: `Replace tests that mocked Supabase JWT claims with tests that mock the SDK session; cover admin/iems/body-member/none for the main booking and admin routes.`, - acceptance: ["Chambers CI green with the new tests; old Supabase auth tests removed."] }, + acceptance: [ + "Chambers CI green with the new tests; old Supabase auth tests removed.", + ], + }, // ───────────────────────── Aplio ───────────────────────── - { team: "APLIO", id: "APLIO-P01", priority: "High", estimate: 2, labels: ["sgauth", "migration"], deps: ["AUTH-T88"], - title: "Export Aplio users for the SGAuth import and receive the id mapping", + { + team: "APLIO", + id: "APLIO-P01", + priority: "High", + estimate: 2, + labels: ["sgauth", "migration"], + deps: ["AUTH-T88"], + title: + "Export Aplio users for the SGAuth import and receive the id mapping", description: `Export \`User\` rows (id, email, name, isAdmin, deletedAt) to JSON for the SGAuth import; receive the SGAuth id-mapping file; verify every active Aplio user maps to an SGAuth user.`, - acceptance: ["Mapping verified with a script; discrepancies resolved before cutover."] }, - { team: "APLIO", id: "APLIO-P02", priority: "High", estimate: 5, labels: ["sgauth", "backend"], deps: ["APLIO-P01", "AUTH-T59", "AUTH-T80"], + acceptance: [ + "Mapping verified with a script; discrepancies resolved before cutover.", + ], + }, + { + team: "APLIO", + id: "APLIO-P02", + priority: "High", + estimate: 5, + labels: ["sgauth", "backend"], + deps: ["APLIO-P01", "AUTH-T59", "AUTH-T80"], title: "Replace local Better Auth with the SGAuth SDK", description: `Remove \`lib/auth/config.ts\` (emailOTP, rate limits), \`app/api/auth/[...path]\`, the OTP email template and Resend usage for OTP, the login/OTP pages, and \`resolveUser\`'s Better Auth branch; implement \`getCurrentUser\`/\`getOptionalUser\`/\`getDeactivatedSessionUser\` over the SDK. Keep Aplio's deactivation concept (\`deletedAt\`) as a product-level state. Drop the \`Session\`, \`Account\`, and \`Verification\` tables in a follow-up migration after cutover.`, - acceptance: ["No `better-auth` dependency remains in Aplio; all `(auth)` routes are protected by the SGAuth proxy.", "Deactivated Aplio users still see the explanatory screen rather than a login loop."] }, - { team: "APLIO", id: "APLIO-P03", priority: "High", estimate: 5, labels: ["sgauth", "data"], deps: ["APLIO-P02"], + acceptance: [ + "No `better-auth` dependency remains in Aplio; all `(auth)` routes are protected by the SGAuth proxy.", + "Deactivated Aplio users still see the explanatory screen rather than a login loop.", + ], + }, + { + team: "APLIO", + id: "APLIO-P03", + priority: "High", + estimate: 5, + labels: ["sgauth", "data"], + deps: ["APLIO-P02"], title: "Key Aplio users by SGAuth user id", description: `Add \`sgauthUserId String @unique\` to \`User\`, backfill from the id-mapping file, and make all lookups go through it; create-on-first-login for new applicants (name/email from the session). Keep Aplio's own \`User.id\` as the FK target for the many audit relations to avoid rewriting every table. Remove \`neonAuthId\`.`, - acceptance: ["Every existing application, answer, and email log still resolves to the right person after backfill (verification query)."] }, - { team: "APLIO", id: "APLIO-P04", priority: "High", estimate: 2, labels: ["sgauth", "permissions"], deps: ["APLIO-P02", "AUTH-T44"], - title: "Derive Aplio admin from an SGAuth position; managers stay product-level", + acceptance: [ + "Every existing application, answer, and email log still resolves to the right person after backfill (verification query).", + ], + }, + { + team: "APLIO", + id: "APLIO-P04", + priority: "High", + estimate: 2, + labels: ["sgauth", "permissions"], + deps: ["APLIO-P02", "AUTH-T44"], + title: + "Derive Aplio admin from an SGAuth position; managers stay product-level", description: `Replace \`User.isAdmin\` with a position check (e.g. \`aplio-admin\` or the relevant exec positions) in \`requireAdmin\`/\`requireAdminOr404\` and \`buildReviewablePositionWhere\`; position managers remain an Aplio relation. Provide a one-time script that assigns the \`aplio-admin\` position in SGAuth to current Aplio admins (via the SGAuth bulk assign API or CSV).`, - acceptance: ["Admin pages gate on positions; the `isAdmin` column is removed after cutover."] }, - { team: "APLIO", id: "APLIO-P05", priority: "High", estimate: 3, labels: ["sgauth", "product"], deps: ["APLIO-P02"], + acceptance: [ + "Admin pages gate on positions; the `isAdmin` column is removed after cutover.", + ], + }, + { + team: "APLIO", + id: "APLIO-P05", + priority: "High", + estimate: 3, + labels: ["sgauth", "product"], + deps: ["APLIO-P02"], title: "Applicant flow on SGAuth accounts (northeastern.edu required)", description: `Applicants now create SGAuth accounts via northeastern.edu self-sign-up with verification. Decision: every student has a northeastern.edu address, so no domain exception exists; applicants who previously used another address must use their Northeastern one. Update the apply entry points to redirect to SGAuth sign-up with a redirect back to the position, and add copy explaining the Northeastern-email requirement. Existing Aplio users with non-NU addresses are imported as legacy accounts (AUTH-T88) and keep working until an admin moves them to their NU address.`, - acceptance: ["A new student can apply end-to-end: SGAuth sign-up → verify → land on the application form.", "Apply pages state the northeastern.edu requirement before the redirect."] }, - { team: "APLIO", id: "APLIO-P06", priority: "Low", estimate: 1, labels: ["sgauth", "dev"], deps: ["APLIO-P02", "AUTH-T34"], + acceptance: [ + "A new student can apply end-to-end: SGAuth sign-up → verify → land on the application form.", + "Apply pages state the northeastern.edu requirement before the redirect.", + ], + }, + { + team: "APLIO", + id: "APLIO-P06", + priority: "Low", + estimate: 1, + labels: ["sgauth", "dev"], + deps: ["APLIO-P02", "AUTH-T34"], title: "Local dev bypass and preview topology", description: `Keep the dev-bypass user cookie for local only (never on any deployed host), and configure \`apply-dev.northeasternsga.com\` with the dev SGAuth URL and cookie name.`, - acceptance: ["Bypass is a no-op on all Vercel deployments; preview branch domain shares the dev SSO session."] }, - { team: "APLIO", id: "APLIO-P07", priority: "High", estimate: 2, labels: ["sgauth", "rollout"], deps: ["APLIO-P03", "APLIO-P04", "APLIO-P05", "AUTH-T91"], + acceptance: [ + "Bypass is a no-op on all Vercel deployments; preview branch domain shares the dev SSO session.", + ], + }, + { + team: "APLIO", + id: "APLIO-P07", + priority: "High", + estimate: 2, + labels: ["sgauth", "rollout"], + deps: ["APLIO-P03", "APLIO-P04", "APLIO-P05", "AUTH-T91"], title: "Aplio hard cutover outside an application window", description: `Schedule the cutover when no application cycle is open; users were pre-imported as password-less accounts (no invite blast); announce the change, explaining that the first sign-in asks for a new password by email; rollback = redeploy previous release (local Better Auth tables retained for 14 days); 3-day support window.`, - acceptance: ["Cutover completed; no applicant lost draft access (verified with a sample of drafts)."] }, - { team: "APLIO", id: "APLIO-P08", priority: "Medium", estimate: 3, labels: ["sgauth", "testing"], deps: ["APLIO-P04"], + acceptance: [ + "Cutover completed; no applicant lost draft access (verified with a sample of drafts).", + ], + }, + { + team: "APLIO", + id: "APLIO-P08", + priority: "Medium", + estimate: 3, + labels: ["sgauth", "testing"], + deps: ["APLIO-P04"], title: "Update Aplio tests for SDK-based auth", description: `Replace Better Auth session mocks with SDK session mocks; cover admin, manager, applicant, deactivated, and anonymous paths for guards and scopes.`, - acceptance: ["Aplio CI green; guard coverage unchanged or higher."] }, + acceptance: ["Aplio CI green; guard coverage unchanged or higher."], + }, // ───────────────────────── SenatePath ───────────────────────── - { team: "SENATEPATH", id: "SENATEPATH-S01", priority: "High", estimate: 5, labels: ["neon", "migration"], deps: [], + { + team: "SENATEPATH", + id: "SENATEPATH-S01", + priority: "High", + estimate: 5, + labels: ["neon", "migration"], + deps: [], title: "Migrate SenatePath's database from Supabase to Neon", description: `Create a Neon project/branches, dump and restore the Postgres schema and data (pg_dump from Supabase → psql into Neon), switch \`DATABASE_URL\`/\`DIRECT_URL\`, optionally upgrade Prisma 6 → 7 with the driver adapter, remove \`@supabase/*\` packages and Supabase Storage usage if any (or keep Storage only if truly needed and document it). SGAuth is Neon-only; SenatePath becomes a standard Neon product.`, - acceptance: ["App runs against Neon in dev and prod; row counts match the Supabase source; no Supabase Auth code remains after S02."] }, - { team: "SENATEPATH", id: "SENATEPATH-S02", priority: "High", estimate: 3, labels: ["sgauth", "backend"], deps: ["SENATEPATH-S01", "AUTH-T59", "AUTH-T80"], + acceptance: [ + "App runs against Neon in dev and prod; row counts match the Supabase source; no Supabase Auth code remains after S02.", + ], + }, + { + team: "SENATEPATH", + id: "SENATEPATH-S02", + priority: "High", + estimate: 3, + labels: ["sgauth", "backend"], + deps: ["SENATEPATH-S01", "AUTH-T59", "AUTH-T80"], title: "Gate the SenatePath admin area with SGAuth positions", description: `Install the SDK; protect \`/admin/**\` with the proxy; replace the Supabase login page with a redirect to SGAuth; gate admin capabilities on curated positions (proposal for the SenatePath owner to confirm: \`director-of-elections\`, \`vice-chair-of-elections\`, \`elections-board-member\`, \`speaker-of-the-senate\`). Public application/nomination forms remain unauthenticated as today.`, - acceptance: ["Admin pages require an SGAuth session with the configured position; public forms unaffected."] }, - { team: "SENATEPATH", id: "SENATEPATH-S03", priority: "Medium", estimate: 2, labels: ["sgauth", "rollout"], deps: ["SENATEPATH-S02", "AUTH-T89"], + acceptance: [ + "Admin pages require an SGAuth session with the configured position; public forms unaffected.", + ], + }, + { + team: "SENATEPATH", + id: "SENATEPATH-S03", + priority: "Medium", + estimate: 2, + labels: ["sgauth", "rollout"], + deps: ["SENATEPATH-S02", "AUTH-T89"], title: "Import SenatePath admins into SGAuth and cut over", description: `Export admin emails for the SGAuth import; confirm they hold the gating position; run the per-product cutover checklist; delete Supabase project after a 14-day retention window.`, - acceptance: ["All previous admins can log in via SGAuth; Supabase project decommissioned."] }, - { team: "SENATEPATH", id: "SENATEPATH-S04", priority: "Low", estimate: 2, labels: ["sgauth", "testing"], deps: ["SENATEPATH-S02"], + acceptance: [ + "All previous admins can log in via SGAuth; Supabase project decommissioned.", + ], + }, + { + team: "SENATEPATH", + id: "SENATEPATH-S04", + priority: "Low", + estimate: 2, + labels: ["sgauth", "testing"], + deps: ["SENATEPATH-S02"], title: "Tests for the SGAuth admin gate", description: `Integration tests (mocked SDK) for admin access with/without the position and for public routes.`, - acceptance: ["Tests in CI cover allow/deny and public access."] }, + acceptance: ["Tests in CI cover allow/deny and public access."], + }, // ───────────────────────── Attendance Manager ───────────────────────── - { team: "ATTENDANCE", id: "ATTENDANCE-M01", priority: "High", estimate: 1, labels: ["sgauth", "decision"], deps: [], - title: "Decision: stay on Supabase (third-party auth) or move to Neon (SDK)", + { + team: "ATTENDANCE", + id: "ATTENDANCE-M01", + priority: "High", + estimate: 1, + labels: ["sgauth", "decision"], + deps: [], + title: + "Decision: stay on Supabase (third-party auth) or move to Neon (SDK)", description: `Record the team's directive. Criteria: does Attendance Manager use Supabase-only features (Storage, Realtime, Edge Functions)? If not, moving to Neon is recommended (simpler, no JWT staleness, standard SDK). If yes, use the third-party auth path. Exactly one of M02 or M03 proceeds.`, - acceptance: ["Decision documented in the repo README with rationale; the unused path's tickets are cancelled."] }, - { team: "ATTENDANCE", id: "ATTENDANCE-M02", priority: "High", estimate: 5, labels: ["sgauth", "supabase"], deps: ["ATTENDANCE-M01", "AUTH-T81", "AUTH-T60"], + acceptance: [ + "Decision documented in the repo README with rationale; the unused path's tickets are cancelled.", + ], + }, + { + team: "ATTENDANCE", + id: "ATTENDANCE-M02", + priority: "High", + estimate: 5, + labels: ["sgauth", "supabase"], + deps: ["ATTENDANCE-M01", "AUTH-T81", "AUTH-T60"], title: "(Supabase path) Consume SGAuth via Supabase third-party auth", description: `Follow the Supabase integration guide: register SGAuth as a third-party auth provider on the Attendance Supabase project via the Management API script; configure supabase-js with the SDK's access-token provider (server) and the proxy route (browser); replace \`supabaseAuthId\` with \`sgauthUserId\` on \`User\` and remove any \`auth.users\` FKs/triggers; rewrite RLS to \`auth.uid()\` and position claims (\`has_position('...')\`); remove Supabase login/signup pages and the custom middleware in favor of the SGAuth proxy; remove the unused \`password\` column. Note the ≤10-minute JWT staleness and the MAU billing line.`, - acceptance: ["A user with the required position can read/write per RLS using an SGAuth token; a user without it is denied at the database.", "No `auth.users` dependency remains; Supabase login pages removed."] }, - { team: "ATTENDANCE", id: "ATTENDANCE-M03", priority: "High", estimate: 5, labels: ["sgauth", "neon"], deps: ["ATTENDANCE-M01", "AUTH-T59", "AUTH-T80"], + acceptance: [ + "A user with the required position can read/write per RLS using an SGAuth token; a user without it is denied at the database.", + "No `auth.users` dependency remains; Supabase login pages removed.", + ], + }, + { + team: "ATTENDANCE", + id: "ATTENDANCE-M03", + priority: "High", + estimate: 5, + labels: ["sgauth", "neon"], + deps: ["ATTENDANCE-M01", "AUTH-T59", "AUTH-T80"], title: "(Neon path) Migrate to Neon and integrate with the SDK", description: `Dump/restore Postgres to Neon, switch Prisma URLs, remove \`@supabase/*\`, install the SDK, protect routes with the proxy, key \`User\` by \`sgauthUserId\`, and move authorization from RLS to app-level checks using positions.`, - acceptance: ["App runs on Neon with SGAuth sessions; row counts match; no Supabase code remains."] }, - { team: "ATTENDANCE", id: "ATTENDANCE-M04", priority: "Medium", estimate: 2, labels: ["sgauth", "permissions"], deps: ["ATTENDANCE-M01", "AUTH-T44"], + acceptance: [ + "App runs on Neon with SGAuth sessions; row counts match; no Supabase code remains.", + ], + }, + { + team: "ATTENDANCE", + id: "ATTENDANCE-M04", + priority: "Medium", + estimate: 2, + labels: ["sgauth", "permissions"], + deps: ["ATTENDANCE-M01", "AUTH-T44"], title: "Map roles to SGAuth positions; keep NUID product-side", description: `Define the permissions map from position keys to Attendance roles (MEMBER/OFFICER/etc.); NUID stays in Attendance's \`User\` table keyed by SGAuth user id and is never sent to SGAuth.`, - acceptance: ["Role checks derive from positions; NUID handling unchanged and documented."] }, - { team: "ATTENDANCE", id: "ATTENDANCE-M05", priority: "Medium", estimate: 2, labels: ["sgauth", "rollout"], deps: ["ATTENDANCE-M04", "AUTH-T89"], + acceptance: [ + "Role checks derive from positions; NUID handling unchanged and documented.", + ], + }, + { + team: "ATTENDANCE", + id: "ATTENDANCE-M05", + priority: "Medium", + estimate: 2, + labels: ["sgauth", "rollout"], + deps: ["ATTENDANCE-M04", "AUTH-T89"], title: "User import and cutover", description: `Export users (email, names, role) for the SGAuth import, receive the id mapping, backfill \`sgauthUserId\`, run the per-product cutover checklist.`, - acceptance: ["All active members map to SGAuth users; cutover completed with rollback rehearsed."] }, - { team: "ATTENDANCE", id: "ATTENDANCE-M06", priority: "Low", estimate: 2, labels: ["sgauth", "testing"], deps: ["ATTENDANCE-M04"], + acceptance: [ + "All active members map to SGAuth users; cutover completed with rollback rehearsed.", + ], + }, + { + team: "ATTENDANCE", + id: "ATTENDANCE-M06", + priority: "Low", + estimate: 2, + labels: ["sgauth", "testing"], + deps: ["ATTENDANCE-M04"], title: "Update auth and middleware tests", description: `Replace Supabase session mocks with SDK (or token) mocks in the existing auth-flow, middleware, and api-auth tests.`, - acceptance: ["CI green; old Supabase auth tests removed."] }, + acceptance: ["CI green; old Supabase auth tests removed."], + }, ];