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>
Algorithm: a fixed-window token bucket implemented as a pure function in
`@tasks/shared` (`consumeTokenBucket`) plus a thin `apps/web` wrapper that
holds per-key state in a module-scoped `Map`. No Redis, no external deps —
horizontally-scaled deploys will need a Redis-backed swap behind the same
`rateLimit()` signature; called out in the JSDoc as a follow-up. The pure
core is unit-tested in `packages/shared` (6 new vitest cases covering
allow/deny, window reset, key isolation, monotonic retryAfterMs, denied-
flood pegging, and option validation); the wrapper is intentionally not
tested here because apps/web has no vitest harness yet.
Wire-ins (the two narrow surfaces called out in the v1 spec):
1. Credentials `authorize` in `apps/web/lib/auth.ts`: 5 attempts per
IP per 60s. IP comes from `next/headers` (x-forwarded-for first
entry, then x-real-ip); when headers() throws or returns nothing we
fall back to keying on "unknown" in prod and skipping the limiter
entirely in dev so a local test loop doesn't lock itself out. On a
trip we `console.warn` and return null — the standard Auth.js
"auth failed" signal — without consulting the DB.
2. `invites.create` in `apps/web/server/routers/invites.ts`: 10
invite-creates per inviter per hour. Keyed by inviter id (not
workspace) so a multi-workspace admin can't multiply their
allowance. On trip we throw TRPCError TOO_MANY_REQUESTS with a
retry-after seconds count baked into the message.
Co-authored-by: Cursor <cursoragent@cursor.com>
Closes Task-invite-recipient-autocomplete. The invite dialog's plain
email input is replaced with a debounced combobox that surfaces the
four real cases — existing member, pending invite, known user from a
sibling workspace, brand-new email — before the inviter hits send.
Subagent ran in parallel while the main thread shipped Task 2's UI;
file-level non-overlap held (subagent stayed in
apps/web/components/teams/invite-recipient-combobox.tsx and the
shared types; main thread stayed in invite-dialog.tsx and the
teams page). This commit folds the subagent's deliverable in plus
the two-line wire-up that swaps the input for the combobox.
Files (5 by subagent + 1 wire-up by main thread):
@tasks/shared:
* packages/shared/src/types/invite-suggestions.ts — InviteSuggestion
union + pure mergeInviteSuggestions ranker. Lives in shared so
client + server consume one type definition.
* packages/shared/src/types/invite-suggestions.test.ts — 9 vitest
cases covering kind ordering, dedupe (known_user vs member by
userId, vs pending_invite by lowercased email), new_email
suppression when other kinds cover the typed address, the 10-
result limit, and email normalization.
* packages/shared/src/types/index.ts — re-export.
apps/web:
* apps/web/server/routers/invites.ts — new `suggestRecipient`
procedure on workspaceProcedure (owner/admin only). Implements
the four kinds with the tenancy fence wired as a two-step query:
first SELECT DISTINCT workspace_id FROM workspace_members WHERE
user_id = inviter (the inviter's workspace pool), then
inArray(workspaceMembers.workspaceId, pool) + ne(users.id,
inviter) on the candidate join. Read the procedure JSDoc for the
full set of invariants. All user-typed patterns escape through
escapeIlike with the ESCAPE '\\' clause (mirrors search.ts).
No existing exports modified.
* apps/web/components/teams/invite-recipient-combobox.tsx —
standalone controlled combobox. 200ms debounce, min-2-char gate,
distinct row styling per kind, ArrowUp/Down/Enter/Esc keyboard
nav, outside-click close.
* apps/web/components/teams/invite-dialog.tsx (wire-up) — Input
swapped for InviteRecipientCombobox. Added an
onFocusExistingMember prop so a future teams-page integration
can scroll/focus the matching row when a `member` suggestion is
picked; for now the dialog just closes cleanly on member-pick.
Gates: 0 lint errors / 15 warnings (14 baseline + 1 incidental
from earlier teams-page work, none from this task's files); 6/6
type-check; 23/23 tests (14 baseline + 9 new).
Acceptance criteria all met except the live-DB tenancy-fence
integration test (skipped because apps/web has no vitest harness;
unblocked by Task-bootstrap-vitest-for-apps-web P2).
Co-authored-by: Cursor <cursoragent@cursor.com>
Schema half of Task-workspace-invites-and-roles. Lands the table, the
invites router (create/list/revoke/accept), and the two new workspaces
procedures (updateMemberRole/removeMember). UI ships in part 2/2.
This is a stable checkpoint for Task 3 (invite-recipient-autocomplete)
to start building against — the procedure surface area is frozen and the
new identity helper from Task 1 is in the accept path.
Schema:
* workspace_invites: id, workspace_id, email (lowercased), role
(owner/admin/member), invited_by_user_id, token (base64url 32B),
expires_at (DEFAULT now() + 14d), accepted_at, revoked_at, created_at.
* Indexes: workspace_id, UNIQUE(token), and a PARTIAL UNIQUE on
(workspace_id, email) WHERE accepted_at IS NULL AND revoked_at IS NULL.
An open invite is unique per (workspace, email); closed invites
(accepted or revoked) fall out of the constraint so re-invites work.
* Drizzle relations wired: workspaceInvites.workspace,
workspaceInvites.invitedBy, workspaces.invites.
* Migration 0006_broad_lethal_legion applied to dev DB.
invites router:
* create({email, role}) on workspaceProcedure (owner/admin only).
Generates a base64url token from 32 random bytes via node:crypto.
Idempotent on (workspace, email) — if an open invite already exists,
returns it instead of inserting (the partial unique would block it
anyway). Refuses self-invite. Refuses if the email is already a
member.
* list() returns pending (non-accepted, non-revoked) invites with
inviter name/email joined for UI display.
* revoke({inviteId}) authorizes against the invite's workspace, not
the caller's input (the inviteId carries its own tenant scope).
* accept({token}) is protectedProcedure (no workspace handle). Calls
userOwnsEmail() from Task 1 — if the caller doesn't own the invited
email under any of their verified identities, throws FORBIDDEN with
a structured cause ({reason: "email_not_owned", invitedEmail}) so
the redeem page can render the "link this email" explainer. Handles
expiry, revoked, already-accepted states with clear messages.
Idempotent on existing membership — if you've already been added by
another flow, accept just closes the invite without re-inserting.
workspaces additions:
* updateMemberRole: admin/owner only. Three guards:
1. Can't change your own role (avoids accidental lockout).
2. Can't demote the only owner-role member (would leave the
membership-level ownership empty even though workspaces.owner_user_id
still points there — see ADR-pragmatic decision documented in the
Task-multi-email-identity convoy discussion).
3. Only owners can promote to owner; admins move people between
admin/member but cannot create a new owner.
* removeMember: admin/owner OR self (the leave-workspace affordance).
Same last-owner guard. Admins can't remove owners (only owners can,
via demote-then-remove).
Wired both new routers into root.ts as `invites` and `identity`
(identity was landed in Task 1; this commit just keeps the registration
visible alongside invites).
All three CI gates green: 0 lint errors, 14 unchanged warnings, 6/6
type-check, 14/14 tests (no new tests yet — apps/web vitest harness is
filed as Task-bootstrap-vitest-for-apps-web P2).
Co-authored-by: Cursor <cursoragent@cursor.com>