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
This commit is contained in:
+19
-4
@@ -7,8 +7,8 @@ VITE_GOOGLE_MAPS_PLATFORM_KEY="YOUR_BROWSER_MAPS_KEY"
|
|||||||
WEB_PORT="3000"
|
WEB_PORT="3000"
|
||||||
|
|
||||||
# Backend env vars
|
# Backend env vars
|
||||||
# For Docker Compose deployments, point DATABASE_URL at the internal "db" host.
|
## For Docker Compose deployments, point DATABASE_URL at the internal "db" host.
|
||||||
# If your password contains special characters, URL-encode it in DATABASE_URL.
|
## If your password contains special characters, URL-encode it in DATABASE_URL.
|
||||||
DATABASE_URL="postgres://postgres:postgres@localhost:5432/leads4less"
|
DATABASE_URL="postgres://postgres:postgres@localhost:5432/leads4less"
|
||||||
COOKIE_SECRET="CHANGE_ME_IN_LOCAL_ENV"
|
COOKIE_SECRET="CHANGE_ME_IN_LOCAL_ENV"
|
||||||
APP_HOST="0.0.0.0"
|
APP_HOST="0.0.0.0"
|
||||||
@@ -16,7 +16,22 @@ APP_PORT="4000"
|
|||||||
APP_ORIGIN="http://localhost:3000"
|
APP_ORIGIN="http://localhost:3000"
|
||||||
SESSION_TTL_DAYS="30"
|
SESSION_TTL_DAYS="30"
|
||||||
GOOGLE_MAPS_SERVER_KEY="YOUR_SERVER_MAPS_KEY"
|
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
|
# Docker Compose database env vars
|
||||||
POSTGRES_DB="leads4less"
|
POSTGRES_DB="leads4less"
|
||||||
@@ -25,4 +40,4 @@ POSTGRES_PASSWORD="CHANGE_ME_IN_LOCAL_ENV"
|
|||||||
PG_BOSS_SCHEMA="pgboss"
|
PG_BOSS_SCHEMA="pgboss"
|
||||||
|
|
||||||
# Example Compose DATABASE_URL
|
# 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"
|
||||||
@@ -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/).
|
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]
|
## [2026-05-22]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ Configure these server-side env vars to enable billing routes:
|
|||||||
- `STRIPE_PRICE_EXPORT_PACK_10K`
|
- `STRIPE_PRICE_EXPORT_PACK_10K`
|
||||||
- `STRIPE_PRICE_EXPORT_PACK_50K`
|
- `STRIPE_PRICE_EXPORT_PACK_50K`
|
||||||
- `STRIPE_BILLING_PORTAL_CONFIGURATION_ID` optional
|
- `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:
|
Notes:
|
||||||
|
|
||||||
|
|||||||
+21
-259
@@ -6,102 +6,29 @@
|
|||||||
- [ ] Protect infrastructure with quotas, credits, throttling, and priority processing.
|
- [ ] Protect infrastructure with quotas, credits, throttling, and priority processing.
|
||||||
- [ ] Preserve room for future AI, enrichment, API, collaboration, and enterprise expansion.
|
- [ ] 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
|
## 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:
|
- [ ] Decide whether to update historical/internal naming artifacts separately:
|
||||||
- `CHANGELOG.md` historical branding references
|
- `CHANGELOG.md` historical branding references
|
||||||
- `package.json` package name
|
- `package.json` package name
|
||||||
|
|
||||||
## 2) Canonical Plan Definitions
|
## 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: 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: 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: 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.
|
- [ ] 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
|
## 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: `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: 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.
|
- [ ] 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.
|
- [ ] 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
|
## 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: 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: 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: 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.
|
- [ ] 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
|
## 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: `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: 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.
|
- [ ] 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.
|
- [ ] Future note: usage ownership is workspace-scoped in storage, but current operational enforcement is still catching up to that model.
|
||||||
|
|
||||||
## 6) Enforcement Architecture
|
## 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 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.
|
- [ ] 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
|
## 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: 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: 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.
|
- [ ] 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
|
## 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: 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: 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: 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.
|
- [ ] 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
|
## 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: 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.
|
- [ ] 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
|
## 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: 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: 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: 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.
|
- [ ] 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
|
## 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:
|
- [ ] Define explicit downgrade behavior:
|
||||||
- effective timing for scheduled vs immediate plan changes
|
- effective timing for scheduled vs immediate plan changes
|
||||||
- entitlement/usage treatment when the target plan is below current usage
|
- entitlement/usage treatment when the target plan is below current usage
|
||||||
- account messaging for pending downgrade state
|
- 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
|
## 12) [DEFER] Operational Enforcement Follow-Up
|
||||||
- [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
|
|
||||||
- [ ] Add queue prioritization by plan tier.
|
- [ ] Add queue prioritization by plan tier.
|
||||||
- [ ] Add throttling/fair-usage controls.
|
- [ ] Add throttling/fair-usage controls.
|
||||||
- [ ] Add export-route enforcement once CSV/export generation moves to a backend endpoint.
|
- [ ] 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: 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.
|
- [ ] 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.
|
- [ ] Decide whether to launch founder LTD at all.
|
||||||
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
|
- [ ] If yes, define strict quantity cap (e.g. first 100-250 customers).
|
||||||
- [ ] Define founder SKUs:
|
- [ ] Define founder SKUs:
|
||||||
@@ -330,10 +106,12 @@
|
|||||||
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
|
- [ ] Ensure founder plans have monthly quotas and exclude unlimited compute/API.
|
||||||
- [ ] Define which future features are excluded from LTD plans.
|
- [ ] 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 1: finalize canonical plan definitions, presentation metadata boundaries, and entitlement model.
|
||||||
- [ ] Phase 2: implement usage ledger and backend enforcement.
|
- [ ] Phase 2: implement usage ledger and backend enforcement.
|
||||||
- [ ] Phase 3: review workspace, user, and collaboration readiness before expanding team/workspace promises.
|
- [ ] 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 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 5: finalize add-on strategy before wiring payment products.
|
||||||
- [ ] Phase 6: integrate payments and subscription lifecycle handling.
|
- [ ] 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 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 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.
|
- [ ] 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?
|
|
||||||
|
|||||||
@@ -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'));
|
||||||
@@ -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);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Pool, PoolClient } from 'pg';
|
import type { Pool, PoolClient } from 'pg';
|
||||||
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
|
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
|
||||||
import { getWorkspaceBillingState } from '../billing/service.js';
|
import { getWorkspaceBillingState } from '../billing/service.js';
|
||||||
import { isBillingAdminEmail } from '../config/env.js';
|
import { isApplicationAdmin } from '../auth/admin.js';
|
||||||
|
|
||||||
type DbClient = Pool | PoolClient;
|
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 summary = await getAccountSummaryForUser(db, user.id);
|
||||||
const billing = await getWorkspaceBillingState(db, workspace.id);
|
const billing = await getWorkspaceBillingState(db, workspace.id);
|
||||||
|
const isAdmin = await isApplicationAdmin(db, user.email);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile: user,
|
profile: user,
|
||||||
@@ -162,10 +163,11 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise
|
|||||||
summary,
|
summary,
|
||||||
billing,
|
billing,
|
||||||
team: {
|
team: {
|
||||||
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
|
canManageMembers: workspace.role === 'owner',
|
||||||
message: 'Workspace member management is coming soon.',
|
message: 'Workspace member management is coming soon.',
|
||||||
},
|
},
|
||||||
isBillingAdmin: isBillingAdminEmail(user.email),
|
isAdmin,
|
||||||
|
isBillingAdmin: isAdmin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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<ApplicationAdminRow>(
|
||||||
|
`
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ const envSchema = z.object({
|
|||||||
STRIPE_PRICE_EXPORT_PACK_10K: z.string().optional(),
|
STRIPE_PRICE_EXPORT_PACK_10K: z.string().optional(),
|
||||||
STRIPE_PRICE_EXPORT_PACK_50K: z.string().optional(),
|
STRIPE_PRICE_EXPORT_PACK_50K: z.string().optional(),
|
||||||
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
|
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
|
||||||
|
ADMIN_EMAILS: z.string().optional(),
|
||||||
BILLING_ADMIN_EMAILS: z.string().optional(),
|
BILLING_ADMIN_EMAILS: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,16 +55,28 @@ export function getEnv(): AppEnv {
|
|||||||
return cachedEnv;
|
return cachedEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isBillingAdminEmail(email: string) {
|
function parseAdminEmailAllowlist(allowlist: string | undefined): string[] {
|
||||||
const allowlist = getEnv().BILLING_ADMIN_EMAILS;
|
|
||||||
|
|
||||||
if (!allowlist) {
|
if (!allowlist) {
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return allowlist
|
return allowlist
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((entry) => entry.trim().toLowerCase())
|
.map((entry) => entry.trim().toLowerCase())
|
||||||
.filter(Boolean)
|
.filter(Boolean);
|
||||||
.includes(email.trim().toLowerCase());
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.code(500).send({ error: 'Failed to load workspace.' });
|
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.' });
|
return reply.code(403).send({ error: 'You do not have permission to update this workspace.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
import { z, ZodError } from 'zod';
|
import { z, ZodError } from 'zod';
|
||||||
import { ensureWorkspaceForUser } from '../account/repository.js';
|
import { ensureWorkspaceForUser } from '../account/repository.js';
|
||||||
import { hydrateAuthUser, requireAuth } from '../auth/middleware.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 { getDbPool } from '../db/pool.js';
|
||||||
import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.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(),
|
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) => {
|
export const analyticsRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.post('/analytics/events', async (request, reply) => {
|
app.post('/analytics/events', async (request, reply) => {
|
||||||
try {
|
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 {
|
try {
|
||||||
const query = summaryQuerySchema.parse(request.query ?? {});
|
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 };
|
return { summary };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
import { z, ZodError } from 'zod';
|
import { z, ZodError } from 'zod';
|
||||||
import { getDbPool } from '../db/pool.js';
|
import { getDbPool } from '../db/pool.js';
|
||||||
import { requireAuth } from '../auth/middleware.js';
|
import { requireAuth } from '../auth/middleware.js';
|
||||||
|
import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
|
||||||
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
|
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
|
||||||
import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js';
|
import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js';
|
||||||
import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js';
|
import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js';
|
||||||
import { constructStripeWebhookEvent, processStripeWebhookEvent } from '../payments/webhooks.js';
|
import { constructStripeWebhookEvent, processStripeWebhookEvent } from '../payments/webhooks.js';
|
||||||
import { ensureWorkspaceForUser } from '../account/repository.js';
|
import { ensureWorkspaceForUser } from '../account/repository.js';
|
||||||
import { isBillingAdminEmail } from '../config/env.js';
|
|
||||||
import {
|
import {
|
||||||
ensureBillingAccountForWorkspace,
|
ensureBillingAccountForWorkspace,
|
||||||
getBillingAdminWorkspaceSummaryByWorkspaceId,
|
getBillingAdminWorkspaceSummaryByWorkspaceId,
|
||||||
@@ -41,14 +41,6 @@ function parseJsonBody<T>(body: unknown, schema: z.ZodSchema<T>) {
|
|||||||
return schema.parse(JSON.parse(body) as unknown);
|
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) => {
|
export const billingRoutes: FastifyPluginAsync = async (app) => {
|
||||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_request, body, done) => {
|
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_request, body, done) => {
|
||||||
done(null, body);
|
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 {
|
try {
|
||||||
const query = adminWorkspaceQuerySchema.parse(request.query ?? {});
|
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(
|
const workspaces = await listBillingAdminWorkspaceSummaries(
|
||||||
getDbPool(),
|
db,
|
||||||
query.query ?? null,
|
query.query ?? null,
|
||||||
query.limit ?? 50,
|
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 {
|
try {
|
||||||
const { workspaceId } = adminWorkspaceParamsSchema.parse(request.params);
|
const { workspaceId } = adminWorkspaceParamsSchema.parse(request.params);
|
||||||
const db = getDbPool();
|
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);
|
const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId);
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
|
|||||||
+2
-1
@@ -18,7 +18,7 @@ export interface SessionUser extends AppUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceType = 'personal' | 'company';
|
export type WorkspaceType = 'personal' | 'company';
|
||||||
export type WorkspaceRole = 'owner' | 'admin' | 'member';
|
export type WorkspaceRole = 'owner' | 'member';
|
||||||
|
|
||||||
export interface AccountWorkspace {
|
export interface AccountWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -174,6 +174,7 @@ export interface AccountPageData {
|
|||||||
summary: AccountSummary;
|
summary: AccountSummary;
|
||||||
billing: AccountBillingState;
|
billing: AccountBillingState;
|
||||||
team: AccountTeamPlaceholder;
|
team: AccountTeamPlaceholder;
|
||||||
|
isAdmin?: boolean;
|
||||||
isBillingAdmin?: boolean;
|
isBillingAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
|||||||
const [adminWorkspaces, setAdminWorkspaces] = useState<BillingAdminWorkspaceSummary[]>([]);
|
const [adminWorkspaces, setAdminWorkspaces] = useState<BillingAdminWorkspaceSummary[]>([]);
|
||||||
const [adminWorkspaceDetail, setAdminWorkspaceDetail] = useState<BillingAdminWorkspaceDetail | null>(null);
|
const [adminWorkspaceDetail, setAdminWorkspaceDetail] = useState<BillingAdminWorkspaceDetail | null>(null);
|
||||||
const [adminLoading, setAdminLoading] = useState(false);
|
const [adminLoading, setAdminLoading] = useState(false);
|
||||||
|
const isAdmin = account?.isAdmin ?? account?.isBillingAdmin ?? false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -75,7 +76,7 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
|||||||
setWorkspaceName(nextAccount.workspace.name);
|
setWorkspaceName(nextAccount.workspace.name);
|
||||||
setNotice(getBillingReturnNotice());
|
setNotice(getBillingReturnNotice());
|
||||||
|
|
||||||
if (nextAccount.isBillingAdmin) {
|
if ((nextAccount.isAdmin ?? nextAccount.isBillingAdmin ?? false)) {
|
||||||
setAdminLoading(true);
|
setAdminLoading(true);
|
||||||
const adminResponse = await listAdminBillingWorkspaces();
|
const adminResponse = await listAdminBillingWorkspaces();
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
@@ -633,7 +634,7 @@ export function AccountPage({ user, onUserUpdated, initialCheckoutPlanCode = nul
|
|||||||
<p className="mt-4 text-sm text-stone-600">{account.team.message}</p>
|
<p className="mt-4 text-sm text-stone-600">{account.team.message}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{account.isBillingAdmin ? (
|
{isAdmin ? (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user