Rule 75: this document describes what the code actually does, with file pointers. Anything not implemented is listed under Known gaps — no claim here is aspirational. Verified by reading the code paths named below plus the live checks recorded in
docs/STATE.md(Playwright E2E on a production build, CSP fix, backup/restore drill).
app/) serving HTML + /api/v1/* route handlers, one
Node process, one PostgreSQL 16 (Docker locally on :5434; self-hosted VPS in
prod — docker-compose.yml, docs/V1_SPEC.md C2).scripts/deploy.sh runs next start -H 127.0.0.1 with a pidfile.
Operator procedure: docs/DEPLOY.md. Nginx template: deploy/nginx.conf.example.org_members; every tenant
query is scoped by org_id taken from the session, never from the request
body.| Control | Implementation |
|---|---|
| Password hashing | Argon2id via @node-rs/argon2, memoryCost 19456, timeCost 2, parallelism 1 — app/src/lib/password.ts |
| Password policy | min 8 / max 128 chars, control characters rejected — app/src/lib/validation.ts |
| Session token | 32 random bytes, base64url; only the sha256 hash is stored (sessions.token_hash) — app/src/lib/tokens.ts, lib/sessions.ts |
| Session cookie | bg_session; httpOnly, SameSite=Lax, Secure when APP_ORIGIN starts with https://, path=/, TTL SESSION_TTL_DAYS (default 30) |
| Session validation | hash lookup + revoked_at IS NULL + expires_at > now(); suspended user ⇒ no session; revoked membership ⇒ org treated as null; suspended org ⇒ session rejected (lib/sessions.ts) |
| Email verification | one-time token, sha256 at rest, 24 h expiry, consumed atomically (auth/verify-email) |
| Password reset | six-digit email OTP, sha256 at rest, 10-minute expiry, five attempts, single use; SMS is reserved for a future delivery adapter. On success all of the user's sessions are revoked (auth/reset-password → revokeAllUserSessions) |
| Logout | revokes the current session row + clears the cookie (auth/logout) |
| Suspension | admin PATCH /admin/users/[id] sets status='suspended' and revokes all that user's live sessions |
| Login enumeration | identical error for unknown email and wrong password; argon2 verification runs for existing users to keep timing flat (auth/login) |
| Auth brute force | Postgres-backed limiter (lib/ratelimit.ts, 0018_rate_limits.sql). One rate_limit_buckets row per action+IP holds the window counter and the lock deadline together, so state survives restarts and is shared across app instances. Login / reset-password / verify-email: 8 failed attempts per IP per minute ⇒ 15-minute lock (success clears the key). Register: 20 attempts / 15 min / IP (every call). Forgot-password: 5 / 15 min / IP (every call — it queues mail). |
| Invitations | 32-byte opaque token, sha256 at rest, 7-day TTL, single use; bound to the invited email so redemption by another account is 403 EMAIL_MISMATCH (lib/invites.ts, auth/accept-invite) |
| Social sign-in | Google/LinkedIn OAuth2 + OpenID, GitHub OAuth, and Microsoft Entra ID OIDC with provider-verified email linking, state CSRF token, creds-gated (buttons hidden until env vars exist) — docs/OAUTH.md |
| Provider unlink | DELETE /api/v1/account/providers: password accounts confirm their password; an OAuth-only account cannot unlink its last sign-in method (409 LAST_SIGNIN_METHOD) — a first password can be set via PUT /api/v1/account/password, which revokes all sessions |
| Two-factor authentication | Optional TOTP (RFC 6238) after password or social sign-in; encrypted AES-256-GCM secret, five-minute one-use challenge (body token for password login, short-lived httpOnly cookie for the OAuth callback), five failed-code attempts per challenge, setup/disable require the current password + code — lib/totp.ts, lib/twofactor.ts, /api/v1/account/2fa, /api/v1/auth/2fa/verify |
| 2FA recovery codes | Eight single-use codes issued at activation, regenerable (PUT /api/v1/account/2fa, password + live factor). sha256-hashed at rest, unambiguous 8-char alphabet, atomic single-use claim, usable as the second factor for sign-in and disable — so a lost phone costs one code, not the account |
app/src/middleware.ts rejects any
non-GET/HEAD/OPTIONS /api/* request whose Origin (else Referer) host is
missing or differs from APP_ORIGIN's host → 403 CSRF_ORIGIN. The session
cookie is SameSite=Lax on top of this.script-src 'self' 'nonce-<uuid>' with the nonce forwarded as x-nonce so
Next stamps its inline RSC bootstrap; default-src 'self',
style-src 'self' 'unsafe-inline', img-src 'self' data:,
connect-src 'self', frame-ancestors 'none', base-uri 'self',
form-action 'self'. Dev relaxes script-src to inline+eval for HMR — the
prod policy exists in exactly one place, so it cannot drift./api/* response carries Cache-Control: no-store.MAX_BODY_BYTES), oversized
→ 413 PAYLOAD_TOO_LARGE, malformed JSON → 400 INVALID_JSON
(app/src/lib/http.ts).apiRoute() converts errors into the API envelope;
unexpected errors are logged server-side and returned as a generic
500 INTERNAL — stack traces never reach clients.$1, $2, …) through pg;
the only string-built SQL is the fixed WHERE/LIMIT/OFFSET fragments in the
admin list endpoints, whose values are still parameterized.lib/validation.ts) and business-profile payloads (lib/profile.ts:
UUID lists, INR amounts as decimal strings, margin 0–100, ≤10 socials, NAP
length caps).app/src/lib/scan/fetch.ts (assertAllowedTarget) before connect — http(s)
only, no credentials in the URL, no loopback / RFC1918 / link-local / CGNAT /
cloud-metadata ranges, and every redirect is re-checked. allowPrivate exists
solely for the local demo sites and is true only when SCAN_DEMO_ENABLED=1
and the URL path is under /api/v1/visibility/demo/. Bodies are capped at
2 MB with an 8 s timeout. User-Agent: BrandGrowthBot/0.1.POST /visibility/scans refuses with 402 QUOTA_EXCEEDED when
plan_limits.scans_per_month is exhausted; the counter increments only after
a report is stored.POST /leads requires consent: true and writes consent_at
(Rule 14). No lead is created from a scan finding without that explicit action.Org roles rank viewer < member < admin < owner; platform admins are
users.role_global = 'admin' (lib/guards.ts).
| Endpoint group | Requirement |
|---|---|
GET /business-profiles, GET /business-profiles/[id] |
org role ≥ viewer |
POST /business-profiles, PATCH /business-profiles/[id] |
org role ≥ admin |
DELETE /business-profiles/[id] |
org role owner only |
GET /campaign-profiles, GET /campaign-profiles/[id] |
org role ≥ viewer |
POST /campaign-profiles, PATCH /campaign-profiles/[id], DELETE /campaign-profiles/[id] |
org role ≥ admin |
GET /orgs/members, GET /orgs/mine, POST /orgs/switch |
session + active membership (switch: any workspace the user is an active member of) |
PATCH /orgs/members/[userId], DELETE /orgs/members/[userId] |
org role owner (self-actions and removing the last owner are 400 INVALID_ACTION) |
POST /orgs/invites, GET /orgs/invites, DELETE /orgs/invites/[id] |
org role ≥ admin, and the target role must be strictly below the actor (403 FORBIDDEN) |
POST /auth/accept-invite |
session + the invite's email must match the session (403 EMAIL_MISMATCH) |
/admin/* |
requirePlatformAdmin() (403 FORBIDDEN otherwise; the admin UI additionally redirects non-admins) |
GET /auth/me |
session required (401 UNAUTHENTICATED otherwise) |
GET /visibility/scans, GET /visibility/scans/[id] |
org role ≥ viewer; SQL org_id from the session |
POST /visibility/scans, POST /visibility/scans/[id]/share, POST /leads |
org role ≥ member; visibility_scan flag on scan POST |
GET /visibility/shared/[token], GET /r/[token] |
public; token is 24 random bytes base64url, unique, unguessable; response has no org_id |
Ownership is enforced in SQL (WHERE id = $1 AND org_id = $2), so a
cross-tenant id resolves to 404 NOT_FOUND rather than leaking existence.
Campaigns additionally re-check that the linked business_profile_id belongs to
the same org on create and on every re-link (400 VALIDATION otherwise).
Inviting is the only path that mints access for a non-member, so the limits live
in one place (lib/invites.ts) as server-side truth that both the routes and the
UI read: ownership is never grantable by invite, admin cannot invite an admin
(peers), and an org always keeps ≥1 owner. Removing a member tears down their
live sessions for that org (sessions.org_id), so an ex-member's next request is
401 UNAUTHENTICATED instead of silently retaining access.
audit_logs (append-only table) receives auth.register, auth.login,
auth.logout, auth.verify_email, auth.reset_password, auth.oauth_login,
profile.create|update|delete, campaign.create|update|delete,
member.invite|invite_accept|invite_revoke|role_change|remove, org.switch,
admin.flag.update, admin.user.active|suspended,
visibility.scan_completed, visibility.report_shared, lead.created
— written by lib/audit.ts, which never throws into the request path.
Money-shaped state is additionally audited by database triggers, so a
change made with SQL is recorded exactly like one made by the app (a request
path can only record what ran through it; the trigger sees every writer,
including ones that do not exist yet). Migrations 0031 (plan changes),
0032 (money state), 0033 (write-path completion), and 0034 (the partner
rate card):
| table | actions |
|---|---|
orgs |
org.created · org.deleted · org.plan_changed · org.trial_changed · org.status_changed |
razorpay_orders |
billing.order_created · billing.order_linked · billing.order_status · billing.order_changed · billing.order_moved_out/in · billing.order_deleted |
org_members (seats) |
org.member_added · org.member_changed · org.member_removed · org.member_moved_out/in |
org_invites (reserved seats) |
org.invite_created · org.invite_changed · org.invite_resolved · org.invite_deleted · org.invite_moved_out/in |
plans · plan_limits |
billing.plan_created · billing.plan_deleted · billing.plan_priced · billing.plan_catalog_changed · billing.plan_limit_changed |
partner_fees (partner rate ranges) |
catalog.partner_fee_recorded · catalog.partner_fee_changed · catalog.partner_fee_reclassified · catalog.partner_fee_removed |
partners (deletion) |
catalog.partner_deleted (one row; the cascaded fee rows stay quiet) |
Two rules hold for every one of them, both enforced in SQL rather than by
convention (audit_write, audit_actor): an audit failure never fails the
change it records (the insert runs inside an exception block that downgrades
to a WARNING, the same posture as lib/audit.ts), and no row points at a
parent that is gone (org_id/user_id are carried only while the
referenced row still exists — otherwise NULL, so deleting a workspace is never
blocked by its own trail). The actor is taken from the transaction-local
app.actor_user_id when a caller sets it. Deliberate exclusions:
usage_counters (derived usage, would bury the trail in volume) and
razorpay_webhook_events (already an append-only ledger keyed by event_id;
its effect is what lands on razorpay_orders). Tenant-authored spend
(campaign_profiles budgets, roi_analyses inputs) is product content, not
account commercial state, and is left to the app's own records. Provider-order
IDs, amounts,
plan/currency, status, payment IDs, and failure reasons are included in order
snapshots; invite token_hash is deliberately excluded. 0033 also ensures
a plan trigger insert failure or malformed/stale actor can never abort the
commercial change it is meant to describe.
job_runs / ingestion_records give the ingestion pipeline an audit trail
(database/migrations/0005_ingestion.sql).
GET /api/v1/healthz (public, liveness + DB ping) and
GET /api/v1/admin/health (platform admin: users/orgs/unverified/outbox/
active sessions).
.env is git-ignored; .env.example is the committed template. No secret is
read at build time and no NEXT_PUBLIC_* value exists (nothing is inlined
into client bundles).DATABASE_URL, APP_ORIGIN, SESSION_TTL_DAYS,
EMAIL_TRANSPORT, OAuth client id/secret pairs. scripts/deploy.sh refuses
to run when DATABASE_URL/APP_ORIGIN are missing.app/.env; keep it mirrored from the root .env
(scripts/deploy.sh copies it on every deploy).EMAIL_TRANSPORT=console (the default) prints verification links and password-reset OTPs to the
server log. EMAIL_TRANSPORT=smtp moves delivery to scripts/mail.sh with
SMTP_USER/SMTP_PASS; the client refuses to authenticate over a channel with
no STARTTLS, never logs the password, and records only a truncated server reply
in email_outbox.last_error.mail-ops-healthcheck.mjs (§4 cron)
audits the Postfix queue depth, SMTP 4.x/5.x response codes, DNS blocklists,
and PTR/FCrDNS for the sending IP. Gmail 4.7.28 (unsolicited-rate-limit) and
5.7.25 (missing PTR) are tracked separately. Failures page the operator before
recipients report missing mail. See docs/GMAIL_RATE_LIMIT_RECOVERY.md for
the rate-limit incident response procedure.RAZORPAY_LINK_PRO / RAZORPAY_LINK_BUSINESS are public payment URLs, not
secrets, but they are validated: only https URLs on a razorpay.com host are
rendered, so a wrong value cannot redirect a customer somewhere unexpected.MFA_ENCRYPTION_KEY is a required 32-byte hex key when enabling 2FA. The app
refuses enrollment without it; only AES-GCM ciphertext is stored in
user_two_factor.secret_ciphertext. Keep it in the deployment secret store
and back it up alongside the database key material, or enrolled users cannot
decrypt their factors after key loss.assistant_messages — the user's own words and the assistant's answers,
plain text, ≤ 40 turns / 180 days, retention-pruned nightly and on read).0026) are per-user, scoped by user_id from
the session like every other personal row; no endpoint returns another
user's thread and there is no export path. Purge-on-delete is structural:
user_id … ON DELETE CASCADE, so DELETE /api/v1/account removes the
thread in the same transaction as the user row. The widget's Clear maps to
DELETE /api/v1/assistant (immediate, unconditional).scripts/backup.sh — pg_dump custom format via Docker (or --local),
sha256 sidecar, pg_restore --list readability check, retention pruning,
lock dir against overlapping cron runs.scripts/restore.sh — refuses on checksum mismatch, lists the archive before
writing, requires a typed DB-name confirmation (or --yes), restores with
--clean --if-exists --single-transaction, prints post-restore row counts and
can sign everyone out afterwards (--revoke-sessions).wal_level=replica, archive_mode=on, 60-second
archive_timeout) and writes to the host-visible WAL_ARCHIVE_DIR.
scripts/pitr-basebackup.sh creates the physical base backup; the two must be
copied off-box together. docs/DISASTER_RECOVERY.md documents a scratch
restore with restore_command and recovery_target_time. RPO is minutes only
while the archive is advancing and a verified base-backup/WAL pair exists;
this is not a claim that same-disk archives survive disk loss.Rule 10/99 — only ToS- and robots-permitted acquisition.
sources row with dynamic_allowed and
robots_respected flags; Playwright fetches are additionally gated behind
--dynamic. The current source is Wikimedia open data with attribution
preserved; candidates rejected for ToS reasons are listed in docs/SOURCES.md.Disallow: /. Registry
data is RDAP (the registry's own protocol). We never crawl a third-party site
the owner did not enter. Demo sites are synthetic markup we serve ourselves.Accepted V1 trade-off — feature flags use a bounded local cache.
lib/flags.ts reads the authoritative feature_flags rows from Postgres and
caches them per Next process for 60 seconds. An admin PATCH invalidates the
cache on the process handling that request; another instance, if one is
introduced later, may therefore observe the old state for at most 60 seconds.
We are deliberately not moving every flag read to Postgres for V1: the
current deployment shape is one Next process, and feature flags govern
availability/rollout rather than authentication, tenant isolation, billing
authorization, or brute-force protection. Those security controls remain
request-scoped or database-backed. A multi-instance deployment that needs
immediate flag convergence must replace this with shared invalidation (for
example Postgres LISTEN/NOTIFY or a shared cache), not rely on a shorter TTL.
The bounded staleness is acceptable for the current public-launch shape.
The rate limiter is Postgres-backed (0018_rate_limits.sql): counters and
lockouts survive restarts and are shared across instances; expired buckets
are pruned by cron (scripts/prune-ratelimits.mjs, DEPLOY.md §4).
Email transport is coded but unconfigured: with EMAIL_TRANSPORT=console
(the default) verification, reset and invite links appear in logs, and
POST /orgs/invites returns acceptUrl to compensate. Setting
EMAIL_TRANSPORT=smtp removes acceptUrl automatically (P-022); real SMTP
credentials are still needed (P-031).
CSRF relies on the Origin/Referer check + SameSite=Lax; no double-submit
token, so a misconfigured proxy that rewrites Origin would weaken it.
TOTP 2FA is enforced on both password and social (OAuth) sign-in, and lockout counters persist in Postgres (see gap 1). There is no password breach-list check.
Docker Compose still publishes Postgres on host 5434. On a VPS, drop that
port mapping: docker compose -f docker-compose.yml -f docker-compose.vps.yml
does exactly that (P-025), and docs/DEPLOY.md §1.1 says how to verify.
TLS certificates and DNS are operator-owned. The committed Nginx template
(deploy/nginx.conf.example) is not a live public site — this repo has no
remote and no VPS credentials (docs/DEPLOY.md §6).
Offsite copies: the operator's Mac pulls dumps and the WAL archive daily via
the in.vir.brandgrowth-offsite-sync launchd agent (checksum-verified,
30-day local retention — scripts/offsite-sync-mac.sh). The physical PITR
base-backup archive is not yet in that pull; recovery is not valid if the
archive stops advancing or the pair is incomplete.
Destructive account/workspace actions are password- or name-confirmed and
audited, but there is no retention hold: DELETE /api/v1/orgs/current removes
tenant rows immediately, so a mistaken owner deletion is recoverable only from
a dump (docs/DISASTER_RECOVERY.md).
Billing is a request surface only — no payment provider is wired and no
plan is granted without an operator (P-032). Nothing here charges a card.
Accepted gap — master cannot be protected by GitHub, so it is watched
instead of enforced. master on the Productive-iT org repo is the branch
everything deploys from, and the repo is private on a free org plan, so
GitHub refuses both branch protection and rulesets (403; the two ways out are
a Team plan or making the repo public, neither of which is a settings
toggle — scripts/setup-branch-protection.sh reports the blocker and is
idempotent once a plan allows it). "No force pushes, no deletions" therefore
has no server-side enforcement. What the free plan does allow is
detection, and that is what is deployed: a nightly operator-Mac check
(scripts/repo-integrity-check.mjs, launchd
in.vir.brandgrowth-repo-integrity, wired in DEPLOY.md §5.1.4) that remembers
the last-known-good tip of master and pages the operator over the direct-SMTP
ops-alert channel when that tip is no longer an ancestor of the live one
(history rewritten) or the branch has vanished. It cannot prevent a rewrite;
it only makes one loud, and it depends on the Mac being awake and on the
witness state file surviving. Removing this gap means paying for the plan.
Contact the repository owner (founder) directly. Do not open a public issue for
a suspected vulnerability; include reproduction steps, affected endpoint and
timestamps so the audit_logs trail can be correlated.
Procedures only — no secret values live in this document. Secret values are stored exclusively in operator-local credential files (§11.2) and, for the production environment file, in the git-ignored repo backup (§6).
~/.bgo-*)Every credential family the operator handles gets one plaintext file on the operator's own machine — never on a shared host, never in the repo:
~/.bgo-<family> (e.g. .bgo-2fa-credentials, .bgo-ai-keys,
.bgo-productiveit-mail-credentials, .bgo-dmarc-sink-credentials).0600 (owner read/write only). Create with
install -m 600 /dev/null ~/.bgo-<family>.Field: value lines, one family per file, with a short comment
header saying what rotates it and when..env exists under the name
ops-secrets-env.production.backup and is git-ignored (.gitignore), so a
lost operator machine still has one recovery path — see §6.Done for the founder account on 2026-09-19; repeat verbatim for any new
operator. Schema: user_two_factor (migration 0016, secret encrypted at rest
with MFA_ENCRYPTION_KEY, §6) and two_factor_recovery_codes (0017).
otpauth:// URI as
fallback. The QR is rendered by the app's own local encoder
(app/src/lib/qr.ts) from the server-issued URI — the enrollment secret is
never sent to a third-party image/chart service. (Scanability of this exact
pipeline can be proven offline, without enrolling anything — see §11.4.)~/.bgo-2fa-credentials (create with 0600): the account email, issuer,
the manual secret, and the otpauth URI. This file is the only record of the
secret if the authenticator app is lost before enrollment completes.Rotating codes later: Settings → Two-factor authentication → Regenerate recovery codes (requires password + a live authenticator code); update the credential file in the same sitting.
twoFactorFail: 8 per minute,
15-minute lockout) — a locked-out legitimate user should wait out the lock
or attempt from a different network; break-glass (§11.5) does not clear
rate-limit rows, they expire on their own.npm run qr:verify (§11.4) says whether the fault is our
encoder or their device. A fail means escalate, not retry (step 3).Support's check when a user has lost their phone and re-pairing keeps failing: it separates "our QR is broken" from "this phone or app is the problem". Run it from the repo root, labelled for the affected account:
npm run qr:verify -- --email <account email> --out /tmp/bgo-2fa-qr.png
It renders the production otpauth:// URI shape (lib/totp.ts) through the
production encoder (app/src/lib/qr.ts, compiled fresh from source) into a
PNG, and prints the 6-digit code for the current step plus one either side —
the same ±1 window verifyTotp() accepts for clock skew.
BrandGrowth OS:<account email>, and its code matches one of the three
printed. The pipeline is healthy and the fault is the user's device: hand
them the PNG as a known-good sample and continue re-pairing (§11.3 step 2).Nothing is enrolled: the secret is generated in-process for that run, is never
sent to the server, and grants no access to any account, so the PNG is safe to
hand to the user or delete. The default output is the git-ignored
.freebuff/2fa-qr-verify.png, so a generated PNG cannot be committed by
accident (--out overrides it). --secret <base32> re-renders a secret you
already hold from ~/.bgo-2fa-credentials (§11.1); --keep-build keeps the
compiled encoder for debugging.
Use only when every recovery code is spent AND the authenticator is unrecoverable. Requires direct VPS access (§1) and is fully audited.
docs/DISASTER_RECOVERY.md) covers it;
note the timestamp before touching anything.BEGIN;
UPDATE user_two_factor SET enabled_at = NULL, updated_at = now()
WHERE user_id = (SELECT id FROM users WHERE email = '<account email>');
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id,
details, created_at)
SELECT id, 'auth.2fa_break_glass_disabled', 'user', id,
to_jsonb('operator break-glass; re-enrollment required'::text), now()
FROM users WHERE email = '<account email>';
COMMIT;
enabled_at = NULL keeps the ciphertext
row), so a phone that reappears later cannot silently sign the account in.