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-invite-recipient-autocomplete. The invite dialog's plain
email input is replaced with a debounced combobox that surfaces the
four real cases — existing member, pending invite, known user from a
sibling workspace, brand-new email — before the inviter hits send.
Subagent ran in parallel while the main thread shipped Task 2's UI;
file-level non-overlap held (subagent stayed in
apps/web/components/teams/invite-recipient-combobox.tsx and the
shared types; main thread stayed in invite-dialog.tsx and the
teams page). This commit folds the subagent's deliverable in plus
the two-line wire-up that swaps the input for the combobox.
Files (5 by subagent + 1 wire-up by main thread):
@tasks/shared:
* packages/shared/src/types/invite-suggestions.ts — InviteSuggestion
union + pure mergeInviteSuggestions ranker. Lives in shared so
client + server consume one type definition.
* packages/shared/src/types/invite-suggestions.test.ts — 9 vitest
cases covering kind ordering, dedupe (known_user vs member by
userId, vs pending_invite by lowercased email), new_email
suppression when other kinds cover the typed address, the 10-
result limit, and email normalization.
* packages/shared/src/types/index.ts — re-export.
apps/web:
* apps/web/server/routers/invites.ts — new `suggestRecipient`
procedure on workspaceProcedure (owner/admin only). Implements
the four kinds with the tenancy fence wired as a two-step query:
first SELECT DISTINCT workspace_id FROM workspace_members WHERE
user_id = inviter (the inviter's workspace pool), then
inArray(workspaceMembers.workspaceId, pool) + ne(users.id,
inviter) on the candidate join. Read the procedure JSDoc for the
full set of invariants. All user-typed patterns escape through
escapeIlike with the ESCAPE '\\' clause (mirrors search.ts).
No existing exports modified.
* apps/web/components/teams/invite-recipient-combobox.tsx —
standalone controlled combobox. 200ms debounce, min-2-char gate,
distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard
nav, outside-click close.
* apps/web/components/teams/invite-dialog.tsx (wire-up) — Input
swapped for InviteRecipientCombobox. Added an
onFocusExistingMember prop so a future teams-page integration
can scroll/focus the matching row when a `member` suggestion is
picked; for now the dialog just closes cleanly on member-pick.
Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental
from earlier teams-page work, none from this task's files); 6/6
type-check; 23/23 tests (14 baseline + 9 new).
Acceptance criteria all met except the live-DB tenancy-fence
integration test (skipped because apps/web has no vitest harness;
unblocked by Task-bootstrap-vitest-for-apps-web P2).
Co-authored-by: Cursor <cursoragent@cursor.com>
Schema half of Task-workspace-invites-and-roles. Lands the table, the
invites router (create/list/revoke/accept), and the two new workspaces
procedures (updateMemberRole/removeMember). UI ships in part 2/2.
This is a stable checkpoint for Task 3 (invite-recipient-autocomplete)
to start building against — the procedure surface area is frozen and the
new identity helper from Task 1 is in the accept path.
Schema:
* workspace_invites: id, workspace_id, email (lowercased), role
(owner/admin/member), invited_by_user_id, token (base64url 32B),
expires_at (DEFAULT now() + 14d), accepted_at, revoked_at, created_at.
* Indexes: workspace_id, UNIQUE(token), and a PARTIAL UNIQUE on
(workspace_id, email) WHERE accepted_at IS NULL AND revoked_at IS NULL.
An open invite is unique per (workspace, email); closed invites
(accepted or revoked) fall out of the constraint so re-invites work.
* Drizzle relations wired: workspaceInvites.workspace,
workspaceInvites.invitedBy, workspaces.invites.
* Migration 0006_broad_lethal_legion applied to dev DB.
invites router:
* create({email, role}) on workspaceProcedure (owner/admin only).
Generates a base64url token from 32 random bytes via node:crypto.
Idempotent on (workspace, email) — if an open invite already exists,
returns it instead of inserting (the partial unique would block it
anyway). Refuses self-invite. Refuses if the email is already a
member.
* list() returns pending (non-accepted, non-revoked) invites with
inviter name/email joined for UI display.
* revoke({inviteId}) authorizes against the invite's workspace, not
the caller's input (the inviteId carries its own tenant scope).
* accept({token}) is protectedProcedure (no workspace handle). Calls
userOwnsEmail() from Task 1 — if the caller doesn't own the invited
email under any of their verified identities, throws FORBIDDEN with
a structured cause ({reason: "email_not_owned", invitedEmail}) so
the redeem page can render the "link this email" explainer. Handles
expiry, revoked, already-accepted states with clear messages.
Idempotent on existing membership — if you've already been added by
another flow, accept just closes the invite without re-inserting.
workspaces additions:
* updateMemberRole: admin/owner only. Three guards:
1. Can't change your own role (avoids accidental lockout).
2. Can't demote the only owner-role member (would leave the
membership-level ownership empty even though workspaces.owner_user_id
still points there — see ADR-pragmatic decision documented in the
Task-multi-email-identity convoy discussion).
3. Only owners can promote to owner; admins move people between
admin/member but cannot create a new owner.
* removeMember: admin/owner OR self (the leave-workspace affordance).
Same last-owner guard. Admins can't remove owners (only owners can,
via demote-then-remove).
Wired both new routers into root.ts as `invites` and `identity`
(identity was landed in Task 1; this commit just keeps the registration
visible alongside invites).
All three CI gates green: 0 lint errors, 14 unchanged warnings, 6/6
type-check, 14/14 tests (no new tests yet — apps/web vitest harness is
filed as Task-bootstrap-vitest-for-apps-web P2).
Co-authored-by: Cursor <cursoragent@cursor.com>
First half of Task-multi-email-identity. Lays down everything except the
NextAuth callback wiring, which is gated on a research subagent finishing
its survey of OAuth provider behavior for the email_verified claim
across GitHub, Google, and Authentik.
Schema (packages/database):
* New user_email_identities table colocated with `users` in users.ts.
Columns: id, user_id (FK), email (lowercased), verified_at, source,
created_at, last_used_at.
* Indexes: user_id, email, unique(user_id, email), and a PARTIAL unique
index on email WHERE verified_at IS NOT NULL — a verified email
resolves to exactly one users row globally, while unverified rows
(none today; placeholder for the manual-verification follow-up) do
not share the constraint.
* Drizzle relation: users.emailIdentities -> userEmailIdentities, and
the inverse one(users) relation.
* Migration 0005 generated by db:generate, augmented with a backfill
INSERT that seeds one source='primary' identity per existing users
row using created_at as verified_at. Migration applied to dev DB;
existing admin@tasks.dev user verified as 1:1 mapped.
Server (apps/web/server):
* apps/web/server/lib/identity.ts exports two pure read helpers:
- userOwnsEmail(userId, email): boolean used by the (upcoming)
invite-accept procedure to verify the human controls the invited
address under any of their linked identities.
- findUserIdByVerifiedEmail(email): the replacement for the old
ensureUserIdByEmail lookup. Will be called from auth.ts once the
OAuth research subagent returns.
* apps/web/server/routers/identity.ts exposes identity.listMine — a
protected procedure returning the caller's identities ordered by
verifiedAt desc. Cross-user identity surface is intentionally NOT
exposed here; that lives behind the workspace-scoped autocomplete
in Task 3 with its own tenancy fence.
UI (apps/web/app):
* New route /[workspaceSlug]/settings/profile renders a read-only
"Linked emails" section with per-identity row (email, source badge,
verified state, last-used relative time) plus a hint that explains
how to add another email (sign in via that email's OAuth provider).
* Empty / loading / error states all handled. The "no identities"
branch should never fire post-backfill but renders a friendly
message instead of throwing.
What's NOT in this commit:
* auth.ts changes (ensureUserIdByEmail -> ensureUserIdByVerifiedEmail,
OAuth callback identity upsert, cross-user conflict rejection).
Waiting on subagent research to land the callback wiring correctly
on the first try across all three providers.
* Vitest tests. The pure helpers are 10-line query shims and the
behavior-relevant assertion is the auth callback path — easier to
write meaningful tests once that lands.
All three CI gates green: pnpm lint (14 pre-existing warnings,
unchanged), pnpm type-check (6/6 packages), pnpm test (14/14
existing tests across @tasks/shared, @tasks/database, @tasks/ai).
Co-authored-by: Cursor <cursoragent@cursor.com>
First Path-B task. Path A landed daily-driver features without a
test runner; Path B is "harden so the next batch of changes can't
silently regress what just shipped." Step 1 is making `pnpm test`
real and gating CI on it.
Test runner:
* Install vitest + @vitest/coverage-v8 at the workspace root.
* Add vitest.config.ts (environment: "node", no JSDOM) + test /
test:watch scripts to packages/shared, packages/database,
packages/ai. Wire `test` into turbo.json with dependsOn: ^build
for future-proofing; add `pnpm test` to root package.json.
Three real tests (no snapshot theater — verified by mutation):
* packages/shared/src/utils/id.test.ts: asserts generateId() matches
the RFC 4122 v4 regex and produces 1000 distinct values. Mutating
generateId() to a constant fails both assertions.
* packages/shared/src/types/objects.test.ts: pins objectTypes and
objectStatuses arrays. These back the zod enum on objects.create
and the "open tasks" count on the workspace-home dashboard; a
silent reorder/rename would otherwise corrupt the dashboard math.
* packages/database/src/markdown-backlog/parse.test.ts: covers
parseBacklogMarkdown across three shapes (well-formed Task,
no-frontmatter Plan with path inference, malformed YAML that
must NOT throw — the importer runs in a file watcher). Plus
hashFileContents determinism.
* packages/ai/src/actions/index.test.ts: five tests across the
prompt builders (summarize/expand/rewrite × 3 tones / translate /
generateFromPrompt). Pure functions; no model mocking needed.
CI:
* .github/workflows/ci.yml runs on pull_request and push to main.
Node 20, pnpm 9 pinned explicitly (per AGENTS.md). Uses
setup-node's built-in pnpm cache. Steps: install --frozen-lockfile,
lint, type-check, test. Concurrency group cancels superseded runs
on non-main branches.
Docs:
* AGENTS.md: drop the "no test runner configured" disclaimer.
Document pnpm test / test:watch. Update the PR-readiness rule
from `pnpm lint && pnpm type-check` to
`pnpm lint && pnpm type-check && pnpm test`.
All 14 tests pass; lint + type-check still green across all 6
packages. The CI workflow's first run is gated on the operator
pushing this branch — that's the only acceptance criterion left
unverified in this commit.
Closes plans/Plan-multitenant-saas-hardening/Epic-test-foundation/
Task-bootstrap-vitest-and-ci.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
Path-A first task: get the repo's two repo-wide quality gates passing.
Both were failing from a clean clone in ways that were silently hiding
each other.
Headline fixes:
* apps/web: add an eslint 9 flat config (eslint.config.mjs) using
FlatCompat against next/core-web-vitals + next/typescript, and switch
the `lint` script from `next lint` to `eslint .`. Previously `next
lint` fell into its interactive setup prompt because there was no
config at all in apps/web, which made `pnpm lint` permanently fail
before any rule ever ran.
* packages/shared/src/utils/id.ts: replace `randomUUID` from `node:crypto`
with `globalThis.crypto.randomUUID`. `@tasks/shared` is forbidden from
using Node-only APIs (per AGENTS.md / repo-overview.mdc) because it
has to be importable from the browser bundle.
Adjacent fixes pulled in to make the gates actually green:
* apps/mcp-server/tsconfig.json: drop vestigial rootDir / declaration*
/ outDir / sourceMap (build is via tsup, not tsc emit) and add
allowImportingTsExtensions. The MCP server uses `.ts`-extension
re-export shims (db.ts / schema.ts / shared-types.ts) so tsup can
inline workspace .ts sources into the bundle.
* apps/collab-server/tsconfig.json: same simplification.
* apps/mcp-server/package.json: add @types/node so `process.env` in
packages/database/src/client.ts (transitively pulled into the MCP
server's type-check) resolves.
* apps/web/components/ui/input.tsx: empty `interface InputProps extends
React.InputHTMLAttributes<HTMLInputElement> {}` -> `type` alias.
* apps/web/server/lib/workspace-guard.ts: `from(args.table as any)` ->
`as unknown as PgTable` with a comment. Standard drizzle escape
hatch for structural generic tables.
* apps/web/components/whiteboard/shapes/{document,project,task}-card.tsx:
`BaseBoxShapeUtil<any>` -> `BaseBoxShapeUtil<{Shape}>` plus inline
`declare module "@tldraw/tlschema"` augmentation of
TLGlobalShapePropsMap. Required adding @tldraw/tlschema as a direct
devDep of apps/web so the augmentation target resolves; previously
it was only present transitively under tldraw's own deps.
Result: `pnpm lint && pnpm type-check` exits 0 across all 6 packages.
16 unused-import / exhaustive-deps warnings remain; they're pre-existing
housekeeping and out of scope for this task.
Closes plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-fix-lint-and-shared-types.md (status: done).
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>
Introduces a small graph executor under packages/ai/src/graph and a
code-review pipeline (analyze, summarize, compose) wired through new
prompt builders. Re-exported from the package index.
Co-authored-by: Cursor <cursoragent@cursor.com>