Phase 2 — Workspace: Task Specifications
Purpose: Build the multi-tenant workspace surface: workspace CRUD + icon upload, personal-workspace auto-creation on registration, members + roles with owner-transfer rules, email invitations, teams/teamspaces, the
packages/permissionsv1 engine with NestJS guard wiring, the sidebar, workspace settings UI, and the multi-user E2E proof. Tasks run strictly in order, TASK-0023 → TASK-0034. Phase exit criteria (verbatim from ROADMAP.md): multi-user workspace flow E2E-tested; permissions enforced server-side; sidebar renders tree from API.
Task Table
| ID | Title | Est. |
|---|---|---|
| TASK-0023 | Workspace CRUD (create/rename/soft-delete + icon upload) | L |
| TASK-0024 | Personal workspace auto-created on registration | M |
| TASK-0025 | Members + roles (Owner/Admin/Member/Guest, transfer) | L |
| TASK-0026 | Invitations (email → token link 7d → accept/decline/revoke) | M |
| TASK-0027 | Teams / teamspaces (CRUD + membership + sidebar positions) | M |
| TASK-0028 | packages/permissions v1 (role base + grants + guard) | L |
| TASK-0029 | Sidebar UI (sections, switcher, collapse, testids) | L |
| TASK-0030 | Workspace settings UI (Profile, Members, Teams, Security) | M |
| TASK-0031 | Workspace switcher + URL [workspace] slug resolution | M |
| TASK-0032 | Workspace audit events | M |
| TASK-0033 | E2E multi-user workspace flow (tests/e2e/workspace.spec.ts) | L |
| TASK-0034 | Integration tests (tenant isolation) | M |
Phase Completion Checklist
- [ ] Every task above meets its Definition of Done
- [ ] Exit: multi-user workspace flow E2E-tested (A creates → invites B → B accepts → role enforcement → ownership transfer)
- [ ] Exit: permissions enforced server-side (403 + audit row for unauthorized ops)
- [ ] Exit: sidebar renders the page/team tree from the API
TASK-0023
Title
Workspace CRUD (create/rename/soft-delete + icon upload)
Objective
POST/PATCH/DELETE /api/v1/workspaces with slug uniqueness + validation, icon upload to object storage (MinIO), settings JSON, and soft delete (deletedAt) — the first real multi-tenant resource in the api.
Background
Phase 1 delivered auth + guard + audit infra. The Workspace model exists (slug unique, iconUrl, logoUrl, description, settings Json, isPersonal, ownerId, billingPlan, usageLimits, deletedAt nullable). Tenant scoping rule for this phase: every workspace query filters by deletedAt: null and membership (the guard from TASK-0028 enforces membership; this task establishes the query convention in the repository layer). Storage: MinIO via the @factory/storage package (S3 SDK + presigned URLs; the buckets factory-files/factory-avatars were created by the TASK-0004 compose init).
Requirements
apps/api/src/modules/workspace/workspace.{module, controller,service}.repo.ts: -POST /api/v1/workspacesbody{ name, slug, description?, icon? }→ creator becomes owner (WorkspaceMemberrole OWNER +Workspace.ownerId); slug: 2–48 chars[a-z0-9-], must start with a letter, reserved list rejected (admin,api,app,assets,billing,www,new,login— const inpackages/shared/src/slug.ts, shared with web); duplicate →409 slug_taken; response201 { workspace: { id, name, slug, iconUrl, description, ownerId, createdAt } }. -PATCH /api/v1/workspaces/:id— rename (name), description, settings (JSON merge at top level only — no deep merge, no keys other than the documented set:defaultPublicShareScope,allowGuestComments— schema inpackages/shared/src/dto/workspace.ts), icon (upload endpoint below). Role: OWNER/ADMIN only (via TASK-0028 guard when it lands; until then the service checksWorkspaceMember.roledirectly — the switch is recorded in the commit). -DELETE /api/v1/workspaces/:id— OWNER only; soft delete:deletedAt = now(cascade-free: memberships/teams/pages remain, hidden by the query convention);200 { deleted: true }; re-deleting →404 workspace_not_found.- Icon upload:
POST /api/v1/workspaces/:id/icon(multipart, OWNER/ADMIN): png/jpeg/webp/avif, ≤ 2 MB, dimension ≥ 64 px (checked after download from the request stream) → stored tofactory-avatarsbucket keyworkspaces/<wsId>/<uuid>.<ext>via@factory/storageputObject; response{ iconUrl }(public URL viaS3_PUBLIC_BASE_URL, or presigned when unset); setsWorkspace.iconUrl+ emitsworkspace.icon_changedaudit (TASK-0032 name). Wrong type/oversize →400 { code: "icon_invalid", details: { reason } }. - List:
GET /api/v1/workspaces→ the caller's non-personal + personal workspaces ({ id, name, slug, iconUrl, isPersonal, role }), newest first, max 100;GET /api/v1/workspaces/:id(by id or slug — one param, documented) → full workspace + caller'srole. - Every mutation: pino log with
workspaceId+requestId; zod DTOs inpackages/shared/src/dto/workspace.ts(shared with the web forms).
Acceptance Criteria
- Create with valid body → 201, owner rows (
Workspace.ownerId+WorkspaceMemberOWNER) exist; duplicate slug (case-insensitive) → 409slug_taken; reserved slug → 400; bad slug charset → 400 with field error. - Rename + settings merge work for OWNER and ADMIN; MEMBER → 403 (service check, later guard); settings merge replaces only the top-level keys present in the patch and keeps the rest (documented semantics; test: patch
{a:9}over{a:1,b:2}→{a:9,b:2}, and a patch that omitsbnever deletes it). - Soft delete by OWNER:
deletedAtset;GET /api/v1/workspacesno longer lists it;GET /api/v1/workspaces/<slug>→ 404; the row still exists in the DB with memberships intact (asserted); a second delete → 404. - Icon upload: a 1 MB png is stored in MinIO (object exists, correct key),
iconUrlset and reachable (HTTP 200 from the compose minio); a 5 MB file → 400icon_invalidtoo_large; a gif → 400wrong_type. - A user with no membership in the workspace gets 403/404 on every endpoint (cross-tenant isolation, full proof in TASK-0034).
Backend Changes
apps/api/src/modules/workspace/{workspace.module.ts, workspace.controller.ts, workspace.service.ts, workspace.repository.ts, icon.service.ts, dto/*};packages/shared/src/{slug.ts, dto/workspace.ts};@factory/storageusage wired (if the package lacks an S3 client, addpackages/storage/src/s3.ts— minimal, SDK-based, presigned GET/PUT).
Frontend Changes
apps/web/src/lib/api.ts: workspace fetch helpers (used by TASK-0029/0031);packages/shared/src/dto/workspace.tsconsumed (no pages yet — settings UI is TASK-0030).
Database Changes
None (existing workspaces, workspace_members tables).
API Changes
GET /api/v1/workspaces,GET /api/v1/workspaces/:idOrSlug,POST /api/v1/workspaces,PATCH /api/v1/workspaces/:id,DELETE /api/v1/workspaces/:id,POST /api/v1/workspaces/:id/icon— documented indocs/api/workspaces.md(new).
Tests
apps/api/src/modules/workspace/workspace.service.test.ts: slug rules, reserved list, ownership rows, soft-delete semantics, settings merge. —tests/integration/workspaces.test.ts: create/rename/delete round-trip, icon upload against real MinIO (Testcontainers minio — add totests/integration/helpers/testcontainers.ts), cross-user 403/404.
Security
- Icon: content-type sniffing (magic bytes, not just the client type header) before storage; path traversal impossible (uuid keys, no user-supplied filenames); storage keys unguessable; public bucket prefix documented as the only public data. — Tenant scoping: repository-level
where: { deletedAt: null }+ membership filter (the convention TASK-0028 enforces centrally).
Documentation
docs/api/workspaces.md(new): 6 endpoints, slug rules, icon contract, error codes. —docs/architecture/tenancy.md(new, short): the query-scoping convention + who enforces it (guard in TASK-0028).
Dependencies
- TASK-0019 (guard — service checks until wiring), TASK-0010 (logging), TASK-0004 (MinIO), TASK-0005 (S3 env).
Definition of Done
6 endpoints + icon upload live; unit + integration tests (incl. real MinIO) green; API docs + tenancy convention written.
TASK-0024
Title
Personal workspace auto-created on registration
Objective
Every new user automatically gets a personal workspace (isPersonal: true) the moment they register — so / always has a workspace to render and Phase 3 pages have a home before the user joins/creates anything else.
Background
TASK-0013 deliberately left a documented hook (onUserRegistered(user)) in the auth service. This task implements it: one transaction, no email, no invitation — the user is OWNER of their personal workspace. Personal workspaces are hidden from the "switch to another workspace" flow but shown in the switcher with a lock glyph (TASK-0031) and can never be transferred/invited (enforced by TASK-0025/0026 rules).
Requirements
apps/api/src/modules/workspace/personal-workspace.service.ts:ensurePersonalWorkspace(userId)— transaction: createWorkspace(isPersonal: true,name= "My workspace",slug=personal-<cuid 8 chars lowercased>— unique by construction, retried once on the (unlikely) clash, never user-visible in URLs — the switcher shows the name, not the slug),ownerId = userId,settings: { defaultPublicShareScope: "private" }(personal default: nothing shared by default — documented decision), plusWorkspaceMemberOWNER row. Idempotent: if the user already has one (any state), return it, create nothing.- Wiring: TASK-0013's hook calls this after the user insert commits (not inside the same transaction — a personal-workspace failure must not roll back registration; on failure:
logger.errorwithuserId+ a compensating retry via the BullMQ queueworkspace.provision(new queue inapps/worker/src/jobs/ workspace-provision.processor.ts, 8 retries, exponential backoff, dead-letter log). The worker job runs the sameensurePersonalWorkspace(idempotent). - Registration response unchanged (
userobject only — the workspace is not revealed by the register endpoint; the web/landing queriesGET /api/v1/workspacesafter login, which now always includes the personal one). - Seeded users:
prisma/seed.tsgains the personal workspaces for demo + admin via the same service logic (direct call in the seed — keep seed deterministic, no queue) — this replaces the ad-hoc personal-workspace creation of TASK-0006;prisma/verify.tskeeps asserting "exactly 2 personal workspaces" (now produced by the shared logic). GET /api/v1/workspacesmarks personal workspaces (isPersonal: true) — the switcher (TASK-0031) renders them in a separate group.
Acceptance Criteria
- Registering a new user (integration test) results in exactly one personal workspace for them (owner rows correct) within 5 s (queue latency budget) and the
GET /api/v1/workspacesincludes it withisPersonal: true. ensurePersonalWorkspaceis idempotent: calling it 3× for the same user creates exactly 1 workspace (unit + integration test).- Failing the direct call (fault injection: make the insert throw) does NOT fail the registration (201 returned) and the queued job retries to success (worker test: poison the first 2 attempts, job succeeds on the 3rd — BullMQ attempt-count assertion).
- Seeded demo/admin users have exactly one personal workspace each produced by the shared service (seed +
pnpm db:verifygreen after the change). - Personal workspace slugs are never two identical across users (uniqueness test with 100 generated slugs — collision-retry logic covered).
Backend Changes
apps/api/src/modules/workspace/personal-workspace.service.ts;apps/api/src/modules/auth/auth.service.ts(hook implementation + queue publish);apps/worker/src/jobs/workspace-provision.processor.ts(new BullMQ queueworkspace.provision— register inapps/worker/src/queues.ts).
Frontend Changes
- None (the landing page already lists workspaces; grouping is TASK-0031).
Database Changes
None (no schema change; seed/verify updated per criterion 4).
API Changes
None (existing endpoints now always return ≥ 1 workspace per user).
Tests
apps/api/src/modules/workspace/personal-workspace.service.test.ts: idempotency, slug generation uniqueness (100 samples), transaction rollback isolation. —tests/integration/personal-workspace.test.ts: register → workspace appears; fault-injection + worker retry (real redis in Testcontainers).
Security
- Personal workspaces are private by default settings (no implicit sharing); they are never listed for other users (membership scoping — TASK-0034 asserts).
Documentation
docs/architecture/tenancy.md: "Personal workspaces" section (creation timing, idempotency, queue, slug format);docs/api/workspaces.md:isPersonalsemantics.
Dependencies
- TASK-0013 (hook), TASK-0023 (workspace service/repository conventions), TASK-0008 (Testcontainers redis for the worker test).
Definition of Done
Auto-provisioning + worker queue live; all 5 criteria proven; seed/verify updated and green; docs written.
TASK-0025
Title
Members + roles (Owner/Admin/Member/Guest, owner transfer)
Objective
Full member management with the 4-role model and its invariants: exactly one OWNER per workspace, owner transfer flow, no self-demote/remove of the last owner, role change auditing — endpoints + the rules engine in one place.
Background
WorkspaceMember (role: WorkspaceRole, invitedBy, joinedAt, unique [workspaceId, userId]) and Workspace.ownerId exist. The rules below are the canonical Phase 2 permission matrix (the packages/permissions engine, TASK-0028, will encode them; this task implements them in apps/api/src/modules/workspace/roles.service.ts so the engine has a reference behavior to match in tests).
Requirements
- Endpoints (all scoped to the workspace, caller must be a member): -
GET /api/v1/workspaces/:id/members→{ id, userId, email, name, role, joinedAt, invitedBy }(join users; max 500, cursor pagination?cursor=&limit=). -PATCH /api/v1/workspaces/:id/members/:userIdbody{ role }→ change role; rules below;200 { member }. -DELETE /api/v1/workspaces/:id/members/:userId→ remove; same rules;200 { removed: true }. -POST /api/v1/workspaces/:id/transfer-ownershipbody{ newOwnerId }→ the target must be a current MEMBER+ (any role except the target's current OWNER), transaction:Workspace.ownerId = newOwnerId+ target'sWorkspaceMember.role = OWNER+ old owner's role → ADMIN (documented: the old owner becomes ADMIN, never drops to nothing — prevents accidental lockout) + audit. - Role rules (enforced in
roles.service.ts, unit-tested exhaustively): - GUEST cannot be promoted directly to OWNER (must pass through MEMBER/ADMIN — documented policy;400 { code: "invalid_role_change", details: { from, to } }). - Exactly one OWNER: demoting/removing the current OWNER →409 owner_transfer_required(point at transfer-ownership). - Only OWNER/ADMIN manage roles; ADMIN cannot promote to OWNER, cannot demote an OWNER, cannot remove an OWNER (403); OWNER can do everything. - A member cannot change/remove their own role — except OWNER self-demote is forbidden outright (409 owner_transfer_required) and MEMBER+ self-remove is allowed (leaving a workspace is a user right, no audit-exception). - You cannot add a role to a non-member via these endpoints (that is invitations, TASK-0026). - Every role change / removal / transfer writes the audit event (TASK-0032 names:
member.role_changedmetadata{ from, to, targetUserId },member.removed{ targetUserId },workspace.owner_transferred{ fromUserId, toUserId }). - Removing a member also removes their
TeamMemberrows in that workspace (cascade service call — teams land with full CRUD in TASK-0027; this task wires the cleanup against the existing model).
Acceptance Criteria
- Role matrix test (integration): OWNER→all 3 roles OK; ADMIN→MEMBER/GUEST OK, ADMIN→OWNER 403, ADMIN touching an OWNER 403; MEMBER/GUEST any management → 403.
- GUEST→OWNER direct → 400
invalid_role_change; GUEST→MEMBER→ADMIN→OWNER chain works step by step. - Demoting/removing the only OWNER → 409
owner_transfer_required(both endpoints); transfer-ownership then works: new owner is OWNER, old owner is ADMIN,Workspace.ownerIdupdated, audit row present. - Member self-remove works and cascades
TeamMemberrows (asserted); self role change by a non-OWNER → 403. - All audit rows land with correct
from/tometadata (DB assertion per action).
Backend Changes
apps/api/src/modules/workspace/{roles.service.ts, member.controller.ts (routes on the existing workspace.controller.ts instead — keep one controller per resource: add routes toworkspace.controller.ts`), workspace.service.ts (transfer, remove cascade)}.
Frontend Changes
- None (the Members tab UI is TASK-0030; it consumes these endpoints).
Database Changes
None.
API Changes
GET /api/v1/workspaces/:id/members,PATCH /api/v1/workspaces/:id/members/:userId,DELETE /api/v1/workspaces/:id/members/:userId,POST /api/v1/workspaces/:id/transfer-ownership— documented indocs/api/workspaces.md+ the role matrix table.
Recorded conflict (members wire shape — AGENTS.md rule 29, 2026-10)
The API (and this task's own spec above) emit the member row flat:
{ userId, email, name, displayName, avatarUrl, role, joinedAt }. Two other
sources disagree with it: docs/api/workspaces.md §3.1 documents a nested
{ user: { id, name, email, image } } shape, and the web's WorkspaceMember
type (with 7 consumers reading m.user.name / m.user.email) expects nested.
The web consumed the flat rows as-is and the Settings + Members pages crashed
under the Next.js dev error overlay ("cannot read properties of undefined
(reading 'name')"). Resolution (this pass, web-only): the web normalizes
flat → nested at the single seam workspaceApi.members() via
normalizeWorkspaceMember() (apps/web/src/lib/api.ts, unit-tested in
src/lib/__tests__/api-members.test.ts); the API serializer is untouched
because the e2e WorkspaceMemberDto (tests/e2e/utils/api.ts),
workspace.spec.ts and the API integration tests pin the flat shape. Aligning
docs/api/workspaces.md §3.1 with the flat wire (or changing the API to
nested + updating all three test suites) is a separate, deliberate change.
Tests
apps/api/src/modules/workspace/roles.service.test.ts: the full rule matrix (each rule = one test case, table-driven). —tests/integration/members.test.ts: criteria 1–5 against Testcontainers.
Security
- Authorization checked server-side per AGENTS.md (the web role select in TASK-0030 renders options the caller may actually perform — derived from the same matrix constants exported from
packages/shared/src/roles.ts— single source of truth for UI + API); audit on every mutation.
Documentation
docs/api/workspaces.md: endpoints + the role matrix (table: actor × action). —packages/shared/src/roles.ts(new const map: role levelsGUEST=10, MEMBER=20, ADMIN=30, OWNER=40+ allowed transitions) — the shared source TASK-0028's engine and the UI both import.
Dependencies
- TASK-0023 (workspace scoping), TASK-0027 (team cascade target — ordering note: the cascade calls a small
TeamRepository.removeMemberFromTeamsthat TASK-0027 completes; stub the repository method in this task against the existing model), TASK-0032 (audit names — use the shared const map from TASK-0020'spackages/shared/src/audit-events.ts, extended here).
Definition of Done
4 endpoints + rule matrix live; matrix + integration tests prove all 5 criteria; shared role constants shipped; docs written.
TASK-0026
Title
Invitations (email → token link 7d → accept/decline/revoke)
Objective
Email invitations with a 7-day single-purpose token link: invite an email at a role, the recipient (registered or not) accepts by following the link, which creates the membership; pending invitations can be revoked; expired/declined states are surfaced in the Members tab.
Background
Invitation model + InvitationStatus (PENDING/ACCEPTED/DECLINED/REVOKED/EXPIRED) exist, with @@unique([workspaceId, email, role]) — the same email cannot be invited twice at the same role concurrently (a re-invite at a new role is a new row; re-inviting the same role revokes the old PENDING row first — documented). The MailService (TASK-0013) sends; dev mode logs the link.
Requirements
- Endpoints: -
POST /api/v1/workspaces/:id/invitations(OWNER/ADMIN) body{ email, role }(role ∈ MEMBER|GUEST|ADMIN — never OWNER via invite; owner is only via transfer —400 role_not_invitableotherwise) → dedupe: member already →409 already_member; existing PENDING for same email+role → refresh itsexpiresAt(202 +resend: true); otherwise create PENDING row (token = 32 random bytes hex,tokenHashstored,expiresAt = now + 7d) + email with link${FRONTEND_URL}/invite/<token>;201 { invitation }. -GET /api/v1/invite/:token(@Public) → token lookup by hash: unknown →404 invite_not_found; PENDING+unexpired →{ workspace: { id, name, slug, iconUrl }, email, role, invitedBy: { id, name } }; REVOKED →404; EXPIRED/expired-now →410 invite_expired(lazy status flip to EXPIRED in the same read — one place); ACCEPTED →200 { status: "accepted" }(the UI tells them to log in and open the workspace). -POST /api/v1/invite/:token/accept(Bearer auth required — they must be logged in; if the logged-in user's email ≠ invitation email →403 email_mismatch) → transaction: createWorkspaceMember(role,invitedBy,joinedAt = now), invitation → ACCEPTED +acceptedAt; idempotent: if already a member,200 { alreadyMember: true }. -POST /api/v1/invite/:token/decline(Bearer, same email-match rule) → invitation → DECLINED;200 { declined: true }. -DELETE /api/v1/workspaces/:id/invitations/:invitationId(OWNER/ADMIN) → PENDING only → REVOKED +revokedAt;200 { revoked: true }; non-PENDING → 404. -GET /api/v1/workspaces/:id/invitations(OWNER/ADMIN) → PENDING + recently (ACCEPTED/DECLINED/REVOKED within 30 d) with{ id, email, role, status, invitedBy, createdAt, expiresAt }, newest first, max 100. - Expiry: a daily worker job (
apps/worker/src/jobs/invite-expiry.processor.ts, queuemaintenance, cron0 3 * * *) flips PENDING→EXPIRED pastexpiresAt(batch 1000); the lazy flip inGET /invite/:tokencovers the sub-day gap. - Email content: workspace name, inviter name, role, link, "expires in 7 days"; dev mode logs the link (same mechanism as TASK-0013).
- Rate limits: invite endpoint 20/hour per workspace per inviter (in-memory throttle, documented); accept endpoint not limited (single-purpose token).
Acceptance Criteria
- Invite flow (integration): invite B at MEMBER → B's email has the link (dev log) → B (logged in) opens
GET /invite/:token(sees ws name + role) → accepts →WorkspaceMemberrow exists (role MEMBER, joinedAt set, invitedBy correct) + invitation ACCEPTED. - Unknown token → 404; expired (set
expiresAtpast in test) → 410 + row flipped to EXPIRED; revoked → 404 (no state leak between the three). - A different logged-in user (email mismatch) accepting → 403
email_mismatch; inviting OWNER → 400role_not_invitable; inviting an existing member → 409already_member. - Re-inviting the same email+role refreshes the expiry (single PENDING row, new
expiresAt); the same email at a different role creates a second row (the unique-constraint semantics hold). - The worker job flips expired rows (job test: seed 3 expired PENDING rows, run the processor, assert 3 EXPIRED + log line).
Backend Changes
apps/api/src/modules/invitation/{invitation.module.ts, invitation.controller.ts (workspace-scoped routes + public token routes), invitation.service.ts};apps/worker/src/jobs/invite-expiry.processor.ts.
Frontend Changes
apps/web/src/app/invite/[token]/page.tsx(@Publicweb page): the accept/decline card — unauthenticated → "Sign in to accept" (redirect/login?next=/invite/<token>); authenticated → workspace preview + role + Accept/Decline buttons + the three terminal states (accepted/expired/not-found) with their exact testids (invite-card,invite-accept,invite-decline,invite-status).
Database Changes
None (existing invitations table).
API Changes
- 6 endpoints above — documented in
docs/api/workspaces.md(invitations section).
Tests
apps/api/src/modules/invitation/invitation.service.test.ts: token lifecycle, dedupe rules, email-match, lazy expiry. —tests/integration/invitations.test.ts: criteria 1–4; worker job test for criterion 5.
Security
- Tokens: 32 random bytes, stored only as sha256, single-purpose (accept/decline only), 7 d TTL, revocable; email-match on accept prevents accepting an invitation meant for someone else with your own account; no public enumeration (404 for unknown/revoked); per-workspace invite throttle.
Documentation
docs/api/workspaces.md: invitation endpoints + state diagram (ASCII: PENDING → ACCEPTED/DECLINED/REVOKED/EXPIRED).
Dependencies
- TASK-0013 (MailService), TASK-0023 (workspace scoping), TASK-0025 (member rows the accept creates use the same service), TASK-0019 (Bearer on accept;
@Publicon the token read).
Definition of Done
6 endpoints + invite page + expiry job live; all 5 criteria proven; docs written.
TASK-0027
Title
Teams / teamspaces (CRUD + membership + sidebar positions)
Objective
Teams ("teamspaces") as first-class workspace sections: CRUD with unique slugs, team membership with a team-level role, fractional sidebar ordering — the container that Phase 3 pages hang off of (Page.teamId).
Background
Team model exists (workspaceId, name, slug, description, color, iconUrl, position fractional default a0, isPrivate, unique implied by workspace+slug — verify the unique constraint: if @@unique([workspaceId, slug]) is missing from schema.prisma, this task adds it in a migration — first schema change of Phase 2 after TASK-0020's audit index). TeamMember (teamId, workspaceMemberId, userId, role default MEMBER) exists.
Requirements
- Endpoints (workspace-scoped, caller a member): -
POST /api/v1/workspaces/:id/teamsbody{ name, slug, description?, color? }(OWNER/ADMIN) →201 { team }; slug 2–48[a-z0-9-]unique per workspace (409 slug_taken);position=@factory/shared/positionspositionAfter(lastTeam.position)(append to the end of the sidebar). -GET /api/v1/workspaces/:id/teams→{ id, name, slug, description, color, iconUrl, position, memberCount, isMember }, ordered by position; any member may list. -PATCH /api/v1/workspaces/:id/teams/:teamId(OWNER/ADMIN or team role ADMIN — team role semantics: team-level ADMIN manages the team, not the workspace — documented) → rename/description/color/icon (icon = emoji string ≤ 8 codepoints or upload via the same icon service as workspaces — bucket keyteams/<teamId>/…). -DELETE /api/v1/workspaces/:id/teams/:teamId(OWNER/ADMIN) → soft behavior: teams have nodeletedAtcolumn — hard delete with precondition: the team must have 0 pages (Phase 3) — in Phase 2 this is simply "0 records inPage.teamId" which is always true pre-Phase-3 → delete team + cascadeTeamMemberrows;200 { deleted: true }. (Recorded decision: teams are deletable only while empty; Phase 3 keeps the rule — moving pages out first.) -POST /api/v1/workspaces/:id/teams/:teamId/membersbody{ userId, role }(workspace OWNER/ADMIN, or team ADMIN for role ≤ team-ADMIN): adds aTeamMember(the target must be a workspace member —409 not_workspace_member);PATCH/DELETEthe membership similarly. -POST /api/v1/workspaces/:id/teams/:teamId/movebody{ beforeTeamId?, afterTeamId? }(OWNER/ADMIN) → new fractional position via the shared engine (positionBetween(prev, next); either bound may be null = start/end);200 { position }. - Positions: all team ordering through
@factory/shared/positionsonly (no manual strings); a unit test here + TASK-0028's engine tests pin the engine's API (if the positions module lackspositionAfter/positionBetween/comparePositions, add them topackages/shared/src/positions.tsnow — they're needed by Phase 3 too). Team.sluguniqueness: add@@unique([workspaceId, slug])toschema.prismaif absent → migration<ts>_team_slug_unique.
Acceptance Criteria
- Create 3 teams → positions strictly increasing per
comparePositions; duplicate slug → 409; rename works; delete of a team with members cascadesTeamMemberrows (0 left). - Team membership: add a workspace member at team role MEMBER (default) / ADMIN; adding a non-workspace user → 409; a team ADMIN can add/remove team members at role ≤ ADMIN but not promote to team OWNER (no team-OWNER exists — the top team role is ADMIN —
400on any higher role). - Move: team B moved before team A → positions swap correctly (assert ordering via
GETlist before/after); move with both bounds null → appends at end. - The migration (if required) applies on a fresh Testcontainers db; existing seeded teams (from TASK-0006 seed) are untouched by it (backfill-safe: the seed already creates teams with unique slugs per workspace — verify).
- Fractional position values never exceed 256 chars (engine invariant — a test generating 1000 successive
positionAftercalls stays within the bound; if the engine can't guarantee it, document the re-normalization hook — the hook is Phase 15, the bound is asserted now).
Backend Changes
apps/api/src/modules/team/{team.module.ts, team.controller.ts, team.service.ts, team.repository.ts};packages/shared/src/positions.ts(add missing helpers + tests if needed);prisma/schema.prisma(+ migration) only if the unique index is missing.
Frontend Changes
- None (Teams tab UI is TASK-0030; sidebar rendering is TASK-0029).
Database Changes
- Possibly
prisma/migrations/<ts>_team_slug_unique/(only if the constraint is absent — check first; record the finding in the PR).
API Changes
GET/POST /api/v1/workspaces/:id/teams,PATCH/DELETE /api/v1/workspaces/:id/ teams/:teamId,POST /api/v1/workspaces/:id/teams/:teamId/members+PATCH/DELETE …/members/:userId,POST …/teams/:teamId/move— documented indocs/api/teams.md(new).
Tests
apps/api/src/modules/team/team.service.test.ts: slug rules, position append/move (engine-backed), cascade delete, team-role permissions. —tests/integration/teams.test.ts: criteria 1–4 against Testcontainers;packages/shared/src/positions.test.tsextension for criterion 5 (if the module's tests don't cover it).
Security
- Team roles are a second permission axis (workspace role ∧ team role); the matrix is documented in
docs/architecture/tenancy.mdand consumed by TASK-0028; no cross-workspace team access (scoping convention).
Documentation
docs/api/teams.md(new): endpoints + team-role matrix;docs/architecture/tenancy.md: team axis section.
Dependencies
- TASK-0023 (icon service reuse), TASK-0025 (workspace membership as precondition), TASK-0006 (seeded teams as migration backfill check).
Definition of Done
Team CRUD + membership + move live; positions only via the shared engine; migration (if needed) proven backfill-safe; all 5 criteria; docs written.
TASK-0028
Title
packages/permissions v1 (role base level + explicit grants + guard)
Objective
The RBAC engine in packages/permissions: a pure checkPermission function combining the workspace role base level with explicit grants (the Permission table), a decision-matrix unit test suite of ≥ 40 cases, and the NestJS PermissionGuard that wires it into apps/api for every workspace-scoped route.
Background
Models exist: WorkspaceMember.role (WorkspaceRole) and Permission (workspaceId, granteeType: "user"|"team"|"role", granteeId, resourceType: "workspace"|"teamspace"|"page"|"database"|"record"|"file", resourceId, level: PermissionLevel NONE|VIEW|COMMENT|EDIT|FULL_ACCESS, inheritsFrom). Phase 3 extends this engine to v2 (page-parent-chain inheritance) — v1 covers workspace + teamspace resources; the function signature is designed so v2 adds a resource-resolver argument, not a rewrite. This task is the server-side authorization backbone for the Phase 2 exit criterion "permissions enforced server-side".
Requirements
packages/permissions/src/engine.ts:checkPermission(input: PermissionInput): PermissionDecisionwherePermissionInput = { workspaceId; resourceId; resourceType: "workspace"|"teamspace"; userId; workspaceRole?: WorkspaceRole; teamRoles?: Record<teamId, WorkspaceRole>; grants: Grant[] /* rows for (granteeType, granteeId) matching user/team/role, preloaded by the caller — the engine is pure, no DB */ ; action: "view"|"comment"|"edit"|"manage_members"|"manage_teams"|"delete_workspace"|"manage_permissions" }andPermissionDecision = { allowed: boolean; level: PermissionLevel; reason: "role_base" | "explicit_grant" | "deny" }. - Role base levels: OWNER → FULL_ACCESS for all actions; ADMIN → EDIT for content actions, manage_members/manage_teams allowed, delete_workspace DENIED (only OWNER deletes — matches TASK-0023), manage_permissions DENIED (OWNER only); MEMBER → COMMENT + edit on their own content (own-content resolution is Phase 3; v1: MEMBER → COMMENT on workspace/teamspace resources); GUEST → VIEW. - Explicit grants: the effective level = max(roleBase, any matching grant level); granteeTyperolematches by role name (e.g. a GUEST role granted EDIT in a workspace acts as EDIT for that user in that workspace);teamgrants apply when the resourceType isteamspaceand the user is in that team (or a team grant applies to all teams ifgranteeIdis*— documented wildcard);usergrants are per-user. -NONEgrant = explicit deny that overrides the role base (the only override direction in v1; recorded decision, matches the schema comment). - Pure + synchronous + deterministic (no clock, no IO) — documented; the NestJS guard is the only IO boundary.- Decision matrix ≥ 40 cases (
packages/permissions/src/engine.test.ts, table-driven): every (role × action) base-level cell (4×7 = 28) + grant interactions (user grant raising GUEST→EDIT; team grant on a teamspace for a member of the team and for a non-member; role grant on GUEST; NONE overriding EDIT; max-of-two-grants; wildcard team grant; grant on a different workspace ignored; missing workspaceRole (non-member) → deny regardless of grants (grants without membership = denied, recorded decision)) → 40+ distinct cases, each assertingallowed,level,reason. - NestJS wiring:
apps/api/src/common/guards/permission.guard.ts(@Permissions(action, { resource: "workspace" | "teamspace" })decorator) — resolves the resource from the route params, loads membership + grants (two indexed queries:workspace_membersby[workspaceId, userId],permissionsby[workspaceId]filtered in memory by grantee — the grants table is small per workspace; cache in Redis for 60 s keyedperm:<workspaceId>:<userId>— Phase 15 hardens, the invalidation on member/role/grant mutation is a bus eventpermissions.invalidatedpublished by the services in TASK-0025/0027), then callscheckPermission; deny →403 { code: "forbidden", details: { action } }(403, not 404 — membership exists; non-members get 404 from the scoping layer — documented split). - Retrofit Phase 2 endpoints onto the guard (replacing the inline role checks of TASK-0023/0025/0027 — same behavior, one mechanism): workspace rename/settings →
manage_members? No — rename/settings → actioneditat FULL_ACCESS tier: define the endpoint→action map inapps/api/src/modules/workspace/permission-mapping.ts(const, table-documented) and unit-test that every workspace-scoped route has exactly one mapped action (route-map extension of TASK-0019's test). apps/api/src/modules/workspace/grant.service.ts+ endpoints:GET /api/v1/workspaces/:id/permissions(OWNER),PATCH /api/v1/workspaces/:id/ permissions(OWNER) body{ granteeType, granteeId, resourceType, resourceId, level }(upsert) andDELETE …/permissions/:id(OWNER) — the management surface the engine enforces (no UI in Phase 2 — the Permissions tab of settings is a Phase 3+ placeholder; the API is live now).
Acceptance Criteria
pnpm testruns the matrix green: ≥ 40 cases, 100% of the (role × action) cells covered, all 6 grant interaction classes covered;packages/permissionscoverage ≥ 80% lines/branches.- A GUEST with a user-grant EDIT on a teamspace gets
allowed: true, level: EDIT, reason: explicit_grantoneditfor that teamspace andreason: role_basedenied formanage_members. - A
NONEgrant on a workspace for a user with role ADMIN →editdenied withreason: deny(override proven). - Guard retrofit: the full Phase 2 endpoint set still passes its integration tests unchanged (behavior-preserving) plus: a MEMBER calling a workspace rename → 403
forbiddenwith the correctactionin details (new test); a non-member calling any workspace-scoped route → 404 (not 403). - Grant endpoints: upsert + delete by OWNER only (403 otherwise); after a PATCH, the next
checkPermissionfor the grantee reflects the new level within the cache TTL (test waits for invalidation — the mutation publishes the event synchronously, so immediate). - Route-map test: every workspace-scoped route has exactly one permission action mapped; an unmapped route fails the test.
Backend Changes
packages/permissions/{package.json (if absent), src/engine.ts, src/types.ts, src/engine.test.ts, README.md};apps/api/src/common/guards/permission.guard.ts,apps/api/src/common/decorators/permissions.decorator.ts;apps/api/src/modules/workspace/permission-mapping.ts;apps/api/src/modules/workspace/grant.service.ts(+ routes on the workspace controller); invalidation events in roles/invitation/team services.
Frontend Changes
None (no settings Permissions tab in Phase 2 — recorded; Phase 3 adds it).
Database Changes
prisma/schema.prisma: add@@index([workspaceId, granteeType, granteeId])onPermissionif absent (query pattern of the guard) → migration<ts>_permission_index; no other schema change.
API Changes
GET/PATCH/DELETE /api/v1/workspaces/:id/permissions…(3 routes, OWNER-only) — documented indocs/api/workspaces.md; 403forbiddencontract indocs/api/errors.md.
Tests
packages/permissions/src/engine.test.ts(the matrix);apps/api/src/common/guards/permission.guard.test.ts: param resolution, cache hit/ invalidation, deny shape;tests/integration/permissions.test.ts: criteria 2–6 end-to-end (grant → behavior change → revoke → behavior back).
Security
- Server-side enforcement only (the frontend may pre-hide UI but the guard is the gate — AGENTS.md); grants require membership (documented);
NONEoverride is the single documented exception to monotonic escalation; all grant mutations audit (TASK-0032permission.grant_changed/permission.grant_removed).
Documentation
packages/permissions/README.md: the engine contract, the v1 matrix table (role × action), grant semantics (incl. wildcard + NONE override), the v2 plan (page-parent-chain inheritance) so Phase 3 extends deliberately. —docs/architecture/tenancy.md: "Permission engine" section (guard flow diagram).
Dependencies
- TASK-0025 (role matrix reference behavior), TASK-0027 (teamspace resource), TASK-0019 (guard conventions, route-map test), TASK-0020 (audit event names).
Definition of Done
Engine + ≥ 40-case matrix + guard + grant endpoints live; retrofit proven behavior-preserving; all 6 criteria; engine README + tenancy docs written. Phase 2 exit criterion "permissions enforced server-side" is met by this task.
TASK-0029
Title
Sidebar UI (sections, switcher, collapse, testids)
Objective
The workspace sidebar in apps/web: Home, Inbox, Search, Favorites, Teamspaces (expandable, from the teams API), Private, Shared, Trash sections with a new-page button, the workspace switcher, and responsive collapse — the navigation shell every later phase fills with content.
Background
Phase 2 has real data for Teamspaces (TASK-0027) and the switcher (TASK-0031); Favorites/Private/Shared/Trash are data-backed where Phase 2 provides it and stubbed-empties otherwise (favorites API lands in Phase 3 — the section renders an empty state, not a fake list; the requirement text below marks each section's data source). Home/Inbox/Search are navigation stubs (link targets land in Phases 3/7/8 — the entries exist and are keyboard-reachable, the pages are 404-redirects to / with a "coming in Phase N" note — recorded decision).
Requirements
- Layout:
apps/web/src/app/(app)/layout.tsx— app shell: left sidebar (data-testid="sidebar", 260 px expanded / 56 px collapsed / hidden < 768 px behind a hamburger), main content area. The shell renders only for authenticated routes (TASK-0018's middleware already guards/). - Sections (top→bottom, each a nav list with
data-testidper entry): - Top: workspace switcher button (sidebar-switcher) → popover with the workspace list (personal grouped under "Personal", others under "Workspaces", icon + name + "current" check, "Create workspace…" → the create dialog from TASK-0030) —data-testid="switcher-popover", itemsswitcher-item-<slug>. -Home(link/, testidnav-home),Inbox(nav-inbox, Phase 7 stub),Search(nav-search, Phase 8 stub — button that opens the search overlay placeholder: an input that shows "Search arrives in Phase 8" — not a fake results list),Favorites(nav-favorites, empty state "No favorites yet — Phase 3"). -TEAMSPACESgroup (header testidnav-teamspaces): each team fromGET /workspaces/:id/teamsas an expandable row (team-row-<id>withteam-name, expand chevron, emoji icon or colored dot fromcolor,team-pages-<id>child list — Phase 2: child pages don't exist yet, so each team expands to an empty state "No pages yet — add one in Phase 3" + anew-page-in-team-<id>button that is disabled with a tooltip (Phase 3 wires it — disabled, not hidden, so the layout is stable); a+ New teamspacerow (new-team) opening the create-team dialog (name + slug, from the shared zod dto). -Private(nav-private, Phase 3 empty state),Shared(nav-shared, Phase 3 empty state),Trash(nav-trash, Phase 3 empty state). - Bottom: new-page button (new-page, primary, disabled + tooltip "Phase 3" — same pattern) + settings gear (sidebar-settings→ workspace settings, TASK-0030) + the user menu (avatar, name, "Log out" wired to TASK-0015). - Data: TanStack Query —
useWorkspaces(),useTeams(workspaceId)(refetch on workspace switch; staleTime 30 s; loading = skeleton rows (testidsidebar-loading), error = retry row (testidsidebar-error), empty teams = "No teamspaces yet" state (testidteams-empty)); the active workspace comes from the URL (/w/[workspace]/…, TASK-0031) — the sidebar re-renders per workspace without a full page reload. - Collapse: chevron on the switcher row toggles 260→56 px (icons only,
aria- expandedon the trigger, persisted tolocalStoragekeyfactory.sidebar); mobile: sidebar becomes an overlay drawer (hamburgersidebar-toggle,Esc/backdrop closes it, focus trapped while open); keyboard: full Tab order top→bottom, arrow keys move within a list (WAI-ARIArole="navigation"+aria-labelper section), all mouse actions have keyboard paths (AGENTS.md DoD accessibility). - Theme-aware via
@factory/uitokens (light/dark verified); no horizontal scroll at 375 px.
Acceptance Criteria
- With the seeded demo workspace (2 teams), the sidebar renders: switcher, the 4 top entries, both team rows, Private/Shared/Trash empty states, new-page (disabled), settings — all testids present (E2E assertion list in TASK-0033).
- Creating a team via
new-team→ the row appears in the TEAMSPACES group ordered byposition(last = bottom) without a manual refetch (query invalidation on mutation). - Switching workspace via the switcher: URL changes to the other workspace's prefix and the team list swaps to that workspace's teams (two-workspace test: personal vs demo — personal shows
teams-empty). - Collapse toggle persists (reload keeps the state); mobile drawer opens/closes with focus trap (E2E: open → focus stays inside → Esc closes → focus returns to the hamburger).
- Loading and error states render (mock the teams query: pending → skeleton; 500 → retry row, retry click refetches).
- Keyboard-only walkthrough: Tab reaches switcher → all sections → new-page → settings → user menu; Enter/Space activates each; no
outline: noneanywhere (focus-visible styling asserted in E2E by computed style on focus).
Backend Changes
None (consumes existing endpoints: workspaces, teams, switcher data).
Frontend Changes
apps/web/src/app/(app)/layout.tsx(shell),apps/web/src/components/sidebar/ {sidebar.tsx, switcher.tsx, teamspace-list.tsx, new-team-dialog.tsx, user-menu.tsx},apps/web/src/lib/queries/{workspaces.ts, teams.ts},apps/web/src/stores/sidebar-store.ts(collapse state).
Database Changes
None.
API Changes
None.
Tests
- Component tests (Vitest + testing-library):
sidebar.test.tsx(section order, empty states, loading/error),switcher.test.tsx(grouping, current badge, mutation invalidation),teamspace-list.test.tsx(ordering by position, expand/collapse aria). — The E2E assertions of criteria 1–6 live in TASK-0033's spec (shared testid conventions — single source:docs/ui/auth-pages.mdtestid section, extended here).
Security
- The sidebar is presentation only: it never trusts its own state for access (a user who hand-edits the URL is still gated by the api — TASK-0034 proves the 403/404); no tokens in URLs or DOM attributes.
Documentation
docs/ui/sidebar.md(new): section inventory, testid table, stub policy (what is disabled vs empty and which phase fills it — the single reference for later phases), accessibility notes.
Dependencies
- TASK-0031 (the URL scheme
/w/[workspace]— ordering note: implement the router part of TASK-0031 first if the sidebar needs the prefix early; the task order stays, this is a documented intra-phase dependency), TASK-0027 (teams data), TASK-0024 (personal workspaces in the switcher), TASK-0023 (workspace create dialog data).
Definition of Done
Shell + sidebar + switcher + dialogs live with all testids; component tests green; the 6 E2E criteria ready for TASK-0033; stub policy documented.
TASK-0030
Title
Workspace settings UI (Profile, Members, Teams, Security)
Objective
The workspace settings page with 4 tabs — Profile (name/description/icon), Members (role select + invite dialog + pending list), Teams (list + create/edit/delete), Security (placeholder) — consuming the Phase 2 APIs.
Background
All backing endpoints exist: TASK-0023 (profile/icon), TASK-0025 (members/roles/ transfer), TASK-0026 (invitations), TASK-0027 (teams). This task is pure UI + optimistic-update wiring. The settings route is /w/[workspace]/settings (TASK-0031 prefix) and requires a member; the tabs render per role (the shared packages/shared/src/roles.ts constants drive which controls appear — the server still enforces).
Requirements
apps/web/src/app/(app)/[workspace]/settings/page.tsx+tabs/ {profile-tab.tsx, members-tab.tsx, teams-tab.tsx, security-tab.tsx}; tab switch in the URL (?tab=members— deep-linkable, testidsettings-tab-<name>).- Profile tab: name input (zod: 2–100), description (≤ 500), icon: current preview + "Upload new" (file input →
POST /workspaces/:id/icon, progress bar, error toast on 400 with the reason) + "Remove" (PATCHiconUrl: null— the backend accepts an empty string → null, documented) + slug display (read-only, with a "changing slugs is Phase 13" note — recorded decision: slug is immutable in Phase 2). Save = single PATCH (dirty-state detection,settings-savebutton disabled when clean, success toast, field errors from 400 mapped). - Members tab: table (avatar/initials, name, email, role select, joined date, row actions) — role select options = the caller's allowed transitions from
roles.ts(a MEMBER sees only "—" for everyone: no options; an OWNER sees all transitions except the forbidden ones — the 409/403 from the server is the backstop and its error code maps to a toast:owner_transfer_required→ "Transfer ownership first" + a "Transfer ownership…" action opening the transfer dialog (member picker → confirm → mutation)); remove = row trash button → confirm dialog (self-remove allowed, owner-remove hidden); pending invitations section below the table (email, role, status chip, invited by, "Copy link" (copies the invite URL — the token link is exposed to OWNER/ADMIN here; recorded security decision with the 7-day expiry as the control), "Revoke" → delete endpoint); invite dialog (invite-dialog: email + role select (MEMBER/GUEST/ADMIN, OWNER never — matches TASK-0026) → mutation → success state "Invitation sent" + the pending row appears). - Teams tab: list (name, emoji/color, member count, position drag handle — disabled in Phase 2, tooltip "Reordering in sidebar is Phase 4" — the move API exists but the UI affordance waits for the DnD framework; recorded), create dialog (name+slug, shared dto), edit (same dialog prefilled), delete (confirm — "Team must be empty" error state maps the future 409).
- Security tab: placeholder card — "2FA, SSO (Phase 14), audit log viewer (Phase 14), data retention (Phase 14)" as a disabled feature list (testid
security-placeholder) — explicit placeholder policy. - States per DoD: every tab has loading (skeleton), error (retry), empty (members can't be empty — at least the owner; teams empty state; pending invites empty state); toasts for all mutations (success/error with the server's message); theme-aware; responsive (tables → cards < 768 px).
Acceptance Criteria
- Profile: rename + description save persist (reload shows the new values); icon upload shows progress and the new icon everywhere (switcher + sidebar) after success; an oversized file shows the server's
too_largemessage as a toast. - Members (as OWNER): changing a MEMBER's role to GUEST persists + shows in the table; attempting to demote the OWNER shows the
owner_transfer_requiredtoast + the transfer dialog; completing a transfer (to the second user) updates both roles (old owner → ADMIN chip visible). - Invitations: inviting a new email → pending row appears + (dev) the logged link can be copied from "Copy link"; revoking removes the row (status REVOKED not listed); a 409
already_memberfrom the API → field error under the email. - Teams: create via dialog → row appears (ordered last); edit renames; delete of a team with members is impossible in Phase 2 (always empty pre-Phase-3) → row disappears.
- Role-gated UI: a MEMBER sees no role options, no invite control, no transfer button (asserted in E2E by absence of the testids, not just hidden — the components don't render).
- Every tab: skeleton on load, retry on forced 500 (E2E route-abort), correct empty states; mobile 375 px: no horizontal scroll, tables as cards.
Backend Changes
- None, except:
PATCH /api/v1/workspaces/:idacceptsicon: ""→ null (tiny additive change, documented indocs/api/workspaces.md).
Frontend Changes
apps/web/src/app/(app)/[workspace]/settings/**(page + 4 tabs + dialogs:transfer-dialog.tsx,invite-dialog.tsx,team-dialog.tsx),apps/web/src/lib/queries/members.ts,invitations.ts(mutations with invalidation), toast system if absent (minimal,@factory/ui-styled, testidtoast-region).
Database Changes
None.
API Changes
- Additive:
icon: ""→ null on workspace PATCH (documented).
Tests
- Component tests:
members-tab.test.tsx(role-option derivation fromroles.ts, error-code → toast mapping, self-remove),invite-dialog.test.tsx(409 mapping),profile-tab.test.tsx(dirty state, icon error),teams-tab.test.tsx(create/edit/delete flows). — The 6 E2E criteria are asserted in TASK-0033 (settings section of that spec).
Security
- Role-gated rendering uses the shared
roles.ts(same constants as the api — drift impossible); "Copy link" is OWNER/ADMIN-only server-side (the invitations list endpoint itself is gated — a MEMBER can't fetch links even by API).
Documentation
docs/ui/settings.md(new): tab inventory, role-gating table, testid list, the placeholder policy (what each disabled item waits for and its phase number).
Dependencies
- TASK-0023–0027 (all endpoints), TASK-0029 (shell to render inside), TASK-0031 (route prefix), TASK-0025 (
roles.tsshared constants).
Definition of Done
4 tabs + 3 dialogs live; all component tests green; the 6 E2E criteria ready for TASK-0033; docs written.
TASK-0031
Title
Workspace switcher + URL [workspace] slug resolution
Objective
Make the workspace a URL-level context: /w/[workspace]/… resolves the slug to a workspace the caller may access, with correct 404/403 states, redirect rules (bare / → current workspace's Home), and the switcher popover wired to it.
Background
TASK-0029's sidebar needs the /w/[workspace] prefix to be a stable contract. The switcher data (TASK-0029 requirement 2) and the membership check (api: GET /api/v1/workspaces/:idOrSlug) already exist — this task builds the route layer, the server-side resolution, and the redirect/404/403 semantics so later phases only add routes under (app)/[workspace]/….
Requirements
- Routes:
apps/web/src/app/(app)/[workspace]/layout.tsx— server component: resolveparams.workspace(slug) viaGET /api/v1/workspaces/:slug(server-side fetch with the user's bearer — the web app proxies through a server route to avoid client-side 401 races:apps/web/src/app/api/proxy/workspaces/[slug]/ route.tsis NOT introduced — instead the layout uses the existing api with the user's token from the session cookie? No: Phase 1 stores tokens client-side. Decision (recorded): the layout does the lookup client-side in a<WorkspaceGate>component (suspense + skeleton) — the api returns 404 for non-members (the scoping convention), so the gate renders: 404 → not-found page (testidworkspace-not-found, "Workspace not found — check the link"), 403 → forbidden page (testidworkspace-forbidden, "You don't have access") — the split exists in the api (member-but-forbidden vs non-member, TASK-0028), so both pages are reachable and distinct. - Redirects: bare
/(authenticated) →/w/<first-workspace>/(personal workspace first — deterministic: isPersonal desc, then createdAt asc — documented);/w/<slug>/trailing → same (no extra segment = Home, which is the Phase 3 page route; in Phase 2 Home renders the landing content: greeting + workspace name + a "Pages arrive in Phase 3" card — testidhome-landing); unknown slug → the not-found page above (not a redirect loop). - Switcher wiring (completing TASK-0029 requirement 2): selecting
switcher-item-<slug>→router.push("/w/<slug>"); the current workspace in the popover gets a check (aria-current="true"); the "Create workspace…" flow → dialog (name + auto-slug preview from the name, editable, shared dto validation) → on successrouter.push("/w/<new-slug>")+ switcher refresh. - Per-workspace state isolation: the sidebar's queries are keyed by workspace id (TanStack Query key includes the slug — switching never flashes another workspace's teams);
localStorageper-workspace UI state (e.g. expanded teams) is namespacedfactory.<workspaceSlug>.<key>(documented). - The
(auth)routes are unaffected; the middleware (TASK-0018) still guards everything; a logged-out/w/<slug>→/login?next=/w/<slug>(deep link preserved — criterion in E2E).
Acceptance Criteria
/w/demo(seeded) renders the shell + home landing for the demo user;/w/does-not-exist→ not-found page (testid present, HTTP 200 soft-404 — documented SPA choice, no server-side 404 status required in Phase 2, recorded);/w/<other-user-workspace>(a workspace the caller has no membership in — the api returns 404 for non-members per the scoping convention, so this case renders the not-found page — the forbidden page is reachable when the caller IS a member but the guard denies an action; both pages exist and are distinct — criterion proven with a crafted member-but-403 via a GUEST on a settings route) — the exact reachable-case matrix is asserted in TASK-0033./for a user with a personal workspace + the demo workspace →/w/personal-…(personal first); after switching to demo, reload stays on demo (the redirect is one-shot to the first workspace, never sticky — documented: no "last workspace" persistence in Phase 2; the switcher is the mechanism).- Switcher: selecting a workspace updates the URL + swaps sidebar data (teams) without a full reload (asserted by a query-key test: no fetch for the old workspace after switch; the new workspace's teams fetched once).
- Logged-out
/w/demo→/login?next=/w/demo; login lands back on/w/demo. - Create-workspace flow: dialog → created → URL on the new workspace's home; the slug preview updates live as the name changes (typing "My Team" →
my-team); an invalid slug (uppercase typed) is normalized + shown (client) and the server re-validates (400 path tested in the component test). - Per-workspace state: expand team A in workspace 1, switch to workspace 2, switch back → team A still expanded (namespaced key), and no other workspace's state leaks (test with two workspaces).
Backend Changes
- None (existing
GET /api/v1/workspaces/:idOrSlug+ create endpoint suffice).
Frontend Changes
apps/web/src/app/(app)/[workspace]/{layout.tsx (WorkspaceGate), page.tsx (home landing)},apps/web/src/components/workspace/{workspace-gate.tsx, not-found-page.tsx, forbidden-page.tsx, create-workspace-dialog.tsx}, switcher wiring inapps/web/src/components/sidebar/switcher.tsx(completion),apps/web/src/middleware.ts(thenextdeep-link pass-through if absent).
Database Changes
None.
API Changes
None.
Tests
- Component tests:
workspace-gate.test.tsx(404/403/loading/success branches),create-workspace-dialog.test.tsx(slug preview normalization, 400 mapping); route behavior covered by TASK-0033's E2E.
Security
- The gate trusts the api's 404/403 split (never the slug alone);
nextredirect values are validated to same-origin paths (open-redirect defense: anynextnot starting with/or containing//is dropped — unit-tested).
Documentation
docs/ui/sidebar.md: URL scheme section (prefix contract for later phases, state namespacing);docs/api/workspaces.md: the 404-vs-403 split documented.
Dependencies
- TASK-0023 (get-by-slug, create), TASK-0024 (personal-first ordering), TASK-0029 (sidebar consuming the gate), TASK-0028 (the 403 split).
Definition of Done
Route layer + gate + redirects + switcher wiring live; all 6 criteria (1, 2, 4 locally verifiable; 3, 5, 6 via TASK-0033); docs written.
TASK-0032
Title
Workspace audit events
Objective
Complete the Phase 2 audit surface: workspace.created, workspace.updated, workspace.icon_changed, workspace.deleted, member.invited, member.invitation_accepted, member.invitation_revoked, member.role_changed, member.removed, workspace.owner_transferred, team.created, team.updated, team.deleted, team.member_added, team.member_removed, permission.grant_changed, permission.grant_removed — all with actor/ip/ua via the TASK-0020 service.
Background
TASK-0020 established the audit module + the 6 auth events + the resilience rule (audit failure never breaks the request). Several Phase 2 services already emit events per their task requirements (TASK-0025 role changes, TASK-0026 invites, TASK-0023 icon) — this task centralizes the names in packages/shared/src/audit-events.ts (extending the TASK-0020 const map), audits the emission points for completeness/correctness, and closes the gaps (workspace create/update/delete, team events, grant events).
Requirements
- Extend
packages/shared/src/audit-events.tswith the 17 names above as a typed const map (AuditAction.WORKSPACE_CREATED = "workspace.created"etc.) + themetadatafield contract per event (table in the doc, e.g.member.role_changed: { from, to, targetUserId },workspace.owner_transferred: { fromUserId, toUserId },permission.grant_changed: { granteeType, granteeId, resourceType, resourceId, level, previousLevel? },team.created: { teamId }— all metadata values are ids/roles/levels, never names or emails — the single PII exception ismember.invitedmetadata{ email }(needed for the invite trail; recorded, consistent with TASK-0020's register exception). - Emission audit (checklist, each = a code inspection + a test): workspace create (TASK-0023 service), workspace rename/description/settings (
workspace.updated, metadata{ changedFields: string[] }), icon (workspace.icon_changed), delete (workspace.deleted— before thedeletedAtwrite, the row still exists for the workspaceId ref), member invite/accept/revoke (TASK-0026 — checkmember.invitation_acceptedfires on accept, not on the accept page load), role change/remove (TASK-0025), transfer (TASK-0025), team create/update/delete + member add/remove (TASK-0027), grant upsert/delete (TASK-0028 grant.service). Missing emissions are added here (no new behavior in other tasks). - Every event:
workspaceIdset (the events are workspace-scoped by definition),actorType: USER(no system actor in Phase 2 — the worker jobs that fire in Phase 2 — invite expiry — write no audit row: an expired invite is a state change without an actor; recorded decision, the expiry is visible in the invitations list status). - The no-secret DB scan from TASK-0020 is extended to the Phase 2 strings (invite token, new workspace names with special characters — the scan queries
metadata::textfor the known sensitive values from the test run). - Ordering/consistency: audit rows are written within the same unit of work as the mutation where the service can (transactional insert where Prisma's transaction spans it — documented per-service which ones are transactional vs fire-and-forget per the resilience rule).
Acceptance Criteria
- A full scripted sequence (integration test: create ws → invite → accept → role change → transfer → team create/add/delete → grant add/delete → icon → rename → delete) produces exactly 17 audit rows (one per event) with correct
action/actorId/workspaceId/ipAddress/userAgent(asserted row-by-row). - Every event name used in the api comes from the shared const map (typecheck: string literals not in the map fail compile — a deliberate-violation demo in the PR body).
- No audit row's
metadatacontains a token, password, or free-text email except the two documented exceptions (register, invite) — the DB scan passes. - Failing the audit insert (fault injection) leaves the corresponding mutation successful (200/201) + an error log line (re-verified for 3 Phase 2 services, not just auth).
workspace.updatedmetadata lists exactly the changed fields (rename-only patch →["name"]; rename+settings →["name","settings"]).
Backend Changes
packages/shared/src/audit-events.ts(extension); emission additions inworkspace.service.ts,invitation.service.ts,team.service.ts,grant.service.ts(whatever the audit finds missing); the checklist table lives in the doc below (single source of truth for which service emits what).
Frontend Changes
None (the audit viewer is Phase 14).
Database Changes
None (the TASK-0020/0027/0028 indexes cover the query patterns; no new schema).
API Changes
None.
Tests
tests/integration/workspace-audit.test.ts: the 17-event sequence (criterion 1) + the fault-injection sample (criterion 4) + the DB scan (criterion 3). —apps/api/src/modules/audit/audit-service-phase2.test.ts(unit): metadata shapes per event (table-driven over the 17 events).
Security
- The audit trail is the incident-response record for membership changes; the actor/ip/ua capture is the "who, from where, with what device" baseline; no PII beyond the two recorded exceptions.
Documentation
docs/security/audit-events.md: Phase 2 section — the 17-event table (event, emitter service, metadata fields, transactionality), the two PII exceptions, the no-actor-for-expiry decision, and the extension protocol for Phase 3.
Dependencies
- TASK-0020 (audit module + service + resilience rule), TASK-0023–0028 (the emitter services).
Definition of Done
All 17 events emitted from the const map; the sequence test proves 17 correct rows; fault-injection + DB scan pass; docs written.
TASK-0033
Title
E2E multi-user workspace flow (tests/e2e/workspace.spec.ts)
Objective
Prove the Phase 2 exit criteria with Playwright in tests/e2e/workspace.spec.ts: user A creates a workspace → invites B → B accepts → B sees the sidebar → role enforcement (B cannot delete the workspace) → A transfers ownership to B — plus the settings UI flows, sidebar rendering, switcher, and the 404/403 states.
Background
The E2E harness (TASK-0008) + helpers (api.ts typed fetch/token storage, db.ts Prisma assertions) + the dev-mail log reader (TASK-0022) are available. Two browser contexts (A and B) against the same dev stack. Unique emails/slugs per run (e2e-<timestamp>) keep the seeded DB clean (no teardown delete — the E2E DB is reset per CI run).
Requirements
tests/e2e/workspace.spec.ts(serial), actors: A = seeded demo user (has the seededdemoworkspace — but the spec creates a fresh workspace to keep the assertions clean), B = newly registered user (register → verify via dev code → login, reusing the TASK-0022 helper for the code). - Flow 1 (create): A on/w/<personal>→ switcher → "Create workspace…" → dialog (name "E2E Flow", sluge2e-flow-<ts>preview) → submit → URL on/w/e2e-flow-…/→ home landing renders the workspace name (testidhome-landing) → DB: workspace row + A's OWNER membership. - Flow 2 (invite + accept): A → settings → Members tab → invite dialog (invite-dialog) with B's email + role MEMBER → pending row appears → "Copy link" → the link's token is extracted (clipboard read or the logged dev link) → B's context: logged in as B → navigate to the invite link →invite-cardshows "E2E Flow" + "Member" role → clickinvite-accept→ "accepted" state → DB: B'sWorkspaceMember(MEMBER, joinedAt set) + invitation ACCEPTED + audit rowmember.invitation_accepted. - Flow 3 (B's sidebar): B navigates to/w/e2e-flow-…→ sidebar renders with the workspace name in the switcher (current check), Home, theteams-emptystate (no teams yet), Private/Shared/Trash empty states — full testid assertion list (the TASK-0029 criterion-1 list). - Flow 4 (role enforcement): B (MEMBER): the settings Members tab renders with NO role options / no invite control / no transfer button (TASK-0030 criterion 5 testids absent); B's direct API callDELETE /api/v1/workspaces/<id>→ 403forbiddenwithdetails.actionpresent (helper call, not the UI — the UI has no delete button for a MEMBER, recorded); B'sPATCHrename → 403; a DB scan: no new audit rows from B's denied attempts — recorded decision: Phase 2 does NOT write apermission.deniedaudit row (the ROADMAP's Phase 3 exit "unauthorized access rejected with audit event" is scoped to Phase 3's page permissions, TASK-0041; Phase 2's 403s are covered by the guard'swarnlog (TASK-0010/0028) + the E2E assertion of the 403 itself). - Flow 5 (transfer): A → Members tab → "Transfer ownership…" → dialog → pick B → confirm → both role chips update (B → OWNER, A → ADMIN); A'sDELETEattempt now also 403 (A is no longer owner — admin can't delete, the TASK-0025 rule); B'sDELETE /api/v1/workspaces/<id>→ 200 (soft delete) → A navigates to/w/e2e-flow-…→ not-found page (testidworkspace-not-found) — the round trip proves the delete. - Flow 6 (switcher + states): with a second A-owned workspace created (quick API call via helper): switcher shows both + A's personal (grouped), current check on the right one; logged-out context:/w/e2e-flow-…→/login?next=/w/e2e-flow-…→ login → back on the workspace (TASK-0031 criterion 4); unknown slug → not-found page.- Sidebar rendering criterion (exit): Flow 3's assertions ARE the "sidebar renders tree from API" proof — plus the teams case: A creates a team via the Teams tab (settings) before Flow 3 → B's sidebar shows the team row (
team-row-<id>); the row's data comes from the API (assert the team name matches the DB row). - Theme/responsive: the settings page + sidebar at 375 px (no horizontal scroll) and in dark scheme (computed style check, TASK-0018 pattern).
- Every DB assertion through
helpers/db.ts; audit rows asserted for:workspace.created,member.invited,member.invitation_accepted,member.role_changed(the transfer's old-owner→ADMIN demote),workspace.owner_transferred,workspace.deleted(6 rows minimum, exactactionvalues). - The spec passes twice consecutively locally (unique timestamps make it idempotent; no shared state between runs).
Acceptance Criteria
pnpm test:e2e(workspace.spec.ts) green on the dev stack, clean machine — run recorded in the PR; the 6 flows complete in order.- Flow 4's 403 assertions pass (B cannot delete/rename; no
permission.deniedaudit row exists — the recorded decision asserted by absence). - Flow 5's full transfer + delete + not-found round trip passes; the 6 audit rows exist with correct metadata (from/to user ids).
- Flow 3's sidebar testid list is complete (the exact list from
docs/ui/sidebar.md) including the team row sourced from the API. - Flow 6: switcher grouping/current-check correct; the
nextdeep-link round trip works; unknown slug → not-found. - Theme + 375 px checks pass; the spec passes twice in a row.
Backend Changes
None (a gap found = a bug in an earlier Phase 2 task, fixed in that module in the same PR, recorded).
Frontend Changes
tests/e2e/workspace.spec.ts;tests/e2e/helpers/users.ts(two-context registration/login helper, reusing the TASK-0022 dev-code reader).
Database Changes
None.
API Changes
None.
Tests
- The spec is the deliverable; local run instruction appended to
docs/development/testing.md.
Security
- The spec never asserts on data of users other than A and B; dev-mail log reading is dev/CI-only (TASK-0022 gate); no production-like origin in the spec (helper origin allowlist).
Documentation
docs/development/testing.md: how to run the workspace E2E. — PR description: exit-criterion → flow mapping (Flow 1–5 → multi-user flow E2E- tested; Flow 4 → permissions enforced server-side; Flow 3/2 → sidebar renders tree from API).
Dependencies
- TASK-0023–0032 (everything), TASK-0008 (harness), TASK-0009 (health), TASK-0022 (dev-code helper pattern).
Definition of Done
tests/e2e/workspace.spec.ts green twice locally + in CI; all 6 criteria proven; exit mapping recorded; docs updated. Phase 2 exit criteria fully met — Phase 3 may start.
TASK-0034
Title
Integration tests (tenant isolation)
Objective
Pin the multi-tenant isolation contract with dedicated integration tests: a user C who is a member of no workspace (and a user D who is a member of a different workspace) cannot read, write, or enumerate anything in workspace W — across every Phase 2 endpoint, with the 404-vs-403 split enforced.
Background
Isolation was asserted piecemeal (TASK-0023 criterion 5, TASK-0025, TASK-0033's 403s). This task makes it a systematic property test: every workspace-scoped endpoint is enumerated (route map from TASK-0019/0028) and hit by non-members with every method — no endpoint may return 2xx for a non-member, and the response code must match the documented split (non-member → 404; member-but-unauthorized action → 403). A regression here is a tenant breach, so the test is exhaustive and CI-blocking.
Requirements
tests/integration/tenant-isolation.test.ts: setup — workspace W (user A owner, user B member), workspace W2 (user D owner only), user C (no memberships beyond their personal workspace); a team in W with a page-attached? (pages are Phase 3 — teams only); one grant in W (user-grant to B); one pending invitation in W.- The enumeration: import the route map (the same source TASK-0028's test uses — the Nest app's
RouterExplorer) filtered toworkspace-scopedroutes (path contains/workspaces/:id…or the token routes) × methods — for each (route, method): C → expect 404 (or 401 if unauthenticated — C is authenticated, so 404); D → expect 404 on W's routes (D is not a member of W — the same 404); B → 2xx on read routes, 403 on write routes not allowed for MEMBER (the permission-mapping table drives the expected action), 404 on team routes of W2. Token routes: C with W's invite token → 403email_mismatch(the token is W's, C is authenticated but not the invitee) — the split for token routes is documented in the test's header comment. - Data-leak assertions (beyond status codes): for every 404/403 response, the body contains none of W's secrets — a helper asserts the response JSON does not contain W's workspace id, any member's email, the invite token, or the grant's granteeId (the no-leak scan, parameterized over the responses collected in the enumeration).
- Query-level isolation spot checks (the repository convention, not just the guard):
GET /api/v1/workspacesfor C returns only C's personal workspace (W and W2 absent — by id AND slug AND name in the response);GET /api/v1/workspaces/:id/ membersfor C on W → 404 (no partial member list). - The test runs against Testcontainers (postgres + redis); a CI gate: it is part of
pnpm test:integration(already so by location) — and it fails the build on any 2xx for a non-member (no soft-fail mode). - A "breach canary": the test suite ends by inserting a
Workspacerow via direct SQL visible only to A and asserting C's list still excludes it (guards against a future query that forgets the membership join — the canary is a different row shape:isPersonal: false, created outside the api).
Acceptance Criteria
- The enumeration covers ≥ 30 (route × method) pairs (the count is asserted in the test — if a new workspace-scoped route is added in a later phase, the count grows and the test still checks it; a hard floor of 30 for Phase 2).
- Zero 2xx responses for C or D on W's routes across the whole enumeration; the 404-vs-403 split holds per the documented rule (asserted per route, not just in aggregate).
- The no-leak scan passes for every collected 4xx body (none of the 4 secret strings present).
- C's
GET /api/v1/workspacesreturns exactly 1 workspace (their personal) — W, W2 absent by id, slug, and name. - The canary row is invisible to C and visible to A (direct SQL insert → two API calls → assertions).
- The suite runs in < 5 min on Testcontainers (the enumeration is HTTP-level, fast) — the runtime is logged in the test output.
Backend Changes
None (this task adds no endpoints; if the enumeration finds a 2xx for a non-member, that's a bug fix in the responsible Phase 2 service, same PR, recorded).
Frontend Changes
None.
Database Changes
None (Testcontainers only; no schema).
API Changes
None.
Tests
tests/integration/tenant-isolation.test.ts(the suite above);tests/integration/helpers/route-map.ts(shares the route enumeration with TASK-0028's route-map test — refactor that test to use the helper too, so there is one source of truth for "what routes exist").
Security
- This suite is the standing tenant-breach detector; the no-leak scan is the second layer (status codes alone can hide data in error bodies); the canary guards the query convention directly.
Documentation
docs/security/tenancy-isolation.md(new): the isolation model (guard + scoping convention + this suite), the 404/403 split, the no-leak contract, how to add a new workspace-scoped route without breaking the suite (update the permission mapping + the expected-code table in one commit).
Dependencies
- TASK-0019/0028 (route map), TASK-0023–0027 (the endpoints), TASK-0008 (Testcontainers).
Definition of Done
Isolation suite live with ≥ 30 route pairs proven; no-leak + canary passing; route-map helper shared; docs written. Phase 2 is complete when TASK-0033 and this task are both green — start Phase 3.