Commit graph

8 commits

Author SHA1 Message Date
Randall Stillwell
336a5890a8 feat(audit): append-only audit_log, workspace archive cascade + restore, audit view
Soft-delete cascade was the missing half of archive: stamping
workspaces.archived_at alone left objects visible to anyone with a
direct id. The cascade runs in one transaction so the partial state
isn't reachable, and restore inverts it for any archived row in the
workspace — provenance-blind on purpose until we have a use case
that needs to distinguish per-workspace from per-object archives.

audit_log keeps the keyset index on (workspace_id, created_at) and
the actor_user_id FK with onDelete set null. recordAudit() refuses
to write a null actor without a metadata.system_actor label so the
audit view always has something to render. workspaces and invites
mutations call recordAudit on success; objects-router instrumentation
and the markdown importer's system-actor flow are filed as P2
follow-ups because each needs a thoughtful "what's audit-worthy?"
pass, not mechanical wiring.

Settings → Audit log lives at /<slug>/settings/audit, owner-gated,
keyset-paginated. ACTION_LABELS is small on purpose; new actions
fall back to their raw key so missing a label degrades gracefully.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 15:35:16 -05:00
Randall Stillwell
7a55d6d1c6 feat(invites): workspace_invites schema + tRPC router + role management (Task 2, part 1/2)
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>
2026-06-02 10:27:44 -05:00
Randall Stillwell
86c014cb66 feat(identity): schema + helpers + read-only profile UI (Task 1, part 1/2)
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>
2026-06-02 10:07:50 -05:00
Randall Stillwell
48defb2ffd test(ci): bootstrap vitest + GitHub Actions and lock in 3-gate quality bar
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>
2026-06-02 09:23:55 -05:00
Randall Stillwell
875b1cfc87 auth: case-insensitive emails, Authentik SSO, first-signin workspace provisioning
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>
2026-06-01 23:45:00 -05:00
Randall Stillwell
c582d621ce multi-tenancy: promote workspaces to top-level table
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>
2026-05-06 23:02:55 -05:00
Randall Stillwell
663bc77afe feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:

App
- New routes: ai, forms, planner, settings (templates/types), teams,
  doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
  builder/renderer/responses, types manager, objects creation dialog,
  card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
  health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in

Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
  (uses CT 102 shared services), removes host port mappings, adds
  Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
  metadata title flipped to ECHODO

Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
  credentials.*, *.key, *.crt, *.pem, ssh keys

Made-with: Cursor
2026-04-26 14:34:34 -05:00
Randall Stillwell
a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00