Algorithm: a fixed-window token bucket implemented as a pure function in
`@tasks/shared` (`consumeTokenBucket`) plus a thin `apps/web` wrapper that
holds per-key state in a module-scoped `Map`. No Redis, no external deps —
horizontally-scaled deploys will need a Redis-backed swap behind the same
`rateLimit()` signature; called out in the JSDoc as a follow-up. The pure
core is unit-tested in `packages/shared` (6 new vitest cases covering
allow/deny, window reset, key isolation, monotonic retryAfterMs, denied-
flood pegging, and option validation); the wrapper is intentionally not
tested here because apps/web has no vitest harness yet.
Wire-ins (the two narrow surfaces called out in the v1 spec):
1. Credentials `authorize` in `apps/web/lib/auth.ts`: 5 attempts per
IP per 60s. IP comes from `next/headers` (x-forwarded-for first
entry, then x-real-ip); when headers() throws or returns nothing we
fall back to keying on "unknown" in prod and skipping the limiter
entirely in dev so a local test loop doesn't lock itself out. On a
trip we `console.warn` and return null — the standard Auth.js
"auth failed" signal — without consulting the DB.
2. `invites.create` in `apps/web/server/routers/invites.ts`: 10
invite-creates per inviter per hour. Keyed by inviter id (not
workspace) so a multi-workspace admin can't multiply their
allowance. On trip we throw TRPCError TOO_MANY_REQUESTS with a
retry-after seconds count baked into the message.
Co-authored-by: Cursor <cursoragent@cursor.com>
Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt
callback around the new user_email_identities table AND the existing
(but until-now empty) accounts table, with per-provider email_verified
resolution and a cross-user conflict guard. Credentials sign-in path is
unchanged.
Per the OAuth research subagent: NextAuth has no adapter configured, so
the accounts table has been sitting empty since this app started. Rather
than leave it that way, the new resolveOAuthUser helper writes to it
on every OAuth sign-in. (provider, providerAccountId) is now the
canonical "this OAuth identity belongs to this user" record and gives
us a fast path that doesn't depend on email matching.
Sign-in resolution order for an OAuth account:
1. Lookup accounts by (provider, providerAccountId).
Hit -> bump last_used_at on the matching identity row, return user_id.
2. Lookup user_email_identities by (email, verified_at IS NOT NULL).
Hit AND the owner has zero existing OAuth accounts -> link this new
OAuth account to that user (covers "Credentials user adds their
first OAuth provider"). Insert a fresh accounts row.
Hit AND the owner already has an OAuth account -> REFUSE. Returning
a token without an id field denies the session; the user lands on
NextAuth's error page. (This is the "Bob's GitHub claims alice's
verified email" rejection.)
3. Fall back to legacy users.email match.
Hit -> link to that user (covers users created before migration 0005).
4. Otherwise mint a new users row + a source='primary' identity in the
identities table, then write the accounts row.
The verified identity row is upserted only when the provider's
email_verified claim is true. The new resolveOAuthEmailVerified helper:
- Google + Authentik: read profile.email_verified directly (the Auth.js
v5 jwt callback receives `profile` on the sign-in trigger). Authentik
caveat documented inline: since the 2025.10 release the claim defaults
to false unless an admin adds a custom property mapping.
- GitHub: GitHubProfile does not expose the claim. We GET /user/emails
with the OAuth access_token and read `verified` on the entry matching
the primary email. Failure to fetch (rate limit, network) is treated
as unverified.
What's intentionally not in this commit:
- Vitest tests for the callback logic. apps/web has no vitest config
yet (the test foundation only wired up the packages). Filed a
follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under
Epic-test-foundation. The auth-callback assertions will land against
that harness when it's stood up.
- Race-condition transaction isolation. The current sequence (account
lookup -> identity lookup -> account/identity upsert) has the same
race window the old ensureUserIdByEmail had — two simultaneous OAuth
sign-ins for a brand-new email could both pass the identity check
before either INSERT fires. Mitigated in practice by the partial
unique on email WHERE verified_at IS NOT NULL — postgres will reject
the second insert — but the loser gets an opaque error. Filed as a
follow-up if it becomes a real issue.
Task file (plans/.../Task-multi-email-identity.md) updated with the
detailed smoke-test playbook an operator needs to run before the OAuth
path goes to production (sign in fresh, sign in repeat, sign in
cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in
via Credentials so the dev fixtures alone do not exercise this code.
Lint + type-check + test all green (14/14 tests, 0 lint errors, 14
unchanged warnings, 6/6 packages type-check).
Co-authored-by: Cursor <cursoragent@cursor.com>
Three pieces of authentication work that need to land together so OAuth
sign-ins produce a usable session.
* `ensureUserIdByEmail` upserts a `users` row on every OAuth sign-in
matched case-insensitively on email, then stamps `token.id` with the
resulting UUID so workspace-scoped tRPC procedures can resolve
membership. Credentials sign-in already returned the DB id from
`authorize`; OAuth now does the equivalent.
* `ensureUserHasWorkspace` mints a personal workspace (and `owner`
member row) on first sign-in for any user that doesn't already
belong to one, so fresh OAuth accounts don't land in the app with
no tenant scope. Idempotent; slug collisions retry with a random
suffix and cap at 5 attempts.
* Migration 0004 adds a `UNIQUE (lower(email))` index on `users` to
match the lookup pattern and prevent two providers from minting
rows that differ only in casing. Existing rows are normalized to
lowercase first; the column-level UNIQUE catches any pre-existing
duplicates so they get resolved by a human rather than silently
merged.
Sign-in / sign-up pages add an Authentik SSO button (gated on
`AUTH_AUTHENTIK_*` env vars). Layout switches to GitHub+Google on top
with Authentik full-width below.
Co-authored-by: Cursor <cursoragent@cursor.com>
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>