From f5e7e966e38ee0923b5457fb137d3ccc815e7838 Mon Sep 17 00:00:00 2001 From: pguerrerox Date: Mon, 25 May 2026 15:25:59 +0000 Subject: [PATCH] feat: introduce app-admin authorization and audit logging - add migrations for owner/member workspace roles and application admins - centralize /admin access checks with DB-backed admin resolution - audit admin analytics/billing route access - update account/admin UI typing and env/docs for ADMIN_EMAILS fallback behavior --- .env.example | 23 +- CHANGELOG.md | 12 + README.md | 3 +- TODO-pricing.md | 280 ++---------------- .../0007_workspace_roles_owner_member.sql | 10 + db/migrations/0008_application_admins.sql | 33 +++ server/src/account/repository.ts | 8 +- server/src/auth/admin.ts | 105 +++++++ server/src/config/env.ts | 25 +- server/src/routes/account.ts | 2 +- server/src/routes/analytics.ts | 26 +- server/src/routes/billing.ts | 36 ++- shared/types.ts | 3 +- src/components/AccountPage.tsx | 5 +- 14 files changed, 269 insertions(+), 302 deletions(-) create mode 100644 db/migrations/0007_workspace_roles_owner_member.sql create mode 100644 db/migrations/0008_application_admins.sql create mode 100644 server/src/auth/admin.ts diff --git a/.env.example b/.env.example index dfbfb22..c083aed 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,8 @@ VITE_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY" WEB_PORT="3000" # Backend env vars -# For Docker Compose deployments, point DATABASE_URL at the internal "db" host. -# If your password contains special characters, URL-encode it in DATABASE_URL. +## For Docker Compose deployments, point DATABASE_URL at the internal "db" host. +## If your password contains special characters, URL-encode it in DATABASE_URL. DATABASE_URL="postgres://postgres:postgres@localhost:5432/leads4less" COOKIE_SECRET="CHANGE_ME_IN_LOCAL_ENV" APP_HOST="0.0.0.0" @@ -16,7 +16,22 @@ APP_PORT="4000" APP_ORIGIN="http://localhost:3000" SESSION_TTL_DAYS="30" GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY" -BILLING_ADMIN_EMAILS="ops@example.com" + +## Stripe integration +STRIPE_SECRET_KEY="sk_test_CHANGE_ME" +STRIPE_PUBLISHABLE_KEY="pk_test_CHANGE_ME" +STRIPE_WEBHOOK_SECRET="whsec_CHANGE_ME" +STRIPE_PRICE_STARTER_MONTHLY="price_CHANGE_ME" +STRIPE_PRICE_STARTER_ANNUAL="price_CHANGE_ME" +STRIPE_PRICE_GROWTH_MONTHLY="price_CHANGE_ME" +STRIPE_PRICE_GROWTH_ANNUAL="price_CHANGE_ME" +STRIPE_PRICE_PRO_MONTHLY="price_CHANGE_ME" +STRIPE_PRICE_PRO_ANNUAL="price_CHANGE_ME" +STRIPE_PRICE_EXPORT_PACK_10K="price_CHANGE_ME" +STRIPE_PRICE_EXPORT_PACK_50K="price_CHANGE_ME" +STRIPE_BILLING_PORTAL_CONFIGURATION_ID="bpc_CHANGE_ME" +ADMIN_EMAILS="ops@example.com" +BILLING_ADMIN_EMAILS="ops@example.com" # Deprecated fallback; use ADMIN_EMAILS # Docker Compose database env vars POSTGRES_DB="leads4less" @@ -25,4 +40,4 @@ POSTGRES_PASSWORD="CHANGE_ME_IN_LOCAL_ENV" PG_BOSS_SCHEMA="pgboss" # Example Compose DATABASE_URL -# DATABASE_URL="postgres://postgres:CHANGE_ME_IN_LOCAL_ENV@db:5432/leads4less" +# DATABASE_URL="postgres://postgres:CHANGE_ME_IN_LOCAL_ENV@db:5432/leads4less" \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 5171532..da8ef0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-05-25] + +### Added +- Added migrations to enforce workspace membership roles as `owner`/`member` only and to introduce DB-backed application-admin identities with access-audit storage. +- Added centralized admin authorization and audit helpers so internal `/admin/*` routes can use one shared access check and log admin support activity. + +### Changed +- Replaced env-only billing-admin authorization with application-admin checks backed by database records, while keeping env allowlist fallback support for rollout safety. +- Updated account and workspace permission handling so only workspace owners can manage workspace settings, and admin tooling visibility is driven by the new app-admin identity. +- Updated environment and setup docs for Stripe keys plus the new preferred `ADMIN_EMAILS` allowlist variable (with `BILLING_ADMIN_EMAILS` retained as a deprecated fallback). +- Reorganized the pricing rollout tracker to reflect completed phases, deferred work, and the new app-admin and workspace-role migration milestones. + ## [2026-05-22] ### Added diff --git a/README.md b/README.md index df7ad5f..2631702 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,8 @@ Configure these server-side env vars to enable billing routes: - `STRIPE_PRICE_EXPORT_PACK_10K` - `STRIPE_PRICE_EXPORT_PACK_50K` - `STRIPE_BILLING_PORTAL_CONFIGURATION_ID` optional -- `BILLING_ADMIN_EMAILS` optional comma-separated allowlist for internal billing admin access +- `ADMIN_EMAILS` optional comma-separated allowlist for internal app-admin access (preferred) +- `BILLING_ADMIN_EMAILS` optional deprecated fallback allowlist used when `ADMIN_EMAILS` is unset Notes: diff --git a/TODO-pricing.md b/TODO-pricing.md index 51f43a4..587b285 100644 --- a/TODO-pricing.md +++ b/TODO-pricing.md @@ -6,102 +6,29 @@ - [ ] Protect infrastructure with quotas, credits, throttling, and priority processing. - [ ] Preserve room for future AI, enrichment, API, collaboration, and enterprise expansion. +## Open Questions +- [ ] What is the initial break-glass process if all app admins are disabled or misconfigured (seed command, SQL playbook, or deployment-time bootstrap)? +- [ ] Should app-admin permissions launch as full-access first, or start with explicit scopes (e.g., `billing.read`, `analytics.read`) from day one? +- [ ] Will research capacity be marketed as runs, credits, or both? +- [ ] Which collaboration/team features are truly launch-ready? +- [ ] Should workspace limits be hard-enforced at launch or soft-gated initially? +- [ ] Which add-ons launch on day one vs later? +- [ ] Is founder/LTD part of launch or a separate campaign? +- [ ] What exact enterprise triggers require custom sales instead of self-serve? +- [ ] Which plan data belongs in the canonical catalog versus presentation metadata? + ## 1) Product & Marketing Alignment -- [x] Update product messaging to emphasize: - - local market intelligence - - geographic prospecting - - territory discovery - - operational prospecting workflows -- [x] Remove or reduce copy that frames the product as lead scraping or raw export tooling. -- [x] Define a concise plan-comparison narrative for Starter, Growth, Pro, and Enterprise. -- [x] Make Growth the obvious value anchor in pricing page design and copy. - [ ] Decide whether to update historical/internal naming artifacts separately: - `CHANGELOG.md` historical branding references - `package.json` package name ## 2) Canonical Plan Definitions -- [x] Create a single source of truth for canonical plan definitions in code. -- [x] Keep the canonical catalog separate from presentation metadata: - - catalog = entitlements/commercial packaging data - - presentation = pricing-card copy, marketing bullets, comparison-table labels -- [x] Keep step `#2` scoped to catalog/type design and pricing-page integration only. -- [x] Define these initial SKUs: - - `starter_monthly` - - `growth_monthly` - - `pro_monthly` - - `enterprise_custom` -- [x] Add annual counterparts with 20% discount support. -- [x] Reserve type support for future founder/LTD SKUs without adding them to the active catalog yet. -- [x] Add explicit catalog identity fields for each plan: - - `tier` - - `billingInterval` - - `isSelfServe` - - `contactSalesRequired` -- [x] Include in each plan definition: - - pricing - - monthly usage limits - - workspace/user limits - - feature flags - - queue priority / processing tier - - add-on eligibility -- [x] Treat workspace/user limits as commercial allowances first, not guaranteed enforceable constraints yet. -- [x] Use customer-facing `researchRunsPerMonth` in the initial catalog and defer internal credit-ledger semantics to step `#3`. -- [x] Add lightweight helper accessors around the catalog, for example: - - `getPlanByCode` - - `getSelfServePlans` - - `isAnnualPlan` - - `getPlanDisplayMeta` -- [x] Expand shared billing/account types only enough to support future plan display: - - current plan code nullable - - billing interval nullable - - billing status/message - - no real subscription persistence yet -- [x] Add explicit listing semantics so public pricing visibility does not depend on billing interval. -- [x] Add plan family / sibling linkage to support future annual toggles, plan switching, and analytics rollups. -- [x] Reduce quantitative pricing bullet duplication by deriving core plan facts from structured catalog limits. -- [x] Encode internal feature readiness notes for marketed-but-not-yet-enforced capabilities. - [ ] Follow-up recommendation: clarify whether `getPlanByCode` should stay active-catalog-only or be renamed to make reserved-code behavior explicit. - [ ] Follow-up recommendation: revisit whether `planFamily` should remain separate from `tier` or be consolidated later. - [ ] Follow-up recommendation: consider moving shared plan price/period formatting helpers into the billing domain once account and pricing UI expand. - [ ] Follow-up recommendation: extend readiness modeling beyond feature flags if later steps need readiness for support, processing, or add-on availability. ## 3) Packaging & Entitlement Model -- [x] Decide the internal usage model: - - plan-based research runs, or - - credit ledger with variable credit consumption per action -- [x] Recommended default: use a credit system internally and simpler plan language externally. -- [x] Keep the public catalog and pricing page centered on plan allowances, not internal billing mechanics. -- [x] Define the research credit schedule, for example: - - small local search = 1 credit - - multi-radius query = 3-5 credits - - enriched search = 10 credits -- [x] Define export limits by plan: - - Starter = 2,500/month - - Growth = 15,000/month - - Pro = 75,000/month -- [x] Define what happens at limit exhaustion: - - block - - upgrade prompt - - add-on purchase path - - enterprise/contact sales path -- [x] Implement a shared entitlement policy layer with: - - usage resources/actions - - plan-to-allowance translation helpers - - action cost estimation helpers - - pure entitlement decision helpers -- [x] Separate capability gating from allowance checks in the entitlement layer. -- [x] Add explicit allowance semantics so `null` does not silently mean allowed/unlimited. -- [x] Add canonical action policy definitions for: - - `basic_search_run` - - `deep_research_preview` - - `deep_research_batch_run` - - `csv_export` - - future `enrichment_run` - - future `api_request` -- [x] Keep step `#3` policy-only: - - no DB persistence yet - - no route enforcement yet - - no billing-provider integration yet - [ ] Future note: `evaluateActionEntitlement()` is policy-only and later steps must provide real remaining-usage inputs from subscription/account state. - [ ] Future note: missing readiness metadata currently implies `launch_ready`; keep readiness annotations current as new gated features are added. - [ ] Future note: `api_requests` and `enrichments` are modeled ahead of full product implementation; do not treat them as launch-ready by default. @@ -111,79 +38,12 @@ - [ ] Future note: `territoryMapping` currently carries deep-research capability semantics and may need a dedicated capability later if gating becomes more granular. ## 4) Feature Gates by Plan -- [x] Implement a shared feature-gate interpreter layer that resolves feature state by plan using: - - plan feature flags - - feature readiness metadata - - self-serve vs enterprise upgrade paths -- [x] Starter - - [x] CSV export - - [x] map search - - [x] radius search - - [x] basic filters - - [x] exclude automations - - [x] exclude API access - - [x] exclude enrichments - - [x] exclude CRM integrations - - [x] exclude collaboration -- [x] Growth - - [x] saved searches - - [x] territory mapping - - [x] advanced filtering - - [x] deduplication - - [x] export history - - [x] tagging & notes - - [x] faster processing - - [x] priority support -- [x] Pro - - [x] shared lists - - [x] scheduled research - - [x] bulk exports - - [x] CRM integrations - - [x] webhooks/API - - [x] enrichment credits - - [x] collaboration features -- [x] Enterprise - - [x] pooled or custom usage - - [x] SSO - - [x] SLA - - [x] white-labeling - - [x] onboarding / account management - - [x] dedicated infrastructure options - - [x] custom integrations -- [x] Align feature-gate interpretation with entitlement action mappings in shared code. -- [x] Keep step `#4` shared-policy only: - - no broad UI rollout yet - - no backend route enforcement yet - - no usage-ledger coupling yet - [ ] Future note: make upgrade recommendations readiness-aware so users are not prompted to upgrade into tiers where the target feature is still `coming_soon`. - [ ] Future note: consolidate action ↔ feature mapping into one canonical source shared by `entitlements.ts` and `feature-gates.ts` to avoid drift between UI gating and backend action policy. - [ ] Future note: for Enterprise plans, included-but-not-ready features should usually resolve to `coming_soon` instead of `contact_sales`. - [ ] Future note: revisit the fallback `coming_soon` state for unavailable or unmapped features before broad UI rollout so hidden vs upgrade vs future behavior stays intentional. ## 5) Billing & Data Model Design -- [x] Design subscription/account state separately from the canonical plan catalog. -- [x] Keep billing-provider identifiers out of the canonical catalog until payments integration work begins. -- [x] Design subscription state storage for current plan, billing interval, and status. -- [x] Design a monthly usage ledger for: - - research credits/runs - - exports - - enrichments - - API usage (future) -- [x] Design add-on purchases and remaining balances. -- [x] Define renewal/reset behavior for monthly quotas. -- [x] Define annual billing behavior and renewal terms. -- [x] Define LTD handling with monthly quotas and non-unlimited usage. -- [x] Implement workspace-scoped billing foundation storage for: - - billing accounts - - usage periods - - usage counters - - add-on purchases - - add-on balances -- [x] Add billing repository/service layers and minimal account-data integration. -- [x] Keep step `#5` foundation-only: - - no Stripe/webhook integration yet - - no route enforcement yet - - no full billing UI rollout yet - [ ] Future note: `remaining = 0` for `not_available` resources is intentional and should stay aligned with entitlement semantics. - [ ] Future note: expired billing periods should fail closed for current usage-window resolution until subscription lifecycle automation can advance billing periods reliably. - [ ] Future note: consider exposing `usagePeriodId` later if enforcement, debugging, or admin tooling needs period-level traceability. @@ -192,126 +52,42 @@ - [ ] Future note: usage ownership is workspace-scoped in storage, but current operational enforcement is still catching up to that model. ## 6) Enforcement Architecture -- [x] Create a centralized entitlement/usage policy service on the backend. -- [x] Ensure all high-cost actions check entitlements before execution. -- [x] Start with enforcement on: - - [x] research routes -- [x] Bootstrap new and existing workspaces into a usable pre-payments Starter billing state so enforcement does not hard-block all chargeable actions before subscriptions exist. -- [x] Reuse a shared deep-research estimate path so entitlement cost estimation and batch creation use the same preview-derived basis. -- [x] Add clear API responses for quota exhaustion and upgrade flows. - [ ] Future note: the current enforcement slice should be treated as the core entitlement/runtime gate, not the full operational control layer. - [ ] Future note: the default Starter bootstrap is a pre-payments usability policy and should be revisited when real subscription lifecycle automation is implemented. ## 7) Workspace, User, and Collaboration Readiness -- [x] Review whether current data ownership is sufficiently workspace-scoped for plan promises. -- [x] Identify gaps between current user-scoped data model and promised team/workspace packaging. -- [x] Document which catalog limits can be enforced immediately versus only represented commercially at launch. -- [x] Define how to enforce: - - [x] users included - - [x] workspace limits - - [x] shared assets/lists - - [x] collaboration permissions -- [x] Decide whether some collaboration features need phased rollout rather than immediate sale. -- [x] Add a shared workspace-readiness matrix covering: - - current ownership scope - - target ownership scope - - enforceability state - - collaboration phase definitions -- [x] Document the current collaboration phase as workspace billing with mostly personal data ownership. -- [x] Define the migration target for workspace ownership of: - - `search_jobs` - - `deep_research_batches` - - `businesses` - - `search_job_results` - [ ] Future note: before true collaboration is sold as real runtime behavior, core domain entities need `workspace_id` ownership and repository/query updates. - [ ] Future note: users included and workspace limits should remain soft-gated until multi-workspace UX and shared data ownership mature. - [ ] Future note: shared lists, tagging/notes, and collaboration permissions should not be treated as hard-enforceable features until the workspace migration is complete. ## 8) Pricing Page & Account UX -- [x] Build pricing page from canonical plan definitions instead of hardcoded copy. -- [x] Derive pricing-card and comparison-table content from presentation metadata layered on top of the canonical catalog. -- [x] Add plan comparison table. -- [x] Add annual/monthly toggle. -- [x] Add upgrade CTAs and contact-sales CTA. -- [x] Add account/billing page showing: - - [x] current plan - - [x] billing interval - - [x] usage this month - - [x] remaining quota - - [x] available add-ons - - [x] upgrade options -- [x] Add quota warning UX before hard exhaustion. -- [x] Keep migration-dependent collaboration messaging honest by surfacing included-but-not-ready capabilities as `Coming soon` instead of pretending they are fully live. - [ ] Future note: the pricing comparison table should stay aligned with workspace-readiness decisions as collaboration and shared asset features move toward workspace ownership. - [ ] Future note: upgrade CTAs are present, but actual checkout/subscription management should remain tied to the post-payments hardening step. - [ ] Future note: pricing and account UX should keep users included, workspace limits, and collaboration-adjacent promises explicitly soft-gated until workspace-owned shared data and hard enforcement are ready. - [ ] Future note: replace placeholder upgrade CTAs in the account billing UI with a real upgrade path, pricing-page jump, contact-sales flow, or explicit `coming soon` behavior before broader rollout. ## 9) Add-On Strategy -- [x] Define export add-ons: - - +10k exports = $29 - - +50k exports = $99 -- [x] Define enrichment packs: - - 1,000 enrichments = $49 -- [x] Reserve future add-ons for: - - AI prospecting assistant - - white-label / agency tools - - higher API capacity -- [x] Decide whether add-ons are one-time, monthly recurring, or both. - [ ] Future note: launch active add-ons should stay limited to one-time export packs until enrichment delivery and payments lifecycle handling are live. - [ ] Future note: recurring feature add-ons should not be sold until the underlying capabilities and subscription management flows exist end-to-end. ## 10) Payments Integration -- [x] Choose billing provider (Stripe). -- [x] Map internal SKUs to external billing products/prices. -- [x] Support subscriptions, annual billing, add-ons, and enterprise/manual invoicing. -- [x] Define webhook handling for subscription state changes. - [ ] Future note: Stripe is now the active integration path; keep the internal plan/add-on catalog as the canonical packaging source and treat Stripe price IDs as environment-specific mappings. - [ ] Future note: the current customer-facing integration supports self-serve subscriptions, export-pack checkout, and the Stripe billing portal, while enterprise invoicing remains a manual sales workflow. - [ ] Future note: webhook idempotency currently relies on the `billing_webhook_events` store; keep Stripe event processing centralized there as billing lifecycle coverage expands. - [ ] Future note: post-payments hardening should tighten downgrade, cancellation, retry, and grace-period policy before broad rollout so Stripe portal actions and runtime entitlements stay aligned. ## 11) Post-Payments Hardening & Admin Visibility +- [x] Application admin model: define app-wide `admin` as a separate identity domain from workspace memberships (`owner`/`member`). +- [x] Implement DB-backed app-admin identities as the primary source of truth (active/disabled status, normalized email principal, optional scoped permissions, audit fields). +- [x] Add migration path for current internal admin access: keep temporary env fallback only during rollout, then remove once DB-seeded admins are verified. +- [x] Centralize admin authorization middleware (`requireAdmin`) and replace route-local billing-admin checks so `/admin/*` authorization semantics are consistent. +- [x] Add admin audit visibility: log admin route access and key admin support actions with actor, route/action, target workspace, and timestamp. - [ ] Define explicit downgrade behavior: - effective timing for scheduled vs immediate plan changes - entitlement/usage treatment when the target plan is below current usage - account messaging for pending downgrade state -- [x] Define explicit cancellation behavior: - - end-of-period vs immediate access policy - - post-cancellation account state and reactivation path - - handling for active add-on balances and usage windows -- [x] Define explicit payment retry and grace-period behavior: - - which Stripe states map to degraded vs blocked access - - grace-period duration and user-facing messaging - - when usage actions should warn, soft-block, or hard-block -- [x] Wire pricing-page CTAs to real billing actions: - - self-serve paid plans -> Stripe checkout - - active paid workspaces -> plan-change or billing-portal path - - enterprise plans -> contact-sales path -- [x] Add account redirect notices after Stripe return: - - successful checkout/portal return confirmation - - canceled or incomplete checkout messaging - - failed or unresolved billing-return guidance -- [x] Expand internal admin billing visibility for operational maintenance and debugging: - - show workspace billing summary with current plan, interval, status, renewal date, cancel-at-period-end, and trial/grace-period state - - show current usage period and counters for research, exports, add-ons, and remaining balances - - show recent Stripe/customer/subscription identifiers and latest webhook processing outcomes - - show recent billing timeline entries for checkout, subscription changes, payment failures, cancellations, and add-on purchases - - flag workspaces with stale billing sync, failed webhook processing, or status mismatches needing support follow-up - - document the minimum admin view(s) needed so support can verify billing state without direct database inspection -## 12) Analytics, Ops, and Revenue Instrumentation -- [x] Track pricing-page conversion by plan. -- [x] Track quota exhaustion events. -- [x] Track upgrade triggers: - - export limit hit - - research limit hit - - feature-gate encounter -- [x] Track add-on attach rate. -- [x] Track plan mix, churn, expansion revenue, and annual conversion. -- [x] Add internal dashboards for billing and usage health. - -## 13) Operational Enforcement Follow-Up +## 12) [DEFER] Operational Enforcement Follow-Up - [ ] Add queue prioritization by plan tier. - [ ] Add throttling/fair-usage controls. - [ ] Add export-route enforcement once CSV/export generation moves to a backend endpoint. @@ -321,7 +97,7 @@ - [ ] Future note: export enforcement remains deferred until CSV/export generation moves to a backend endpoint. - [ ] Future note: enrichment-route enforcement remains deferred until enrichment actions/routes are implemented. -## 14) Founder / LTD Strategy +## 13) [DEFER] Founder / LTD Strategy - [ ] Decide whether to launch founder LTD at all. - [ ] If yes, define strict quantity cap (e.g. first 100-250 customers). - [ ] Define founder SKUs: @@ -330,10 +106,12 @@ - [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API. - [ ] Define which future features are excluded from LTD plans. -## 15) Rollout Plan +## 14) Rollout Plan - [ ] Phase 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model. - [ ] Phase 2: implement usage ledger and backend enforcement. - [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises. +- [ ] Phase 3a: execute workspace-role migration (`owner`/`member` only), convert legacy workspace admins to members by default, and validate owner-only management paths. +- [ ] Phase 3b: launch DB-backed app-admin identity management and migrate `/admin/*` authorization to centralized app-admin middleware. - [ ] Phase 4: update pricing page and account/billing UI based on the workspace/collaboration readiness decisions. - [ ] Phase 5: finalize add-on strategy before wiring payment products. - [ ] Phase 6: integrate payments and subscription lifecycle handling. @@ -342,19 +120,3 @@ - [ ] Phase 9: launch collaboration, API, enrichment, and enterprise features as architecture matures. - [ ] Phase 10: complete deferred operational enforcement work such as queue prioritization, throttling, and backend export enforcement when runtime scale justifies it. - [ ] Phase 11: decide and implement founder/LTD strategy only after the app/site, billing lifecycle, admin/support visibility, analytics, and broader product maturity work are in place. - -## Recommended Execution Order -- [ ] Next: `#11 Post-Payments Hardening & Admin Visibility` -- [ ] Then: `#13 Analytics, Ops, and Revenue Instrumentation` -- [ ] Then: collaboration, API, enrichment, and enterprise feature maturation from `#15 Rollout Plan` -- [ ] Keep `#14 Operational Enforcement Follow-Up` deferred until async worker routing, backend exports, or higher-volume execution patterns make it necessary. -- [ ] Last: `#12 Founder / LTD Strategy` once the app/site, billing lifecycle, admin/support visibility, analytics, and broader product maturity work are in place. - -## Open Questions -- [ ] Will research capacity be marketed as runs, credits, or both? -- [ ] Which collaboration/team features are truly launch-ready? -- [ ] Should workspace limits be hard-enforced at launch or soft-gated initially? -- [ ] Which add-ons launch on day one vs later? -- [ ] Is founder/LTD part of launch or a separate campaign? -- [ ] What exact enterprise triggers require custom sales instead of self-serve? -- [ ] Which plan data belongs in the canonical catalog versus presentation metadata? diff --git a/db/migrations/0007_workspace_roles_owner_member.sql b/db/migrations/0007_workspace_roles_owner_member.sql new file mode 100644 index 0000000..5b6b7e2 --- /dev/null +++ b/db/migrations/0007_workspace_roles_owner_member.sql @@ -0,0 +1,10 @@ +update public.workspace_memberships +set role = 'member' +where role = 'admin'; + +alter table public.workspace_memberships +drop constraint if exists workspace_memberships_role_check; + +alter table public.workspace_memberships +add constraint workspace_memberships_role_check +check (role in ('owner', 'member')); diff --git a/db/migrations/0008_application_admins.sql b/db/migrations/0008_application_admins.sql new file mode 100644 index 0000000..1f305ca --- /dev/null +++ b/db/migrations/0008_application_admins.sql @@ -0,0 +1,33 @@ +create table if not exists public.application_admins ( + id uuid primary key default gen_random_uuid(), + email text not null, + email_normalized text not null, + status text not null default 'active' check (status in ('active', 'disabled')), + permissions_json jsonb not null default '[]'::jsonb, + created_by_user_id uuid references public.users (id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists application_admins_email_normalized_idx on public.application_admins (email_normalized); + +drop trigger if exists set_application_admins_updated_at on public.application_admins; +create trigger set_application_admins_updated_at +before update on public.application_admins +for each row +execute function public.set_updated_at(); + +create table if not exists public.admin_access_audit ( + id uuid primary key default gen_random_uuid(), + actor_user_id uuid references public.users (id) on delete set null, + actor_email text, + route text not null, + action text not null, + target_workspace_id uuid references public.workspaces (id) on delete set null, + metadata_json jsonb not null default '{}'::jsonb, + occurred_at timestamptz not null default now() +); + +create index if not exists admin_access_audit_occurred_at_idx on public.admin_access_audit (occurred_at desc); +create index if not exists admin_access_audit_actor_user_id_idx on public.admin_access_audit (actor_user_id); +create index if not exists admin_access_audit_target_workspace_id_idx on public.admin_access_audit (target_workspace_id); diff --git a/server/src/account/repository.ts b/server/src/account/repository.ts index 46e28e0..511f3f9 100644 --- a/server/src/account/repository.ts +++ b/server/src/account/repository.ts @@ -1,7 +1,7 @@ import type { Pool, PoolClient } from 'pg'; import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js'; import { getWorkspaceBillingState } from '../billing/service.js'; -import { isBillingAdminEmail } from '../config/env.js'; +import { isApplicationAdmin } from '../auth/admin.js'; type DbClient = Pool | PoolClient; @@ -155,6 +155,7 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise const summary = await getAccountSummaryForUser(db, user.id); const billing = await getWorkspaceBillingState(db, workspace.id); + const isAdmin = await isApplicationAdmin(db, user.email); return { profile: user, @@ -162,10 +163,11 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise summary, billing, team: { - canManageMembers: workspace.role === 'owner' || workspace.role === 'admin', + canManageMembers: workspace.role === 'owner', message: 'Workspace member management is coming soon.', }, - isBillingAdmin: isBillingAdminEmail(user.email), + isAdmin, + isBillingAdmin: isAdmin, }; } diff --git a/server/src/auth/admin.ts b/server/src/auth/admin.ts new file mode 100644 index 0000000..65a3b92 --- /dev/null +++ b/server/src/auth/admin.ts @@ -0,0 +1,105 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { Pool, PoolClient } from 'pg'; +import { getAdminEmailAllowlist } from '../config/env.js'; +import { getDbPool } from '../db/pool.js'; + +type DbClient = Pool | PoolClient; + +type ApplicationAdminRow = { + id: string; + email: string; + email_normalized: string; + status: 'active' | 'disabled'; + permissions_json: unknown; + created_by_user_id: string | null; + created_at: string; + updated_at: string; +}; + +type AdminAccessAuditPayload = { + actorUserId?: string | null; + actorEmail?: string | null; + route: string; + action: string; + targetWorkspaceId?: string | null; + metadataJson?: Record; + occurredAt?: string; +}; + +export function normalizeEmail(email: string) { + return email.trim().toLowerCase(); +} + +export function isAdminEmailAllowlisted(email: string) { + return getAdminEmailAllowlist().includes(normalizeEmail(email)); +} + +export async function getApplicationAdminByEmail(db: DbClient, email: string) { + const result = await db.query( + ` + select id, email, email_normalized, status, permissions_json, created_by_user_id, created_at, updated_at + from public.application_admins + where email_normalized = $1 + limit 1 + `, + [normalizeEmail(email)], + ); + + return result.rows[0] ?? null; +} + +export async function isApplicationAdmin(db: DbClient, email: string) { + const admin = await getApplicationAdminByEmail(db, email); + + if (admin) { + if (admin.status === 'disabled') { + return false; + } + + if (admin.status === 'active') { + return true; + } + } + + return isAdminEmailAllowlisted(email); +} + +export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) { + const user = request.authUser; + + if (!user) { + return reply.code(403).send({ error: 'Admin access is required.' }); + } + + const hasAccess = await isApplicationAdmin(getDbPool(), user.email); + if (!hasAccess) { + return reply.code(403).send({ error: 'Admin access is required.' }); + } + + return undefined; +} + +export async function recordAdminAccessAudit(db: DbClient, payload: AdminAccessAuditPayload) { + await db.query( + ` + insert into public.admin_access_audit ( + actor_user_id, + actor_email, + route, + action, + target_workspace_id, + metadata_json, + occurred_at + ) values ($1, $2, $3, $4, $5, $6::jsonb, coalesce($7::timestamptz, now())) + `, + [ + payload.actorUserId ?? null, + payload.actorEmail ?? null, + payload.route, + payload.action, + payload.targetWorkspaceId ?? null, + JSON.stringify(payload.metadataJson ?? {}), + payload.occurredAt ?? null, + ], + ); +} diff --git a/server/src/config/env.ts b/server/src/config/env.ts index b7250c1..ec0696f 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -38,6 +38,7 @@ const envSchema = z.object({ STRIPE_PRICE_EXPORT_PACK_10K: z.string().optional(), STRIPE_PRICE_EXPORT_PACK_50K: z.string().optional(), STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(), + ADMIN_EMAILS: z.string().optional(), BILLING_ADMIN_EMAILS: z.string().optional(), }); @@ -54,16 +55,28 @@ export function getEnv(): AppEnv { return cachedEnv; } -export function isBillingAdminEmail(email: string) { - const allowlist = getEnv().BILLING_ADMIN_EMAILS; - +function parseAdminEmailAllowlist(allowlist: string | undefined): string[] { if (!allowlist) { - return false; + return []; } return allowlist .split(',') .map((entry) => entry.trim().toLowerCase()) - .filter(Boolean) - .includes(email.trim().toLowerCase()); + .filter(Boolean); +} + +export function getAdminEmailAllowlist() { + const env = getEnv(); + return parseAdminEmailAllowlist(env.ADMIN_EMAILS || env.BILLING_ADMIN_EMAILS); +} + +export function isBillingAdminEmail(email: string) { + const allowlist = getAdminEmailAllowlist(); + + if (allowlist.length === 0) { + return false; + } + + return allowlist.includes(email.trim().toLowerCase()); } diff --git a/server/src/routes/account.ts b/server/src/routes/account.ts index 29126b2..69a703a 100644 --- a/server/src/routes/account.ts +++ b/server/src/routes/account.ts @@ -31,7 +31,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => { return reply.code(500).send({ error: 'Failed to load workspace.' }); } - if (payload.workspaceName && workspace.role !== 'owner' && workspace.role !== 'admin') { + if (payload.workspaceName && workspace.role !== 'owner') { return reply.code(403).send({ error: 'You do not have permission to update this workspace.' }); } diff --git a/server/src/routes/analytics.ts b/server/src/routes/analytics.ts index f01b485..7253067 100644 --- a/server/src/routes/analytics.ts +++ b/server/src/routes/analytics.ts @@ -1,8 +1,8 @@ -import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyPluginAsync } from 'fastify'; import { z, ZodError } from 'zod'; import { ensureWorkspaceForUser } from '../account/repository.js'; import { hydrateAuthUser, requireAuth } from '../auth/middleware.js'; -import { isBillingAdminEmail } from '../config/env.js'; +import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js'; import { getDbPool } from '../db/pool.js'; import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js'; @@ -37,14 +37,6 @@ const summaryQuerySchema = z.object({ days: z.coerce.number().int().min(7).max(90).optional(), }); -async function requireBillingAdmin(request: FastifyRequest, reply: FastifyReply) { - if (!request.authUser || !isBillingAdminEmail(request.authUser.email)) { - return reply.code(403).send({ error: 'Billing admin access is required.' }); - } - - return undefined; -} - export const analyticsRoutes: FastifyPluginAsync = async (app) => { app.post('/analytics/events', async (request, reply) => { try { @@ -75,10 +67,20 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => { } }); - app.get('/admin/analytics/summary', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => { + app.get('/admin/analytics/summary', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { try { const query = summaryQuerySchema.parse(request.query ?? {}); - const summary = await getAdminAnalyticsSummary(getDbPool(), query.days ?? 30); + const db = getDbPool(); + await recordAdminAccessAudit(db, { + actorUserId: request.authUser?.id, + actorEmail: request.authUser?.email, + route: request.routeOptions.url ?? request.url, + action: 'analytics_summary', + metadataJson: { + days: query.days ?? 30, + }, + }); + const summary = await getAdminAnalyticsSummary(db, query.days ?? 30); return { summary }; } catch (error) { if (error instanceof ZodError) { diff --git a/server/src/routes/billing.ts b/server/src/routes/billing.ts index dcac8a9..7cd36aa 100644 --- a/server/src/routes/billing.ts +++ b/server/src/routes/billing.ts @@ -1,13 +1,13 @@ -import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import type { FastifyPluginAsync } from 'fastify'; import { z, ZodError } from 'zod'; import { getDbPool } from '../db/pool.js'; import { requireAuth } from '../auth/middleware.js'; +import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js'; import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js'; import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js'; import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js'; import { constructStripeWebhookEvent, processStripeWebhookEvent } from '../payments/webhooks.js'; import { ensureWorkspaceForUser } from '../account/repository.js'; -import { isBillingAdminEmail } from '../config/env.js'; import { ensureBillingAccountForWorkspace, getBillingAdminWorkspaceSummaryByWorkspaceId, @@ -41,14 +41,6 @@ function parseJsonBody(body: unknown, schema: z.ZodSchema) { return schema.parse(JSON.parse(body) as unknown); } -async function requireBillingAdmin(request: FastifyRequest, reply: FastifyReply) { - if (!request.authUser || !isBillingAdminEmail(request.authUser.email)) { - return reply.code(403).send({ error: 'Billing admin access is required.' }); - } - - return undefined; -} - export const billingRoutes: FastifyPluginAsync = async (app) => { app.addContentTypeParser('application/json', { parseAs: 'string' }, (_request, body, done) => { done(null, body); @@ -121,11 +113,22 @@ export const billingRoutes: FastifyPluginAsync = async (app) => { } }); - app.get('/admin/billing/workspaces', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => { + app.get('/admin/billing/workspaces', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { try { const query = adminWorkspaceQuerySchema.parse(request.query ?? {}); + const db = getDbPool(); + await recordAdminAccessAudit(db, { + actorUserId: request.authUser?.id, + actorEmail: request.authUser?.email, + route: request.routeOptions.url ?? request.url, + action: 'billing_workspaces_list', + metadataJson: { + query: query.query ?? null, + limit: query.limit ?? 50, + }, + }); const workspaces = await listBillingAdminWorkspaceSummaries( - getDbPool(), + db, query.query ?? null, query.limit ?? 50, ); @@ -140,10 +143,17 @@ export const billingRoutes: FastifyPluginAsync = async (app) => { } }); - app.get('/admin/billing/workspaces/:workspaceId', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => { + app.get('/admin/billing/workspaces/:workspaceId', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => { try { const { workspaceId } = adminWorkspaceParamsSchema.parse(request.params); const db = getDbPool(); + await recordAdminAccessAudit(db, { + actorUserId: request.authUser?.id, + actorEmail: request.authUser?.email, + route: request.routeOptions.url ?? request.url, + action: 'billing_workspace_detail', + targetWorkspaceId: workspaceId, + }); const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId); if (!summary) { diff --git a/shared/types.ts b/shared/types.ts index 3cb7874..27ddecb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -18,7 +18,7 @@ export interface SessionUser extends AppUser { } export type WorkspaceType = 'personal' | 'company'; -export type WorkspaceRole = 'owner' | 'admin' | 'member'; +export type WorkspaceRole = 'owner' | 'member'; export interface AccountWorkspace { id: string; @@ -174,6 +174,7 @@ export interface AccountPageData { summary: AccountSummary; billing: AccountBillingState; team: AccountTeamPlaceholder; + isAdmin?: boolean; isBillingAdmin?: boolean; } diff --git a/src/components/AccountPage.tsx b/src/components/AccountPage.tsx index 1aa132a..f31b4ca 100644 --- a/src/components/AccountPage.tsx +++ b/src/components/AccountPage.tsx @@ -54,6 +54,7 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul const [adminWorkspaces, setAdminWorkspaces] = useState([]); const [adminWorkspaceDetail, setAdminWorkspaceDetail] = useState(null); const [adminLoading, setAdminLoading] = useState(false); + const isAdmin = account?.isAdmin ?? account?.isBillingAdmin ?? false; useEffect(() => { let isMounted = true; @@ -75,7 +76,7 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul setWorkspaceName(nextAccount.workspace.name); setNotice(getBillingReturnNotice()); - if (nextAccount.isBillingAdmin) { + if ((nextAccount.isAdmin ?? nextAccount.isBillingAdmin ?? false)) { setAdminLoading(true); const adminResponse = await listAdminBillingWorkspaces(); if (isMounted) { @@ -633,7 +634,7 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul

{account.team.message}

- {account.isBillingAdmin ? ( + {isAdmin ? (