# Build a private client portal for projects and document exchange
> Build and check a restricted client portal with persistent projects, status history, named membership and two-way reviewed documents.
By BIGCHANGE.AI Editorial
Published: 2026-10-22T17:35:39.528Z
Updated: 2026-10-22T19:08:20.425Z
Canonical: https://bigchange.ai/blog/private-client-portal-project-document-exchange-guide

AI-generated conceptual illustration by BIG CHANGE.
A service business can use Replit Agent to build a client portal that keeps projects, status history and reviewed documents behind named access. The hard part is the boundary around each client's records and files. This guide gives Agent a complete app brief, PostgreSQL schema and server route contract, then sets out the checks the owner must observe before inviting a real client. It is based on current Replit and Clerk documentation checked on 22 October 2026. BIG CHANGE did not build the app, open an account, upload a file, run the race checks or release a portal.
## The big change
- **What changed:** Replit documents Agent planning, managed Clerk sign-in, persistent SQL and private App Storage as parts of an app build. Its Invite only publishing option can restrict host access to named Replit users.
- **Why it matters:** Those components can support a portal with owner-managed client membership, project history and two-way document review. The server must still check every project and file request against the signed-in user and current SQL membership.
- **What to watch:** A private bucket and an Invite only URL do not establish which client may read a file. Real-client use depends on observed access tests, separate production data bindings, acceptable host invitation rights and the owner's consent and retention rules.
The intended reader owns a service business and has authority to invite named clients and process their project records and small files. The completion test is specific: at the checked restricted URL, an invited signed-out visitor can load the document-free landing and sign-in flow; a permitted client then reopens its project and a shared file after reload, while a different client and an inactive member are denied those private records. An app screen, successful sign-in or Agent's completion message does not settle that test.
This is a new whole-app task. Our [feature feedback board guide](https://bigchange.ai/blog/replit-agent-feature-feedback-board-guide) covers requests and voting; the [existing React app sign-in guide](https://bigchange.ai/blog/react-supabase-email-auth-protected-data-guide) adds protected data to an app already built. The [private team scoring guide](https://bigchange.ai/blog/private-ai-content-scoring-app-rubric-human-review-guide) handles a team's text estimates and human review. Here, each client needs its own projects, dated status changes and approved file exchange.
## 1. Check the account and the data decision
Obtain permission to put the actual client records on the selected services. Check the business's contractual and legal requirements for location, retention, export, deletion, malware scanning and access auditing before handling real documents. The reference below accepts files up to 5 MiB and only PDF, PNG or plain text. Checking extension, declared MIME type and byte signature is a type screen, not malware certification. If the real files need controls this app has not implemented, hold the whole portal for real-client use.
Inspect the intended Replit account for Agent, managed Clerk Auth, SQL, App Storage, the ability to create two buckets and Invite only publishing. Review current terms and charges there. Replit's App Storage billing documentation describes storage and operation charges, including a seven-day minimum for stored objects; this guide supplies no measured bill or fixed project price. Ask a prospective client whether two identities are acceptable: the host invitation requires their Replit sign-in, and the app requires a separately verified Clerk identity plus SQL membership. A workspace administrator can retain host access without having portal membership. If the invitation exposes more workspace or project access than the owner accepts, stop before real-client release.
Choose a retention and exit procedure before uploading client material. The cited App Storage documentation does not offer object versioning, lifecycle rules or a retention policy. In this design, **Withdraw** hides a shared file from clients while retaining its bytes and owner history; **Delete permanently** is a separate owner action with a settlement guard and exact-key storage readback. Neither withdrawal nor membership revocation proves provider-side erasure.
## 2. Give Agent the complete build contract
Start Agent in Plan mode with Appendix A as the prompt, Appendix B as the proposed SQL migration and Appendix C as the API and acceptance contract. Replace only the business-specific placeholders with approved values. Keep credentials and real client material out of the planning prompt. Review Agent's plan before generation, then inspect the produced server middleware, SQL predicates, file handling, UI and locked dependency versions against all three appendices. The reference selects a supported Node.js Active or Maintenance LTS runtime, Express, React, managed Clerk, Replit PostgreSQL and the official App Storage JavaScript SDK. Check the actual Replit runtime and locked dependencies for compatibility; hold the build if a supported LTS cannot run there. Node.js lists 24 and 22 as LTS and 20 as end of life as of 22 October 2026 in its [release schedule](https://nodejs.org/en/about/previous-releases). This does not establish which runtime the reader's Replit account supports.
The first owner membership is seeded through authenticated database administration with the exact verified Clerk user ID in that environment. There is no public bootstrap or client self-promotion route. The owner creates organizations and projects, records status history, grants and disables exact named client memberships, and reviews files. A client sees only an active assigned organization's projects, its own pending uploads and files the owner shared. Every private app route applies the server predicate again; an old page and a guessed document ID confer no rights. The document-free landing, sign-in flow and exact generated auth transport are reachable at the app layer without portal membership, subject to the separate Invite only host gate.
## 3. Build and test in Development with synthetic records
Create distinct Development and intended Production App Storage buckets in the same Replit project. Read each bucket ID from the account's App Storage settings, bind the Development ID only in server configuration and verify SDK selection with a harmless canary. Replit says a bucket can be used by both environments, so publishing alone does not isolate objects. Inspect the actual Development and Production SQL databases as well; do not assume one copies into the other. If separate bucket or database binding cannot be established, hold real-client use.
Run Appendix B against the intended Development database after reviewing it. Use two synthetic organizations, separate test clients and harmless permitted PDF, PNG and TXT files. Create a project and status change as owner. Sign in as client A, reload, and check its record survives. Sign in as B and request A's IDs directly through the API; B must receive no project details or file bytes. Disable A's membership and repeat A's saved requests. Check that the landing, sign-in and generated auth transport work before testing both permitted and denied behavior for every private project, status, membership, upload, review, history and download route in Appendix C.
The file checks require more care than a happy-path upload. Upload a client document and confirm that only its uploader sees the pending file; approve it and confirm the intended organization can download the exact bytes. Withdraw it and confirm a saved URL is denied while the owner still reads the ready bytes and history. Restore it only through the owner's explicit reviewed action. Try the separate permanent deletion and confirm that the app hides the bytes before storage deletion; a `deleted` tombstone requires confirmed exact-key absence after the upload writer has settled.
With harmless synthetic bytes, hold an upload before its storage result and try Delete permanently. It must return 409 without a tombstone. Let that one upload finish and only then try a fresh deletion. Also test a lost upload response, lost delete response, concurrent review actions, a raw path-like filename, wrong type or size, cross-origin mutation, wrong bucket and checksum mismatch. A single `exists=false` while an upload might still finish cannot justify deletion. Record actual HTTP responses, SQL states and exact-key storage readbacks. Repair a failure and repeat its allowed and denied checks. Appendix C gives the full matrix and conservative manual recovery path; none of these tests was performed by BIG CHANGE.
## 4. Inspect the restricted release, then verify a real client
The owner checks the exact generated revision, permissions, locked dependencies, host sharing, managed production Clerk identity, distinct production SQL database and bucket ID, secret binding, charges and retention plan. Publish only if effective Invite only sharing is available and its rights are acceptable. Replit's host invitation is the outer access gate. Within that gate, the document-free landing and sign-in can load for a signed-out visitor; Clerk identity and current SQL membership gate private app records and actions. Seed the first owner in the Production database using the verified **production** Clerk ID. Confirm the production bucket canary, then rerun the synthetic allowed and denied matrix at the published URL. Keep the release restricted if a check fails.
Only after that readback and the client's permission should the owner invite one named real client through the actual host and Clerk paths, verify its exact identity, grant its intended organization membership and exchange one approved small file. Reopen the client project and shared file after reload. Try the corresponding record and file requests from another client and an inactive membership, and record the observed denial. If the app cannot pass that whole test, its real-client release remains on hold. No account, live URL, upload, race result or client outcome is claimed here.
## Recovery and limits
For an access failure, identify the exact host invitation, Clerk environment, verified user ID, active SQL membership and joined project before changing a grant. For a wrong file or storage error, identify the document row and configured bucket without putting its key or contents in the browser or ordinary logs. An uncertain upload remains hidden in `uploading` with no settlement time; do not retry it blindly or convert it to a deletable failure. A deletion with uncertain SDK result stays hidden in `deleting` until the owner confirms no writer can complete and reads the exact key. If a late writer violates the state invariant, keep the file hidden, reconcile that key and investigate before expanding access. The contract's `503` response is a stop for a bad byte read, not an empty download.
This is a documentation-only reference design. It makes no measured claim about security, durability, malware safety, storage location, charges, performance or successful publication. Its purpose is to make the owner and generated app demonstrate the complete portal outcome before real records are entrusted to it.
## Appendix A: Copyable Plan-mode app brief
This is a documentation-only reference for a reader to build and test. It is not an observed Replit app or a security certification. The reader supplies their own permitted project/status text and client files, controls the account, inspects generated code and costs, and obtains the client's consent to use the chosen services. Use synthetic client names and harmless test files first. A real client gets access only after the owner has checked the exact deployed revision and the account's terms, permissions and data-handling needs. Stop if sensitive files require malware scanning, contractual retention, regional storage, audited access, or a security assessment that this small app has not implemented.
## Give Agent this whole brief in Plan mode
Build one private, persistent client portal for my service business. Use a supported Node.js Active or Maintenance LTS runtime (for example Node 24 only if available in this Replit account and compatible with the locked dependencies), an Express server and React UI. Check the installed runtime and dependencies before release; HOLD if no supported LTS can run this app. Use Replit-managed Clerk Auth, Replit SQL PostgreSQL, and Replit App Storage through the official `@replit/object-storage` JavaScript SDK. Implement Appendix B SQL and Appendix C API/storage contract exactly. Do not invent seeded customers, projects, upload results, security checks, pricing or a deployed URL. Do not create public file URLs, signed URLs, a static uploads directory, client-supplied storage paths, demo bypasses, anonymous signup-to-client access, or an endpoint that returns all tenants' data. Use parameterized SQL and server-side checks on every route. Client-side filtering is only presentation.
The owner creates client organizations and projects, sets project statuses, adds or revokes named Clerk users to each organization, and reviews client-uploaded documents. Staff may read assigned organizations only if the owner assigns them explicitly; for the first version, omit staff and use just owner/client roles. A client can see only its assigned organization's projects and status history, download owner-shared files, upload a file to one of its projects, and see its own pending upload. The owner sees all projects/files, can approve or reject a client upload, share an owner file, withdraw a shared file without destroying it, restore a withdrawn file after review, and separately request irreversible byte deletion. Rejected or withdrawn files are invisible to clients. No messaging, billing, AI inference, email or automation is part of this app. The AI is Agent helping build the app; the portal itself makes no model calls.
The owner must seed the **first owner membership** using authenticated database administration after obtaining the exact verified Clerk user ID. No public bootstrap endpoint. The owner may grant a client membership only after a verified invitation and explicit organization selection. The server ignores user IDs, roles, organization IDs, uploader identities and file keys supplied as claims in browser requests; it derives those from a verified session and its database rows. A disabled membership loses private app record and action access on the next request, even with an existing Clerk session; the landing and provider sign-in transport remain available within the host gate. Revoking one organization membership must not revoke another. A client must never be able to create an organization, select a different client on a project, change status, grant access, approve a document or download a file by guessing an ID.
The route and storage contract covers the actual working upload/download path. Configure an enforceable 5 MiB file limit, with separately bounded multipart fields and overhead, so a valid maximum-size file is accepted without unbounded memory use. Accept only `.pdf`, `.png` and `.txt` after extension, declared type and byte-signature/strict UTF-8 checks; zero-byte files are rejected. Such checks do not certify content safe. Always download through the authenticated Express route as `attachment`, `application/octet-stream`, `nosniff` and `Cache-Control: no-store`; do not render uploads inline as HTML or PDF. Client documents remain pending until owner approval; the uploading client can see/download only its own pending file, and other client members cannot. A file first becomes client-shared through the server-controlled visibility transition. See the exact errors and recovery in Appendix C.
Use two deliberately different App Storage bucket IDs for Development and Production, each created in the **same** Replit project, with a server-only per-environment binding. Replit says a bucket can be used by both environments; merely publishing does not isolate it. The server initializes the SDK with the configured bucket ID and fails closed if it is missing. The owner reads the IDs from the App Storage Settings UI, checks which environment is running, inspects the installed SDK version and confirms a harmless canary stays in the intended bucket before any real upload. A published app must never bind to the development bucket. No bucket ID, secret, service credential or raw object key goes to the browser or logs. No use of bucket listing for authorization. If the actual account cannot create/bind separate buckets, keep this portal on HOLD for real client files.
Private bucket is a platform boundary, not a client authorization system. Replit documents that buckets are private, but any app route or signed URL that serves an object can expose it; this design issues no signed URLs. **Withdrawal** is a reversible SQL visibility change on an already-ready shared file: it immediately denies clients but keeps bytes and owner history/download. **Deletion** is a separate destructive owner action permitted only after upload has conclusively settled; it hides the SQL row before attempting SDK `delete` and records a tombstone only after verified absence while no writer can still complete. An uploading or uncertain row cannot be deleted, even if one `exists` call says false. Object deletion is irreversible and App Storage lacks lifecycle rules, object versioning and retention policies. Record the business's real retention/export/deletion policy before using client material. Revocation blocks app access immediately but does not prove a deleted object or provider backup. Failed/unknown deletion stays hidden in `deleting` for exact-key reconciliation. Never silently purge or claim retention compliance.
## Server construction and review boundary
Inspect Agent's actual managed Clerk integration and mount its generated proxy middleware before Express body parsers. Preserve the exact generated provider transport, sign-in/sign-up/callback and necessary auth static/bootstrap paths without app SQL membership or custom app CSRF gates; keep the provider's own security controls. Do not invent a proxy path or open a broad public wildcard. Keep the document-free app landing/sign-in reachable for signed-out host-invited visitors. Use `clerkMiddleware()` before protected app routes and `getAuth(req)` in each protected route, then require the verified `userId` and a current `portal_member` row for private client/project/document HTML, data, downloads, APIs and admin actions. A signed-in but unlisted user receives 403 on those private routes, not an automatic client grant. Never rely on a user ID posted from React or on Clerk metadata alone for app membership. Configure session-bound Origin and CSRF checks for state-changing app routes; reject invalid app form and JSON mutations and test the denied case without intercepting provider auth transport. Cookies must use HTTPS settings in the actual published account. Keep storage SDK imports and database credentials server-only. Pin actual installed package versions in lockfile; the manuals do not assert what Agent will generate.
SQL updates involving ownership, membership, status or document visibility use transactions and compare affected-row counts. A cross-tenant ID returns 404 without confirming another client's records exist. Owner UI lists clients/projects/status history/file review queue. Client UI shows only server-returned permitted projects and documents. It cannot construct an App Storage URL. Use escape-by-default text display for names/status notes, never `innerHTML` for client text or filenames. The app has one business owner and organizations inside it; no invitation email is sent by this reference. The owner distributes access only through the actual verified Clerk/Replit account path outside these routes.
The owner navigation has Organizations (create, activate/deactivate, named member grant/revoke), each organization's Projects (create and change status with an audit note), and a per-project Documents queue (upload, approve/reject, share/withdraw/restore, owner download, separate Delete permanently control and owner-only state history). The client navigation has only My projects, one project detail with dated status history, and Documents with separate shared and my-pending sections. A withdrawn file disappears from client list and download immediately; the owner can still inspect its record, history and bytes while `ready`. Upload shows the 5 MiB and PDF/PNG/TXT limit before selection, a pending/ready/error state after submission, and a safe retry explanation tied to the same idempotency key. Review actions ask the owner to confirm the exact filename/project before changing visibility. Destructive Delete permanently has a separate confirmation and is disabled for `uploading`, `deleting`, `failed` or uncertain storage state; the UI explains the hold. Empty states distinguish no assigned project from failed loading; a failed file read never becomes a blank download. Display only safe metadata supplied by the API.
Publishing: inspect actual Replit Agent plan/source, generated Clerk proxy order before Express body parsers, exact auth transport/landing exceptions, Clerk development-versus-production instance, SQL environment, production bucket ID, secret binding, dependency lockfile, and host Sharing control. Replit's **Invite only** host option requires visitors to sign in through Replit and be invited through workspace access management; the client also needs its separate Clerk app identity and explicit portal membership. Confirm this double account requirement is acceptable to each real client. Do not interpret an Invite only host as authorization for a particular client's records. Workspace admins retain host access, so those identities still must fail the app membership check unless granted. The owner checks the exact production revision, costs and client audience, publishes Invite only if available, seeds only the verified production owner identity, and repeats the acceptance matrix at the live URL. If Invite only is unavailable or Replit access would grant broader workspace/project rights than the owner accepts, HOLD the real-client release; a public release is not a substitute.
Completion requires owner-managed client organizations, persistent project records and dated status history, two-way private document exchange with owner review, and a checked named-client access and denial result. A sign-in screen or portal-looking dashboard alone does not meet this client workflow.
Primary manuals: [Agent](https://docs.replit.com/features/agent/overview), [managed Clerk](https://docs.replit.com/features/auth-and-identity/clerk-auth), [SQL](https://docs.replit.com/features/data-and-storage/sql-database), [Development and Production database](https://docs.replit.com/features/data-and-storage/development-and-production), [App Storage](https://docs.replit.com/features/data-and-storage/object-storage), [JavaScript SDK](https://docs.replit.com/features/sdks/object-storage-javascript-sdk), [private publishing](https://docs.replit.com/features/publishing/private-deployments), [App Storage billing](https://docs.replit.com/billing/object-storage-billing), [Clerk Express middleware](https://clerk.com/docs/reference/express/clerk-middleware), and [Clerk backend getUser](https://clerk.com/docs/reference/backend/user/get-user). These are current official manuals checked on 22 October 2026; the build and account remain untested here.
## Appendix B: Proposed PostgreSQL migration
Review the environment before running this reference migration. No database migration was executed for this article.
```sql
-- Reference migration for one service-business portal. Run on the intended
-- Development DB first; inspect the separate Production DB before its migration.
-- Clerk user IDs are obtained from verified server sessions, never a browser role.
BEGIN;
CREATE TABLE portal_member (
user_id text PRIMARY KEY,
role text NOT NULL CHECK (role IN ('owner','client')),
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE client_org (
id uuid PRIMARY KEY,
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE client_org_member (
org_id uuid NOT NULL REFERENCES client_org(id) ON DELETE RESTRICT,
user_id text NOT NULL REFERENCES portal_member(user_id) ON DELETE RESTRICT,
active boolean NOT NULL DEFAULT true,
granted_by text NOT NULL REFERENCES portal_member(user_id),
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (org_id,user_id)
);
CREATE TABLE project (
id uuid PRIMARY KEY,
org_id uuid NOT NULL REFERENCES client_org(id) ON DELETE RESTRICT,
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 160),
status text NOT NULL CHECK (status IN ('planned','active','waiting','completed')),
summary text NOT NULL DEFAULT '' CHECK (char_length(summary) <= 2000),
created_by text NOT NULL REFERENCES portal_member(user_id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (id,org_id)
);
CREATE TABLE project_status_event (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE RESTRICT,
from_status text CHECK (from_status IN ('planned','active','waiting','completed')),
to_status text NOT NULL CHECK (to_status IN ('planned','active','waiting','completed')),
note text NOT NULL DEFAULT '' CHECK (char_length(note) <= 1000),
changed_by text NOT NULL REFERENCES portal_member(user_id),
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX project_status_event_project_time ON project_status_event(project_id,changed_at DESC);
CREATE TABLE portal_document (
id uuid PRIMARY KEY,
org_id uuid NOT NULL,
project_id uuid NOT NULL,
uploaded_by text NOT NULL REFERENCES portal_member(user_id),
idempotency_key text NOT NULL CHECK (char_length(idempotency_key) BETWEEN 32 AND 80),
original_name text NOT NULL CHECK (char_length(original_name) BETWEEN 1 AND 180),
declared_type text NOT NULL CHECK (declared_type IN ('application/pdf','image/png','text/plain')),
byte_count integer NOT NULL CHECK (byte_count BETWEEN 1 AND 5242880),
sha256_hex char(64) NOT NULL CHECK (sha256_hex ~ '^[0-9a-f]{64}$'),
object_key text NOT NULL UNIQUE,
storage_state text NOT NULL CHECK (storage_state IN ('uploading','ready','failed','deleting','deleted')),
upload_settled_at timestamptz,
visibility text NOT NULL CHECK (visibility IN ('owner_only','pending','shared','rejected','withdrawn')),
reviewed_by text REFERENCES portal_member(user_id),
reviewed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
FOREIGN KEY (project_id,org_id) REFERENCES project(id,org_id) ON DELETE RESTRICT,
UNIQUE (uploaded_by,project_id,idempotency_key),
CHECK ((storage_state = 'uploading' AND upload_settled_at IS NULL)
OR (storage_state <> 'uploading' AND upload_settled_at IS NOT NULL)),
CHECK ((storage_state NOT IN ('deleting','deleted')) OR visibility = 'withdrawn')
);
CREATE INDEX portal_document_project_created ON portal_document(project_id,created_at DESC);
CREATE INDEX portal_document_org_visibility ON portal_document(org_id,visibility);
-- This append-only application history is written in the same SQL transaction
-- as each state/visibility transition. The owner can inspect it after hiding
-- or deleting bytes; clients never receive hidden-file event metadata.
CREATE TABLE portal_document_event (
id uuid PRIMARY KEY,
document_id uuid NOT NULL REFERENCES portal_document(id) ON DELETE RESTRICT,
action text NOT NULL CHECK (action IN ('reserved','upload_ready','upload_failed',
'shared','approved','rejected','withdrawn','restored','delete_started',
'late_write_detected','deleted')),
prior_storage_state text CHECK (prior_storage_state IN ('uploading','ready','failed','deleting','deleted')),
next_storage_state text NOT NULL CHECK (next_storage_state IN ('uploading','ready','failed','deleting','deleted')),
prior_visibility text CHECK (prior_visibility IN ('owner_only','pending','shared','rejected','withdrawn')),
next_visibility text NOT NULL CHECK (next_visibility IN ('owner_only','pending','shared','rejected','withdrawn')),
changed_by text NOT NULL REFERENCES portal_member(user_id),
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX portal_document_event_document_time ON portal_document_event(document_id,changed_at DESC);
COMMIT;
-- Owner provisioning is an authenticated administrator action, separate from
-- public routes. Use the exact verified Clerk user ID for that environment.
-- Do not paste a guessed ID or execute a seed against the wrong database.
-- Example transaction shape, with actual values bound as SQL parameters:
-- BEGIN;
-- INSERT INTO portal_member(user_id,role) VALUES ($1,'owner');
-- COMMIT;
```
## Appendix C: Server routes, file lifecycle and acceptance checks
These are implementation requirements and exact SDK calls for Agent's generated app, not a claim that the app was run. The listed business routes are under `/api`; they are private app routes, not provider auth transport. Inspect the generated managed Clerk proxy and mount it before Express body parsers. Allow only its exact required provider transport, sign-in/sign-up/callback and auth static/bootstrap paths through without app SQL membership and custom app CSRF, retaining provider security. Keep the document-free landing/sign-in accessible at the app layer to signed-out visitors who pass any Invite only host gate; do not use a broad public wildcard or expose private page data in bootstrap responses. Every private app route uses `clerkMiddleware()` and `getAuth(req)`, requires a verified `userId`, and queries current `portal_member.active=true` plus the route-specific role, organization and project predicates from SQL. Guest -> 401; signed-in unlisted/inactive -> 403 on private routes. Each state-changing app route requires same-origin and session-bound CSRF checks plus JSON schema or multipart limits. The browser never submits a role, owner ID, uploader ID, object key or storage bucket ID as authority. App-record UUID path parameters must parse before SQL; Clerk user IDs are exact verified strings, not UUIDs. Wrong-tenant resources -> 404. Return only bounded, escaped UI text and permitted metadata; do not return `object_key`, SQL credentials or raw Clerk tokens. Use a shared server authorization helper in every route, not one-off client-side tests.
For every private app POST/PATCH/DELETE, compare the request `Origin` exactly to the configured HTTPS app origin and reject absent/foreign origins for this browser-only API; issue a server-bound CSRF token with the app session and require its value in a custom header (and a multipart form field for upload). Reject a mismatch before changing SQL or storage. Apply this app rule to the listed business routes, not the exact generated Clerk/provider sign-in, sign-up, callback or proxy transport; retain the provider's own request protections. Set session cookies with the actual provider's Secure/SameSite configuration and test same-site success plus foreign-origin and token-mismatch denial. Do not infer that an Origin check alone covers all clients or that a React route guard replaces server authorization.
## Exact server-side predicates
`isOwner(user)`: active `portal_member` with `role='owner'`.
`canClientSeeOrg(user,org)`: active `portal_member` with `role='client'` AND active `client_org_member(user_id,org_id)` AND active `client_org`. The owner can see all active organizations, but owner functions still require owner role. Every project query joins its `org_id` to that predicate in SQL. Never use a browser-submitted `org_id` alone. A client may belong to two organizations; each project remains scoped to its actual organization.
`canReadDocument(user,document)`: authorized to the document's project/org, `storage_state='ready'`, and either owner; or client with `visibility='shared'`; or the client uploader of a `visibility='pending'` file. A `rejected`, `withdrawn`, `owner_only`, `uploading`, `failed`, `deleting` or `deleted` document is never downloadable by a client, even if an old page still shows its ID. Owner can read `owner_only`, `pending`, `shared`, `rejected` and **withdrawn** bytes only while ready; owner download is denied once deletion starts. All document access queries join `portal_document(project_id,org_id)` to `project(id,org_id)` and the active membership predicate. One file ID may not be spliced into another project route.
`canUpload(user,project)`: owner with an active project/org, or client with active membership in that project's active organization. Files uploaded by owner start `owner_only`; files uploaded by client start `pending`. The server sets uploader and visibility from the verified role. The owner can approve/reject only a ready pending client file, share only a ready owner-only file, withdraw only a ready shared file, and restore only a ready withdrawn file. Rejection and withdrawal immediately remove client read permission. No client route can edit review state.
## Routes and payloads
| Route | Authorized behavior | Validation / result |
| --- | --- | --- |
| `GET /api/me` | Verified member's own role and permitted org IDs/names | No object keys or other member list |
| `GET /api/orgs` | Owner sees active organizations; client sees only active assignments | Empty list is not a reason to grant access |
| `POST /api/orgs` | Owner creates organization | `name` trimmed 1–120; server UUID; `201` |
| `PATCH /api/orgs/:orgId` | Owner edits name or active flag | Validated fields and exact current row in SQL transaction; inactive org immediately blocks client project/file access |
| `POST /api/orgs/:orgId/members` | Owner grants an existing verified Clerk identity | `userId` exact invitee: server calls the current environment's Clerk `users.getUser(userId)` before SQL, rejects unknown IDs and has owner verify the named invitee in the Auth pane; `role=client` provisioned/checked by owner in a SQL transaction; no arbitrary email-to-user-ID inference; `201` or `409` |
| `DELETE /api/orgs/:orgId/members/:userId` | Owner disables one org membership | Transaction/affected row; next request denied; last owner is not represented in this table |
| `GET /api/projects` | Owner all active projects; client only its active org projects | Server SQL scope, sorted and paginated, no count of foreign rows |
| `POST /api/projects` | Owner only | Active `orgId`; title 1–160, summary <=2000, status enum; insert project and initial status event in one transaction |
| `GET /api/projects/:projectId` | Owner or active project-org client | Project, status history and permitted document metadata only |
| `PATCH /api/projects/:projectId/status` | Owner only | Enum + note <=1000; row lock; change and append event atomically; return updated version |
| `POST /api/projects/:projectId/documents` | Owner or active client in this project's org | One multipart file plus 32–80 character idempotency key. Max 5 MiB, 1 file, max safe filename 180 chars. Server returns `202 uploading`/`201 ready` or explicit error; no key/URL. |
| `GET /api/projects/:projectId/documents` | Same project-org predicate | Owner sees all states, including withdrawn/deleting/failed for repair; client sees ready shared files and its own ready pending uploads only |
| `GET /api/projects/:projectId/documents/:documentId/download` | `canReadDocument` on the exact joined project/file | Server downloads bytes and returns attachment after size/hash checks; no redirect/signed URL |
| `GET /api/projects/:projectId/documents/:documentId/history` | Owner only | Safe append-only SQL state/review events for this exact project/file; no key, contents or client-visible hidden metadata |
| `PATCH /api/projects/:projectId/documents/:documentId/review` | Owner only | `share` owner-only -> shared; `approve/reject` pending client -> shared/rejected; `withdraw` shared -> withdrawn; `restore` withdrawn -> shared. All require ready bytes, expected old state, row lock/conditional affected-row check and same-transaction event; `409` on stale review. Withdraw keeps bytes. |
| `DELETE /api/projects/:projectId/documents/:documentId` | Owner only, separate destructive confirmation | Only `ready` with non-null `upload_settled_at`; transaction changes to `deleting`/withdrawn before SDK `delete`. `uploading`, `failed`, `deleting` or unknown writer -> `409`/operator HOLD; no tombstone until exact-key absence is verified while no writer can finish. |
No direct endpoint lists all users, all storage objects or another client's files. An owner can disable a `portal_member` through authenticated administration; never allow a client to toggle membership through an API. If owner-managed account creation is added later, review that exact provider interface separately. This guide does not invent a Clerk invitation API or automatic email.
For the membership grant, the supported Clerk backend method is `clerkClient.users.getUser(userId)`; in Express the official backend overview shows `createClerkClient({secretKey:process.env.CLERK_SECRET_KEY})` from `@clerk/express`. Use Replit's automatically selected **development or production** Clerk secret; never replace those managed keys, print them, expose the returned user profile to a client or grant on a failed/ambiguous lookup. The owner confirms that the returned identity is the intended client and that its Replit host invitation was separately managed. The SQL transaction inserts or checks `portal_member(role='client',active=true)` and the exact `client_org_member` row; reject an existing `owner` identity as a client grant. A database row alone never creates a Clerk or Replit account.
## Upload and storage implementation
Use a multipart parser with an enforceable 5 MiB *file* limit, one-file count, a small total-field limit and a bounded memory buffer. Reject/abort over-limit streams with 413 before storing anything. Check real file bytes: PDF starts `%PDF-`, PNG has the 8-byte PNG signature, TXT decodes as strict UTF-8 with no NUL and matches `.txt`; require extension and declared MIME to agree. **Reject** a raw filename containing a forward slash, backslash, control characters or bidi controls, or a full name equal to a single dot or two dots, or more than 180 characters, with 422 before any SQL insert; do not silently strip a path and accept the upload. Verify that the multipart parser exposes the raw filename before normalization; if it cannot, choose one that does or HOLD. Store only the validated display name and still escape it for output/download headers. Do not trust MIME or a byte signature as malware detection. If a client's policy needs scanning, HOLD. Encrypt/transport claims require actual provider/account review, not assumptions.
After session, membership and project authorization, parse and validate the bounded file, compute SHA-256 of the exact Buffer, and begin one SQL transaction. For this `(uploaded_by,project_id,idempotency_key)`, an existing row with identical SHA, size and declared type returns its current metadata/status, including an unresolved `uploading` or hidden deleted record; a different binding returns 409. A new row gets server `crypto.randomUUID()` for `id` and a separate random `object_key` under the configured environment prefix; insert `storage_state='uploading'`, `upload_settled_at=NULL` and role-derived visibility, plus a `reserved` history event. The unique SQL constraints resolve concurrent identical requests: on conflict re-read the row and return it if bound identically. Commit before the external storage call. Only the process that inserted the row uploads, at most once. Do not hold a SQL transaction over storage I/O. A row with `uploading` and null settled time is **not deletion eligible**, including after a process crash or a single `exists=false` read.
At server startup obtain the **actual environment's** bucket ID from server-only configuration and initialize the documented SDK:
```javascript
import { Client } from '@replit/object-storage';
const storage = new Client();
await storage.init(configuredBucketId); // ID read from this environment's App Storage settings
```
Verify in the actual installed SDK that `init(bucketId)` selected that bucket and test with a harmless canary. The official method signatures used by the file path are `exists(objectName)`, `uploadFromBytes(objectName, Buffer)`, `downloadAsBytes(objectName)` and `delete(objectName)`, each returning a `Result` with `ok` and either `value` or `error`. The SDK explicitly says upload **overwrites** an existing name. Therefore never use a client filename or ID as an object name; call `exists(serverGeneratedKey)` and abort on `!ok` or `value===true`, rather than overwrite. Then `uploadFromBytes(serverGeneratedKey,fileBuffer)` exactly once and require `ok`. If the call returns a verified success, use one SQL transaction to conditionally change the same row `uploading -> ready`, set `upload_settled_at=now()`, and append `upload_ready`; require one affected row before announcing ready. This order proves the app has no still-running writer for a ready row. A definite terminal no-write failure may change `uploading -> failed`, set settled time and append `upload_failed` only after the operation is known finished and exact-key absence is confirmed. A timeout, lost response, process crash, uncertain SDK error or `exists=false` while the write could still finish is **not** a terminal failure: leave `uploading`/null settled time, hide bytes and block DELETE. Inspect the exact process/request and key; if a writer is proved finished and `downloadAsBytes` verifies SHA/length, reconcile that row to ready. If completion or absence cannot be proved, retain the unresolved row on HOLD. Never blindly repeat the upload under the same or a new key. If a late successful response arrives, it may finalize only that still-`uploading` row under row lock. If the row is unexpectedly `failed`, `deleting` or `deleted`, do not return ready or leave a deleted tombstone: under the same row lock set/keep `deleting` and withdrawn, mark the writer settled, append `late_write_detected`, and run the exact-key delete/exists reconciliation below. This is an invariant-violation recovery, not a normal deletion path; hold expansion and investigate how the state advanced. A SQL transition failure leaves a private unreconciled object, not an announced success.
## Withdrawal, deletion and state order
All visibility changes require current owner authorization and the exact joined project/file. In a SQL transaction lock `portal_document FOR UPDATE`, require `storage_state='ready'` and `upload_settled_at IS NOT NULL`, compare the expected old visibility, update one row, and append one `portal_document_event` with old/new state and owner actor. `share` is only owner-uploaded `owner_only -> shared`; `approve` and `reject` are client-uploaded `pending -> shared/rejected`; `withdraw` is **any** ready `shared -> withdrawn`; `restore` is ready `withdrawn -> shared` only after owner review. A withdrawn object remains in App Storage and is owner-downloadable, with owner-only history. It is absent from client lists and downloads on the next request. A rejected client upload remains owner-readable while ready and cannot be restored through `restore`; a new review decision would need an explicitly reviewed route change. Stale expected state or a concurrent deletion returns 409; no event is written on failure.
`DELETE` is a distinct, deliberate destructive action, never the implementation of Withdraw. After current owner/project authorization and separate UI confirmation, start one SQL transaction and lock the exact document row. Require `storage_state='ready'` **and** non-null `upload_settled_at`; reject `uploading`, `failed`, `deleting`, `deleted` and any uncertain operation with 409. Since only the inserting process can write once and ready is set only after its upload has settled, no writer for this key can subsequently finish. Atomically set `storage_state='deleting'`, `visibility='withdrawn'`, and append `delete_started`; require one affected row. Commit before object I/O. The row now denies client **and owner** downloads. Review/restore/update endpoints lock the same row and require ready, so they cannot reopen it. Never issue a second upload for this key.
Call the documented `storage.delete(storedKey)` once and check `ok`; then call `storage.exists(storedKey)` for an exact-key readback. A successful delete result plus `exists` returning `ok:true,value:false`, with the settled-writer invariant, permits one conditional `deleting -> deleted` transaction and `deleted` history event; retain the SQL tombstone and no byte claim beyond that verified observation. If delete returned error/was lost, or exists returns error/true, keep `deleting` and hidden. An operator first establishes that the upload had settled and that no writer can complete, then reads the same row/key and `exists` again: if confirmed absent, finalize the tombstone; if present, retry only this exact `delete` after checking the prior operation outcome; if unknown, HOLD. A lone `exists=false` on `uploading` never authorizes deletion or tombstoning. A crash after `delete_started` leaves `deleting`, not a fake deleted record. Do not automatically expire the upload state, reuse its key or claim provider backup/retention deletion. Client revocation and withdrawal never call `delete`.
For a download, resolve the document ID plus project/org plus current membership in SQL **before** referencing `object_key`. Never accept a path, URL or key from the request. Call `downloadAsBytes(storedKey)`, require `ok`, a Buffer of 1–5,242,880 bytes (at most 5 MiB) and SHA/length matching SQL. On missing/error/mismatch return 503 to an authorized user and investigate exact record; never return partial bytes or a neighboring file. Set `Content-Type: application/octet-stream`, `Content-Disposition: attachment; filename="safe-ascii-name"` (sanitize again), `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-store`, and no inline preview. Every file byte flows through this checked route. Disallow public static serving and signed URLs.
Storage private-by-default does not isolate clients within this app; the server predicate does. The App Storage docs say a bucket belongs to one Replit project, can be used from both Development and Production, blocks public grants, but signed URLs and public app routes still expose files. Use separate bucket IDs intentionally and confirm them in both environments. The docs also say no lifecycle rules, object versioning or retention policy. Record the actual storage charges and a business retention/exit procedure before real client use; no fixed cost or deletion deadline is asserted here.
## Error and recovery map
For private app routes: 401 guest; 403 active session without required app membership or owner role; 404 invalid/foreign project or document; 409 stale status/review, idempotency key rebound, or DELETE on uploading/failed/deleting/deleted/uncertain bytes; 413 large upload; 415 unsupported/mismatched file type; 422 malformed fields/zero bytes or unsafe raw filename; 503 database/storage unavailable, unknown object state or checksum mismatch. DELETE may return `202 deleting` if storage readback is unresolved and `200 deleted` only after the settled-writer invariant and exact-key absence check. Both states deny every app download. The UI gives a plain action without echoing private IDs or stack traces. Owner logs contain a random request ID, route class, safe error class and document UUID where authorized; no filenames, contents, keys, credentials or client text. A retry after uncertain upload first looks up the same idempotency key/row, never allocates a second object silently. A failed production migration or wrong bucket keeps Invite only restricted and stops real client uploads until exact environment readback and matrix rerun.
## Required observed matrix before any real-client release
Use two synthetic client organizations A/B, separate named Clerk/Replit identities A/B, and a distinct owner identity. Record timestamp, exact deployed revision, production DB and bucket IDs privately, actual HTTP/result and remediation for each row. Then repeat a bounded subset with one **permitted real client** and its own approved file only after the owner has checked the policy and account.
| Check | Expected observation |
| --- | --- |
| Host-invited signed-out visitor loads document-free landing/sign-in; inspect generated Clerk proxy before Express body parsers and exercise its exact sign-in/sign-up/callback and auth bootstrap paths | Landing and provider auth transport complete under provider controls without app SQL membership or custom app CSRF; host Invite only still applies; no private project or file data in public pages/bootstrap |
| Guest private request; newly signed-in unlisted user; Replit workspace admin lacking portal membership | 401/403 or host denial as appropriate on private HTML, data, downloads, APIs and admin actions; no project or file bytes; sign-in does not grant membership |
| Owner creates A/B and projects; A reads own project/status; reload | A record persists and B project is absent |
| A guesses B org/project/document UUID and swaps document ID under A project route | 404 and no B name, metadata or bytes |
| A attempts org create, status edit, member grant, file approval | 403, unchanged rows |
| B downloads A's shared file; inactive A membership tries saved URL | 404/403, no bytes; owner read remains scoped to active project |
| Owner deactivates A organization while A holds an open page | Next project/file request denied; reactivation restores only previously permitted rows |
| A uploads allowed PDF/PNG/TXT; owner sees pending; A sees own pending; another A-org member does not | Correct states, same byte length/SHA on authorized download, no inline render |
| Owner approves A upload; A-org member then downloads; owner rejects another upload | Approved shared file works; rejected file denied to client |
| Owner uploads file; client tries before/after explicit share, after withdrawal, after owner restore | Denied, then allowed, then denied on next request, then allowed. Withdrawal keeps the ready bytes for owner download and appends history; no SDK delete. |
| Client-uploaded approved file is withdrawn; client guesses old URL; owner reads history and bytes | Client denied, owner can read while ready; restore is explicit owner action. Rejected file stays owner-readable but client-denied. |
| Owner selects separate Delete permanently on a settled ready file | `deleting` hides it from owner/client immediately; exact SDK delete/exists readback precedes `deleted` tombstone and history. No restore/download after deletion starts. |
| Oversize, zero-byte, HTML or mismatched MIME/signature, raw path-like filename, foreign or absent Origin and invalid app session CSRF on a business mutation | 413/422/415/422/403; no ready object or leaked text; no silent path stripping; provider auth transport remains functional under its own controls |
| Duplicate same upload key; same key changed bytes; concurrent same key | One stored object; existing row, 409, then one row respectively |
| Hold upload response before it settles; owner invokes DELETE; then upload completes late | DELETE 409, row remains uploading/no tombstone while writer is possible; late completion conditionally settles same row, then a fresh owner delete may begin. No orphan after a deleted tombstone. |
| Inject an impossible older/buggy state where a late upload reports success after a deletion state | Never publish/leave a deleted claim; record `late_write_detected`, keep hidden `deleting`, reconcile only the exact key after writer settles and investigate the violated guard. This is a documentation-only simulated check, not a claimed observed race. |
| Two owner actions share/withdraw/delete same ready row concurrently | Row lock plus expected state yields one ordered transition/event; deletion cannot be reopened by restore and all later downloads denied. |
| Simulated uncertain upload, delete error/lost response, `exists` error/true, SQL transition error, wrong bucket, checksum mismatch | No false ready/deleted result; uploading or deleting row stays hidden as appropriate, exact-key manual recovery after writer proof; never serve bad bytes. A single `exists=false` while writer may remain does not tombstone. |
| Production revision, Clerk IDs, SQL persistence and separate bucket canary after Invite only publish | Actual readback; no assumption that Development data copied to Production |
| Permitted real client with real consent and small approved file; another client account denial | Actual restricted URL/identity/readback evidence, not an invented testimonial or security claim |
If a check fails, keep the release restricted and identify the exact failed record/environment. Repair only the relevant generated code/configuration, retest the denial and allowed path, and do not use real client data to debug an uncertain storage boundary.
## Sources
- [Replit Agent overview](https://docs.replit.com/features/agent/overview) — Agent Plan mode and generated app inspection; no generated result observed.
- [Replit-managed Clerk Auth](https://docs.replit.com/features/auth-and-identity/clerk-auth) — Managed identity and development/production separation.
- [Replit SQL Database](https://docs.replit.com/features/data-and-storage/sql-database) — Persistent PostgreSQL service.
- [Replit Development and Production database](https://docs.replit.com/features/data-and-storage/development-and-production) — Check distinct database paths in the actual project.
- [Replit App Storage](https://docs.replit.com/features/data-and-storage/object-storage) — Private bucket behavior, bucket IDs and absence of lifecycle/versioning/retention features.
- [Replit App Storage JavaScript SDK](https://docs.replit.com/features/sdks/object-storage-javascript-sdk) — init(bucketId), exists, byte upload/download and delete Result methods; upload overwrite.
- [Replit private deployments](https://docs.replit.com/features/publishing/private-deployments) — Invite only host access and workspace admin boundary.
- [Replit App Storage billing](https://docs.replit.com/billing/object-storage-billing) — Charge categories and seven-day object minimum.
- [Clerk Express middleware](https://clerk.com/docs/reference/express/clerk-middleware) — clerkMiddleware and getAuth server session pattern.
- [Clerk backend getUser](https://clerk.com/docs/reference/backend/user/get-user) — Exact user ID verification before membership grant.
- [Node.js release schedule](https://nodejs.org/en/about/previous-releases) — Node 24 and 22 listed LTS; 20 EOL. Production should use Active or Maintenance LTS; Replit runtime/dependency compatibility not tested.
The BIG CHANGE newsletter
The big picture. At your pace.
Recent stories on AI and robotics, the shifts worth watching and practical ideas to use. Choose a daily briefing, weekly digest or monthly perspective.
Next scheduled send (UTC): . Your first edition arrives at the next scheduled send after you confirm.
Your privacy, your choice.
Necessary storage supports site security and remembers your choices. Optional Google Analytics stays off until you allow it. You can read every story with necessary storage only. Privacy details