Skip to content

SUR-265 & SUR-230 — Intranet Foundations

SUR-265 & SUR-230 — Intranet Foundations

Date: 2026-05-02 Author: Deji + Claude (paired) Linear: SUR-265 · SUR-230 Milestones: SUR-265 → v1.5 (Monetization). SUR-230 → Develop Marketing Analytics. Status: Spec — pre-implementation

Revision history:

  • 2026-05-02 v1 — initial spec with two repos (surfc-wiki/ + admin inside the PWA)
  • 2026-05-02 v2 — collapsed to single surfc-intranet/ repo serving both wiki and admin under one Cloudflare Access perimeter at intranet.surfc.app/{wiki,admin}. Auth model simplified to CF Access JWT only (no Supabase session inside the intranet). All Edge Functions verify the CF Access JWT instead of a Supabase JWT.

1. Framing — “the beginnings of a Surfc intranet”

Surfc currently has two public surfaces (surfc.app marketing, app.surfc.app PWA) and one internal-leaning surface (help.surfc.app, the VitePress-driven public help site). Both SUR-265 and SUR-230 add internal surfaces:

  • SUR-265 — Internal Wiki is the static half: where the team writes things down (roadmap, ADRs, RAID, research, onboarding).
  • SUR-230 — Admin Dashboard is the operational half: where the team takes action (approve waitlist, set quotas, watch metrics, triage tickets).

We ship them as one codebase under one domain under one auth perimeter. The intranet is a single product surface — wiki for reading, admin for action — both inside surfc-intranet/ and both behind Cloudflare Access on intranet.surfc.app.

SUR-265 WikiSUR-230 Admin
Surfaceintranet.surfc.app/wiki/*intranet.surfc.app/admin/*
Reponew surfc-intranet/ (sibling of surfc/, surfc-web/) — same repo as admin
StackAstro + Starlight integration scoped to /wiki/*Astro pages + React islands at /admin/*
IdentityCloudflare Access (Zero Trust) — email allowlist; same perimeter for both surfaces
Authn for Edge Functionsn/a (read-only static content)CF Access JWT verified by Edge Function
Update cadencecontinuous (markdown PRs)event-driven (waitlist approvals, quota tweaks)
Read/writemostly readread + write
Scope todayone user (Deji) + future collaboratorsone admin (Deji)

The intranet root intranet.surfc.app/ is a small custom Astro landing page with two cards — “Wiki” and “Admin” — and the user clicks through. No auto-redirect (the index helps newcomers find the second surface). Cross-linking between wiki and admin uses normal <a> tags; both share the same CF Access session so navigation is seamless.


2. SUR-265 — Internal Wiki

2.1 Problem

Internal documentation lives across:

  • Files in surfc/docs/ (mixed: some user-facing for help.surfc.app, some internal — RAID, architecture, research, specs, plans, spikes).
  • Files in surfc-web/docs/ (Astro publish guides).
  • Linear issues + project docs (https://linear.app/surfc/document/…).
  • This-conversation context that never gets persisted.

There’s no canonical, browsable, search-friendly home for “how Surfc works as a company and a product.” Anyone (including future-Deji or a new collaborator) trying to onboard has to grep across two repos and a third-party tool. The Wiki ends that.

2.2 Goals

  1. Single, browsable, searchable home for all internal Surfc documentation.
  2. Zero-friction publishing — write Markdown, push, deploy. No CMS.
  3. Clear separation from public help so we never accidentally leak roadmap, RAID, or commercial detail to help.surfc.app.
  4. Cheap auth — no auth code to write or maintain.
  5. Composable with future collaborators — adding someone is “add their email to a Cloudflare Access allowlist.”

2.3 Non-goals (v1)

  • Realtime co-editing (Notion-style).
  • AI chat over wiki contents (deferred — could layer Claude API + Starlight’s content collection on top later).
  • Wiki-driven workflows (e.g. RAID entries auto-creating Linear tickets).
  • Migrating Linear documents into the wiki (Linear stays the source of truth for issue tracking; the wiki references issues by ID).
  • Public read-only mirror of any wiki page.

2.4 Decisions (locked)

#DecisionRationale
D1New repo surfc-intranet/ sibling of surfc/ and surfc-web/. One repo houses both the wiki (SUR-265) and the admin dashboard (SUR-230).Single auth perimeter, single deploy, single Cloudflare project. The intranet is one product; the codebase reflects that.
D2Astro Starlight as the SSG for wiki content.Per ticket title; modern, fast, sidebar-first, MDX-supported, Pagefind built-in for client-side search. Astro is already the team’s marketing stack.
D3Cloudflare Pages for hosting.Already where surfc-web/ lives (post SUR-254); same Node 22 runtime; native Cloudflare Access integration.
D4Cloudflare Access (Zero Trust) for auth, gating the entire intranet.surfc.app domain.Zero auth code. Email-allowlist policy at the edge. Same perimeter protects wiki AND admin. Single sign-on (Google through CF Access).
D5docs/ stays for help.surfc.app; wiki gets its own content tree. Some files move out of docs/ because they’re not user-facing.Avoids dual-purpose ambiguity; keeps the public help site lean.
D6URL: intranet.surfc.app. Wiki at /wiki/*, admin at /admin/*, root / is a small landing page.Single domain → single CF Access policy → single SSO experience.
D7Astro pages with React islands for /admin/*. Starlight is scoped to /wiki/* only via content-folder layout.Astro and Starlight coexist naturally; Starlight only routes files under src/content/docs/, leaving src/pages/admin/* to plain Astro. React islands handle interactivity (forms, charts) without a SPA shell.

2.5 Repo structure

surfc-intranet/
├── astro.config.mjs # @astrojs/starlight integration
├── package.json
├── .nvmrc # Node 22 (matches surfc-web/)
├── README.md
├── public/
│ ├── _headers # Cloudflare cache rules
│ └── favicon.svg
├── src/
│ ├── pages/
│ │ ├── index.astro # intranet landing — two cards (Wiki, Admin)
│ │ └── admin/ # ADMIN SURFACE (SUR-230)
│ │ ├── index.astro # KPI tiles
│ │ ├── waitlist/
│ │ │ ├── index.astro # pending list + approve action
│ │ │ └── [id].astro # single-request detail
│ │ ├── users/
│ │ │ ├── index.astro # paginated user table
│ │ │ └── [userId].astro # user detail + allowance editor
│ │ ├── stats.astro # PostHog charts
│ │ └── audit.astro # audit log viewer
│ ├── content/
│ │ ├── docs/ # WIKI SURFACE (SUR-265) — Starlight-routed
│ │ │ ├── wiki/
│ │ │ │ ├── index.mdx # wiki landing
│ │ │ │ ├── how-we-work/
│ │ │ │ │ ├── index.md
│ │ │ │ │ ├── workflow.md
│ │ │ │ │ ├── decision-log.md
│ │ │ │ │ └── onboarding.md
│ │ │ │ ├── product/
│ │ │ │ │ ├── roadmap.md
│ │ │ │ │ ├── specs/
│ │ │ │ │ │ ├── 2026-04-21-landing-page-redesign.md
│ │ │ │ │ │ └── 2026-05-02-sur-265-sur-230-intranet-foundations.md
│ │ │ │ │ ├── requirements/
│ │ │ │ │ └── tech-stack.md
│ │ │ │ ├── research/
│ │ │ │ │ ├── user/
│ │ │ │ │ ├── product/
│ │ │ │ │ └── market/
│ │ │ │ ├── architecture/
│ │ │ │ │ ├── system.md
│ │ │ │ │ ├── data.md
│ │ │ │ │ ├── deployment.md
│ │ │ │ │ ├── decisions/
│ │ │ │ │ └── components.md # NEW — component-name → purpose registry
│ │ │ │ ├── data/
│ │ │ │ │ ├── tables.md # NEW — column-level catalog
│ │ │ │ │ └── pipelines.md
│ │ │ │ ├── operations/
│ │ │ │ │ ├── ci-cd.md # NEW
│ │ │ │ │ └── runbooks/
│ │ │ │ └── raid/
│ │ │ │ ├── index.md # current rolling RAID
│ │ │ │ └── archive/ # dated snapshots
│ │ └── config.ts # collection schemas (Zod)
│ ├── components/
│ │ └── admin/ # React islands used by admin pages
│ │ ├── WaitlistTable.jsx # interactive waitlist table
│ │ ├── AllowanceEditor.jsx # form for month_limit / allocation_override
│ │ ├── StatsPanel.jsx # PostHog charts wrapper
│ │ ├── AuditTable.jsx # audit log viewer
│ │ └── AdminFunctionClient.js # fetch wrapper that forwards CF-Access-Jwt-Assertion
│ ├── layouts/
│ │ └── AdminLayout.astro # shared chrome for /admin/*
│ └── styles/
│ └── custom.css # Surfc accent colour, font

Routing summary:

  • /src/pages/index.astro (the intranet landing).
  • /wiki/* → handled by Starlight from src/content/docs/wiki/*. (Files outside wiki/ are ignored by the Starlight sidebar.)
  • /admin/* → handled by src/pages/admin/* Astro pages with React islands for interactivity.

Wiki sidebar (Starlight): Wiki Home → How We Work → Product → Research → Architecture → Data → Operations → RAID. The Starlight sidebar config uses autogenerate: { directory: 'wiki/<section>' } for each top-level entry.

2.6 Content migration plan

Three buckets. [KEEP] stays in surfc/docs/ for help.surfc.app. [MOVE] shifts to surfc-intranet/src/content/docs/wiki/. [NEW] is content that doesn’t exist yet.

Existing pathActionNew path (in surfc-intranet/src/content/docs/wiki/)
docs/getting-started/*KEEP
docs/link-new-device.mdKEEP
docs/support/*KEEP
docs/product-roadmap.mdMOVEproduct/roadmap.md
docs/how-we-work.mdMOVEhow-we-work/index.md
docs/architecture/SYSTEM_ARCHITECTURE.mdMOVEarchitecture/system.md
docs/architecture/DATA_ARCHITECTURE.mdMOVEarchitecture/data.md
docs/architecture/DEPLOYMENT_ARCHITECTURE.mdMOVEarchitecture/deployment.md
docs/architecture/TECHNOLOGY_STACK.mdMOVEproduct/tech-stack.md
docs/architecture/RISKS_GAPS_ASSUMPTIONS.mdMOVEraid/index.md (root)
docs/architecture/RAID_REPORT_*.mdMOVEraid/archive/YYYY-MM-DD.md
docs/architecture/drafts/*MOVEarchitecture/drafts/*
docs/architectural_decisions/*MOVEarchitecture/decisions/*
docs/research/*MOVEresearch/{user,product,market}/* (re-bucketed)
docs/spikes/*MOVEresearch/spikes/*
docs/superpowers/specs/*MOVEproduct/specs/*
docs/superpowers/plans/*MOVEproduct/plans/*
docs/tickets/*MOVEproduct/tickets/*
docs/outreach/*MOVEhow-we-work/outreach/*
docs/repository-evaluation.mdMOVEarchitecture/decisions/repository-evaluation.md
docs/sur-218-handoff.mdMOVEproduct/handoffs/sur-218.md
docs/TECH_DEBT_AUDIT_2026-04-24.mdMOVEarchitecture/tech-debt/2026-04-24.md
docs/guides/configuring-approve-waitlist-edge-function.mdMOVEoperations/runbooks/approve-waitlist.md
docs/linear-setup.mdMOVEhow-we-work/linear-setup.md
docs/supabase-setup.mdMOVEoperations/runbooks/supabase-setup.md
docs/ui-ux/*MOVEproduct/ui-ux/*
NEWarchitecture/components.md (component-name → purpose registry)
NEWdata/tables.md (column-level table catalog)
NEWoperations/ci-cd.md
NEWhow-we-work/onboarding.md (employee onboarding)

Migration mechanic. Single PR in each repo:

  • In surfc/: a chore(docs): relocate internal docs to intranet [SUR-265] PR that deletes the moved files and adds a top-level INTRANET.md pointing at https://intranet.surfc.app/wiki/. Updates the existing CLAUDE.md references — there are 8 today (search for docs/architecture/ and docs/superpowers/) — so future Claude sessions look in the right place.
  • In surfc-intranet/: the initial commit drops every file in its new home with git mv history preserved (commit message feat: bootstrap intranet wiki from surfc/docs/ migration).

2.7 Two new content types worth calling out

Component registry (architecture/components.md). SUR-265 explicitly asks for “all component names and their purpose.” This is a flat table:

ComponentRepoPathPurpose
App.jsxsurfc/src/App.jsxRoot component; auth gates; route tree
useUIsurfc/src/hooks/useUI.jsUI-toggle state (selected idea, modals, sidebar)
keyManagersurfc/src/crypto/keyManager.jsMK lifecycle: generate, wrap, unwrap, in-memory cache
noteEncryptionsurfc/src/crypto/noteEncryption.jsAES-GCM-256 encrypt/decrypt for note text
passkeyEnrollmentsurfc/src/crypto/passkeyEnrollment.jsAll navigator.credentials calls + WebAuthn PRF
anthropic-proxysurfc/supabase/functions/anthropic-proxy/Edge Function: managed AI proxy + safety pipeline
guardrailsurfc/supabase/functions/anthropic-proxy/guardrail.tsAzure Content Safety wrapper
WaitlistFormsurfc-web/src/components/WaitlistForm.astroPublic waitlist form
BaseLayoutsurfc-web/src/layouts/BaseLayout.astroMarketing shared head + Termly + PostHog

Living doc — every PR that adds or removes a component updates this table. Rule: if a component is named in CLAUDE.md, it must have a row here.

Data catalog (data/tables.md). Column-level for every public.* table in Supabase, plus every Dexie store. Format:

### `user_profiles`
Per-user profile + monetization state. Source: `supabase/migrations/0009_user_profiles.sql`,
extended by `0012_per_user_quota_limits.sql`.
| Column | Type | Nullable | Default | Purpose |
|---|---|---|---|---|
| `profile_id` | uuid | no | gen_random_uuid() | Surrogate PK |
| `user_id` | uuid | no | — | FK auth.users(id), unique |
| `name` | text | yes | — | Display name from waitlist row |
| `display_name` | text | yes | — | User-editable |
| `email` | text | yes | — | Mirror of auth.users.email |
| `role` | text | no | 'user' | CHECK ('user', 'admin') |
| `month_limit` | int4 | no | 50 | Single source of truth for monthly managed-AI cap |
| `month_usage` | int4 | no | 0 | Snapshot of current-month requests |
| `percent_use` | real | generated | — | month_usage / month_limit |
| `allocation_override` | int4 | yes | — | Additive boost on top of month_limit |
| `allocation_override_expires_at` | date | yes | — | Override expiry; ignored if past |
| `member_since` | int4 | no | — | Unix epoch of auth.users.created_at |
| `created_at` | timestamptz | no | now() | |
| `updated_at` | timestamptz | no | now() | |

scripts/check-schema.js already exists in the repo for schema-contract verification — extend it with a new npm run check:catalog step that diffs the live information_schema.columns against the data/tables.md catalog and fails CI if they drift. (Stretch goal — defer if it slows v1.)

2.8 Auth — Cloudflare Access setup

One Access application protects the entire intranet.surfc.app domain — covers /, /wiki/*, AND /admin/*. Same email allowlist; same SSO experience for both surfaces.

  1. Cloudflare Zero Trust → Application → Self-hosted → name Surfc Intranet, domain intranet.surfc.app (path: * so the entire site is gated).
  2. Policy: Allow → Include → Emails → deji.dipeolu@gmail.com. (Single-user allowlist for v1; collaborators added one row at a time.)
  3. Identity provider: “One-time PIN” (no setup, email-based PIN) plus Google (free, integrates with Deji’s Google sign-in).
  4. Session duration: 24h (matches app.surfc.app Supabase session UX).
  5. Service Token (optional, for local dev): create a service token if you ever need to script against admin Edge Functions; otherwise local dev runs npm run dev against localhost with no Access in front.
  6. Capture the AUD tag — Cloudflare assigns each Access application a unique audience tag (a long hex string). Edge Functions need this to validate JWT claims. Note it down; store as CF_ACCESS_AUD Edge Function secret. Also note the team domain (<team>.cloudflareaccess.com); store as CF_ACCESS_TEAM_DOMAIN.
  7. Bypass for Cloudflare Pages preview deploys? No — preview URLs are still gated. The Access app is bound to the production hostname only.

Failure modes & recovery:

  • If Cloudflare Access misfires (rare but happens), the intranet is unreachable — including admin operations. Single point of failure. Mitigation: keep the markdown in a private GitHub repo so it’s readable on github.com if the wiki is down; for admin operations, fall back to direct Supabase SQL in the dashboard (the same pre-SUR-230 workflow). Document that fallback in operations/runbooks/admin-fallback.md.

2.9 Astro config sketch — Starlight (wiki) + plain Astro (admin) coexisting

astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import react from '@astrojs/react'; // for /admin React islands
export default defineConfig({
site: 'https://intranet.surfc.app',
trailingSlash: 'always',
integrations: [
react(), // enables React islands in /admin
starlight({
title: 'Surfc Wiki',
logo: { src: './public/logo.svg', replacesTitle: false },
// Starlight only routes content under `src/content/docs/`. By placing all
// wiki content under `src/content/docs/wiki/`, Starlight URLs naturally
// become `/wiki/...`. Files under `src/pages/admin/` are untouched by
// Starlight and rendered as plain Astro pages.
sidebar: [
{ label: 'Wiki Home', link: '/wiki/' },
{ label: 'How We Work', autogenerate: { directory: 'wiki/how-we-work' } },
{ label: 'Product', autogenerate: { directory: 'wiki/product' } },
{ label: 'Research', autogenerate: { directory: 'wiki/research' } },
{ label: 'Architecture',autogenerate: { directory: 'wiki/architecture' } },
{ label: 'Data', autogenerate: { directory: 'wiki/data' } },
{ label: 'Operations', autogenerate: { directory: 'wiki/operations' } },
{ label: 'RAID', autogenerate: { directory: 'wiki/raid' } },
],
customCss: ['./src/styles/custom.css'],
lastUpdated: true,
pagefind: true, // built-in client-side search (scoped to wiki content)
editLink: {
baseUrl: 'https://github.com/<owner>/surfc-intranet/edit/main/',
},
}),
],
});

src/pages/index.astro (the intranet root) is hand-written and ignores Starlight; it’s just a small landing with two cards linking to /wiki/ and /admin/. src/pages/admin/*.astro use a custom AdminLayout.astro (sidebar + topbar) and embed React island components for any interactive bit (forms, tables, charts).

2.10 Acceptance criteria — SUR-265 (wiki portion of the intranet)

  • New repo surfc-intranet/ exists with package.json, .nvmrc (Node 22), astro.config.mjs per §2.9, and @astrojs/starlight + @astrojs/react integrations.
  • npm run build produces a static site in dist/.
  • Cloudflare Pages project surfc-intranet deployed; site loads at intranet.surfc.app.
  • Cloudflare Access policy in place at the domain level; deji.dipeolu@gmail.com is on the allowlist; hitting intranet.surfc.app (any path) while signed out triggers Access auth.
  • intranet.surfc.app/ renders the landing page with “Wiki” and “Admin” cards.
  • intranet.surfc.app/wiki/ renders the Starlight wiki index.
  • All files in the migration table moved (with git mv to preserve history) from surfc/docs/ to surfc-intranet/src/content/docs/wiki/.
  • surfc/CLAUDE.md references to moved files are updated to https://intranet.surfc.app/wiki/... URLs.
  • New file wiki/architecture/components.md exists with at least the 10 components above.
  • New file wiki/data/tables.md exists with at least user_profiles, waitlist_requests, notes, books, custom_ideas, wrapped_key_blobs, ai_usage_daily.
  • New file wiki/operations/ci-cd.md documents all three build pipelines (Netlify for surfc/, Cloudflare Pages for surfc-web/, Cloudflare Pages for surfc-intranet/).
  • New file wiki/how-we-work/onboarding.md exists with at least: how to set up local dev, how to get cloud access, how to make a first PR.
  • Pagefind search returns relevant results across all wiki content (does not need to index /admin/*).
  • Each wiki page shows a “Last updated” timestamp and an “Edit on GitHub” link.

2.11 Open questions — SUR-265

#QuestionDefault / answer
Q1Should the intranet repo be private or public?Private (locked). Some content (RAID, monetisation, research) is commercially sensitive.
Q2Cloudflare Access identity providers — Google + One-Time-PIN, or just one?Both (locked). Google for Deji; OTP-by-email for occasional guests.
Q3Logo / favicon — reuse surfc/public/icons/icon-192.png or new “intranet” treatment?Reuse Surfc logo with a small “Intranet” tag. No design budget for v1.
Q4Should architecture/components.md be auto-generated from a code scan?No — manual for v1. Auto-gen is a separate ticket.
Q5Should data/tables.md be auto-generated from information_schema?Stretch only. Add it as a follow-on if npm run check:catalog becomes worth building.
Q6Do we want a wiki-side “Roadmap” view that pulls from Linear in real time?No (v1). Markdown roadmap stays the source of truth; Linear is the issue tracker.

3. SUR-230 — Admin Dashboard

3.1 Problem

Today, every admin operation is “open the Supabase SQL editor and write SQL”:

  • Approving a waitlist user = UPDATE waitlist_requests SET status='approved' WHERE email = '...'
  • Setting an allowance = UPDATE user_profiles SET allocation_override = 200, allocation_override_expires_at = '2026-06-01' WHERE user_id = '...'
  • Checking pending count = SELECT COUNT(*) FROM waitlist_requests WHERE status = 'pending'
  • Checking DAU/WAU = open PostHog dashboard in another tab.

This is fine at one user but it (a) is error-prone (one bad WHERE clause and you nuke production data), (b) doesn’t scale to a non-technical collaborator, and (c) keeps Surfc’s operational pulse split across three tools.

3.2 Goals

  1. One place for all routine admin operations, co-located with the wiki under one auth perimeter.
  2. Minimal new data model — reuse waitlist_requests, user_profiles, ai_usage_daily, wrapped_key_blobs. The schema already supports everything in the lean v1.
  3. Single sign-on — Cloudflare Access is the only admin identity surface; no separate Supabase login required to use admin tools.
  4. Auditable — every admin write logs to a small admin_audit_log table.
  5. Doesn’t touch the user-facing PWA bundle — admin lives entirely in surfc-intranet/; the PWA stays user-facing.

3.3 Non-goals (v1)

  • Inbound ticketing / triage (the “Inbound triage / ticketing” bullet in the original ticket — deferred to a follow-on).
  • Cohort analytics / funnel exploration (use PostHog directly).
  • Bulk operations (bulk-approve, bulk-allowance) — defer until needed.
  • Rich-text user notes (e.g. “this user emailed about X”) — defer.
  • Email composer to send custom emails to users.

3.4 Decisions (locked)

#DecisionRationale
D1Inside surfc-intranet/ at intranet.surfc.app/admin/* (Astro pages + React islands). NOT inside the PWA.Single intranet codebase; one CF Access perimeter for wiki + admin; no admin code in the user-facing PWA bundle.
D2Lean v1 scope — waitlist + allowance + summary stats only. Ticketing deferred.Mitigates schema-design risk on ticketing; ships value sooner.
D3All admin reads and writes go through Edge Functions; CF Access JWT is the auth signal.The intranet has no Supabase user session; the Edge Function validates the Cf-Access-Jwt-Assertion header against Cloudflare’s JWKs, extracts the email, looks up user_profiles for the role check, then performs the operation with service role. Audit row written for every write.
D4PostHog Query API via Edge Function for stats (project token stays server-side).Avoids leaking the PostHog read API key into the client.
D5New admin_audit_log table for all admin writes.Cheap insurance — an admin will eventually fat-finger something.
D6Admin-aware RLS on waitlist_requests and user_profiles is kept as defense-in-depth, but is no longer load-bearing for the intranet (because intranet reads go through Edge Functions, not via Supabase JWT).Cheap to add; protects against a hypothetical future world where someone uses the Supabase client as an admin user.

3.5 Surface — routes

All gated by Cloudflare Access at the domain level (per §2.8). Anyone reaching these pages has already cleared the email allowlist. Edge Function calls additionally verify the role.

intranet.surfc.app/ # landing — Wiki + Admin cards
intranet.surfc.app/admin/ # overview — KPI tiles
intranet.surfc.app/admin/waitlist # pending list + approve action
intranet.surfc.app/admin/waitlist/:id # single waitlist request detail
intranet.surfc.app/admin/users # all users — paginated table
intranet.surfc.app/admin/users/:userId # single user — quota editor + activity
intranet.surfc.app/admin/stats # PostHog-backed stats panel
intranet.surfc.app/admin/audit # last 100 admin write events

Non-allowlisted users never reach any of these — Cloudflare Access bounces them at the edge.

Cross-link from the PWA. None for v1. Admins reach the intranet by typing intranet.surfc.app directly (or via a browser bookmark). A “Admin ↗” link in ProfileScreen.jsx was considered and cut on review — it would have been the only thing in SUR-230 that touches the surfc/ PWA repo, breaking the otherwise-clean “intranet-only” boundary. Easy to add later if it becomes painful.

<AdminGuard> is removed — the PWA no longer hosts /admin routes. Same for the lazy AdminRoutes chunk; nothing about the PWA bundle changes for SUR-230 except the small ProfileScreen link.

3.6 Schema additions

3.6.1 admin_audit_log

CREATE TABLE IF NOT EXISTS admin_audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id uuid NOT NULL REFERENCES auth.users(id),
action text NOT NULL,
target_type text NOT NULL, -- 'waitlist_request' | 'user_profile'
target_id uuid NOT NULL,
before jsonb, -- snapshot before the change
after jsonb, -- snapshot after the change
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE admin_audit_log ENABLE ROW LEVEL SECURITY;
-- Admins can read audit log; nobody can write directly (only Edge Functions).
CREATE POLICY "admins read audit log"
ON admin_audit_log FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM user_profiles WHERE user_id = auth.uid() AND role = 'admin')
);
CREATE INDEX admin_audit_log_created_at_idx ON admin_audit_log (created_at DESC);
CREATE INDEX admin_audit_log_target_idx ON admin_audit_log (target_type, target_id);

3.6.2 Admin-aware RLS extensions (defense-in-depth)

Add admin-aware read policies to waitlist_requests and user_profiles. In the v2 architecture these policies are not load-bearing — the intranet’s admin reads go through Edge Functions with service role, not via a Supabase JWT — but they’re cheap to add and protect against a hypothetical future where admin tools shift back to a per-user Supabase session.

-- waitlist_requests: admins can read all
CREATE POLICY "admins read all waitlist requests"
ON waitlist_requests FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM user_profiles WHERE user_id = auth.uid() AND role = 'admin')
);
-- user_profiles: admins can read all
CREATE POLICY "admins read all user profiles"
ON user_profiles FOR SELECT
TO authenticated
USING (
EXISTS (SELECT 1 FROM user_profiles WHERE user_id = auth.uid() AND role = 'admin')
);

Migration filename: 0014_admin_dashboard.sql.

3.7 Edge Functions

Three new functions, all sharing a CF Access JWT verifier helper. No existing Edge Function is modified.

3.7.0 Shared helper — verifyCfAccessJwt(req)

A small TypeScript module at supabase/functions/_shared/cfAccess.ts (Deno-compatible) that:

  1. Reads the Cf-Access-Jwt-Assertion header (or CF-Authorization cookie).
  2. Fetches Cloudflare’s JWKs from https://${CF_ACCESS_TEAM_DOMAIN}/cdn-cgi/access/certs (cached in-process for the function lifetime).
  3. Verifies the JWT signature, issuer, and audience claim against CF_ACCESS_AUD.
  4. Returns the decoded claims ({ email, sub, aud, iss, ... }) or throws.

Required Edge Function secrets across all three new functions: CF_ACCESS_TEAM_DOMAIN, CF_ACCESS_AUD. Use the standard jose package for JWT verification (small, pure-JS, Deno-compatible).

Then each admin function additionally: 5. Looks up the email in user_profiles (SELECT user_id, role FROM user_profiles WHERE email = $1). 6. Returns 403 unless role = 'admin'. 7. Records the admin’s user_id for the audit row.

3.7.1 admin-approve-waitlist (NEW)

POST { waitlist_id: uuid }. Returns { ok, approved_at }.

Steps:

  1. verifyCfAccessJwt(req) → caller email; look up admin user_id via user_profiles (403 if missing or role≠admin).
  2. Snapshot waitlist_requests row before.
  3. UPDATE waitlist_requests SET status='approved', approved_at=now() WHERE id=$1 AND status='pending' RETURNING *. (Idempotent — re-approving is a no-op; no audit row written.)
  4. Snapshot row after.
  5. Insert into admin_audit_log.
  6. Return success. (The existing approve-waitlist Edge Function fires automatically via the database webhook, sending the welcome email and upserting user_profiles.)

3.7.2 admin-set-allowance (NEW)

POST { user_id: uuid, month_limit?: int, allocation_override?: int, allocation_override_expires_at?: date }.

Steps:

  1. verifyCfAccessJwt(req) → admin lookup (403 if not admin).
  2. Validate payload: month_limit ∈ [0, 10000], allocation_override ∈ [0, 10000], allocation_override_expires_at is null or a valid future date.
  3. Snapshot user_profiles row before.
  4. UPDATE only the fields present in the payload.
  5. Snapshot after.
  6. Insert into admin_audit_log.
  7. Return updated row.

3.7.3 admin-posthog-query (NEW)

POST { query: string } where query is one of a fixed allowlist of named queries — NOT an arbitrary HogQL string (avoid giving the client a SQL injection surface against PostHog). Allowlist for v1:

NamePurpose
dau_30dDAU rolling 30 days
wau_12wWAU rolling 12 weeks
mau_6mMAU rolling 6 months
nps_30dNPS responses last 30 days (if event nps_response exists)
marketing_pageviews_30dPageviews on surfc.app last 30 days
waitlist_funnel_30dMarketing-page → waitlist-submit conversion

Steps:

  1. verifyCfAccessJwt(req) → admin lookup (403 if not admin).
  2. Look up the named query in a server-side template map.
  3. POST to https://eu.posthog.com/api/projects/<PROJECT_ID>/query/ with Authorization: Bearer ${POSTHOG_PERSONAL_API_KEY}.
  4. Return the result verbatim. Cache for 5 minutes via Cache-Control: max-age=300.

Required Edge Function secrets: POSTHOG_PROJECT_ID, POSTHOG_PERSONAL_API_KEY.

⚠️ Token note (2026-05-02): The PostHog token in .env (VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, phc_...) is the Project API Key — public, write-only, used by posthog-js for event capture. It cannot read data via the Query API. We need a separate Personal API Key (phx_...) with scope query:read, created at https://eu.posthog.com/settings/user-api-keys. Stored only as the POSTHOG_PERSONAL_API_KEY Supabase Edge Function secret — never in .env, never in any client repo. The numeric project ID (visible in PostHog URLs as /project/<n>/) is stored alongside as POSTHOG_PROJECT_ID.

3.8 Client — Astro pages + React islands

The admin surface is built from Astro pages (src/pages/admin/*.astro) for routing and layout, with React islands (src/components/admin/*.jsx) for anything interactive. Each Astro page server-renders its chrome and embeds one or more React islands marked client:load for forms, tables, and charts.

Astro pages are perfect for this because they’re cheap (no SPA shell to ship) and they coexist with Starlight in the same repo. Each page has its own URL; navigation between admin pages is a normal <a href>. CF Access cookies travel naturally; React islands re-mount on each page load (acceptable — admin operations are infrequent).

3.8.1 Files

src/pages/admin/
├── index.astro # KPI tiles
├── waitlist/
│ ├── index.astro # embeds <WaitlistTable />
│ └── [id].astro # single waitlist row detail
├── users/
│ ├── index.astro # embeds <UserTable />
│ └── [userId].astro # embeds <AllowanceEditor /> + user detail
├── stats.astro # embeds <StatsPanel />
└── audit.astro # embeds <AuditTable />
src/components/admin/
├── WaitlistTable.jsx # interactive table; approve button calls Edge Function
├── UserTable.jsx # paginated, sortable
├── AllowanceEditor.jsx # form for month_limit, allocation_override, expires_at
├── StatsPanel.jsx # PostHog charts wrapper
├── AuditTable.jsx # audit log viewer with expandable diff
└── adminClient.js # fetch wrapper to call admin Edge Functions
src/layouts/
└── AdminLayout.astro # sidebar (Home/Waitlist/Users/Stats/Audit) + topbar

3.8.2 Data layer — adminClient.js

A small fetch wrapper that forwards CF Access cookies to admin Edge Functions. No Supabase client is needed in the intranet for admin reads — every read goes through an Edge Function with the same CF Access JWT auth flow as writes.

src/components/admin/adminClient.js
const SUPABASE_FUNCTIONS_URL = import.meta.env.PUBLIC_SUPABASE_FUNCTIONS_URL;
async function callAdmin(name, payload = {}) {
const res = await fetch(`${SUPABASE_FUNCTIONS_URL}/${name}`, {
method: 'POST',
credentials: 'include', // forwards CF Access cookies
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`${name} ${res.status}: ${await res.text()}`);
return res.json();
}
// Reads
export const adminListPendingWaitlist = () => callAdmin('admin-list-pending-waitlist');
export const adminListAllUsers = (opts) => callAdmin('admin-list-users', opts);
export const adminGetUser = (userId) => callAdmin('admin-get-user', { user_id: userId });
export const adminGetAuditLog = (opts) => callAdmin('admin-get-audit-log', opts);
// Writes
export const adminApproveWaitlist = (id) => callAdmin('admin-approve-waitlist', { waitlist_id: id });
export const adminSetAllowance = (userId, payload) => callAdmin('admin-set-allowance', { user_id: userId, ...payload });
export const adminPosthogQuery = (name) => callAdmin('admin-posthog-query', { query: name });

Note: because admin reads now go through Edge Functions, there are four additional Edge Function endpoints alongside the three writes:

  • admin-list-pending-waitlist — returns pending rows from waitlist_requests.
  • admin-list-users — paginated user_profiles rows.
  • admin-get-user — single user_profiles row.
  • admin-get-audit-log — last N audit rows.

These four are thin (auth check + simple SELECT); they share the same verifyCfAccessJwt helper.

3.9 Acceptance criteria — SUR-230 (lean v1)

Schema & Edge Functions

  • Migration 0014_admin_dashboard.sql adds admin_audit_log + admin-aware read RLS (defense-in-depth) on waitlist_requests and user_profiles. npm run check:schema passes.
  • Shared helper supabase/functions/_shared/cfAccess.ts validates the Cf-Access-Jwt-Assertion header against Cloudflare JWKs.
  • Edge Function admin-approve-waitlist deployed; verifies CF Access JWT + admin role; updates waitlist_requests; writes audit row. Existing webhook → approve-waitlist continues to fire and send email.
  • Edge Function admin-set-allowance deployed; same auth flow; updates only the supplied columns on user_profiles; writes audit row.
  • Edge Function admin-posthog-query deployed; allowlist-validated query names only; PostHog token never in client.
  • Edge Functions admin-list-pending-waitlist, admin-list-users, admin-get-user, admin-get-audit-log deployed; same auth flow; read-only.

Intranet pages

  • intranet.surfc.app/admin/ renders KPI tiles: pending waitlist count, approved-user count, total month_usage across all users, marketing pageviews last 7d.
  • intranet.surfc.app/admin/waitlist lists pending waitlist rows; one-click approve; row disappears after approval.
  • intranet.surfc.app/admin/users lists all users paginated; sortable by percent_use (highest first).
  • intranet.surfc.app/admin/users/:userId shows user detail; allowance editor saves via admin-set-allowance.
  • intranet.surfc.app/admin/stats renders DAU/WAU/MAU sparklines + marketing pageviews + waitlist funnel.
  • intranet.surfc.app/admin/audit lists last 100 admin writes with expandable before/after diff.
  • All admin pages share AdminLayout.astro chrome.
  • PWA ProfileScreen shows “Admin ↗” link only when userProfile.role === 'admin'; opens https://intranet.surfc.app/admin/ in a new tab.

Tests

  • Unit test for verifyCfAccessJwt — happy path with a valid token, signature-invalid, audience-mismatch, expired.
  • Edge Function tests for role lookup on each admin function (admin allowed, non-admin 403).
  • Integration test for adminApproveWaitlist — happy path + idempotent re-approval.
  • Integration test for adminSetAllowance — happy path + validation rejection.
  • React-island tests for WaitlistTable and AllowanceEditor (mocked adminClient calls).

3.10 Open questions — SUR-230

#QuestionDefault if unanswered
Q1What “summary stats” exactly belong on /admin?DAU/WAU/MAU, total approved users, pending count, total month_usage, marketing pageviews last 7d. Anything else is /admin/stats.
Q2Do we need NPS today? Is there an nps_response event being captured anywhere?No event today. Defer NPS — add the event first (separate ticket), then surface here.
Q3Should approving a waitlist row in the admin UI also let the admin pre-set their month_limit?No (v1). New users get the default 50; admin edits the allowance separately if needed.
Q4Should the audit log capture admin reads as well as writes?Writes only (v1). Read-everything would be noisy.
Q5What’s the retention policy for admin_audit_log?Indefinite for now. Tiny table (one row per admin write), no cleanup needed.
Q6Do we want PostHog Insights embeds (an iframe) instead of building our own chart panel?Build our own. Embeds need configuration in PostHog and break behind Cloudflare Access. The Query API is more flexible.
Q7Should /admin be reachable from /wiki via cross-link?Resolved by single-domain architecture. The intranet root / already has cards for both. A small “Admin ↗” link in the Starlight sidebar is fine if added; same CF Access session, no auth handoff needed.

4. Sequencing & dependencies

Both share the surfc-intranet/ repo, so the first ticket landed must bootstrap the repo + CF Access perimeter (SUR-265a + SUR-265b). After that bootstrap, the two tickets can proceed independently. Recommended order:

  1. Intranet bootstrap (SUR-265a + SUR-265b — half a week). New repo, Astro + Starlight + React, Cloudflare Pages, Cloudflare Access policy. Just the shell; no content yet.
  2. SUR-230 second (1–2 weeks). Schema migration + Edge Functions + Astro admin pages + React islands. Unlocks faster waitlist throughput immediately.
  3. SUR-265 third (2–3 weeks). Bulk of the wiki content migration + the four new docs.

If you flip steps 2 and 3, SUR-265 produces a usable wiki sooner but doesn’t reduce operational toil; SUR-230 reduces toil starting day one. (Same recommendation as v1 of this spec.)

Hard dependency: SUR-265a + SUR-265b precede everything. The repo and CF Access perimeter must exist before any other intranet work can land.

Soft dependency: PostHog Personal API key + project ID provisioned before SUR-230d (admin-posthog-query) deploys.


5. Risks

RiskLikelihoodImpactMitigation
Cloudflare Access misconfiguration locks Deji out of the entire intranet — both wiki AND adminMedHighSingle-domain architecture means a CF Access outage takes both surfaces down. Keep markdown in private GitHub repo (read on github.com); for admin operations, fall back to direct Supabase SQL in the dashboard (documented in operations/runbooks/admin-fallback.md).
CF Access JWT verification logic in the shared helper has a subtle bug — e.g. wrong aud claim check, incorrect JWKs cache invalidationLowHighVerification uses the standard jose library, not a hand-rolled crypto path. Unit tests cover happy + 4 failure modes (bad signature, wrong audience, expired, missing header). Failing closed (return 401) is the default.
admin-posthog-query allowlist is too restrictive and forces frequent Edge Function redeploysMedLowAcceptable churn for v1; revisit if painful (queries-as-config in Supabase Storage is a Phase 2 idea).
Wiki migration breaks links from surfc/CLAUDE.md and from the app.surfc.app /help route into help articlesLowLowMigration table preserves all docs/getting-started/* paths (they [KEEP]). CLAUDE.md update is part of the same PR.
PostHog EU API rate limits cause /admin/stats to load slowlyLowLow5-min cache header; admin loads stats infrequently anyway.
An admin accidentally sets a 1-billion month_limit and another user races up usageVery lowMedUI input validation: month_limit clamped to [0, 10000]. Edge Function re-validates server-side. Audit log catches the mistake.
Astro Starlight + plain Astro pages routing collision — Starlight tries to take over /admin/*Very lowMedStarlight only routes content under src/content/docs/; src/pages/admin/* is owned by Astro. Verified at SUR-265a bootstrap with a smoke test (curl /admin/ returns the custom layout, not Starlight chrome).
Admin ProfileScreen link in PWA opens a CF-Access-blocked page when the admin is signed in to PWA but not to CF AccessLowLowCF Access prompts for sign-in on the new tab; user clears the prompt with their existing Google session. One-time friction per CF Access session (24h).

6. Suggested Linear sub-tasks

SUR-265 sub-tasks (intranet shell + wiki content)

  • SUR-265a: Bootstrap surfc-intranet/ repo (Astro + Starlight + React) + Cloudflare Pages deploy at intranet.surfc.app. Includes the / landing page and a placeholder /wiki/ index.
  • SUR-265b: Configure Cloudflare Access policy + IdPs for the entire intranet.surfc.app domain (covers /wiki and /admin). Capture and store CF_ACCESS_AUD + CF_ACCESS_TEAM_DOMAIN for Edge Functions.
  • SUR-265c: Migrate existing internal docs from surfc/docs/ to surfc-intranet/src/content/docs/wiki/.
  • SUR-265d: Write wiki/architecture/components.md registry.
  • SUR-265e: Write wiki/data/tables.md catalog.
  • SUR-265f: Write wiki/operations/ci-cd.md and wiki/how-we-work/onboarding.md.
  • SUR-265g: Update surfc/CLAUDE.md references to new intranet.surfc.app/wiki/... URLs.

SUR-230 sub-tasks (admin surface inside the intranet)

  • SUR-230a: Migration 0014_admin_dashboard.sqladmin_audit_log + admin-aware read RLS (defense-in-depth).
  • SUR-230b: Shared Edge Function helper _shared/cfAccess.ts — CF Access JWT verification using jose and Cloudflare JWKs.
  • SUR-230c: Edge Function admin-approve-waitlist.
  • SUR-230d: Edge Function admin-set-allowance.
  • SUR-230e: Edge Function admin-posthog-query + allowlisted queries.
  • SUR-230f: Read-side Edge Functions: admin-list-pending-waitlist, admin-list-users, admin-get-user, admin-get-audit-log.
  • SUR-230g: AdminLayout.astro + /admin/ index page (KPI tiles).
  • SUR-230h: /admin/waitlist page + WaitlistTable.jsx island + approval flow.
  • SUR-230i: /admin/users page + UserTable.jsx island + /admin/users/[userId] page + AllowanceEditor.jsx island.
  • SUR-230j: /admin/stats page + StatsPanel.jsx island with PostHog charts.
  • SUR-230k: /admin/audit page + AuditTable.jsx island.

(Earlier draft of this list included a 12th sub-task to add an “Admin ↗” link to ProfileScreen.jsx in the PWA. Cut on review — keeps SUR-230 a clean intranet-only ticket. Admins reach the intranet by typing intranet.surfc.app directly. Bookmark it.)


7. Out-of-scope follow-ons (file as separate Linear issues later)

  • Inbound ticketing/triage (the deferred half of SUR-230). Schema sketch: support_tickets(id, user_id?, channel, subject, body, status, assignee, created_at, updated_at, resolved_at). Channels: email, in-app, manual. Replaces the current Inbox-as-Gmail flow.
  • AI chat over wiki content — index intranet.surfc.app/wiki/* content into a vector store; expose a sidebar chat in Starlight backed by Claude.
  • Schema-contract → catalog auto-generation — extend scripts/check-schema.js to also emit wiki/data/tables.md so the catalog stays in lockstep with information_schema.
  • Wiki ↔ Linear sync — auto-render the current cycle’s issues on wiki/product/roadmap.md from the Linear API.
  • Admin v2: bulk operations — bulk approve, bulk allowance, CSV export.
  • Admin v2: in-context user notes — admin adds notes to a user record (e.g. “this user emailed about X”); RLS keeps notes invisible to the user.
  • Service-token / scripted admin access — for future automation, a CF Access service token allows scripts (e.g. CI, scheduled cleanup jobs) to call admin Edge Functions without a human session.

8. Final sanity check

Before this spec turns into PRs, the following should be true:

  • Intranet repo visibility — private (section 2.11 Q1, locked).
  • Cloudflare Access IdPs — Google + One-Time-PIN (section 2.11 Q2, locked).
  • PostHog Personal API key (phx_..., scope query:read) provisioned and project ID recorded (section 3.7.3).
  • No current production user relies on a month_limit outside [0, 10000] (section 5).
  • Sequence: SUR-265a + SUR-265b first (intranet bootstrap), then SUR-230, then the rest of SUR-265 (section 4).
  • CF Access AUD tag + team domain captured and stored as Edge Function secrets (section 2.8 step 6) — only achievable after SUR-265b lands.

Items 3 and 6 are the only remaining blockers. Item 6 falls out naturally from SUR-265b execution; item 3 is a 5-minute manual step.