Vibecode Draftroom
track this build4 phases, 12 steps, beginner friendly0%A competent coding agent can rebuild Draftroom's core agency workflow in a weekend: clients, projects, tasks, file versions, contextual feedback, review queues, approvals, client review links, delivery tracking, and workspace permissions. The gap is not the CRUD screens; it is production-grade collaboration, notification reliability, permission edge cases, file handling, mobile review polish, and the accumulated project history that makes a shared agency system dependable every day.
You are building a lean indie version of Draftroom. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Draftroom · indie build An agency workflow tool whose hard problems are tenant isolation that cannot be forgotten in a new route, review links that expose exactly one scope, and file versions where the approved bytes are still the approved bytes a year later. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Remix with TypeScript and Tailwind | server-rendered routes keep the authorisation decision on the server | | Data | PostgreSQL through Prisma, with workspace scope applied in one place | a scope you apply per route is a scope you will forget | | Identity | Hosted authentication for the team; clients get scoped links, not accounts | an account for every client is a permission model you will get wrong | | Files | Object storage with a new key per version and no guessable paths | an overwritten file makes a past approval meaningless | | Review | A server-side allow-list serialiser for the public review page | hiding a field in the template still ships it in the response | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the migrations. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: Tenant scope and the single-current-version rule are both database constraints, and constraints are the point. - Get it: A managed instance, or a container locally. - Verify: Migrations apply and the current-version constraint rejects a second current row - [ ] **A hosted authentication and object storage project** · free tier is ample for one agency - Why: Team login and file storage are the two pieces worth renting, and signed uploads are how files avoid your server. - Get it: Create a project, enable email authentication, and create a private storage bucket. - Verify: A team member can sign in and a signed upload lands in the bucket - [ ] **Two seeded client workspaces with overlapping names** · free - Why: The cross-tenant test is only convincing when two workspaces contain similarly named projects, which is exactly when a leak would be missed by eye. - Get it: Seed one demo agency with several clients, and a second workspace whose project names deliberately collide. - Verify: The cross-tenant suite runs against both - [ ] **Your link expiry, rate limits and retention** · free - Why: Each is enforced in code and asserted by a test, and a review link default of 'never expires' is a decision even when nobody made it. - Get it: Write down the default review link lifetime, the write rate limit per token and per address, and how long archived work and old file versions are kept. - Verify: All three are in the configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any platform administrator or impersonation. - Frame-accurate video annotation. - Billing, time tracking and resource planning. - Single sign-on and white labelling. - Production-hardened real-time and notification behavior across many simultaneous users - Years of edge-case handling around client permissions, review links, files, revisions, and approvals - Polished mobile review and collaboration behavior that non-technical clients can use without training - Operational history, project context, and team habits already accumulated inside an existing Draftroom workspace - Enterprise features such as SSO, advanced reporting, white-labeling, onboarding, and support If one of those is essential to you, that is the reason to keep paying for Draftroom, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Draftroom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a creative workflow where a client sees exactly one thing and internal comments stay internal to replace Draftroom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two leaks end this product and neither is fixable with an apology: one client seeing another client's work, and an internal comment appearing on a client review link. So workspace scope lives in the data layer, applied by something every query passes through, not by a filter somebody remembered to add in a route at six in the evening. And the review page is rendered from an explicit list of permitted fields, never by serialising a task and hiding the private parts in the template, because hidden in the template means present in the response. The corollary: both are proved by a suite that attacks them on every route, with a foreign workspace identifier and with an internal comment, before anything else counts as finished. A permission model nobody has attacked is a hope. ### Stack (fixed, do not substitute) - Runtime: Remix with TypeScript and Tailwind · server-rendered routes keep the authorisation decision on the server - Data: PostgreSQL through Prisma, with workspace scope applied in one place · a scope you apply per route is a scope you will forget - Identity: Hosted authentication for the team; clients get scoped links, not accounts · an account for every client is a permission model you will get wrong - Files: Object storage with a new key per version and no guessable paths · an overwritten file makes a past approval meaningless - Review: A server-side allow-list serialiser for the public review page · hiding a field in the template still ships it in the response ### Data model (create this before Phase 1) - workspaces(id, name, created_at) and memberships(user_id, workspace_id, role) · role is OWNER or MEMBER, and membership is the only route to any data - clients(id, workspace_id, name, archived_at) and projects(id, workspace_id, client_id, name, archived_at) - tasks(id, workspace_id, project_id, title, status, priority, start_date, due_date, delivery_date, created_at, updated_at) - file_assets(id, workspace_id, task_id, name) and file_versions(id, asset_id, ordinal, storage_key, bytes, sniffed_type, sha256, uploaded_by, uploaded_at, is_current) - comments(id, workspace_id, task_id, file_version_id, author_id, body, visibility) · visibility is INTERNAL or CLIENT_VISIBLE and defaults to INTERNAL - approvals(id, workspace_id, file_version_id, decision, decided_by, decided_via_link_id, comment, decided_at) · pinned to a version, never to an asset - review_links(id, workspace_id, token_hash, scope_json, permissions_json, expires_at, revoked_at, created_by, last_used_at) - Every table carries workspace_id, including the ones you could reach by a join, because the join you forget is the leak. ### Phase 1 · Tenancy you cannot forget Build: Put workspace_id on every table and apply the scope in one place that all data access passes through, derived from the session rather than from anything in the request. Write a test that walks every route with an identifier belonging to another workspace and asserts a not-found rather than a forbidden, so existence is not confirmed either. Trap: Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. Done when: A request for a resource in another workspace returns not found on every route, asserted by a test that enumerates routes rather than listing them by hand. No data access path bypasses the scoping layer, verified by review and by a test that fails if a raw query is added. The workspace comes from the session, never from a parameter or a header. Adding a route without scoping fails the suite. Do not build yet: No projects, files, comments or review links. ### Phase 2 · Work that moves through a stated flow Build: Clients, projects and tasks with a transition table for the status flow from backlog through to delivered. Reject any move the table does not allow, with the attempted move named. Write an append-only activity row for every create, edit, assignment and status change. Trap: Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. Done when: A disallowed transition is rejected server-side and names the attempted move, tested for every pair in the table. Every create, edit, assignment and status change writes an activity row, asserted by counting before and after a workflow. Activity rows are append-only, enforced by the schema. The board and the list view produce identical status results for the same task, tested. Do not build yet: No file versions, review links or notifications yet. ### Phase 3 · Versions where the approved bytes stay approved Build: Each upload writes a new object under a new unguessable key and a new version row. Exactly one version per asset is current, held by a constraint. Approvals record a version id. Uploads go direct to storage with a short-lived signed URL, and the stored type comes from the bytes. Trap: Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. Done when: Uploading a replacement leaves the previous object retrievable at its own key, tested by fetching it after the upload. Exactly one version per asset is current, enforced by a constraint rather than by application code, tested by trying to set two. An approval records a version id and still resolves to the same bytes after three more uploads, tested. Storage keys are unguessable and a key from one workspace cannot be read by another, tested. Do not build yet: No review links, notifications or export yet. ### Phase 4 · A review link that shows one thing Build: Generate a random token, store only its hash, and attach a scope naming the exact versions and the permissions: view, comment, decide. Render the review page from an allow-list serialiser that names every field it emits. Support expiry and revocation, and rate limit the write actions on the public route. Trap: Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. Done when: The review response body contains no internal comment text and no field outside the allow-list, asserted by scanning the serialised response in a test. A token is random, stored hashed, and an adjacent or altered token resolves to nothing. An expired or revoked link returns nothing and says so without revealing what it used to point at. Write actions on the public route are rate limited per token and per address, tested. Do not build yet: No notifications, export or deletion yet. ### Phase 5 · Leaving, and being able to Build: Export one client's projects, tasks, comments, file versions and approvals as JSON plus the files themselves. Back up the database and the object storage together and exercise a restore. Archive rather than delete by default, and make a real deletion remove the objects too. Notify on assignment, review request and decision, with read state. Trap: Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair. Done when: An export of one client re-imports into an empty workspace with tasks, versions, comments and approvals intact, tested. A restore from backup can open an approved file and its bytes match the recorded hash, tested rather than assumed. Archiving hides a client without deleting rows; a real deletion removes the stored objects too, verified by fetching a key afterwards. Notifications carry read state and a decision notifies the assignee within one cycle. Do not build yet: Nothing after this. ### Out of scope (and why) - Any platform-wide administrator, impersonation or global user management. There is no super-admin route, role or middleware, because the only thing it adds is a way for one mistake to reach every workspace. - Frame-accurate video annotation, which is a specialist product and doing it badly is worse than linking to one that does it properly. - Billing, time tracking, resource planning and Gantt charts, each of which is a separate product wearing this one as a hat. - Single sign-on, white labelling and advanced reporting, which are the enterprise tier rather than the workflow. ### README must contain - That workspace scope is applied in one place every query passes through, and that a cross-tenant test enumerates routes automatically - That the review page is serialised from an explicit allow-list, because a field hidden in a template is still in the response - That every upload is a new version under a new key and approvals are pinned to a version id - That there is deliberately no platform administrator, impersonation or global management of any kind - That backups cover the database and object storage together, and that the restore test opens an approved file ===== AGENTS.md ===== # Agent instructions · Draftroom indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Remix with TypeScript and Tailwind, PostgreSQL through Prisma, with workspace scope applied in one place, Hosted authentication for the team; clients get scoped links, not accounts, Object storage with a new key per version and no guessable paths, A server-side allow-list serialiser for the public review page. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. ===== BUILD_PLAN.md ===== # Build plan · Draftroom An agency workflow tool whose hard problems are tenant isolation that cannot be forgotten in a new route, review links that expose exactly one scope, and file versions where the approved bytes are still the approved bytes a year later. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Tenancy you cannot forget Every query is workspace-scoped by construction, and a suite proves it on every route. ### Steps 1. Put the scope in one place Derived from the session, applied by construction. Files: `app/db/scope.server.ts`, `prisma/schema.prisma` 2. Enumerate routes in the cross-tenant test A hand-written list goes stale the first busy week. Files: `tests/cross-tenant.test.ts` 3. Return not found, not forbidden Forbidden confirms the thing exists, which is itself a leak. Files: `app/lib/errors.server.ts` ### Done when - [ ] Every route returns not found for a foreign id - [ ] No path bypasses the scoping layer - [ ] Workspace comes from the session only - [ ] An unscoped new route fails the suite ### Watch out - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. ## Phase 2 · Work that moves through a stated flow A task cannot reach delivered without passing the states in between, and every move is recorded. ### Steps 1. Put allowed transitions in a table The server decides, whatever the board allows you to drag. Files: `app/lib/workflow.server.ts`, `tests/transitions.test.ts` 2. Write activity on every change The timeline is what answers 'who moved this and when'. Files: `app/lib/activity.server.ts` 3. Make activity append-only An editable audit trail is decoration. Files: `prisma/schema.prisma` ### Done when - [ ] Disallowed transitions are rejected by name - [ ] Every change writes an activity row - [ ] Activity cannot be edited or deleted - [ ] Board and list agree on status ### Watch out - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. ## Phase 3 · Versions where the approved bytes stay approved Every upload is a new version, the current one is unambiguous, and an approval points at a version. ### Steps 1. New key per version, never an overwrite The approved bytes have to stay retrievable. Files: `app/lib/storage.server.ts`, `tests/versions.test.ts` 2. Hold 'exactly one current' with a constraint Application code forgets under concurrency. Files: `prisma/migrations/` 3. Pin approvals to a version id An approval of an asset is an approval of nothing in particular. Files: `app/lib/approvals.server.ts` ### Done when - [ ] A replacement leaves the previous object retrievable - [ ] Exactly one current version is enforced by a constraint - [ ] An approval still resolves to the same bytes later - [ ] Storage keys are unguessable and workspace-bound ### Watch out - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. ## Phase 4 · A review link that shows one thing A client opening a link sees the files it names and nothing else, ever. ### Steps 1. Serialise from an allow-list, field by field Hidden in the template means present in the response. Files: `app/lib/review-serialiser.server.ts`, `tests/review-leak.test.ts` 2. Hash the token, scope it to named versions The scope is data, not an assumption about the route. Files: `app/lib/review-links.server.ts` 3. Expire, revoke and rate limit A link that lives forever is a link that leaks eventually. Files: `app/routes/review.$token.tsx` ### Done when - [ ] No internal comment text appears in a review response - [ ] Only allow-listed fields are emitted - [ ] Expired and revoked links reveal nothing - [ ] Public write actions are rate limited ### Watch out - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. ## Not in this build - Any platform administrator or impersonation. - Frame-accurate video annotation. - Billing, time tracking and resource planning. - Single sign-on and white labelling. ## After v1, if you want it - A content calendar over the same tasks, which is a view rather than a new model - Client-visible comment threads with email replies, once the allow-list serialiser has held for a month - Per-client branding on the review page, drawn from data rather than from a separate template ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Every table carries workspace_id, including the joinable ones. DATABASE_URL=postgres://user:redacted@localhost:5432/draftroom # Required. Hosted authentication for team members only. Clients get links. AUTH_URL=https://project.supabase.co # Required · secret. Server side only. It must never reach the browser bundle. AUTH_SERVICE_KEY=redacted # Required. Private. Keys are unguessable and one per version. STORAGE_BUCKET=draftroom-files # Required. A link with no expiry is a leak with a delay. REVIEW_LINK_DEFAULT_DAYS=14 # Required. Per token and per address on the public review route. REVIEW_WRITE_RATE_PER_HOUR=60 # Required. Enforced in the storage policy, not only in the browser. UPLOAD_MAX_BYTES=524288000 # Required. How long superseded versions are kept. Approvals point at them. VERSION_RETENTION_DAYS=1095
You are building a lean indie version of Draftroom. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Draftroom · indie build An agency workflow tool whose hard problems are tenant isolation that cannot be forgotten in a new route, review links that expose exactly one scope, and file versions where the approved bytes are still the approved bytes a year later. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Remix with TypeScript and Tailwind | server-rendered routes keep the authorisation decision on the server | | Data | PostgreSQL through Prisma, with workspace scope applied in one place | a scope you apply per route is a scope you will forget | | Identity | Hosted authentication for the team; clients get scoped links, not accounts | an account for every client is a permission model you will get wrong | | Files | Object storage with a new key per version and no guessable paths | an overwritten file makes a past approval meaningless | | Review | A server-side allow-list serialiser for the public review page | hiding a field in the template still ships it in the response | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the migrations. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: Tenant scope and the single-current-version rule are both database constraints, and constraints are the point. - Get it: A managed instance, or a container locally. - Verify: Migrations apply and the current-version constraint rejects a second current row - [ ] **A hosted authentication and object storage project** · free tier is ample for one agency - Why: Team login and file storage are the two pieces worth renting, and signed uploads are how files avoid your server. - Get it: Create a project, enable email authentication, and create a private storage bucket. - Verify: A team member can sign in and a signed upload lands in the bucket - [ ] **Two seeded client workspaces with overlapping names** · free - Why: The cross-tenant test is only convincing when two workspaces contain similarly named projects, which is exactly when a leak would be missed by eye. - Get it: Seed one demo agency with several clients, and a second workspace whose project names deliberately collide. - Verify: The cross-tenant suite runs against both - [ ] **Your link expiry, rate limits and retention** · free - Why: Each is enforced in code and asserted by a test, and a review link default of 'never expires' is a decision even when nobody made it. - Get it: Write down the default review link lifetime, the write rate limit per token and per address, and how long archived work and old file versions are kept. - Verify: All three are in the configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any platform administrator or impersonation. - Frame-accurate video annotation. - Billing, time tracking and resource planning. - Single sign-on and white labelling. - Production-hardened real-time and notification behavior across many simultaneous users - Years of edge-case handling around client permissions, review links, files, revisions, and approvals - Polished mobile review and collaboration behavior that non-technical clients can use without training - Operational history, project context, and team habits already accumulated inside an existing Draftroom workspace - Enterprise features such as SSO, advanced reporting, white-labeling, onboarding, and support If one of those is essential to you, that is the reason to keep paying for Draftroom, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Draftroom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a creative workflow where a client sees exactly one thing and internal comments stay internal to replace Draftroom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two leaks end this product and neither is fixable with an apology: one client seeing another client's work, and an internal comment appearing on a client review link. So workspace scope lives in the data layer, applied by something every query passes through, not by a filter somebody remembered to add in a route at six in the evening. And the review page is rendered from an explicit list of permitted fields, never by serialising a task and hiding the private parts in the template, because hidden in the template means present in the response. The corollary: both are proved by a suite that attacks them on every route, with a foreign workspace identifier and with an internal comment, before anything else counts as finished. A permission model nobody has attacked is a hope. ### Stack (fixed, do not substitute) - Runtime: Remix with TypeScript and Tailwind · server-rendered routes keep the authorisation decision on the server - Data: PostgreSQL through Prisma, with workspace scope applied in one place · a scope you apply per route is a scope you will forget - Identity: Hosted authentication for the team; clients get scoped links, not accounts · an account for every client is a permission model you will get wrong - Files: Object storage with a new key per version and no guessable paths · an overwritten file makes a past approval meaningless - Review: A server-side allow-list serialiser for the public review page · hiding a field in the template still ships it in the response ### Data model (create this before Phase 1) - workspaces(id, name, created_at) and memberships(user_id, workspace_id, role) · role is OWNER or MEMBER, and membership is the only route to any data - clients(id, workspace_id, name, archived_at) and projects(id, workspace_id, client_id, name, archived_at) - tasks(id, workspace_id, project_id, title, status, priority, start_date, due_date, delivery_date, created_at, updated_at) - file_assets(id, workspace_id, task_id, name) and file_versions(id, asset_id, ordinal, storage_key, bytes, sniffed_type, sha256, uploaded_by, uploaded_at, is_current) - comments(id, workspace_id, task_id, file_version_id, author_id, body, visibility) · visibility is INTERNAL or CLIENT_VISIBLE and defaults to INTERNAL - approvals(id, workspace_id, file_version_id, decision, decided_by, decided_via_link_id, comment, decided_at) · pinned to a version, never to an asset - review_links(id, workspace_id, token_hash, scope_json, permissions_json, expires_at, revoked_at, created_by, last_used_at) - Every table carries workspace_id, including the ones you could reach by a join, because the join you forget is the leak. ### Phase 1 · Tenancy you cannot forget Build: Put workspace_id on every table and apply the scope in one place that all data access passes through, derived from the session rather than from anything in the request. Write a test that walks every route with an identifier belonging to another workspace and asserts a not-found rather than a forbidden, so existence is not confirmed either. Trap: Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. Done when: A request for a resource in another workspace returns not found on every route, asserted by a test that enumerates routes rather than listing them by hand. No data access path bypasses the scoping layer, verified by review and by a test that fails if a raw query is added. The workspace comes from the session, never from a parameter or a header. Adding a route without scoping fails the suite. Do not build yet: No projects, files, comments or review links. ### Phase 2 · Work that moves through a stated flow Build: Clients, projects and tasks with a transition table for the status flow from backlog through to delivered. Reject any move the table does not allow, with the attempted move named. Write an append-only activity row for every create, edit, assignment and status change. Trap: Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. Done when: A disallowed transition is rejected server-side and names the attempted move, tested for every pair in the table. Every create, edit, assignment and status change writes an activity row, asserted by counting before and after a workflow. Activity rows are append-only, enforced by the schema. The board and the list view produce identical status results for the same task, tested. Do not build yet: No file versions, review links or notifications yet. ### Phase 3 · Versions where the approved bytes stay approved Build: Each upload writes a new object under a new unguessable key and a new version row. Exactly one version per asset is current, held by a constraint. Approvals record a version id. Uploads go direct to storage with a short-lived signed URL, and the stored type comes from the bytes. Trap: Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. Done when: Uploading a replacement leaves the previous object retrievable at its own key, tested by fetching it after the upload. Exactly one version per asset is current, enforced by a constraint rather than by application code, tested by trying to set two. An approval records a version id and still resolves to the same bytes after three more uploads, tested. Storage keys are unguessable and a key from one workspace cannot be read by another, tested. Do not build yet: No review links, notifications or export yet. ### Phase 4 · A review link that shows one thing Build: Generate a random token, store only its hash, and attach a scope naming the exact versions and the permissions: view, comment, decide. Render the review page from an allow-list serialiser that names every field it emits. Support expiry and revocation, and rate limit the write actions on the public route. Trap: Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. Done when: The review response body contains no internal comment text and no field outside the allow-list, asserted by scanning the serialised response in a test. A token is random, stored hashed, and an adjacent or altered token resolves to nothing. An expired or revoked link returns nothing and says so without revealing what it used to point at. Write actions on the public route are rate limited per token and per address, tested. Do not build yet: No notifications, export or deletion yet. ### Phase 5 · Leaving, and being able to Build: Export one client's projects, tasks, comments, file versions and approvals as JSON plus the files themselves. Back up the database and the object storage together and exercise a restore. Archive rather than delete by default, and make a real deletion remove the objects too. Notify on assignment, review request and decision, with read state. Trap: Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair. Done when: An export of one client re-imports into an empty workspace with tasks, versions, comments and approvals intact, tested. A restore from backup can open an approved file and its bytes match the recorded hash, tested rather than assumed. Archiving hides a client without deleting rows; a real deletion removes the stored objects too, verified by fetching a key afterwards. Notifications carry read state and a decision notifies the assignee within one cycle. Do not build yet: Nothing after this. ### Out of scope (and why) - Any platform-wide administrator, impersonation or global user management. There is no super-admin route, role or middleware, because the only thing it adds is a way for one mistake to reach every workspace. - Frame-accurate video annotation, which is a specialist product and doing it badly is worse than linking to one that does it properly. - Billing, time tracking, resource planning and Gantt charts, each of which is a separate product wearing this one as a hat. - Single sign-on, white labelling and advanced reporting, which are the enterprise tier rather than the workflow. ### README must contain - That workspace scope is applied in one place every query passes through, and that a cross-tenant test enumerates routes automatically - That the review page is serialised from an explicit allow-list, because a field hidden in a template is still in the response - That every upload is a new version under a new key and approvals are pinned to a version id - That there is deliberately no platform administrator, impersonation or global management of any kind - That backups cover the database and object storage together, and that the restore test opens an approved file ===== AGENTS.md ===== # Agent instructions · Draftroom indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Remix with TypeScript and Tailwind, PostgreSQL through Prisma, with workspace scope applied in one place, Hosted authentication for the team; clients get scoped links, not accounts, Object storage with a new key per version and no guessable paths, A server-side allow-list serialiser for the public review page. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. ===== BUILD_PLAN.md ===== # Build plan · Draftroom An agency workflow tool whose hard problems are tenant isolation that cannot be forgotten in a new route, review links that expose exactly one scope, and file versions where the approved bytes are still the approved bytes a year later. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Tenancy you cannot forget Every query is workspace-scoped by construction, and a suite proves it on every route. ### Steps 1. Put the scope in one place Derived from the session, applied by construction. Files: `app/db/scope.server.ts`, `prisma/schema.prisma` 2. Enumerate routes in the cross-tenant test A hand-written list goes stale the first busy week. Files: `tests/cross-tenant.test.ts` 3. Return not found, not forbidden Forbidden confirms the thing exists, which is itself a leak. Files: `app/lib/errors.server.ts` ### Done when - [ ] Every route returns not found for a foreign id - [ ] No path bypasses the scoping layer - [ ] Workspace comes from the session only - [ ] An unscoped new route fails the suite ### Watch out - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. ## Phase 2 · Work that moves through a stated flow A task cannot reach delivered without passing the states in between, and every move is recorded. ### Steps 1. Put allowed transitions in a table The server decides, whatever the board allows you to drag. Files: `app/lib/workflow.server.ts`, `tests/transitions.test.ts` 2. Write activity on every change The timeline is what answers 'who moved this and when'. Files: `app/lib/activity.server.ts` 3. Make activity append-only An editable audit trail is decoration. Files: `prisma/schema.prisma` ### Done when - [ ] Disallowed transitions are rejected by name - [ ] Every change writes an activity row - [ ] Activity cannot be edited or deleted - [ ] Board and list agree on status ### Watch out - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. ## Phase 3 · Versions where the approved bytes stay approved Every upload is a new version, the current one is unambiguous, and an approval points at a version. ### Steps 1. New key per version, never an overwrite The approved bytes have to stay retrievable. Files: `app/lib/storage.server.ts`, `tests/versions.test.ts` 2. Hold 'exactly one current' with a constraint Application code forgets under concurrency. Files: `prisma/migrations/` 3. Pin approvals to a version id An approval of an asset is an approval of nothing in particular. Files: `app/lib/approvals.server.ts` ### Done when - [ ] A replacement leaves the previous object retrievable - [ ] Exactly one current version is enforced by a constraint - [ ] An approval still resolves to the same bytes later - [ ] Storage keys are unguessable and workspace-bound ### Watch out - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. ## Phase 4 · A review link that shows one thing A client opening a link sees the files it names and nothing else, ever. ### Steps 1. Serialise from an allow-list, field by field Hidden in the template means present in the response. Files: `app/lib/review-serialiser.server.ts`, `tests/review-leak.test.ts` 2. Hash the token, scope it to named versions The scope is data, not an assumption about the route. Files: `app/lib/review-links.server.ts` 3. Expire, revoke and rate limit A link that lives forever is a link that leaks eventually. Files: `app/routes/review.$token.tsx` ### Done when - [ ] No internal comment text appears in a review response - [ ] Only allow-listed fields are emitted - [ ] Expired and revoked links reveal nothing - [ ] Public write actions are rate limited ### Watch out - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. ## Not in this build - Any platform administrator or impersonation. - Frame-accurate video annotation. - Billing, time tracking and resource planning. - Single sign-on and white labelling. ## After v1, if you want it - A content calendar over the same tasks, which is a view rather than a new model - Client-visible comment threads with email replies, once the allow-list serialiser has held for a month - Per-client branding on the review page, drawn from data rather than from a separate template ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Every table carries workspace_id, including the joinable ones. DATABASE_URL=postgres://user:redacted@localhost:5432/draftroom # Required. Hosted authentication for team members only. Clients get links. AUTH_URL=https://project.supabase.co # Required · secret. Server side only. It must never reach the browser bundle. AUTH_SERVICE_KEY=redacted # Required. Private. Keys are unguessable and one per version. STORAGE_BUCKET=draftroom-files # Required. A link with no expiry is a leak with a delay. REVIEW_LINK_DEFAULT_DAYS=14 # Required. Per token and per address on the public review route. REVIEW_WRITE_RATE_PER_HOUR=60 # Required. Enforced in the storage policy, not only in the browser. UPLOAD_MAX_BYTES=524288000 # Required. How long superseded versions are kept. Approvals point at them. VERSION_RETENTION_DAYS=1095
You are building a production product version of Draftroom. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Draftroom · product brief ## Problem A competent coding agent can rebuild Draftroom's core agency workflow in a weekend: clients, projects, tasks, file versions, contextual feedback, review queues, approvals, client review links, delivery tracking, and workspace permissions. The gap is not the CRUD screens; it is production-grade collaboration, notification reliability, permission edge cases, file handling, mobile review polish, and the accumulated project history that makes a shared agency system dependable every day. ## Product outcome A workflow an agency and its clients can both live in: no client ever sees another's work, no internal comment reaches a review link, every approval still points at the exact bytes that were approved, and a client's whole history exports on request. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js - Supabase project with PostgreSQL, Auth, and Storage - Environment variables for Supabase and database credentials ## Explicit non-goals for v1 - Any platform administrator or impersonation. - Frame-accurate video annotation. - Billing, time tracking and resource planning. - Single sign-on and white labelling. - Production-hardened real-time and notification behavior across many simultaneous users - Years of edge-case handling around client permissions, review links, files, revisions, and approvals - Polished mobile review and collaboration behavior that non-technical clients can use without training - Operational history, project context, and team habits already accumulated inside an existing Draftroom workspace - Enterprise features such as SSO, advanced reporting, white-labeling, onboarding, and support ## Success criteria - Every route is confirmed to return not found for a foreign workspace id - No internal comment text is confirmed present in a review response - A replacement upload is confirmed to leave the previous object retrievable - An expired link is confirmed to reveal nothing - A restored approved file is confirmed to match its recorded hash ===== BRIEF.md ===== # Build brief · Draftroom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a creative workflow where a client sees exactly one thing and internal comments stay internal to replace Draftroom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two leaks end this product and neither is fixable with an apology: one client seeing another client's work, and an internal comment appearing on a client review link. So workspace scope lives in the data layer, applied by something every query passes through, not by a filter somebody remembered to add in a route at six in the evening. And the review page is rendered from an explicit list of permitted fields, never by serialising a task and hiding the private parts in the template, because hidden in the template means present in the response. The corollary: both are proved by a suite that attacks them on every route, with a foreign workspace identifier and with an internal comment, before anything else counts as finished. A permission model nobody has attacked is a hope. ### Stack (fixed, do not substitute) - Runtime: Remix with TypeScript and Tailwind · server-rendered routes keep the authorisation decision on the server - Data: PostgreSQL through Prisma, with workspace scope applied in one place · a scope you apply per route is a scope you will forget - Identity: Hosted authentication for the team; clients get scoped links, not accounts · an account for every client is a permission model you will get wrong - Files: Object storage with a new key per version and no guessable paths · an overwritten file makes a past approval meaningless - Review: A server-side allow-list serialiser for the public review page · hiding a field in the template still ships it in the response ### Data model (create this before Phase 1) - workspaces(id, name, created_at) and memberships(user_id, workspace_id, role) · role is OWNER or MEMBER, and membership is the only route to any data - clients(id, workspace_id, name, archived_at) and projects(id, workspace_id, client_id, name, archived_at) - tasks(id, workspace_id, project_id, title, status, priority, start_date, due_date, delivery_date, created_at, updated_at) - file_assets(id, workspace_id, task_id, name) and file_versions(id, asset_id, ordinal, storage_key, bytes, sniffed_type, sha256, uploaded_by, uploaded_at, is_current) - comments(id, workspace_id, task_id, file_version_id, author_id, body, visibility) · visibility is INTERNAL or CLIENT_VISIBLE and defaults to INTERNAL - approvals(id, workspace_id, file_version_id, decision, decided_by, decided_via_link_id, comment, decided_at) · pinned to a version, never to an asset - review_links(id, workspace_id, token_hash, scope_json, permissions_json, expires_at, revoked_at, created_by, last_used_at) - Every table carries workspace_id, including the ones you could reach by a join, because the join you forget is the leak. ### Phase 1 · Tenancy you cannot forget Build: Put workspace_id on every table and apply the scope in one place that all data access passes through, derived from the session rather than from anything in the request. Write a test that walks every route with an identifier belonging to another workspace and asserts a not-found rather than a forbidden, so existence is not confirmed either. Trap: Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. Done when: A request for a resource in another workspace returns not found on every route, asserted by a test that enumerates routes rather than listing them by hand. No data access path bypasses the scoping layer, verified by review and by a test that fails if a raw query is added. The workspace comes from the session, never from a parameter or a header. Adding a route without scoping fails the suite. Do not build yet: No projects, files, comments or review links. ### Phase 2 · Work that moves through a stated flow Build: Clients, projects and tasks with a transition table for the status flow from backlog through to delivered. Reject any move the table does not allow, with the attempted move named. Write an append-only activity row for every create, edit, assignment and status change. Trap: Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. Done when: A disallowed transition is rejected server-side and names the attempted move, tested for every pair in the table. Every create, edit, assignment and status change writes an activity row, asserted by counting before and after a workflow. Activity rows are append-only, enforced by the schema. The board and the list view produce identical status results for the same task, tested. Do not build yet: No file versions, review links or notifications yet. ### Phase 3 · Versions where the approved bytes stay approved Build: Each upload writes a new object under a new unguessable key and a new version row. Exactly one version per asset is current, held by a constraint. Approvals record a version id. Uploads go direct to storage with a short-lived signed URL, and the stored type comes from the bytes. Trap: Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. Done when: Uploading a replacement leaves the previous object retrievable at its own key, tested by fetching it after the upload. Exactly one version per asset is current, enforced by a constraint rather than by application code, tested by trying to set two. An approval records a version id and still resolves to the same bytes after three more uploads, tested. Storage keys are unguessable and a key from one workspace cannot be read by another, tested. Do not build yet: No review links, notifications or export yet. ### Phase 4 · A review link that shows one thing Build: Generate a random token, store only its hash, and attach a scope naming the exact versions and the permissions: view, comment, decide. Render the review page from an allow-list serialiser that names every field it emits. Support expiry and revocation, and rate limit the write actions on the public route. Trap: Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. Done when: The review response body contains no internal comment text and no field outside the allow-list, asserted by scanning the serialised response in a test. A token is random, stored hashed, and an adjacent or altered token resolves to nothing. An expired or revoked link returns nothing and says so without revealing what it used to point at. Write actions on the public route are rate limited per token and per address, tested. Do not build yet: No notifications, export or deletion yet. ### Phase 5 · Leaving, and being able to Build: Export one client's projects, tasks, comments, file versions and approvals as JSON plus the files themselves. Back up the database and the object storage together and exercise a restore. Archive rather than delete by default, and make a real deletion remove the objects too. Notify on assignment, review request and decision, with read state. Trap: Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair. Done when: An export of one client re-imports into an empty workspace with tasks, versions, comments and approvals intact, tested. A restore from backup can open an approved file and its bytes match the recorded hash, tested rather than assumed. Archiving hides a client without deleting rows; a real deletion removes the stored objects too, verified by fetching a key afterwards. Notifications carry read state and a decision notifies the assignee within one cycle. Do not build yet: Nothing after this. ### Out of scope (and why) - Any platform-wide administrator, impersonation or global user management. There is no super-admin route, role or middleware, because the only thing it adds is a way for one mistake to reach every workspace. - Frame-accurate video annotation, which is a specialist product and doing it badly is worse than linking to one that does it properly. - Billing, time tracking, resource planning and Gantt charts, each of which is a separate product wearing this one as a hat. - Single sign-on, white labelling and advanced reporting, which are the enterprise tier rather than the workflow. ### README must contain - That workspace scope is applied in one place every query passes through, and that a cross-tenant test enumerates routes automatically - That the review page is serialised from an explicit allow-list, because a field hidden in a template is still in the response - That every upload is a new version under a new key and approvals are pinned to a version id - That there is deliberately no platform administrator, impersonation or global management of any kind - That backups cover the database and object storage together, and that the restore test opens an approved file ===== ARCHITECTURE.md ===== # Architecture · Draftroom ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Remix with TypeScript and Tailwind | server-rendered routes keep the authorisation decision on the server | | Data | PostgreSQL through Prisma, with workspace scope applied in one place | a scope you apply per route is a scope you will forget | | Identity | Hosted authentication for the team; clients get scoped links, not accounts | an account for every client is a permission model you will get wrong | | Files | Object storage with a new key per version and no guessable paths | an overwritten file makes a past approval meaningless | | Review | A server-side allow-list serialiser for the public review page | hiding a field in the template still ships it in the response | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Tenancy | Workspace scope applied by construction. | Not swappable for per-route filtering, which is the leak. | | Workflow | Transitions and append-only activity. | Any board, provided the server still decides the transition. | | Versions | One object per version and the current-version constraint. | Any storage, provided nothing is ever overwritten. | | Review | Hashed tokens, scopes and the allow-list serialiser. | Any renderer that emits only allow-listed fields. | | Portability | Client export and paired backups. | Any format, provided the restore test opens an approved file. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Every table carries workspace_id, including the joinable ones. - `AUTH_URL` · required · Hosted authentication for team members only. Clients get links. - `AUTH_SERVICE_KEY` · required, secret · Server side only. It must never reach the browser bundle. - `STORAGE_BUCKET` · required · Private. Keys are unguessable and one per version. - `REVIEW_LINK_DEFAULT_DAYS` · required · A link with no expiry is a leak with a delay. - `REVIEW_WRITE_RATE_PER_HOUR` · required · Per token and per address on the public review route. - `UPLOAD_MAX_BYTES` · required · Enforced in the storage policy, not only in the browser. - `VERSION_RETENTION_DAYS` · required · How long superseded versions are kept. Approvals point at them. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Draftroom product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Remix with TypeScript and Tailwind, PostgreSQL through Prisma, with workspace scope applied in one place, Hosted authentication for the team; clients get scoped links, not accounts, Object storage with a new key per version and no guessable paths, A server-side allow-list serialiser for the public review page. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. - Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair. ===== MILESTONES.md ===== # Delivery milestones · Draftroom Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Tenancy you cannot forget Every query is workspace-scoped by construction, and a suite proves it on every route. ### Steps 1. Put the scope in one place Derived from the session, applied by construction. Files: `app/db/scope.server.ts`, `prisma/schema.prisma` 2. Enumerate routes in the cross-tenant test A hand-written list goes stale the first busy week. Files: `tests/cross-tenant.test.ts` 3. Return not found, not forbidden Forbidden confirms the thing exists, which is itself a leak. Files: `app/lib/errors.server.ts` ### Done when - [ ] Every route returns not found for a foreign id - [ ] No path bypasses the scoping layer - [ ] Workspace comes from the session only - [ ] An unscoped new route fails the suite ### Watch out - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. ## M2 · Work that moves through a stated flow A task cannot reach delivered without passing the states in between, and every move is recorded. ### Steps 1. Put allowed transitions in a table The server decides, whatever the board allows you to drag. Files: `app/lib/workflow.server.ts`, `tests/transitions.test.ts` 2. Write activity on every change The timeline is what answers 'who moved this and when'. Files: `app/lib/activity.server.ts` 3. Make activity append-only An editable audit trail is decoration. Files: `prisma/schema.prisma` ### Done when - [ ] Disallowed transitions are rejected by name - [ ] Every change writes an activity row - [ ] Activity cannot be edited or deleted - [ ] Board and list agree on status ### Watch out - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. ## M3 · Versions where the approved bytes stay approved Every upload is a new version, the current one is unambiguous, and an approval points at a version. ### Steps 1. New key per version, never an overwrite The approved bytes have to stay retrievable. Files: `app/lib/storage.server.ts`, `tests/versions.test.ts` 2. Hold 'exactly one current' with a constraint Application code forgets under concurrency. Files: `prisma/migrations/` 3. Pin approvals to a version id An approval of an asset is an approval of nothing in particular. Files: `app/lib/approvals.server.ts` ### Done when - [ ] A replacement leaves the previous object retrievable - [ ] Exactly one current version is enforced by a constraint - [ ] An approval still resolves to the same bytes later - [ ] Storage keys are unguessable and workspace-bound ### Watch out - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. ## M4 · A review link that shows one thing A client opening a link sees the files it names and nothing else, ever. ### Steps 1. Serialise from an allow-list, field by field Hidden in the template means present in the response. Files: `app/lib/review-serialiser.server.ts`, `tests/review-leak.test.ts` 2. Hash the token, scope it to named versions The scope is data, not an assumption about the route. Files: `app/lib/review-links.server.ts` 3. Expire, revoke and rate limit A link that lives forever is a link that leaks eventually. Files: `app/routes/review.$token.tsx` ### Done when - [ ] No internal comment text appears in a review response - [ ] Only allow-listed fields are emitted - [ ] Expired and revoked links reveal nothing - [ ] Public write actions are rate limited ### Watch out - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. ## M5 · Leaving, and being able to (production only) A client's whole history can be exported, a workspace can be restored intact, and nothing disappears quietly. ### Steps 1. Export a client as data plus files An agency that cannot leave will not join. Files: `app/lib/export.server.ts`, `tests/export-roundtrip.test.ts` 2. Back up database and storage together Either alone restores something useless. Files: `ops/backup.sh`, `tests/restore.test.ts` 3. Archive by default, delete for real when asked And delete the objects, not just the rows. Files: `app/lib/archive.server.ts` ### Done when - [ ] A client export re-imports intact - [ ] A restored approved file matches its hash - [ ] Deletion removes stored objects - [ ] Notifications carry read state ### Watch out - Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair. ===== OPERATIONS.md ===== # Operations · Draftroom ## Backup The database and the object storage on the same schedule, treated as one unit. Either alone restores something that looks complete and is not: rows pointing at missing files, or files nobody can attribute to a client. ## Restore Restore both, then open an approved file version and compare its hash with the recorded one. That single check exercises the database, the storage and the link between them, which is the only part that actually matters. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Active review links and their ages, failed cross-tenant test runs in continuous integration, upload failures, rate limit rejections on the public review route, and storage growth. An old review link nobody revoked is the quiet risk. ## Incident checklist A suspected leak means revoking every active review link first and reading the link usage timestamps second. A disputed approval is answered by the version hash, which is why approvals point at versions. Neither is settled by memory. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Every route is confirmed to return not found for a foreign workspace id - [ ] No internal comment text is confirmed present in a review response - [ ] A replacement upload is confirmed to leave the previous object retrievable - [ ] An expired link is confirmed to reveal nothing - [ ] A restored approved file is confirmed to match its recorded hash ## Launch constraint Do not market omitted Draftroom capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Every table carries workspace_id, including the joinable ones. DATABASE_URL=postgres://user:redacted@localhost:5432/draftroom # Required. Hosted authentication for team members only. Clients get links. AUTH_URL=https://project.supabase.co # Required · secret. Server side only. It must never reach the browser bundle. AUTH_SERVICE_KEY=redacted # Required. Private. Keys are unguessable and one per version. STORAGE_BUCKET=draftroom-files # Required. A link with no expiry is a leak with a delay. REVIEW_LINK_DEFAULT_DAYS=14 # Required. Per token and per address on the public review route. REVIEW_WRITE_RATE_PER_HOUR=60 # Required. Enforced in the storage policy, not only in the browser. UPLOAD_MAX_BYTES=524288000 # Required. How long superseded versions are kept. Approvals point at them. VERSION_RETENTION_DAYS=1095
# Draftroom · indie build An agency workflow tool whose hard problems are tenant isolation that cannot be forgotten in a new route, review links that expose exactly one scope, and file versions where the approved bytes are still the approved bytes a year later. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Remix with TypeScript and Tailwind | server-rendered routes keep the authorisation decision on the server | | Data | PostgreSQL through Prisma, with workspace scope applied in one place | a scope you apply per route is a scope you will forget | | Identity | Hosted authentication for the team; clients get scoped links, not accounts | an account for every client is a permission model you will get wrong | | Files | Object storage with a new key per version and no guessable paths | an overwritten file makes a past approval meaningless | | Review | A server-side allow-list serialiser for the public review page | hiding a field in the template still ships it in the response | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application and the migrations. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: Tenant scope and the single-current-version rule are both database constraints, and constraints are the point. - Get it: A managed instance, or a container locally. - Verify: Migrations apply and the current-version constraint rejects a second current row - [ ] **A hosted authentication and object storage project** · free tier is ample for one agency - Why: Team login and file storage are the two pieces worth renting, and signed uploads are how files avoid your server. - Get it: Create a project, enable email authentication, and create a private storage bucket. - Verify: A team member can sign in and a signed upload lands in the bucket - [ ] **Two seeded client workspaces with overlapping names** · free - Why: The cross-tenant test is only convincing when two workspaces contain similarly named projects, which is exactly when a leak would be missed by eye. - Get it: Seed one demo agency with several clients, and a second workspace whose project names deliberately collide. - Verify: The cross-tenant suite runs against both - [ ] **Your link expiry, rate limits and retention** · free - Why: Each is enforced in code and asserted by a test, and a review link default of 'never expires' is a decision even when nobody made it. - Get it: Write down the default review link lifetime, the write rate limit per token and per address, and how long archived work and old file versions are kept. - Verify: All three are in the configuration and named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Any platform administrator or impersonation. - Frame-accurate video annotation. - Billing, time tracking and resource planning. - Single sign-on and white labelling. - Production-hardened real-time and notification behavior across many simultaneous users - Years of edge-case handling around client permissions, review links, files, revisions, and approvals - Polished mobile review and collaboration behavior that non-technical clients can use without training - Operational history, project context, and team habits already accumulated inside an existing Draftroom workspace - Enterprise features such as SSO, advanced reporting, white-labeling, onboarding, and support If one of those is essential to you, that is the reason to keep paying for Draftroom, and the README should say so rather than pretend.
# Build brief · Draftroom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a creative workflow where a client sees exactly one thing and internal comments stay internal to replace Draftroom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two leaks end this product and neither is fixable with an apology: one client seeing another client's work, and an internal comment appearing on a client review link. So workspace scope lives in the data layer, applied by something every query passes through, not by a filter somebody remembered to add in a route at six in the evening. And the review page is rendered from an explicit list of permitted fields, never by serialising a task and hiding the private parts in the template, because hidden in the template means present in the response. The corollary: both are proved by a suite that attacks them on every route, with a foreign workspace identifier and with an internal comment, before anything else counts as finished. A permission model nobody has attacked is a hope. ### Stack (fixed, do not substitute) - Runtime: Remix with TypeScript and Tailwind · server-rendered routes keep the authorisation decision on the server - Data: PostgreSQL through Prisma, with workspace scope applied in one place · a scope you apply per route is a scope you will forget - Identity: Hosted authentication for the team; clients get scoped links, not accounts · an account for every client is a permission model you will get wrong - Files: Object storage with a new key per version and no guessable paths · an overwritten file makes a past approval meaningless - Review: A server-side allow-list serialiser for the public review page · hiding a field in the template still ships it in the response ### Data model (create this before Phase 1) - workspaces(id, name, created_at) and memberships(user_id, workspace_id, role) · role is OWNER or MEMBER, and membership is the only route to any data - clients(id, workspace_id, name, archived_at) and projects(id, workspace_id, client_id, name, archived_at) - tasks(id, workspace_id, project_id, title, status, priority, start_date, due_date, delivery_date, created_at, updated_at) - file_assets(id, workspace_id, task_id, name) and file_versions(id, asset_id, ordinal, storage_key, bytes, sniffed_type, sha256, uploaded_by, uploaded_at, is_current) - comments(id, workspace_id, task_id, file_version_id, author_id, body, visibility) · visibility is INTERNAL or CLIENT_VISIBLE and defaults to INTERNAL - approvals(id, workspace_id, file_version_id, decision, decided_by, decided_via_link_id, comment, decided_at) · pinned to a version, never to an asset - review_links(id, workspace_id, token_hash, scope_json, permissions_json, expires_at, revoked_at, created_by, last_used_at) - Every table carries workspace_id, including the ones you could reach by a join, because the join you forget is the leak. ### Phase 1 · Tenancy you cannot forget Build: Put workspace_id on every table and apply the scope in one place that all data access passes through, derived from the session rather than from anything in the request. Write a test that walks every route with an identifier belonging to another workspace and asserts a not-found rather than a forbidden, so existence is not confirmed either. Trap: Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. Done when: A request for a resource in another workspace returns not found on every route, asserted by a test that enumerates routes rather than listing them by hand. No data access path bypasses the scoping layer, verified by review and by a test that fails if a raw query is added. The workspace comes from the session, never from a parameter or a header. Adding a route without scoping fails the suite. Do not build yet: No projects, files, comments or review links. ### Phase 2 · Work that moves through a stated flow Build: Clients, projects and tasks with a transition table for the status flow from backlog through to delivered. Reject any move the table does not allow, with the attempted move named. Write an append-only activity row for every create, edit, assignment and status change. Trap: Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. Done when: A disallowed transition is rejected server-side and names the attempted move, tested for every pair in the table. Every create, edit, assignment and status change writes an activity row, asserted by counting before and after a workflow. Activity rows are append-only, enforced by the schema. The board and the list view produce identical status results for the same task, tested. Do not build yet: No file versions, review links or notifications yet. ### Phase 3 · Versions where the approved bytes stay approved Build: Each upload writes a new object under a new unguessable key and a new version row. Exactly one version per asset is current, held by a constraint. Approvals record a version id. Uploads go direct to storage with a short-lived signed URL, and the stored type comes from the bytes. Trap: Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. Done when: Uploading a replacement leaves the previous object retrievable at its own key, tested by fetching it after the upload. Exactly one version per asset is current, enforced by a constraint rather than by application code, tested by trying to set two. An approval records a version id and still resolves to the same bytes after three more uploads, tested. Storage keys are unguessable and a key from one workspace cannot be read by another, tested. Do not build yet: No review links, notifications or export yet. ### Phase 4 · A review link that shows one thing Build: Generate a random token, store only its hash, and attach a scope naming the exact versions and the permissions: view, comment, decide. Render the review page from an allow-list serialiser that names every field it emits. Support expiry and revocation, and rate limit the write actions on the public route. Trap: Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. Done when: The review response body contains no internal comment text and no field outside the allow-list, asserted by scanning the serialised response in a test. A token is random, stored hashed, and an adjacent or altered token resolves to nothing. An expired or revoked link returns nothing and says so without revealing what it used to point at. Write actions on the public route are rate limited per token and per address, tested. Do not build yet: No notifications, export or deletion yet. ### Phase 5 · Leaving, and being able to Build: Export one client's projects, tasks, comments, file versions and approvals as JSON plus the files themselves. Back up the database and the object storage together and exercise a restore. Archive rather than delete by default, and make a real deletion remove the objects too. Notify on assignment, review request and decision, with read state. Trap: Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair. Done when: An export of one client re-imports into an empty workspace with tasks, versions, comments and approvals intact, tested. A restore from backup can open an approved file and its bytes match the recorded hash, tested rather than assumed. Archiving hides a client without deleting rows; a real deletion removes the stored objects too, verified by fetching a key afterwards. Notifications carry read state and a decision notifies the assignee within one cycle. Do not build yet: Nothing after this. ### Out of scope (and why) - Any platform-wide administrator, impersonation or global user management. There is no super-admin route, role or middleware, because the only thing it adds is a way for one mistake to reach every workspace. - Frame-accurate video annotation, which is a specialist product and doing it badly is worse than linking to one that does it properly. - Billing, time tracking, resource planning and Gantt charts, each of which is a separate product wearing this one as a hat. - Single sign-on, white labelling and advanced reporting, which are the enterprise tier rather than the workflow. ### README must contain - That workspace scope is applied in one place every query passes through, and that a cross-tenant test enumerates routes automatically - That the review page is serialised from an explicit allow-list, because a field hidden in a template is still in the response - That every upload is a new version under a new key and approvals are pinned to a version id - That there is deliberately no platform administrator, impersonation or global management of any kind - That backups cover the database and object storage together, and that the restore test opens an approved file
# Agent instructions · Draftroom indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Remix with TypeScript and Tailwind, PostgreSQL through Prisma, with workspace scope applied in one place, Hosted authentication for the team; clients get scoped links, not accounts, Object storage with a new key per version and no guessable paths, A server-side allow-list serialiser for the public review page. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text.
# Build plan · Draftroom An agency workflow tool whose hard problems are tenant isolation that cannot be forgotten in a new route, review links that expose exactly one scope, and file versions where the approved bytes are still the approved bytes a year later. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Tenancy you cannot forget Every query is workspace-scoped by construction, and a suite proves it on every route. ### Steps 1. Put the scope in one place Derived from the session, applied by construction. Files: `app/db/scope.server.ts`, `prisma/schema.prisma` 2. Enumerate routes in the cross-tenant test A hand-written list goes stale the first busy week. Files: `tests/cross-tenant.test.ts` 3. Return not found, not forbidden Forbidden confirms the thing exists, which is itself a leak. Files: `app/lib/errors.server.ts` ### Done when - [ ] Every route returns not found for a foreign id - [ ] No path bypasses the scoping layer - [ ] Workspace comes from the session only - [ ] An unscoped new route fails the suite ### Watch out - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. ## Phase 2 · Work that moves through a stated flow A task cannot reach delivered without passing the states in between, and every move is recorded. ### Steps 1. Put allowed transitions in a table The server decides, whatever the board allows you to drag. Files: `app/lib/workflow.server.ts`, `tests/transitions.test.ts` 2. Write activity on every change The timeline is what answers 'who moved this and when'. Files: `app/lib/activity.server.ts` 3. Make activity append-only An editable audit trail is decoration. Files: `prisma/schema.prisma` ### Done when - [ ] Disallowed transitions are rejected by name - [ ] Every change writes an activity row - [ ] Activity cannot be edited or deleted - [ ] Board and list agree on status ### Watch out - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. ## Phase 3 · Versions where the approved bytes stay approved Every upload is a new version, the current one is unambiguous, and an approval points at a version. ### Steps 1. New key per version, never an overwrite The approved bytes have to stay retrievable. Files: `app/lib/storage.server.ts`, `tests/versions.test.ts` 2. Hold 'exactly one current' with a constraint Application code forgets under concurrency. Files: `prisma/migrations/` 3. Pin approvals to a version id An approval of an asset is an approval of nothing in particular. Files: `app/lib/approvals.server.ts` ### Done when - [ ] A replacement leaves the previous object retrievable - [ ] Exactly one current version is enforced by a constraint - [ ] An approval still resolves to the same bytes later - [ ] Storage keys are unguessable and workspace-bound ### Watch out - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. ## Phase 4 · A review link that shows one thing A client opening a link sees the files it names and nothing else, ever. ### Steps 1. Serialise from an allow-list, field by field Hidden in the template means present in the response. Files: `app/lib/review-serialiser.server.ts`, `tests/review-leak.test.ts` 2. Hash the token, scope it to named versions The scope is data, not an assumption about the route. Files: `app/lib/review-links.server.ts` 3. Expire, revoke and rate limit A link that lives forever is a link that leaks eventually. Files: `app/routes/review.$token.tsx` ### Done when - [ ] No internal comment text appears in a review response - [ ] Only allow-listed fields are emitted - [ ] Expired and revoked links reveal nothing - [ ] Public write actions are rate limited ### Watch out - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. ## Not in this build - Any platform administrator or impersonation. - Frame-accurate video annotation. - Billing, time tracking and resource planning. - Single sign-on and white labelling. ## After v1, if you want it - A content calendar over the same tasks, which is a view rather than a new model - Client-visible comment threads with email replies, once the allow-list serialiser has held for a month - Per-client branding on the review page, drawn from data rather than from a separate template
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Every table carries workspace_id, including the joinable ones. DATABASE_URL=postgres://user:redacted@localhost:5432/draftroom # Required. Hosted authentication for team members only. Clients get links. AUTH_URL=https://project.supabase.co # Required · secret. Server side only. It must never reach the browser bundle. AUTH_SERVICE_KEY=redacted # Required. Private. Keys are unguessable and one per version. STORAGE_BUCKET=draftroom-files # Required. A link with no expiry is a leak with a delay. REVIEW_LINK_DEFAULT_DAYS=14 # Required. Per token and per address on the public review route. REVIEW_WRITE_RATE_PER_HOUR=60 # Required. Enforced in the storage policy, not only in the browser. UPLOAD_MAX_BYTES=524288000 # Required. How long superseded versions are kept. Approvals point at them. VERSION_RETENTION_DAYS=1095
# Draftroom · product brief ## Problem A competent coding agent can rebuild Draftroom's core agency workflow in a weekend: clients, projects, tasks, file versions, contextual feedback, review queues, approvals, client review links, delivery tracking, and workspace permissions. The gap is not the CRUD screens; it is production-grade collaboration, notification reliability, permission edge cases, file handling, mobile review polish, and the accumulated project history that makes a shared agency system dependable every day. ## Product outcome A workflow an agency and its clients can both live in: no client ever sees another's work, no internal comment reaches a review link, every approval still points at the exact bytes that were approved, and a client's whole history exports on request. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Node.js - Supabase project with PostgreSQL, Auth, and Storage - Environment variables for Supabase and database credentials ## Explicit non-goals for v1 - Any platform administrator or impersonation. - Frame-accurate video annotation. - Billing, time tracking and resource planning. - Single sign-on and white labelling. - Production-hardened real-time and notification behavior across many simultaneous users - Years of edge-case handling around client permissions, review links, files, revisions, and approvals - Polished mobile review and collaboration behavior that non-technical clients can use without training - Operational history, project context, and team habits already accumulated inside an existing Draftroom workspace - Enterprise features such as SSO, advanced reporting, white-labeling, onboarding, and support ## Success criteria - Every route is confirmed to return not found for a foreign workspace id - No internal comment text is confirmed present in a review response - A replacement upload is confirmed to leave the previous object retrievable - An expired link is confirmed to reveal nothing - A restored approved file is confirmed to match its recorded hash
# Build brief · Draftroom The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a creative workflow where a client sees exactly one thing and internal comments stay internal to replace Draftroom. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two leaks end this product and neither is fixable with an apology: one client seeing another client's work, and an internal comment appearing on a client review link. So workspace scope lives in the data layer, applied by something every query passes through, not by a filter somebody remembered to add in a route at six in the evening. And the review page is rendered from an explicit list of permitted fields, never by serialising a task and hiding the private parts in the template, because hidden in the template means present in the response. The corollary: both are proved by a suite that attacks them on every route, with a foreign workspace identifier and with an internal comment, before anything else counts as finished. A permission model nobody has attacked is a hope. ### Stack (fixed, do not substitute) - Runtime: Remix with TypeScript and Tailwind · server-rendered routes keep the authorisation decision on the server - Data: PostgreSQL through Prisma, with workspace scope applied in one place · a scope you apply per route is a scope you will forget - Identity: Hosted authentication for the team; clients get scoped links, not accounts · an account for every client is a permission model you will get wrong - Files: Object storage with a new key per version and no guessable paths · an overwritten file makes a past approval meaningless - Review: A server-side allow-list serialiser for the public review page · hiding a field in the template still ships it in the response ### Data model (create this before Phase 1) - workspaces(id, name, created_at) and memberships(user_id, workspace_id, role) · role is OWNER or MEMBER, and membership is the only route to any data - clients(id, workspace_id, name, archived_at) and projects(id, workspace_id, client_id, name, archived_at) - tasks(id, workspace_id, project_id, title, status, priority, start_date, due_date, delivery_date, created_at, updated_at) - file_assets(id, workspace_id, task_id, name) and file_versions(id, asset_id, ordinal, storage_key, bytes, sniffed_type, sha256, uploaded_by, uploaded_at, is_current) - comments(id, workspace_id, task_id, file_version_id, author_id, body, visibility) · visibility is INTERNAL or CLIENT_VISIBLE and defaults to INTERNAL - approvals(id, workspace_id, file_version_id, decision, decided_by, decided_via_link_id, comment, decided_at) · pinned to a version, never to an asset - review_links(id, workspace_id, token_hash, scope_json, permissions_json, expires_at, revoked_at, created_by, last_used_at) - Every table carries workspace_id, including the ones you could reach by a join, because the join you forget is the leak. ### Phase 1 · Tenancy you cannot forget Build: Put workspace_id on every table and apply the scope in one place that all data access passes through, derived from the session rather than from anything in the request. Write a test that walks every route with an identifier belonging to another workspace and asserts a not-found rather than a forbidden, so existence is not confirmed either. Trap: Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. Done when: A request for a resource in another workspace returns not found on every route, asserted by a test that enumerates routes rather than listing them by hand. No data access path bypasses the scoping layer, verified by review and by a test that fails if a raw query is added. The workspace comes from the session, never from a parameter or a header. Adding a route without scoping fails the suite. Do not build yet: No projects, files, comments or review links. ### Phase 2 · Work that moves through a stated flow Build: Clients, projects and tasks with a transition table for the status flow from backlog through to delivered. Reject any move the table does not allow, with the attempted move named. Write an append-only activity row for every create, edit, assignment and status change. Trap: Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. Done when: A disallowed transition is rejected server-side and names the attempted move, tested for every pair in the table. Every create, edit, assignment and status change writes an activity row, asserted by counting before and after a workflow. Activity rows are append-only, enforced by the schema. The board and the list view produce identical status results for the same task, tested. Do not build yet: No file versions, review links or notifications yet. ### Phase 3 · Versions where the approved bytes stay approved Build: Each upload writes a new object under a new unguessable key and a new version row. Exactly one version per asset is current, held by a constraint. Approvals record a version id. Uploads go direct to storage with a short-lived signed URL, and the stored type comes from the bytes. Trap: Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. Done when: Uploading a replacement leaves the previous object retrievable at its own key, tested by fetching it after the upload. Exactly one version per asset is current, enforced by a constraint rather than by application code, tested by trying to set two. An approval records a version id and still resolves to the same bytes after three more uploads, tested. Storage keys are unguessable and a key from one workspace cannot be read by another, tested. Do not build yet: No review links, notifications or export yet. ### Phase 4 · A review link that shows one thing Build: Generate a random token, store only its hash, and attach a scope naming the exact versions and the permissions: view, comment, decide. Render the review page from an allow-list serialiser that names every field it emits. Support expiry and revocation, and rate limit the write actions on the public route. Trap: Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. Done when: The review response body contains no internal comment text and no field outside the allow-list, asserted by scanning the serialised response in a test. A token is random, stored hashed, and an adjacent or altered token resolves to nothing. An expired or revoked link returns nothing and says so without revealing what it used to point at. Write actions on the public route are rate limited per token and per address, tested. Do not build yet: No notifications, export or deletion yet. ### Phase 5 · Leaving, and being able to Build: Export one client's projects, tasks, comments, file versions and approvals as JSON plus the files themselves. Back up the database and the object storage together and exercise a restore. Archive rather than delete by default, and make a real deletion remove the objects too. Notify on assignment, review request and decision, with read state. Trap: Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair. Done when: An export of one client re-imports into an empty workspace with tasks, versions, comments and approvals intact, tested. A restore from backup can open an approved file and its bytes match the recorded hash, tested rather than assumed. Archiving hides a client without deleting rows; a real deletion removes the stored objects too, verified by fetching a key afterwards. Notifications carry read state and a decision notifies the assignee within one cycle. Do not build yet: Nothing after this. ### Out of scope (and why) - Any platform-wide administrator, impersonation or global user management. There is no super-admin route, role or middleware, because the only thing it adds is a way for one mistake to reach every workspace. - Frame-accurate video annotation, which is a specialist product and doing it badly is worse than linking to one that does it properly. - Billing, time tracking, resource planning and Gantt charts, each of which is a separate product wearing this one as a hat. - Single sign-on, white labelling and advanced reporting, which are the enterprise tier rather than the workflow. ### README must contain - That workspace scope is applied in one place every query passes through, and that a cross-tenant test enumerates routes automatically - That the review page is serialised from an explicit allow-list, because a field hidden in a template is still in the response - That every upload is a new version under a new key and approvals are pinned to a version id - That there is deliberately no platform administrator, impersonation or global management of any kind - That backups cover the database and object storage together, and that the restore test opens an approved file
# Architecture · Draftroom ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Remix with TypeScript and Tailwind | server-rendered routes keep the authorisation decision on the server | | Data | PostgreSQL through Prisma, with workspace scope applied in one place | a scope you apply per route is a scope you will forget | | Identity | Hosted authentication for the team; clients get scoped links, not accounts | an account for every client is a permission model you will get wrong | | Files | Object storage with a new key per version and no guessable paths | an overwritten file makes a past approval meaningless | | Review | A server-side allow-list serialiser for the public review page | hiding a field in the template still ships it in the response | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Tenancy | Workspace scope applied by construction. | Not swappable for per-route filtering, which is the leak. | | Workflow | Transitions and append-only activity. | Any board, provided the server still decides the transition. | | Versions | One object per version and the current-version constraint. | Any storage, provided nothing is ever overwritten. | | Review | Hashed tokens, scopes and the allow-list serialiser. | Any renderer that emits only allow-listed fields. | | Portability | Client export and paired backups. | Any format, provided the restore test opens an approved file. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Every table carries workspace_id, including the joinable ones. - `AUTH_URL` · required · Hosted authentication for team members only. Clients get links. - `AUTH_SERVICE_KEY` · required, secret · Server side only. It must never reach the browser bundle. - `STORAGE_BUCKET` · required · Private. Keys are unguessable and one per version. - `REVIEW_LINK_DEFAULT_DAYS` · required · A link with no expiry is a leak with a delay. - `REVIEW_WRITE_RATE_PER_HOUR` · required · Per token and per address on the public review route. - `UPLOAD_MAX_BYTES` · required · Enforced in the storage policy, not only in the browser. - `VERSION_RETENTION_DAYS` · required · How long superseded versions are kept. Approvals point at them. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Draftroom product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Remix with TypeScript and Tailwind, PostgreSQL through Prisma, with workspace scope applied in one place, Hosted authentication for the team; clients get scoped links, not accounts, Object storage with a new key per version and no guessable paths, A server-side allow-list serialiser for the public review page. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. - Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair.
# Delivery milestones · Draftroom Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Tenancy you cannot forget Every query is workspace-scoped by construction, and a suite proves it on every route. ### Steps 1. Put the scope in one place Derived from the session, applied by construction. Files: `app/db/scope.server.ts`, `prisma/schema.prisma` 2. Enumerate routes in the cross-tenant test A hand-written list goes stale the first busy week. Files: `tests/cross-tenant.test.ts` 3. Return not found, not forbidden Forbidden confirms the thing exists, which is itself a leak. Files: `app/lib/errors.server.ts` ### Done when - [ ] Every route returns not found for a foreign id - [ ] No path bypasses the scoping layer - [ ] Workspace comes from the session only - [ ] An unscoped new route fails the suite ### Watch out - Scoping by adding a workspace condition to the where clause in each route works until somebody adds a route in a hurry, and then there is no error and no alarm, just another agency's campaign on a page. Put the scope somewhere every query must pass, and make the cross-tenant test enumerate routes automatically so a new one is covered the day it is written. ## M2 · Work that moves through a stated flow A task cannot reach delivered without passing the states in between, and every move is recorded. ### Steps 1. Put allowed transitions in a table The server decides, whatever the board allows you to drag. Files: `app/lib/workflow.server.ts`, `tests/transitions.test.ts` 2. Write activity on every change The timeline is what answers 'who moved this and when'. Files: `app/lib/activity.server.ts` 3. Make activity append-only An editable audit trail is decoration. Files: `prisma/schema.prisma` ### Done when - [ ] Disallowed transitions are rejected by name - [ ] Every change writes an activity row - [ ] Activity cannot be edited or deleted - [ ] Board and list agree on status ### Watch out - Letting the board set any status because the drag-and-drop library makes it easy means work arrives at delivered without ever having been approved, and nobody can reconstruct whether it was reviewed or simply dragged. Put the allowed transitions in a table the server checks, and record every move with who made it. ## M3 · Versions where the approved bytes stay approved Every upload is a new version, the current one is unambiguous, and an approval points at a version. ### Steps 1. New key per version, never an overwrite The approved bytes have to stay retrievable. Files: `app/lib/storage.server.ts`, `tests/versions.test.ts` 2. Hold 'exactly one current' with a constraint Application code forgets under concurrency. Files: `prisma/migrations/` 3. Pin approvals to a version id An approval of an asset is an approval of nothing in particular. Files: `app/lib/approvals.server.ts` ### Done when - [ ] A replacement leaves the previous object retrievable - [ ] Exactly one current version is enforced by a constraint - [ ] An approval still resolves to the same bytes later - [ ] Storage keys are unguessable and workspace-bound ### Watch out - Replacing a file by writing over the same storage key means yesterday's approval now refers to today's bytes, so the client approved something nobody can produce and the dispute is unwinnable. Write a new key per version, never overwrite, and pin every approval to a version id rather than to the asset. ## M4 · A review link that shows one thing A client opening a link sees the files it names and nothing else, ever. ### Steps 1. Serialise from an allow-list, field by field Hidden in the template means present in the response. Files: `app/lib/review-serialiser.server.ts`, `tests/review-leak.test.ts` 2. Hash the token, scope it to named versions The scope is data, not an assumption about the route. Files: `app/lib/review-links.server.ts` 3. Expire, revoke and rate limit A link that lives forever is a link that leaks eventually. Files: `app/routes/review.$token.tsx` ### Done when - [ ] No internal comment text appears in a review response - [ ] Only allow-listed fields are emitted - [ ] Expired and revoked links reveal nothing - [ ] Public write actions are rate limited ### Watch out - Building the review page by passing the task object to the template and hiding the internal comments with a condition in the markup ships every internal comment in the response payload, where anyone can read it with the browser's developer tools; the same mistake with a nested project object exposes the client's other work. Serialise from an explicit list of fields on the server, and assert in a test that the response body contains no internal comment text. ## M5 · Leaving, and being able to (production only) A client's whole history can be exported, a workspace can be restored intact, and nothing disappears quietly. ### Steps 1. Export a client as data plus files An agency that cannot leave will not join. Files: `app/lib/export.server.ts`, `tests/export-roundtrip.test.ts` 2. Back up database and storage together Either alone restores something useless. Files: `ops/backup.sh`, `tests/restore.test.ts` 3. Archive by default, delete for real when asked And delete the objects, not just the rows. Files: `app/lib/archive.server.ts` ### Done when - [ ] A client export re-imports intact - [ ] A restored approved file matches its hash - [ ] Deletion removes stored objects - [ ] Notifications carry read state ### Watch out - Backing up the database on a schedule and leaving object storage to its own devices produces a restore in which every approval, comment and version row is intact and every file is a broken link, which is the same as having lost the work. Back up both together, and prove the restore by opening an approved file from the restored pair.
# Operations · Draftroom ## Backup The database and the object storage on the same schedule, treated as one unit. Either alone restores something that looks complete and is not: rows pointing at missing files, or files nobody can attribute to a client. ## Restore Restore both, then open an approved file version and compare its hash with the recorded one. That single check exercises the database, the storage and the link between them, which is the only part that actually matters. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Active review links and their ages, failed cross-tenant test runs in continuous integration, upload failures, rate limit rejections on the public review route, and storage growth. An old review link nobody revoked is the quiet risk. ## Incident checklist A suspected leak means revoking every active review link first and reading the link usage timestamps second. A disputed approval is answered by the version hash, which is why approvals point at versions. Neither is settled by memory. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Every route is confirmed to return not found for a foreign workspace id - [ ] No internal comment text is confirmed present in a review response - [ ] A replacement upload is confirmed to leave the previous object retrievable - [ ] An expired link is confirmed to reveal nothing - [ ] A restored approved file is confirmed to match its recorded hash ## Launch constraint Do not market omitted Draftroom capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Every table carries workspace_id, including the joinable ones. DATABASE_URL=postgres://user:redacted@localhost:5432/draftroom # Required. Hosted authentication for team members only. Clients get links. AUTH_URL=https://project.supabase.co # Required · secret. Server side only. It must never reach the browser bundle. AUTH_SERVICE_KEY=redacted # Required. Private. Keys are unguessable and one per version. STORAGE_BUCKET=draftroom-files # Required. A link with no expiry is a leak with a delay. REVIEW_LINK_DEFAULT_DAYS=14 # Required. Per token and per address on the public review route. REVIEW_WRITE_RATE_PER_HOUR=60 # Required. Enforced in the storage policy, not only in the browser. UPLOAD_MAX_BYTES=524288000 # Required. How long superseded versions are kept. Approvals point at them. VERSION_RETENTION_DAYS=1095
$ choose a build depth, inspect the files, then open the complete pack in your agent
The basic mechanics are straightforward to recreate, but an agency does not pay for a Kanban board in isolation. It pays for a shared workflow that designers, account managers, founders, freelancers, and clients can trust every day. Reliable permissions, version history, review links, approval state, notifications, mobile behavior, file handling, and the cost of moving active client work make the hosted product more valuable than a weekend clone once the whole team depends on it.
xProduction-hardened real-time and notification behavior across many simultaneous users
xYears of edge-case handling around client permissions, review links, files, revisions, and approvals
xPolished mobile review and collaboration behavior that non-technical clients can use without training
xOperational history, project context, and team habits already accumulated inside an existing Draftroom workspace
xEnterprise features such as SSO, advanced reporting, white-labeling, onboarding, and support
Nothing worth pointing at. That's why the prompt exists.
Draftroom pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $99/workspace | — | Up to 20 active clients or brand projects; unlimited internal team members; 100GB storage |
| growth | $149/workspace | — | Up to 50 active clients or brand projects; unlimited team members; 1TB storage |
| scale | $299/workspace | — | Unlimited active clients or brand projects; unlimited team members; 2TB storage |
| enterprise | custom | — | Custom limits. |
free tierno public free tier
billingMonthly prices are published; annual pricing is not publicly specified.
verified 2026-08-15 · source ↗
Is Draftroom free?
No public free tier is currently advertised; Draftroom is presented as an early-access product. Paid is Starter at $99/mo (checked 2026-08-15).
Vibecode Draftroom
Kinda. The core of Draftroom is buildable in a weekend with the prompt on this page, but there are real gaps: Production-hardened real-time and notification behavior across many simultaneous users, Years of edge-case handling around client permissions, review links, files, revisions, and approvals. Read the honest list above before committing.
How much does Draftroom cost?
Draftroom costs about $99/month (Starter, checked 2026-08-15), which is $1188 per year.
What do I lose by replacing Draftroom?
Honestly: Production-hardened real-time and notification behavior across many simultaneous users; Years of edge-case handling around client permissions, review links, files, revisions, and approvals; Polished mobile review and collaboration behavior that non-technical clients can use without training; Operational history, project context, and team habits already accumulated inside an existing Draftroom workspace; Enterprise features such as SSO, advanced reporting, white-labeling, onboarding, and support. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Draftroom?
No mature open-source alternative worth pointing at, which is exactly why the one-shot prompt on this page exists.