Status -> done. Acceptance criteria all checked off except the operator
smoke test (full invite -> accept across two browsers), which requires
a live dev stack. Added a 'design decisions captured here' section
covering (a) why caller role is derived from workspace_members not
from the resolve query, (b) the new tRPC errorFormatter that exposes
error.cause, and (c) why the invite dialog doesn't offer 'owner' role
even though the schema accepts it.
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>
User pushed back on "strict email match in v1" — the right architectural
answer is multi-email identity (one users row owning multiple verified
emails), not a stopgap. Scaling the convoy accordingly:
1. Task-multi-email-identity (NEW, P1, foundation)
- user_email_identities table (user_id, email lowercased, verified_at,
source: primary | oauth:<provider> | manual)
- Refactor ensureUserIdByEmail -> ensureUserIdByVerifiedEmail against
the new table.
- OAuth callback writes a source='oauth:<provider>' identity when the
provider returns email_verified=true. Cross-user conflict rejects.
- Profile UI: "Linked emails" section, read-only in v1.
- Exports userOwnsEmail(userId, emailLower) for invite accept to call.
2. Task-workspace-invites-and-roles (existing, narrowed)
- All the original spec.
- Accept procedure calls userOwnsEmail() instead of comparing
users.email directly. Mismatch renders an explainer page, not a
silent accept.
3. Task-invite-recipient-autocomplete (NEW, P1, polish)
- invites.suggestRecipient returns typed suggestions across four
kinds: member / pending_invite / known_user / new_email.
- Tenancy fence on known_user is the security-relevant assertion;
test for it explicitly.
- Combobox UI renders each kind with its own affordance.
Three follow-ups filed explicitly to keep this convoy PR-sized:
- Task-manual-email-verification (add an email outside OAuth)
- Task-disconnect-linked-email (destructive, needs last-verified guard)
- Task-account-merge (handle the legacy duplicate-users case)
Epic file refreshed with the new task table, follow-up table, and a
phase ordering note. Identity lands first because it touches the
sign-in path; invites and autocomplete can ship in their own PRs.
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 task 5/5 is verification-only and requires the deployed
homelab stack + two browser sessions — outside an agent session's
reach. Documented in the task file what's been verified
automatically (lint + type-check, including @tasks/collab-server,
all green; no Path-A changes touched the collab server) and what
still needs hands-on testing against the deploy.
Leaves the task in `ready` status until an operator runs the live
7-step checklist.
Co-authored-by: Cursor <cursoragent@cursor.com>
Path-A task 4/5. The root landing logic in apps/web/app/page.tsx was
redirecting to `/${workspaceId}` (UUID, ugly) and using no ORDER BY
(so two sessions could land on different workspaces). It also looped
zero-workspace users through `/sign-in`.
Changes:
* Inner-join workspaceMembers with workspaces to fetch the slug, not
just the id. Order by membership createdAt ascending so users
consistently hit their oldest workspace.
* Redirect to /{slug} (slug, not UUID).
* Removed the unused `objects` / `and` imports that were lint
warnings.
* Zero-workspace branch redirects to /sign-in?error=no_workspace as a
defensive fallback; documented inline that this is unreachable for
fresh sign-ins post `ensureUserHasWorkspace` in apps/web/lib/auth.ts.
The dashboard at /{slug}/ is no longer a mockup (post commit f64d307
which wired it to objects.stats and objects.listRecent), so landing
there now shows real state.
Filed plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-onboarding-zero-workspace-flow.md (P2) as the follow-up that
turns the defensive fallback into a proper welcome flow with a
shared workspace-provisioning helper.
`pnpm lint && pnpm type-check` clean. Closes
plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-pick-workspace-landing-route.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
Path-A task 3/5. Replaces the setTimeout mock that returned the literal
"Full AI integration is coming soon!" string with a real streaming
provider call.
* apps/web/app/api/chat/route.ts (new): POST handler that runs the
same auth + resolveWorkspace pipeline workspaceProcedure uses, then
streams a response from streamText().toDataStreamResponse(). Maps
resolveWorkspace's TRPCError codes to HTTP status (401/403/404/400).
Returns a structured 503 with a human-readable hint when
OPENAI_API_KEY is unset, so the misconfiguration is surfaced rather
than masked by a fake stream.
* apps/web/app/(app)/[workspaceSlug]/ai/page.tsx: replace the local
message-state + setTimeout placeholder with useChat from
@ai-sdk/react. workspace slug is sent on every request body so the
server can enforce tenant scoping. Adds a ChatErrorBanner that
parses the JSON error body the route emits and renders amber for
the "unavailable" case, destructive for other failures.
* apps/web/package.json: pull in @ai-sdk/react as a direct dep
(previously only transitive via `ai`).
The existing aiRouter.chat tRPC mutation is left intact — it powers
the right-panel command palette via the non-streaming generateText
path, and rebuilding that as streaming was outside the scope of
making the dedicated chat page usable.
Provider selection still flows from env per packages/ai conventions:
OPENAI_API_KEY gates availability, OPENAI_BASE_URL lets operators
route through Ollama on CT 108 transparently, OPENAI_MODEL overrides
the default gpt-4o-mini.
`pnpm lint && pnpm type-check` clean. Closes
plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-wire-ai-chat-to-trpc.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
Path-A task 2/5. Replaces the hardcoded `stats` (24/8/12) and
hardcoded `recent` list on the workspace-home page with real
workspace-scoped data.
* server/routers/objects.ts: add two new procedures.
- `objects.stats` returns { openTasks, containers }. Open-task count
treats null status as open; only `done` and `closed` (per
packages/shared object-statuses) are terminal. Container count
aggregates project + space + group rows.
- `objects.listRecent({ limit })` returns the N most-recently-updated
rows, descending by updated_at. Excludes archived and excludes
`workspace`/`group` from the activity feed (containers clutter
"what did I just touch" recency).
Both go through workspaceProcedure, so the workspace_id filter
comes from the middleware-resolved ctx.workspace.id rather than
any user input.
* app/(app)/[workspaceSlug]/page.tsx: rewrite to consume the new
procedures via @trpc/react-query. Adds:
- Skeleton loading state (no flash of zeros).
- Empty state with a "New task" CTA on workspaces with no objects.
- Real "X ago" labels on the recent feed.
- Click-through links from recent rows to /{slug}/{id}.
- A locally-mounted CreateObjectDialog instance independent of the
global one in AppShell so the empty-state CTA can pre-seed
defaultType="task" without coordinating shared state.
* components/ui/skeleton.tsx: new (standard shadcn pulse skeleton).
Used by the dashboard but reusable across the app.
The scaffolded "Due this week" stat is dropped: `objects` has no
due_at column and the task explicitly preferred dropping a card to
schema-creep.
`pnpm lint && pnpm type-check` clean. Closes
plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-wire-workspace-home-dashboard.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 new plan trees that fill in the gaps surfaced during repo review.
Together they map out what remains between the current scaffold-with-stubs
state and a daily-usable, multitenant, agent-coordinated app.
* Plan-daily-driver-finish (P0): turn stubs into real data. Five tasks
covering the lint/shared-types breakage, hardcoded dashboard mocks,
AI-page setTimeout placeholder, post-signin landing decision, and a
cross-browser collab smoke test against the deployed Hocuspocus
instance.
* Plan-multitenant-saas-hardening (P1): everything multitenant needs
beyond what Plan-multitenant-cursor-sync already covers. Invites and
role management, soft-delete + append-only audit log, rate limits on
the auth + mutation hot paths, and a Vitest + GitHub Actions test
foundation so PRs can't ship red.
* Plan-agent-coordination (P2): the layer that makes a Task-*.md
runnable, not just readable. Adds workflow_prompt with task -> epic
-> plan inheritance, an agent_runs table for auditable sessions, and
two new MCP tools (claim_task / complete_task) that replace the
freeform update_object composition agents do today. Includes an
intentionally-deferred Epic-optional-orchestrator that captures the
Symphony-shaped runner as a decision point rather than an immediate
build.
Each task is bead-scale (one focused Cursor session) with explicit
in-scope, out-of-scope, and anti-goal sections so a future agent can
pick up a single Task-*.md and start without scrollback context.
Co-authored-by: Cursor <cursoragent@cursor.com>