THE WORLD IS NOT STANDING STILL.RSS
BIG CHANGE.
BIG CHANGE, one tap away
BIG CHANGE, one tap away

Make room for the big picture.

Add BIG CHANGE to your home screen or desktop. It opens in its own window, with the latest stories when you’re online.

iPhone & iPad
Open bigchange.ai in Safari. Tap Share, then Add to Home Screen. Turn on Open as Web App if shown, then tap Add.
Android
Open bigchange.ai in Chrome. In the browser menu, choose Install app or Add to Home screen.
Desktop
In Chrome or Edge, use the install icon in the address bar or the browser’s app menu. In Safari on Mac, choose File → Add to Dock.

Markdown edition

# Build a private team knowledge-base app with an AI coding agent

> Give an AI coding agent a complete private team knowledge-base app brief, then test roles, exact-version review, published-only search, conflicts and recovery.

By BIGCHANGE.AI Editorial

Published: 2026-10-22T20:37:38.944Z
Updated: 2026-10-22T20:37:38.944Z
Canonical: https://bigchange.ai/blog/ai-agent-team-knowledge-base-app-guide

![Conceptual charcoal illustration of one orange-marked reference volume standing in front of older closed volumes on a built-in shelf.](https://bigchange.ai/api/media/file/team-knowledge-base-current-reference-hero-v1.png)
AI-generated conceptual illustration by BIG CHANGE.

A shared folder of notes becomes hard to trust when a draft looks final or a former colleague can still open a link. This guide gives a team owner a complete brief for an AI coding agent to build a private text-document app with named members, review, publication, history and search. The app is a project to build and test in your own accounts; the code and access controls here have not been run for you.

## The big change

- **What changed:** [Replit Agent](https://docs.replit.com/features/agent/overview) can take a full application plan and generate the React and Express project. The brief below supplies the parts a short prompt tends to leave ambiguous: who may see each version, what a publish button changes and how to recover from a conflict.
- **Why it matters:** A reader should search the approved version of a team page even while an editor works on the next draft. The PostgreSQL schema keeps immutable versions and a separate pointer to the published one.
- **What to watch:** Signing in through Clerk does not grant team membership. Check the generated authorization code, the published host and the database with synthetic users before adding real documents.

The result is a conventional document application built with an AI coding agent. It does not call a language model at runtime or invent answers from company material. If you need a personal, source-backed LLM wiki instead, our [earlier guide](https://bigchange.ai/blog/source-backed-llm-wiki-multi-source-guide) covers that distinct task.

## Before you start

You need authority to store the team's text in the intended Replit account, a team owner willing to manage membership and review, and permission to enroll each colleague. Set up separate Development and Production environments for Replit's managed Clerk authentication and PostgreSQL database. Keep provider secrets on the server. Start with synthetic pages and test identities; never paste confidential team documents into the Agent prompt.

Check the actual account's plan, publishing access, database storage, backups, retention, region and charges. [Replit documents separate development and production databases](https://docs.replit.com/features/data-and-storage/development-and-production), but you still need to inspect the generated project's bindings and first owner in each environment. [Host-level access controls](https://docs.replit.com/features/publishing/private-deployments) and membership inside your app solve different parts of access: an invited host visitor does not automatically become a document reader.

Use an Active or Maintenance LTS Node release compatible with the project's actual runtime and locked dependencies. On 22 October 2026, [Node's release table](https://nodejs.org/en/about/previous-releases) listed 22 and 24 as LTS and 20 as end of life. The generated Replit runtime and package lockfile decide which supported release you can actually use.

## 1. Give the agent the entire app brief

Open Replit Agent in Plan mode. Paste Appendix A, Appendix B and Appendix C together, and ask it to show the planned routes, permission matrix, schema and test approach before it writes the project. Do not shorten the request to “make a wiki.” The deliverable includes a React reader home, editor workspace and owner console, an Express API, managed Clerk sign-in and SQL membership. The appendix text is meant to be copied in full.

**Expected output:** A plan that names every page state, every role, the exact published-version pointer, the protected URL patterns and the test identities. If it proposes client-side-only role checks, anonymous first-owner creation or live company documents as seed data, revise the plan before building.

## 2. Inspect identity and storage before seeding

Check the generated server's managed Clerk proxy path against the installed integration. It must run before Express body parsers, and the public app shell must contain no team data. [Clerk's Express middleware](https://clerk.com/docs/reference/express/clerk-middleware) attaches authentication state; the app still has to authorize each team request against its own current SQL membership. The first owner is seeded through authenticated database administration using that environment's verified Clerk user ID, never through a public bootstrap form. An owner may add an existing named identity after verifying the person; this contract sends no invitation email.

Run the migration in Appendix B on a fresh Development database. Verify its tables, constraints and immutable-history triggers. Seed two synthetic teams and test owner, editor and reader identities. Keep the seed record and a backup under the owner's control. Read back the identities and team rows before exercising content routes.

**Expected output:** An unlisted signed-in user sees an empty team list. A guest cannot enter protected app routes. A member sees only an active team to which that exact verified user ID belongs.

## 3. Build the document lifecycle

Have the agent implement Appendix C's route and transaction contract. A database transaction is a group of changes committed together. Here it prevents a new version or review from being saved halfway. A row lock makes competing edits and membership changes take an explicit order. A state version is the number the form read before saving; a mismatched number means someone changed the page since then.

Create a synthetic page as an editor, save a second revision and read back both immutable versions. Ask two editors to save from the same state version. Exactly one may commit; the other receives a conflict and keeps unsaved text for a human comparison. Review an exact current version as owner, publish it, then edit again. Reader detail and search must still show the earlier published version until the owner approves and publishes the new one.

**Expected output:** The UI shows committed version numbers, a readable conflict comparison and the current published version. It never reports “saved” on a request that failed or silently merges two edits.

## 4. Check search, archive and recovery

The search in Appendix C uses PostgreSQL's [full-text functions](https://www.postgresql.org/docs/current/textsearch-controls.html) and a [GIN expression index](https://www.postgresql.org/docs/current/textsearch-tables.html). It matches normalized terms in published title and body text; it is neither semantic AI search nor a check that a document is accurate. Search must join the page's published pointer and current team membership. A newer draft, archived page or another team's page must contribute no result, count or snippet.

Archive a synthetic page as owner. It should disappear from reader browse, detail and search without deleting versions. Unarchive returns it to a private draft state. Restore an older version as a new draft, then require another exact-version review and publish. Restart the app and read back the published pointer, draft history, audit rows and membership. Restore a backup to a separate environment and compare the records before trusting the procedure.

**Expected output:** The archive can be recovered without changing old version rows. Search returns only the bounded first page of current published matches for the selected active team.

## 5. Test access at the intended host

Use the complete acceptance matrix in Appendix C with two synthetic teams. Compare direct requests to API, content HTML and single-page-app fallback URLs as guest, unlisted identity, reader, editor, owner and revoked member. An absent, foreign or inactive team and a revoked membership must produce the same generic 404 body on team-scoped routes. An active member using a route beyond their role gets 403. This distinction prevents error responses from becoming a team directory.

Repeat these requests at the actual restricted published URL with Production identities and data. An invited signed-out visitor should reach only the document-free landing and managed sign-in flow; an uninvited host visitor may be stopped by Replit before reaching the app. Confirm the Production first-owner seed and that a new Clerk signup still has no team access. Review exact costs, secrets, backups and permissions before entering real text. If an access, version, search or recovery check fails, hold real use and fix the observed failure.

The body field allows up to 20,000 Unicode code points, while a whole JSON request has a separate 65,536-byte UTF-8 limit. A long body with multibyte characters can exceed the byte cap sooner; the API returns 413 and the editor retains the unsaved input. Try that case explicitly. The app handles plain text and an optional attribution URL; it does not extract PDFs, sync a drive or run model-generated answers. Those additions need their own storage, permission and source checks.

## Appendix A: Complete app brief to paste into the coding agent

This is a documentation-only reference for a team owner to give an AI coding agent. No application, team account, document import, access test, search result or release was observed. The team supplies its own permitted text, named members, editorial policy and account settings. Begin with synthetic pages and test identities; keep real documents out of the build prompt and generated example data.

Build a **persistent team knowledge base** with page authorship, review and publication. Members sign in, an owner controls membership and roles, editors create and revise text documents, an owner reviews and publishes an exact version, readers browse and search published versions, and owners can archive and recover pages without erasing history. A page can have a newer unpublished draft while readers still see its earlier published version. Store content, version history, review decisions and audit events in PostgreSQL. An AI coding agent builds the app; no runtime language model or embedding API is needed to answer searches. Search returns matching team documents.

Use Replit Agent in Plan mode with this entire brief and the accompanying SQL, API and workflow. Implement a React UI, Express server, Replit-managed Clerk authentication and Replit SQL PostgreSQL. Choose a Node.js release that is Active or Maintenance LTS on the reader's build date (22 or 24 as checked 22 October 2026), **only after** inspecting the actual Replit development/published runtime and the locked versions of Express, React, `@clerk/express` and the PostgreSQL client. Hold if the required managed integration or compatible LTS runtime is unavailable. Inspect current Agent/hosting, Clerk and database terms, charges, storage limits, residency, backups and retention before moving real team material. The generated project and account still require inspection.

## Team and document boundary

The first team and owner are seeded by authenticated database administration using the exact verified Clerk user ID in that environment. There is no anonymous or browser self-promotion path. The owner enrolls an existing named Clerk identity only after checking the human and the permission to include them. Each team has `owner`, `editor` and `reader` roles stored in SQL, independent of Clerk profile fields. One person may belong to multiple teams; every content list, page, version, review, search and audit query derives access from the current server-verified Clerk session and the selected team's active membership. A single team-scoped gate precedes resource lookup: absent team, foreign team, inactive team and revoked/unlisted membership all produce the same generic 404 body, with no content or existence hint. A current member lacking a route role gets 403. A revoked member loses team data access on the next request. A team cannot lose its final active owner through the app.

This reference handles **text documents**: a title, summary, 1-20,000-Unicode-code-point plain-text body, optional source label/HTTPS URL, and change note. A separate 64 KiB serialized UTF-8 JSON request limit also applies, so a body near the character maximum can exceed the request limit and receive 413; the form must retain unsaved input. A permitted Markdown document can be reviewed and pasted as text. The UI displays text literally with escaped HTML; it never executes raw HTML, remote images, scripts or links from page body. The optional source URL is an attribution field, not proof of accuracy or an automatic import. No PDF, binary upload, third-party drive connection or autonomous corpus scrape is promised. If the team requires those formats or connectors, hold that additional scope until their storage, extraction, consent and access contracts are verified. A useful team KB of actual owned text documents does not depend on a model's ability to read confidential files.

An editor starts a page draft, then saves revisions only with the last state/version shown in the UI. A conflicting concurrent edit returns 409 and preserves both the committed version and the editor's unsaved text for human comparison; it never silently merges or overwrites. All versions remain immutable, with actor, date and change note. The owner reviews the **exact current version** and records approval or rejection with a note. Publishing requires a current approval, moves the published pointer atomically and leaves older versions available to authorized editors/owners. A later edit does not alter what readers see until a new review and publish. An owner archives a page to remove it from reader lists/search without deleting its versions; unarchive returns it to draft for another explicit review/publish. Restoring older content creates a **new draft version**; it never rewrites history or automatically reopens publication.

Use PostgreSQL full-text search over page versions, but select only each page's current **published pointer** within the authenticated team. A reader sees published titles, summary, literal body, last publication version/date and owner-curated origin label/URL. Editors/owners also have a separate draft/history/review view. Empty/short queries, page counts and error messages must not reveal other teams' documents or the existence of drafts. Search ranking is relevance, not factual correctness. A human checks source claims, permissions and stale material before relying on a page.

## Reader-facing screens and release

The React app needs a document-free landing and Clerk sign-in/sign-up shell, then team selection based on actual memberships; a reader home with published browse, search and page detail; an editor page list, create/edit form and compare-to-published/history view; and an owner review queue, version-specific decision, publish/archive/restore controls, member/role administration and audit view. Show draft/published/archived states and exact version numbers. Do not label a draft saved merely because a button was clicked: read back committed ID/version after the server transaction. Render all content as text and use exact allowed-origin plus session-bound CSRF checks for browser mutations. Mount the managed Clerk proxy before Express body parsers. Keep the document-free landing/auth shell, its required static bootstrap assets, and the exact installed provider sign-in, sign-up, callback and authentication transport/proxy paths outside app SQL membership and custom CSRF checks while preserving the provider's own security checks. Inspect those generated paths; do not invent a fixed callback or exempt a broad wildcard. Protect every app content/API/admin route and direct SPA fallback with verified session and current membership. Keep secrets server-side and private responses `no-store`.

For release, use the project's actually available restricted publishing option if compatible. Replit host invitations and Replit project administration are separate from identities in the managed Clerk app and its SQL memberships: host access alone grants no document access, and an uninvited host user may not reach even the public-at-app-level auth shell. Test an invited signed-out visitor's document-free shell/proxy, a newly signed-in but unlisted Clerk identity, a reader, editor, owner, revoked member and a member of a different team at the published URL. Protect all content routes even if the host gate is enabled. Ensure Development and Production have their own Clerk identities, SQL data and first-owner seed through authenticated project/database administration. If the host cannot enforce the intended access boundary, leave real documents out of production. The owner reviews the generated app, exact package versions, permission matrix, costs, backup/restore procedure and tested results before making it available. The checks below are for the owner to run in the generated app.

Sources: [Replit Agent](https://docs.replit.com/features/agent/overview), [Replit managed Clerk](https://docs.replit.com/features/auth-and-identity/clerk-auth), [Replit database environments](https://docs.replit.com/features/data-and-storage/development-and-production), [Replit publishing access](https://docs.replit.com/features/publishing/private-deployments), [Clerk Express authentication](https://clerk.com/docs/reference/express/get-auth), [PostgreSQL full-text search](https://www.postgresql.org/docs/current/textsearch-controls.html), [PostgreSQL text-search indexes](https://www.postgresql.org/docs/current/textsearch-tables.html), [PostgreSQL row locks](https://www.postgresql.org/docs/current/explicit-locking.html), and [Node.js release status](https://nodejs.org/en/about/previous-releases).

## Appendix B: Full PostgreSQL migration

Run only after inspecting a fresh target database and the generated app. Keep the following SQL together.

```sql
-- Reference migration for a NEW empty PostgreSQL database. Inspect the
-- intended Development and Production databases separately before applying.
-- All UUIDs are server-generated; Clerk IDs are provider strings, not UUIDs.
BEGIN;

CREATE TABLE kb_team (
  id uuid PRIMARY KEY,
  slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,79}$'),
  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 kb_member (
  team_id uuid NOT NULL REFERENCES kb_team(id) ON DELETE RESTRICT,
  clerk_user_id text NOT NULL,
  role text NOT NULL CHECK (role IN ('owner','editor','reader')),
  active boolean NOT NULL DEFAULT true,
  joined_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (team_id,clerk_user_id)
);

-- Page state is mutable. Version content and reviews below are append-only.
-- A published page can have a later draft: readers use published_version_no.
CREATE TABLE kb_page (
  team_id uuid NOT NULL REFERENCES kb_team(id) ON DELETE RESTRICT,
  id uuid NOT NULL,
  slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,79}$'),
  status text NOT NULL CHECK (status IN ('draft','published','archived')),
  current_version_no bigint NOT NULL CHECK (current_version_no > 0),
  published_version_no bigint CHECK (published_version_no > 0),
  state_version bigint NOT NULL DEFAULT 1 CHECK (state_version > 0),
  created_by_user_id text NOT NULL,
  create_request_key text NOT NULL CHECK (char_length(create_request_key) BETWEEN 32 AND 80),
  create_binding_sha256 char(64) NOT NULL CHECK (create_binding_sha256 ~ '^[0-9a-f]{64}$'),
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz,
  archived_at timestamptz,
  PRIMARY KEY (team_id,id),
  UNIQUE (team_id,slug),
  UNIQUE (team_id,created_by_user_id,create_request_key),
  FOREIGN KEY (team_id,created_by_user_id)
    REFERENCES kb_member(team_id,clerk_user_id) ON DELETE RESTRICT,
  CHECK (published_version_no IS NULL OR published_version_no <= current_version_no),
  CHECK (status <> 'published' OR (published_version_no IS NOT NULL AND published_at IS NOT NULL))
);

CREATE TABLE kb_page_version (
  team_id uuid NOT NULL,
  page_id uuid NOT NULL,
  version_no bigint NOT NULL CHECK (version_no > 0),
  title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 160),
  summary text NOT NULL DEFAULT '' CHECK (char_length(summary) <= 300),
  -- PostgreSQL character bound; the API separately caps serialized UTF-8
  -- JSON requests at 65,536 bytes, so not every 20,000-char body fits.
  body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 20000),
  origin_label text CHECK (origin_label IS NULL OR char_length(origin_label) BETWEEN 1 AND 180),
  origin_url text CHECK (origin_url IS NULL OR char_length(origin_url) BETWEEN 1 AND 1000),
  change_note text NOT NULL CHECK (char_length(change_note) BETWEEN 1 AND 500),
  base_version_no bigint,
  created_by_user_id text NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (team_id,page_id,version_no),
  FOREIGN KEY (team_id,page_id) REFERENCES kb_page(team_id,id) ON DELETE RESTRICT,
  FOREIGN KEY (team_id,created_by_user_id)
    REFERENCES kb_member(team_id,clerk_user_id) ON DELETE RESTRICT,
  CHECK (base_version_no IS NULL OR (base_version_no > 0 AND base_version_no < version_no))
);

-- Initial page insert points at version 1; its version row is inserted in the
-- same transaction. Deferred composite FKs validate both pointers at COMMIT.
ALTER TABLE kb_page ADD CONSTRAINT kb_current_version_fk
  FOREIGN KEY (team_id,id,current_version_no)
  REFERENCES kb_page_version(team_id,page_id,version_no)
  DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE kb_page ADD CONSTRAINT kb_published_version_fk
  FOREIGN KEY (team_id,id,published_version_no)
  REFERENCES kb_page_version(team_id,page_id,version_no)
  DEFERRABLE INITIALLY DEFERRED;

CREATE TABLE kb_page_review (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  team_id uuid NOT NULL,
  page_id uuid NOT NULL,
  version_no bigint NOT NULL,
  reviewed_state_version bigint NOT NULL CHECK (reviewed_state_version > 0),
  decision text NOT NULL CHECK (decision IN ('approved','rejected')),
  note text NOT NULL CHECK (char_length(note) BETWEEN 1 AND 1000),
  reviewer_user_id text NOT NULL,
  reviewed_at timestamptz NOT NULL DEFAULT now(),
  FOREIGN KEY (team_id,page_id,version_no)
    REFERENCES kb_page_version(team_id,page_id,version_no) ON DELETE RESTRICT,
  FOREIGN KEY (team_id,reviewer_user_id)
    REFERENCES kb_member(team_id,clerk_user_id) ON DELETE RESTRICT
);
CREATE INDEX kb_review_latest ON kb_page_review(team_id,page_id,version_no,id DESC);

CREATE TABLE kb_audit (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  team_id uuid NOT NULL REFERENCES kb_team(id) ON DELETE RESTRICT,
  actor_user_id text,
  action text NOT NULL CHECK (action IN
    ('member_add','member_role','member_deactivate','page_create','page_edit',
     'page_review','page_publish','page_archive','page_unarchive','page_restore')),
  page_id uuid,
  page_version_no bigint,
  target_user_id text,
  old_role text CHECK (old_role IS NULL OR old_role IN ('owner','editor','reader')),
  new_role text CHECK (new_role IS NULL OR new_role IN ('owner','editor','reader')),
  old_active boolean,
  new_active boolean,
  reason text CHECK (reason IS NULL OR char_length(reason) BETWEEN 1 AND 1000),
  occurred_at timestamptz NOT NULL DEFAULT now(),
  FOREIGN KEY (team_id,actor_user_id)
    REFERENCES kb_member(team_id,clerk_user_id) ON DELETE RESTRICT,
  FOREIGN KEY (team_id,page_id) REFERENCES kb_page(team_id,id) ON DELETE RESTRICT
);
CREATE INDEX kb_audit_team_time ON kb_audit(team_id,occurred_at DESC);

-- Only immutable version rows participate in full-text indexing. Queries
-- must join through kb_page.published_version_no and active team membership.
CREATE INDEX kb_version_search_gin ON kb_page_version USING GIN
  (to_tsvector('simple'::regconfig,title || ' ' || body));

CREATE FUNCTION kb_reject_history_change() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  RAISE EXCEPTION 'knowledge-base history is append-only';
END;
$$;
CREATE TRIGGER kb_version_immutable BEFORE UPDATE OR DELETE ON kb_page_version
  FOR EACH ROW EXECUTE FUNCTION kb_reject_history_change();
CREATE TRIGGER kb_review_immutable BEFORE UPDATE OR DELETE ON kb_page_review
  FOR EACH ROW EXECUTE FUNCTION kb_reject_history_change();
CREATE TRIGGER kb_audit_immutable BEFORE UPDATE OR DELETE ON kb_audit
  FOR EACH ROW EXECUTE FUNCTION kb_reject_history_change();
COMMIT;

-- Seed the first team and owner through authenticated database administration
-- using a verified Clerk user ID in THIS environment. No HTTP bootstrap.
-- INSERT INTO kb_team(id,slug,name) VALUES ($1,$2,$3);
-- INSERT INTO kb_member(team_id,clerk_user_id,role) VALUES ($1,$4,'owner');
```

## Appendix C: Full API, transaction and acceptance contract

These are implementation requirements and reader-run checks, not observed app behavior. Review the generated source and actual installed package versions. Use parameterized SQL on one checked-out connection per transaction, HTTPS, server-only secrets, exact allowed Origins, `Cache-Control: private, no-store` on content HTML/data, and a fixed **64 KiB (65,536-byte) serialized UTF-8 JSON request limit**, checked before JSON parsing. This byte cap is additional to the schema's per-field Unicode-code-point limits: a 20,000-code-point body may exceed 64 KiB once encoded with the other fields, so 20,000 is not a promise that every such body fits. Reject excess bytes with 413 and keep the editor's unsaved input on the client; reject valid-size but overlength fields with 400. Browser mutations require a session-bound CSRF token: the server issues a short-lived HMAC-signed token containing the current verified Clerk session ID, user ID, expiry and nonce, then verifies the signature, expiry and both IDs on POST/PATCH. This is separate from Clerk authentication. Render title, summary, body and notes as literal escaped text; never execute page HTML or turn unreviewed Markdown into unsafe links.

## Identity and API grammar

Inspect the installed managed integration's exact proxy/auth paths. Mount the Replit-managed Clerk proxy **before Express JSON/body parsers**, then the body limit/parser and `clerkMiddleware()` before app data routes. Keep `/` as a document-free landing page, `/sign-in` and `/sign-up` auth UI, their required static bootstrap assets, and the **exact installed** provider sign-in, sign-up, callback and authentication transport/proxy paths outside app SQL membership and custom CSRF checks; preserve the provider's own security checks. Inspect the generated paths without assuming a fixed callback. The host's restricted-access gate may still require a Replit host invitation before these pages open. None of these public-at-app-level routes receives team records, SQL grants, document content or private SPA bootstrap data. Do not use a broad public wildcard or let an unknown SPA fallback bypass auth. Protect `/app`, direct team/document HTML paths, all `/api` content and administration routes, and their SPA fallbacks; `clerkMiddleware()` only attaches auth, so handlers must check session and SQL membership themselves. For protected routes call `getAuth(req, { acceptsToken: 'session_token' })` and require its authenticated user ID and session ID. Derive `clerk_user_id` from that verified server result, never a request body, email address or URL token. A guest or machine token gets 401.

For every `:teamId` content route, validate UUID syntax first (400), then run **one team gate before any page, history, audit or member-resource lookup**: read an active `kb_team` joined to an active `kb_member` for the server-derived Clerk user ID. If the gate returns no row, respond with the **same generic 404 status/body and no distinguishing count or timing claim** for nonexistent team, foreign team, inactive team, and revoked/unlisted membership. Only after that gate may an active member's insufficient route role return 403; an authorized team's nonexistent, draft or foreign page/version also returns generic 404. An authenticated user with no teams receives an empty `GET /api/teams` list; the global CSRF endpoint may return a generic 403 until it has an active membership. Owner/editor/reader roles come only from SQL. IDs for teams/pages are server-generated UUIDs; malformed UUIDs return 400. Clerk IDs are provider strings and must not be UUID-validated. Slugs match `^[a-z0-9][a-z0-9-]{0,79}$`; normalize owner-proposed slugs server-side and reject collisions with 409. Unknown JSON fields are rejected.

The first team and owner are created through authenticated administration, not an HTTP route. `POST /api/teams/:teamId/owner/members` takes only an exact existing Clerk user ID and role. The owner verifies the intended human and calls the installed backend `clerkClient.users.getUser(userId)` before inserting membership; it does not invite or email the user. The owner can change a role or deactivate a membership, but under the shared team lock cannot remove/demote the last active owner. All such changes append audit records. A deactivated member stays in historical author/reviewer rows but cannot use the app.

| Route | Required result and authority |
| --- | --- |
| `GET /api/session/csrf` | Current authenticated user with at least one active membership receives a short-lived session-bound token in a private no-store response; authenticated user with none gets generic 403, guest 401. |
| `GET /api/teams` | List only current active memberships and active team names/IDs; never enumerate all teams. |
| `GET /api/teams/:teamId/pages?cursor=` | Active member sees bounded published pages only; owner/editor has a separate draft/history list. Order by page UUID ascending, use a validated last-seen UUID cursor with the same team/status filter, maximum 25; a guessed cursor grants no access. |
| `GET /api/teams/:teamId/pages/:pageId` | After the team gate, any active team member sees only the page's **published version** while status is `published`; absent/archived/draft/foreign page ID gets generic 404. Include published version number, origin metadata and date. |
| `GET /api/teams/:teamId/search?q=` | Active member, query 2-120 characters, at most the first 20 ranked results. Search only current published versions in this team; drafts, archived pages and other teams never appear in results, counts or snippets. The UI identifies this as a bounded first page. |
| `GET /api/teams/:teamId/editor/pages` and `/pages/:pageId/versions/:versionNo` | Owner/editor only: current draft, published pointer, immutable version history and review state. Every version lookup joins team and page ID; no foreign-history leak. |
| `POST /api/teams/:teamId/editor/pages` | Owner/editor creates one draft page and version 1. Body `{slug,title,summary,body,originLabel,originUrl,changeNote,requestKey}`; return committed ID, version and state. |
| `POST /api/teams/:teamId/editor/pages/:pageId/versions` | Owner/editor body `{expectedStateVersion,title,summary,body,originLabel,originUrl,changeNote}`. Insert immutable version `current+1` and update current pointer/state version atomically; conflict 409 retains user's unsaved input for manual compare. A published pointer remains unchanged. |
| `POST /api/teams/:teamId/owner/pages/:pageId/reviews` | Active owner body `{expectedStateVersion,targetVersionNo,decision,note}`. Append approval/rejection of exact current version and audit event under page lock; no content overwrite. |
| `POST /api/teams/:teamId/owner/pages/:pageId/publish` | Active owner body `{expectedStateVersion,targetVersionNo}`. Require exact current version and latest review of it `approved`; atomically set published pointer/status and audit. |
| `POST /api/teams/:teamId/owner/pages/:pageId/archive` / `unarchive` | Active owner, expected state version and reason. Archive removes page from reader list/search without deleting versions; unarchive returns to draft, requiring a fresh exact-version review/publish before readers regain access. |
| `POST /api/teams/:teamId/editor/pages/:pageId/restore` | Owner/editor body `{expectedStateVersion,fromVersionNo,changeNote}` on a non-archived page. Copy historical text/metadata into a new **draft version** with `base_version_no=fromVersionNo`; no mutation of the old row or automatic publish. An owner must unarchive first if needed. |
| `POST /api/teams/:teamId/owner/members`, `PATCH /api/teams/:teamId/owner/members/:clerkUserId` | Owner only. POST `{clerkUserId,role}` validates exact existing Clerk identity; PATCH `{role,active}` changes one membership after last-owner check. No invitation or email send. |
| `GET /api/teams/:teamId/owner/members` | Owner only, bounded current membership list for administration. Return Clerk user ID, role and active state; do not expose email or other provider profile fields. |
| `GET /api/teams/:teamId/owner/audit` | Owner only, bounded audits. Return IDs/actions/times/roles, never document body or secrets. |

## Transaction sequence and immutable history

For every write, use PostgreSQL `READ COMMITTED` and acquire locks in this order: `kb_team FOR UPDATE`, then relevant `kb_member` rows by Clerk ID, then `kb_page FOR UPDATE` if applicable. A missing team or actor membership after a wait gets the same generic team-gate 404; after locks, recheck team active, actor/target membership/role, page team/status and expected `state_version` **before** page/resource lookup or mutation. A role change uses the same team/member locks; an editor whose role was revoked **before its waiting write acquires the team lock** must not commit. A write that obtained the team lock first may commit before the revocation. A deadlock or connection failure rolls back; a bounded retry repeats all checks and never claims a commit until a readback proves it. No handler updates or deletes `kb_page_version`, `kb_page_review` or `kb_audit`; database triggers reject such mutation.

On create, authenticate, validate body/key and require current active team membership with owner/editor role before any key lookup or response. Compute SHA-256 of UTF-8 JSON in this exact key order: `{teamId,actorUserId,slug,title,summary,body,originLabel,originUrl,changeNote}` with normalized strings and explicit `null` optionals. Look up the `(team_id,created_by_user_id,create_request_key)` before mutable create checks; a matching stored `create_binding_sha256` returns the original page only while the actor remains authorized, while changed binding is 409. Repeat the membership check and lookup after the team/member locks and before inserting. In one transaction insert page (current version 1, draft), version 1 and audit; deferred composite version FK checks at commit. On a unique-key collision, roll back the failed transaction before re-reading authorization and the exact key/binding. A lost response must not create a duplicate page. The client generates a random 32-80-character request key and reuses it for the same creation attempt.

For a version edit, require exact active team, editor/owner role, page not archived and expected state version. Insert `kb_page_version` number `current+1` with the actor and change note, then advance `current_version_no` and `state_version` in the same transaction. Never edit the existing version or change `published_version_no`. Two concurrent editors with one expected state version yield one winner and one 409. The UI compares the losing text with the winner's committed version and lets a human make a new explicit edit; it does not auto-merge. Validate title/summary/body and origin URL on the server: optional URL must use HTTPS, no fetch occurs, and source metadata is not an attestation that page claims are true.

An owner review locks the page, checks `targetVersionNo=current_version_no`, `expectedStateVersion` and exact current content, increments page `state_version`, appends `kb_page_review` with that **new** `reviewed_state_version`, then audits in the same transaction. The latest review for that exact version is the row with highest review ID. Publish again locks team/member/page, checks target=current, status `draft` or `published` with a newer draft, and latest review `approved` **with `reviewed_state_version = page.state_version`**. It then updates status/published pointer/`published_at`/state version and audits. If an edit, rejection, archive or unarchive changed state after approval, publish is 409 until a fresh exact-version approval. A later editor revision keeps the prior published pointer/date visible to readers until newly reviewed. Archive sets status `archived`, records the owner's reason in `kb_audit` and increments state version; public browse/search/detail require `status='published'`. Unarchive sets status `draft`, retains historical published pointer/date but exposes nothing to readers until a new review/publish. Restore is permitted only on a non-archived page: it inserts a fresh version copied from the chosen immutable version, points current to it, and requires review/publish. No hard-delete route is part of this app; a separately governed retention/deletion request needs its own authorized process.

## Published-only search and privacy

Use PostgreSQL's supported full-text functions with an explicit `simple` configuration so indexing and query parsing match. Index immutable versions with `to_tsvector('simple', title || ' ' || body)`. A representative parameterized read is:

```sql
SELECT p.id, p.slug, v.version_no, v.title, v.summary,
       ts_rank_cd(to_tsvector('simple', v.title || ' ' || v.body),
                  websearch_to_tsquery('simple', $3)) AS rank
FROM kb_page AS p
JOIN kb_page_version AS v
  ON (v.team_id, v.page_id, v.version_no) =
     (p.team_id, p.id, p.published_version_no)
JOIN kb_team AS t ON t.id = p.team_id AND t.active
JOIN kb_member AS m ON m.team_id = p.team_id
  AND m.clerk_user_id = $2 AND m.active
WHERE p.team_id = $1 AND p.status = 'published'
  AND to_tsvector('simple', v.title || ' ' || v.body)
      @@ websearch_to_tsquery('simple', $3)
ORDER BY rank DESC, p.id
LIMIT 20;
```

The app binds `$1` to the selected verified team UUID, `$2` to the server-derived Clerk user ID and `$3` to the validated query. This design returns a bounded first page only; never use a client-supplied member ID as authority. A no-match query returns an empty list, not fallback drafts or a model-generated answer. Return plain title/summary and allowed page URL, not a raw SQL snippet or private version. The text-search index may hold draft terms internally, but readers never query the index without the published-pointer and membership joins. Revoke app SQL read access to table-level debug endpoints and prevent logs/errors from echoing full search terms or document text.

## Acceptance cases to run in the reader's environment

Check with two synthetic teams and owner/editor/reader identities. Through the host's restricted gate, an invited signed-out visitor can load only the document-free landing/auth shell, required static bootstrap assets and exact installed provider sign-in, sign-up, callback and authentication transport/proxy paths, with the provider's own security checks intact; direct `/app`, team HTML, SPA fallback and all data API requests return 401 with no document data. A newly signed-in but unlisted Clerk identity gets an empty team list and cannot open content. Compare requests for an existing foreign team, nonexistent team, inactive team and revoked membership: their team-scoped routes must have the **same generic 404 status/body**, including search, page, history, audit and member URLs; malformed UUID is 400. An active reader's owner route gets 403. Team A members cannot read team B's page, versions, search count, audit or member list even with guessed UUIDs. Reader sees one published version, not a later editor draft; cannot write, review or see history. Editor can create/edit/restore but not approve, publish, archive or change members. Owner reviews an exact version and publishes it; editing after approval makes that approval stale for a later version. Rejection prevents publication. Two editors racing with one expected state version yield one saved revision and one conflict with the losing text retained locally. Duplicate same-key creates after a lost response return the one original page; changed binding is 409. A deactivation committed before a waiting edit takes the team lock prevents that edit; last-owner deactivation/role change is rejected. Archive removes results/detail; unarchive remains private until renewed approval/publish; restore creates a new version and preserves the old one and audit. Search returns only team-scoped published titles that match; draft-only terms and foreign-team terms return no results. Submit a 20,000-code-point body whose serialized UTF-8 JSON exceeds 65,536 bytes: expect 413 and retained unsaved UI text, while valid-size overlength fields get 400. Verify persistence after restart, permitted/denied direct API and HTML requests at the published host, exact Production host invitation, Clerk identity, database/first-owner seed and an owner-reviewed backup/restore plan. Record observed results and resolve failures before real use.

## Sources & further reading

The [Replit Agent overview](https://docs.replit.com/features/agent/overview) describes the planning and build tool; [managed Clerk authentication](https://docs.replit.com/features/auth-and-identity/clerk-auth), [development and production databases](https://docs.replit.com/features/data-and-storage/development-and-production) and [publishing access](https://docs.replit.com/features/publishing/private-deployments) cover the platform pieces whose account settings you must inspect. [Clerk Express getAuth](https://clerk.com/docs/reference/express/get-auth), [middleware](https://clerk.com/docs/reference/express/clerk-middleware) and [getUser](https://clerk.com/docs/reference/backend/user/get-user) explain identity state and exact user lookup; they do not replace app SQL membership. PostgreSQL documents [text search](https://www.postgresql.org/docs/current/textsearch-controls.html), [text-search indexes](https://www.postgresql.org/docs/current/textsearch-tables.html), [constraints](https://www.postgresql.org/docs/current/ddl-constraints.html) and [row locks](https://www.postgresql.org/docs/current/explicit-locking.html). [Node release status](https://nodejs.org/en/about/previous-releases) is the date-sensitive runtime check.

## Sources

- [Replit Agent overview](https://docs.replit.com/features/agent/overview) — Plan mode and coding-agent premise; generated app unobserved
- [Replit managed Clerk authentication](https://docs.replit.com/features/auth-and-identity/clerk-auth) — Proxy before body parsers; separate Development/Production identities
- [Replit database environments](https://docs.replit.com/features/data-and-storage/development-and-production) — Development/Production database separation; actual bindings require inspection
- [Replit publishing access](https://docs.replit.com/features/publishing/private-deployments) — Restricted host access distinct from application role grants
- [Clerk Express getAuth](https://clerk.com/docs/reference/express/get-auth) — Server-side session identity
- [Clerk Express middleware](https://clerk.com/docs/reference/express/clerk-middleware) — Middleware attaches auth but does not grant SQL team access
- [Clerk backend getUser](https://clerk.com/docs/reference/backend/user/get-user) — Verify exact provider user ID for member enrollment
- [PostgreSQL text search controls](https://www.postgresql.org/docs/current/textsearch-controls.html) — Configured lexeme query, vector and ranking functions
- [PostgreSQL search tables and indexes](https://www.postgresql.org/docs/current/textsearch-tables.html) — Explicit configuration and GIN expression index
- [PostgreSQL explicit locking](https://www.postgresql.org/docs/current/explicit-locking.html) — Row locks for proposed transaction protocol
- [PostgreSQL constraints](https://www.postgresql.org/docs/current/ddl-constraints.html) — Relational constraints and foreign keys
- [Node release status](https://nodejs.org/en/about/previous-releases) — 22 and 24 LTS, 20 EOL on 22 October 2026