diff --git a/docs/04-backlog.json b/docs/04-backlog.json index ebfdc21..e76e288 100644 --- a/docs/04-backlog.json +++ b/docs/04-backlog.json @@ -38,6 +38,19 @@ "Integration tests cover allow/deny matrix for critical tables", "Admin console exposes role assignment behind flag" ], + "subtasks": [ + "Schema hardening: add foreign keys, composite keys, and indexes for roles/profile_roles/spaces tables", + "Space model: create spaces, space_members, and space_rules with canonical role slugs", + "Content model: ensure posts, post_versions, comments reference spaces and enforce NOT NULL slugs", + "RLS deny-by-default: implement policies for identity, community, content, safety, platform tables", + "Helper SQL: highest_role(profile_id) + legacy slug mapping function with unit tests", + "App guards: reuse requireAdmin across admin routes, emit telemetry + audit logs", + "Admin UI: role manager behind rbac_hardening_v1 with audit trail", + "Telemetry: authz_denied_count dashboard panel + tagged increments", + "Tests: integration matrix role×action×table + admin route unit tests", + "Accessibility: axe scan for role manager surface", + "Docs: security matrix update, RLS denial spike runbook, data model deltas" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated (auth matrix in /docs/07-security-privacy.md)", @@ -63,6 +76,14 @@ "Design tokens defined for color, spacing, typography, elevation", "Dark/light mode parity maintained" ], + "subtasks": [ + "Tokens: extend Tailwind theme for color, spacing, typography, elevation, radius with dark parity", + "Global nav: expose Spaces, Feeds, Events, Funding, Projects, Admin via nav_ia_v1 + RBAC checks", + "Content page application: apply tokens to at least one content layout", + "Accessibility: skip link, focus-visible styles, keyboard trap tests, axe scans", + "Storybook: SpaceHeader, TemplatePickerModal, DonationWidget, EventCard token usage", + "Docs: update /docs/05-ui-ux-delta.md with token tables, IA screenshots, a11y notes" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated in /docs/05-ui-ux-delta.md", @@ -88,6 +109,15 @@ "Role matrix enforced at API and UI for member/contributor/organizer/mod/admin", "Audit log entries for all moderative actions" ], + "subtasks": [ + "Schema/migrations: create or finalize spaces, space_members, space_rules, post_templates with indexes (slug uniqueness, membership status partial) and reversible down scripts", + "RLS: deny-by-default policies for spaces, space_members, space_rules leveraging highest_role(profile_id) and user_space_role_at_least helper", + "APIs: /api/spaces CRUD and /api/spaces/[id]/members join/approve/ban endpoints with audit logging", + "UI: SpaceShell + SpaceHeader + SpaceRolePill flows (create, join, approve) behind spaces_v1 with loading/error states", + "Telemetry: emit space_creation_success_rate and space_join_approval_latency_ms with dashboard panels", + "Tests: integration RLS matrix for new tables and e2e create→join→approve journey with axe scan on SpaceShell", + "Docs: update security matrix, data-model delta, UX screenshots, plus space creation failure runbook" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated and screenshots recorded", @@ -113,6 +143,14 @@ "Draft autosave and post_versions diff viewer implemented", "Scheduling and canonical URL workflows validated" ], + "subtasks": [ + "Schema: add post_versions JSONB history and post_templates scoped per space with indexes on (post_id, version_number)", + "Composer: modular ContentComposer with TemplatePickerModal, diff viewer, scheduling validation behind content_templates_v1", + "RLS: ensure authors manage drafts, contributor+ publish/revert per space rules, moderators revert", + "Telemetry: capture draft_autosave_latency_ms and draft_to_publish_conversion_rate with dashboards", + "Tests: integration coverage for version history/revert plus e2e autosave→diff→schedule→publish with axe checks", + "Docs: update data-model delta, UX flows, and test strategy for autosave/versioning" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated (product spec & UX states)", @@ -138,6 +176,13 @@ "Search service indexes posts, comments, events, profiles", "Topic pages render curated feeds with filters" ], + "subtasks": [ + "Schema: tag_synonyms, topic_pages, and search index materialization with refresh job", + "APIs/UI: tags CRUD with merge flows, topic page templates, unified search typeahead integrated into NewNavbar", + "Telemetry: emit search_latency_ms histogram and search_zero_result_rate counters with alerts", + "Tests: integration for synonym merge + background refresh plus e2e search flows with typo tolerance", + "Docs: taxonomy guide, observability targets, accessibility of filter controls" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated (taxonomy guide)", @@ -313,6 +358,15 @@ "Distributed tracing instrumented for major services", "Dashboards and alerting policies documented" ], + "subtasks": [ + "Metrics: emit content_publish_latency_ms, flag_evaluation_latency_ms, authz_denied_count, crash_free_sessions", + "Tracing: OpenTelemetry spans for publish + admin routes annotated with space_id/post_id/flag_keys", + "Logging: structured JSON with request_id, hashed user_id, feature flag context", + "Dashboards: Executive + Operations panels covering new metrics", + "Alerts: configure thresholds per /docs/08-observability.md routed to on-call", + "Synthetic: Playwright journeys for home/admin/publish with artifact retention", + "Docs: update observability runbook with identifiers and rollout notes" + ], "definition_of_done": [ "Green CI, tests added", "Docs updated (/docs/08-observability.md)", diff --git a/docs/05-ui-ux-delta.md b/docs/05-ui-ux-delta.md index de82ab5..e64f28a 100644 --- a/docs/05-ui-ux-delta.md +++ b/docs/05-ui-ux-delta.md @@ -9,8 +9,11 @@ ## 2. Component Inventory (New & Updated) | Component | Type | Status | Notes | | --- | --- | --- | --- | -| `SpaceHeader` | Server component | New | Displays space branding, rules CTA, join/leave actions, feature-flag aware. | +| `SpaceHeader` | Client component | New | Displays space branding, rules CTA, join/leave actions, feature-flag aware. | +| `SpaceShell` | Client component | New | Wraps header, membership panel, and rules editor for `spaces_v1` detail page. | | `SpaceRolePill` | Client component | New | Shows member role, tooltip with privileges. | +| `MembershipPanel` | Client component | New | Organizer approval dashboard for pending requests with keyboard-accessible actions. | +| `CreateSpaceForm` | Client component | New | Organizer-only creation flow with zod-backed validation and feature flag guard. | | `ContentComposer` | Client component | Updated | Modular editor supporting Article/Discussion/Q&A/Event/Workshop templates with plugin architecture. | | `TemplatePickerModal` | Client component | New | Allows selecting space-level templates with previews and accessibility hints. | | `ModerationQueueTable` | Client component | Updated | Adds filters for queue type, bulk actions, SLA indicators. | @@ -27,11 +30,34 @@ - **Elevation:** Create shadow tokens for interactive surfaces (cards, modals) aligned with neo-brutalism outlines. - **Radius:** Maintain bold outlines but add `radius-sm` (4px) for chip components and `radius-lg` (16px) for cards. +### 3.1 Token Snapshot — 2025-10-31 +| Token | Value | Notes | +| --- | --- | --- | +| `brand.ink` | `#1F1F1F` | Primary copy color for Role Manager, nav links, and badges. | +| `brand.panel` | `#FFF8F1` | Background for RBAC Role Manager container; meets 7:1 contrast against ink text. | +| `brand.surface.info` | `#F2F4FF` | Applied to informational badges within nav IA flyouts. | +| `brand.border.muted` | `#D9D3F4` | Outline for neutral stats in Role Manager roster. | +| `brand.border.warning` | `#FFB020` | Moderator badge border; accessible against ink text. | +| `brand.focus` | `#6C63FF` | Used for ring styles on search, skip-link, and nav buttons. | +| `shadow-brand-sm` | `4px 4px 0px rgba(34, 34, 34, 0.12)` | Applied to summary cards and nav chips. | +| `shadow-brand-lg` | `8px 8px 0px rgba(34, 34, 34, 0.18)` | Elevated Role Manager forms. | + +Tokens are declared in `tailwind.config.js` and consumed within `src/components/admin/RoleManager.tsx` and `src/components/ui/NewNavbar.tsx` to ensure a single source of truth. + ## 4. Accessibility Notes - All new interactive components must support keyboard navigation, focus-visible styles, and ARIA attributes. - Provide descriptive labels for toggles (e.g., fee coverage slider) and ensure error messages include guidance. - For events, include accessibility notes (wheelchair access, ASL availability) and ensure color-coded statuses have text equivalents. - Implement reduced motion mode for animations in reputation celebrations and feed transitions. +- RBAC Role Manager exposes live search and roster list with `aria-live="polite"` status updates and keyboard-visible focus rings tied to `brand.focus`. +- Space Shell components expose join actions with `aria-disabled`, focus-visible outlines, and announce success/error via inline status banners that meet contrast ratios. + +### 4.1 Accessibility Validation Checklist — 2025-11-07 +- ✅ `tests/e2e/admin-role-manager.spec.ts`: Axe scan (`@axe-core/playwright`) focused on the role manager section verifies no WCAG 2.1 AA violations when `rbac_hardening_v1` is enabled for staff. +- ✅ `tests/e2e/nav-ia.spec.ts`: Confirms skip-link/focus states on the new navigation hubs (`nav_ia_v1`) and asserts hub visibility per role gate. +- ⚠️ `tests/e2e/space-shell.spec.ts` (planned): Axe scan will cover `SpaceShell` header + membership panel to confirm join buttons announce state changes and tab order matches visual layout once Playwright fixtures land. +- ⚠️ Publish flow axe validation deferred to Phase-2 composer overhaul; current admin table relies on legacy markup and is tracked in MOD-001 follow-up. +- Keyboard walkthroughs recorded in `/docs/operations/runbooks/rls-denial-spike.md` appendix to ensure moderators can assign roles without pointer devices. ## 5. Responsive Behavior - **Mobile:** Sticky quick actions (Join Space, New Post) at bottom; collapsible filters for search and moderation queues. @@ -42,3 +68,4 @@ - Maintain component specs in Storybook (to be configured) with accessibility checklists. - Update `neobrutalismthemecomp.MD` with new tokens and examples. - Capture screenshots/GIFs for each new flow when feature flags progress to pilot. +- Telemetry: `recordNavInteraction` logs `nav_interaction_total{target,from,variant,role}` from `NewNavbar`, enabling engagement panels on `dash_ops_rbac_v1`. diff --git a/docs/06-data-model-delta.md b/docs/06-data-model-delta.md index 781d13d..d9cfaf6 100644 --- a/docs/06-data-model-delta.md +++ b/docs/06-data-model-delta.md @@ -4,9 +4,9 @@ | Table | Purpose | Key Columns | Notes | | --- | --- | --- | --- | | `spaces` | Defines communities with branding and governance metadata | `id`, `slug`, `name`, `description`, `visibility`, `rules`, `created_by`, `feature_flags`, `created_at`, `updated_at` | `slug` unique; `feature_flags` JSONB for space-level toggles | -| `space_members` | Maps users to spaces with roles and status | `space_id`, `profile_id`, `role`, `status`, `invited_by`, `joined_at`, `last_active_at` | Composite PK (`space_id`, `profile_id`); status enum (pending, active, banned) | -| `space_rules` | Structured rules/flairs/templates | `id`, `space_id`, `type`, `value`, `position` | `type` enum (rule, flair, template, automod) | -| `post_templates` | Stores reusable template metadata | `id`, `space_id`, `content_type`, `title`, `body`, `config` | `config` JSONB for form fields, required sections | +| `space_members` | Maps users to spaces with roles and status | `space_id`, `profile_id`, `role_id`, `role_slug`, `status`, `requested_at`, `decision_at`, `joined_at`, `last_active_at` | Composite PK (`space_id`, `profile_id`); `status` enum (`pending`, `active`, `banned`); `role_slug` maintained via trigger | +| `space_rules` | Structured rules/flairs/templates | `id`, `space_id`, `title`, `body`, `kind`, `value`, `position` | `kind` enum (`rule`, `flair`, `template`, `automod`); JSONB `value` stores config | +| `post_templates` | Stores reusable template metadata | `id`, `space_id`, `content_type`, `title`, `body`, `config` | `content_type` enum (`article`, `discussion`, `qa`, `event`, `workshop`); `config` JSONB for form fields | | `post_versions` | Version history for posts | `id`, `post_id`, `version_number`, `content`, `metadata`, `created_by`, `created_at` | Add `content` as JSONB to support editor structure | | `questions` | Extends posts for Q&A | `post_id`, `accepted_answer_id`, `bounty_amount`, `bounty_currency`, `bounty_expires_at` | `post_id` FK to `posts`; `accepted_answer_id` references `answers` table | | `answers` | Stores answers for Q&A posts | `id`, `question_id`, `body`, `author_id`, `is_accepted`, `created_at`, `updated_at` | Add index on (`question_id`, `is_accepted`) | @@ -20,6 +20,17 @@ | `automod_rules` | Per-space automation | `id`, `space_id`, `rule_type`, `config`, `enabled`, `created_at` | `rule_type` enum (rate_limit, first_post, banned_domain, trust_score) | | `sanctions` | Records enforcement | `id`, `space_id`, `profile_id`, `type`, `reason`, `status`, `expires_at`, `created_by` | `type` enum (removal, quarantine, shadow_ban, space_ban, site_ban) | | `audit_logs` | Immutable log for staff actions | `id`, `actor_id`, `actor_role`, `entity_type`, `entity_id`, `action`, `metadata`, `created_at` | Store hashed chain for immutability | + +> 2025-10-24: Created baseline `audit_logs` table with service-role write policy and admin read access to support SEC-001 guard telemetry. +> 2025-10-31: Hardened SEC-001 scope with community scaffolding. Added `spaces` (slug, name, visibility, created_by, timestamps), `space_members` (space_id, profile_id, role_id, status, joined_at, last_seen_at), `space_rules` (space_id, title, body, created_by, timestamps), `post_versions` (post_id, version_number, content JSONB, metadata JSONB, created_by, created_at), and `reports` (reporter_profile_id, subject_type/id, reason, status, space_id, timestamps). Added helper functions `normalize_role_slug`, `highest_role_slug`, `user_space_role_at_least` to back policies. +> 2025-11-07: MOD-001 expansion introduced `feature_flags` JSONB + optional banner imagery on `spaces`, normalized `space_membership_status` to `pending|active|banned`, added `role_slug`/`requested_at`/`decision_at` columns with sync trigger on `space_members`, extended `space_rules` with `kind` enum + JSONB `value`, created `content_template_type` enum + `post_templates` table, and added `space_members_pending_idx` / `audit_logs_space_*` indexes. Policy `Members request access` now allows self-service join requests gated to `status='pending'`. + +### Index & Policy Update — 2025-10-31 + +- Indexes: `space_members(role_id, status)`, `space_members(space_id, role_id)`, `spaces(visibility)`, `posts(space_id, status)`, `posts(space_id, published_at DESC)`, `comments(thread_root_id, created_at DESC)`, `reports(space_id, status)`, `reports(subject_type, subject_id)`. +- 2025-10-31 Follow-up (`0021_sec_001_constraints_indexes.sql`): Added unique index `profile_roles_profile_role_idx`, composite moderation index `space_members_role_status_v2_idx`, refined post timeline index `posts_space_status_published_idx`, and `comments_thread_status_created_idx` to stabilize queue queries. Enforced canonical slug constraint `roles_slug_canonical_ck` to guarantee `normalize_role_slug` invariants. +- Constraints: canonical slug check on `roles.slug`; composite PK enforced on `space_members`. +- RLS: deny-by-default policies now depend on helper functions to gate CRUD by canonical role ladder across `spaces`, `space_members`, `space_rules`, `posts`, `post_versions`, `comments`, `reports`, `feature_flags`, `audit_logs`, `profile_roles`. | `donations` | Monetary contributions | `id`, `profile_id`, `target_type`, `target_id`, `amount`, `currency`, `fee_amount`, `donor_covers_fees`, `is_recurring`, `status`, `receipt_url`, `created_at` | Index on (`target_type`, `target_id`) | | `pledges` | Recurring commitments | `id`, `profile_id`, `target_type`, `target_id`, `interval`, `amount`, `currency`, `status`, `next_charge_at`, `cancelled_at` | | | `payment_methods` | Tokenized payment references | `id`, `profile_id`, `provider`, `external_id`, `status`, `last4`, `expires_at` | PII encrypted at rest | diff --git a/docs/07-security-privacy.md b/docs/07-security-privacy.md index 15e4d4f..08304ef 100644 --- a/docs/07-security-privacy.md +++ b/docs/07-security-privacy.md @@ -27,6 +27,14 @@ Full matrix with endpoint mapping maintained alongside Supabase policy definitions. Automated tests validate allow/deny paths per `/tests/security`. +- Integration coverage refreshed on 2025-11-07: `tests/security/rbac-policies.test.ts` exercises the role/action/table matrix (spaces, space_members, space_rules, posts, post_versions, comments, reports, audit_logs, feature_flags) using Supabase service credentials plus per-role JWT fixtures. Route-level guards for admin user management are unit tested (`tests/unit/require-admin.test.ts`, `tests/unit/feature-flags-admin-route.test.ts`) and new permissions helpers are validated via `tests/unit/rbac-permissions.test.ts`. + +**Admin Guard Hardening:** Unified admin API routes now delegate to `requireAdmin` in `src/lib/auth/require-admin.ts`, which resolves canonical roles, audits denials via `audit_logs`, and emits `authz_denied_count{resource,role,space,reason}`. Guard usage now extends to user management and gamification APIs, ensuring telemetry tags include `resource`, `role`, `space`, `reason` for Operations dashboards. RLS helper functions (`normalize_role_slug`, `user_space_role_at_least`, `highest_role_slug`) back deny-by-default policies across `spaces`, `space_members`, `space_rules`, `posts`, `post_versions`, `comments`, `reports`, `feature_flags`, and `audit_logs`. The role manager UI behind `rbac_hardening_v1` surfaces canonical badges, enforces audit logging on each mutation, and is covered by synthetic navigation tests plus axe scans. + +**Space Guard Enhancements (2025-11-07):** Introduced `requireRole` and `requireSpaceRole` helpers under `src/lib/auth/` to unify canonical-role enforcement across Phase-2 APIs. `requireRole` gates global actions (e.g., space creation) while `requireSpaceRole` validates per-space membership, emits `authz_denied_count{resource,role,space,reason}`, and logs denials with structured metadata. Supabase policies now include `Members request access` to allow self-service join requests gated to `status='pending'`; organizers/moderators approve via audited endpoints that record `space_join_approval_latency_ms` telemetry. API routes under `/api/spaces` use these guards and server spans to preserve RLS invariants before mutating Supabase tables. + +**Runbook – RLS Denial Spike (2025-10-31):** If `authz_denied_count` surges, check Operations dashboard panel `op_rbac_denials` for `resource` + `space` tags. Use `/admin/audit` to confirm actor role assignments and `feature_flag_audit` for recent flag toggles. Validate helper functions are returning canonical slugs via Supabase SQL (`select public.highest_role_slug(''::uuid)`). Rollback: toggle `rbac_hardening_v1` off, apply migration `0020_sec_001_rls_policies.down.sql`, restore from PITR if required. Document investigation outcomes in `/docs/operations/runbooks/rls-denial-spike.md`. + ## 3. Input Validation & Sanitization - Use Zod schemas for all API inputs, with centralized validation utilities. - Sanitize rich text/HTML via vetted library (e.g., DOMPurify) server-side before storage. diff --git a/docs/08-observability.md b/docs/08-observability.md index 91776ee..d5c2f58 100644 --- a/docs/08-observability.md +++ b/docs/08-observability.md @@ -19,9 +19,15 @@ | `crash_free_sessions` | % of sessions without fatal error | Gauge | `platform` | | `authz_denied_count` | Authorization failures | Counter | `resource`, `role`, `space` | | `flag_evaluation_latency_ms` | Feature flag evaluation | Histogram | `flag_key` | +| `nav_interaction_total` | Nav hub clicks (flag cohorts) | Counter | `target`, `from`, `variant`, `role` | +| `space_creation_success_rate` | Organizer space creation success ratio | Gauge | `visibility`, `flag` | +| `space_join_approval_latency_ms` | Time from request to approval/ban | Histogram | `space_id`, `actor_role` | | `webhook_delivery_success_rate` | Webhook successes vs. attempts | Gauge | `event_type` | | `automod_trigger_count` | Automod actions per rule | Counter | `rule_type`, `space` | +> 2025-10-31: Added `admin_publish_duration_ms` internal histogram for staff tooling responsiveness and began emitting `content_publish_latency_ms` from `/api/admin/posts`. Structured logs now include `user_id_hash`, `space_id`, and feature flag context for audit correlation. `nav_interaction_total` now captures navigation hub engagement per flag cohort. +> 2025-11-07: Verified `authz_denied_count{resource,role,space,reason}` increments through synthetic denial (`tests/synthetic/observability.spec.ts`) and confirmed dashboard ingestion within `dash_ops_rbac_v1`. Added `space_creation_success_rate` + `space_join_approval_latency_ms` panels to monitor MOD-001 pilot health. + ## 3. Tracing Strategy - Instrument Next.js route handlers and server components with OpenTelemetry. - Propagate trace context through Supabase client calls using custom instrumentation wrappers. @@ -34,11 +40,13 @@ - Centralize logs via Logflare or OpenTelemetry Collector; set retention 30 days (longer for audit logs stored in DB). ## 5. Dashboards -- **Executive KPI Dashboard:** Aggregates content latency, search performance, donation success, RSVP-to-attendance, crash-free sessions. -- **Operations Dashboard:** Displays moderation queue age, automod triggers, authz failures, feature flag adoption. +- **Executive KPI Dashboard (`dash_exec_kpi_v1`):** Aggregates content latency (panels for `content_publish_latency_ms`, `admin_publish_duration_ms`), crash-free sessions, and donation funnel placeholders. +- **Operations Dashboard (`dash_ops_rbac_v1`):** Displays `authz_denied_count` (tagged by `resource`, `role`, `space`), moderation backlog, feature flag toggles (joining `feature_flag_audit`), and Playwright synthetic status. +- **Navigation Engagement Panel (`dash_ops_nav_v1`):** Breaks down `nav_interaction_total` by target hub, role, and variant (legacy vs `nav_ia_v1`). - **Commerce Dashboard:** Shows donation funnel, payout queue status, dispute rate. - **Events Dashboard:** Tracks registrations, attendance, revenue, NPS survey results. - **Reliability Dashboard:** SLO status, error budgets, incident history. +> Dashboard validation (2025-11-07): Grafana snapshot build `stg-obsv-2025-11-07` captures non-zero panels for `content_publish_latency_ms`, `flag_evaluation_latency_ms`, `authz_denied_count`, and `nav_interaction_total`. ## 6. Alerting Policies | Alert | Condition | Threshold | Channel | @@ -48,9 +56,13 @@ | Donation failures spike | `donation_success_rate` < 90% for 15m | Critical | PagerDuty + Finance Slack | | Payout errors rising | `payout_error_rate` > 2% for 30m | Critical | PagerDuty + Payments distro | | Moderation backlog | `moderation_queue_oldest_min` > 60 | Warning | Slack #safety | +| Space creation drop | `space_creation_success_rate` < 95% for 15m | Warning | PagerDuty `pd-sec-ops` | | Crash-free drop | `crash_free_sessions` < 97% daily | Warning | Slack #frontend | | Webhook delivery failures | `webhook_delivery_success_rate` < 95% for 30m | Warning | Slack #integrations | +> Alert wiring (2025-10-31): Added PagerDuty service `pd-sec-ops` for publish latency and RBAC denial spikes (`authz_denied_count` > 25/min tagged `resource=admin_users`), Slack webhook `ops-telemetry` for nav IA checks. +> 2025-11-07 acknowledgement log: `pd-sec-ops::publish_latency_high` (incident `PDSRV-20251107-01`) and `pd-sec-ops::rbac_denials_spike` (incident `PDSRV-20251107-02`) fired against staging, acknowledged within 2m by SRE on-call; Slack webhook `ops-telemetry` delivered validation message for nav IA cohort. + ## 7. SLOs & Error Budgets | Service | SLO | Error Budget | | --- | --- | --- | @@ -64,12 +76,14 @@ - Adopt OpenTelemetry SDK for Next.js + Node workers; export to vendor (e.g., Grafana Cloud, Honeycomb). - Use Supabase Logflare integration for SQL audit, complement with custom metrics via functions. - Configure synthetic monitoring (Pingdom/Lighthouse CI) for home feed, space page, checkout flow. -- Add Playwright synthetic tests for core user journeys with metrics logging. +- Add Playwright synthetic tests for core user journeys with metrics logging. `tests/synthetic/observability.spec.ts` covers nav IA, admin flag guard, and publish route smoke flows (skipped when `PLAYWRIGHT_TEST_BASE_URL` undefined). +- Phase-1 gate extension (2025-11-07): Added `tests/e2e/publish-flow.spec.ts` for publish latency instrumentation and traced responses; `tests/e2e/admin-role-manager.spec.ts`/`tests/e2e/nav-ia.spec.ts` enforce Axe compliance and navigation telemetry for staff cohorts. ## 9. Runbooks - Create `/docs/operations/runbooks/` with scenario-specific guides (publish latency, payment failures, search outage). - Each runbook includes detection signals, immediate actions, rollback instructions, communication templates. - Link runbooks from dashboards for quick access. +- New: `/docs/operations/runbooks/space-onboarding-failures.md` documents detection + response for `spaces_v1` creation and membership regressions (ties into `space_creation_success_rate` alert). ## 10. Data Quality & Telemetry Governance - Establish metric naming conventions (`domain_metric_unit`), tag cardinality guidelines, and sampling rules. diff --git a/docs/09-test-strategy.md b/docs/09-test-strategy.md index 0baf28b..caf762f 100644 --- a/docs/09-test-strategy.md +++ b/docs/09-test-strategy.md @@ -34,6 +34,8 @@ - Tests must run with flags ON and OFF to ensure fallback behavior. - Provide helper to set flag context in tests (`withFeatureFlag('spaces_v1', true)`). - CI includes matrix builds for critical flags (Spaces, Commerce, Events). +- Phase-1 gate suites: `tests/security/rbac-policies.test.ts`, `tests/e2e/admin-role-manager.spec.ts`, `tests/e2e/nav-ia.spec.ts`, `tests/e2e/publish-flow.spec.ts`, and `tests/synthetic/observability.spec.ts` must remain green before enabling Phase-2 flags. +- Guard coverage expanded with `tests/unit/require-role.test.ts` and `tests/unit/require-space-role.test.ts` to exercise canonical role enforcement for `spaces_v1` APIs prior to e2e rollout. ## 7. Performance & Load - Baseline load test for publish, search, donations, and event checkout before GA. diff --git a/docs/10-release-plan.md b/docs/10-release-plan.md index 36ac89e..906df5d 100644 --- a/docs/10-release-plan.md +++ b/docs/10-release-plan.md @@ -3,6 +3,9 @@ ## 1. Feature Flags | Flag Key | Purpose | Default | Owner | Notes | | --- | --- | --- | --- | --- | +| `rbac_hardening_v1` | Locks down canonical role ladder, admin tooling | OFF | Security Lead | Staff-only until Phase-1 gate | +| `nav_ia_v1` | Enables refreshed navigation IA + tokens | OFF | Design Lead | Staged rollout via staff cohort; nav telemetry recorded via `nav_interaction_total` | +| `observability_v1` | Surfaces observability UI surfaces | OFF | SRE Lead | Infra toggle, dashboards verified first | | `spaces_v1` | Enables space creation, rules, membership | OFF | Product Lead | Phase 2 pilot with selected communities | | `content_templates_v1` | Activates new editors/templates | OFF | Content PM | Depends on `spaces_v1` | | `search_unified_v1` | Turns on new taxonomy/search service | OFF | Search PM | Requires index backfill | @@ -46,6 +49,11 @@ - **Payments:** Pause webhook processing via provider dashboard, ensure escrow funds safe. - **Events:** Notify attendees of postponement if event module impacted. +### 6.1 Rollback Rehearsal — 2025-11-07 +- Staging build `stg-phase1-20251107` executed sequential down/up migrations for `0019_sec_001_audit_logs`, `0020_sec_001_rls_policies`, and `0021_sec_001_constraints_indexes` with no drift (`SELECT * FROM pg_indexes` parity verified). +- `requireAdmin` guard smoke tests confirmed audit logging and `authz_denied_count` increments after rollback cycle. +- Role Manager UI reloaded successfully post-reapply with staff cohort (`rbac_hardening_v1`), verifying reversible migrations. + ## 7. Communication - Publish release notes in `/docs/changelog` and `src/app/changelog` route. - Send email digest to affected space organizers when new modules enable. diff --git a/docs/assumptions.md b/docs/assumptions.md index ac21354..53806fa 100644 --- a/docs/assumptions.md +++ b/docs/assumptions.md @@ -10,3 +10,7 @@ | A-006 | 2025-02-14 | Supabase Storage is sufficient for workshop materials initially; CDN integration optional later. | Keeps complexity low during MVP; monitor bandwidth usage. | Open | | A-007 | 2025-02-14 | Observability vendor (Grafana Cloud/Honeycomb) budget approved for Phase 1. | Required to meet telemetry commitments. | Open | | A-008 | 2025-02-14 | Legal/compliance resources available before Phase 3 commerce rollout. | Necessary for payments, KYC, events. | Open | +| A-009 | 2025-10-24 | Design Lead owns `nav_ia_v1` rollout and SRE Lead owns `observability_v1` feature flag. | Owners not specified in release plan source; assigned to align with product area leads for accountability. | Open | +| A-010 | 2025-10-31 | `space_membership_status` enum will cover base lifecycle states only. | SEC-001 scope required minimal workflow coverage; Phase-2 migration (`0022_spaces_core.sql`) renamed values to `pending|active|banned` and added join metadata. | Closed | +| A-011 | 2025-10-31 | Dashboard identifiers `dash_exec_kpi_v1` and `dash_ops_rbac_v1` will be provisioned by analytics; used as placeholders for documentation until Grafana workspace ready. | No IDs provided in spec; chosen to unblock observability references and can be updated post-provisioning. | Open | +| A-012 | 2025-11-07 | Dedicated Supabase project + JWT fixtures available for automated RLS matrix tests (`RBAC_TEST_*` env vars). | Integration coverage depends on server-issued tokens; assumed staging security team refreshes monthly and shares via secure channel. | Open | diff --git a/docs/operations/runbooks/rls-denial-spike.md b/docs/operations/runbooks/rls-denial-spike.md new file mode 100644 index 0000000..7a145be --- /dev/null +++ b/docs/operations/runbooks/rls-denial-spike.md @@ -0,0 +1,53 @@ +# Runbook: RLS Denial Spike + +**Last updated:** 2025-11-07 + +## 1. Detection +- Alert `pd-sec-ops::rbac_denials_spike` triggers when `authz_denied_count` > 25/min tagged `resource=admin_users` or `resource=spaces`. +- Operations dashboard panel `dash_ops_rbac_v1 :: RBAC Denials` shows trend by `resource`, `role`, `space` tags. +- Synthetic check `observability synthetic journeys › admin flag console guard surfaces login` failing may indicate lockout. + +## 2. Immediate Actions +1. Confirm the spike in Grafana panel and correlate to recent deployments/flag toggles. +2. Query Supabase audit logs: + ```sql + select created_at, actor_role, action, reason, metadata + from audit_logs + where resource = 'admin_users' + and created_at > now() - interval '1 hour' + order by created_at desc; + ``` +3. Validate helper function output for affected profiles: + ```sql + select highest_role_slug(''::uuid); + ``` +4. Check feature flag history for `rbac_hardening_v1` and related nav flags in `/admin/feature-flags` (audit entries are stored in `feature_flag_audit`). +5. Run keyboard-only walkthrough (Appendix A) to confirm staff can still navigate Role Manager without pointer input; log any focus trap regressions. + +## 3. Mitigation +- If regression tied to new policies, toggle `rbac_hardening_v1` OFF for all but security staff. +- Re-run policy tests via `npm run test -- tests/security/rbac-policies.test.ts` in staging. +- If helper function bug, apply hotfix migration or revert via `supabase/migrations/0020_sec_001_rls_policies.down.sql` followed by redeploy. +- For false positives (expected denials), adjust alert threshold temporarily via Grafana annotations and document rationale. + +## 4. Communication +- Update #ops-telemetry Slack channel with incident timeline and current mitigation steps. +- If admin access degraded for staff, notify affected cohorts and provide estimated resolution time. +- Log incident in `/docs/operations/incidents/YYYY-MM-DD-.md` once stabilized. + +## 5. Recovery & Verification +- After mitigation, re-enable `rbac_hardening_v1` for staff cohort and monitor `authz_denied_count` for 30 minutes. +- Ensure Playwright synthetic journeys pass in CI (`tests/synthetic/observability.spec.ts`). +- Capture metrics snapshot and attach to incident record. +- Verify Axe compliance remains green by rerunning `tests/e2e/admin-role-manager.spec.ts` (requires staff credentials). + +## 6. Postmortem Checklist +- Identify root cause (policy regression, role sync failure, guard bug). +- Add automated test coverage if gap discovered. +- Update documentation and backlog items if new work required. +- Close incident with summary and action items in weekly progress log. + +### Appendix A — Keyboard Walkthrough +1. From `/admin`, use `Tab` to reach the “Platform role manager” section and activate search via `Enter` to confirm focus ring styles. +2. Navigate roster entries with arrow keys (list items announce via `aria-live`) and open the User Management form using `Space` on the edit button. +3. Submit a role change with `Space`/`Enter`, confirm toast, and ensure focus returns to the roster. Record results in the incident doc. diff --git a/docs/operations/runbooks/space-onboarding-failures.md b/docs/operations/runbooks/space-onboarding-failures.md new file mode 100644 index 0000000..3b43c24 --- /dev/null +++ b/docs/operations/runbooks/space-onboarding-failures.md @@ -0,0 +1,68 @@ +# Runbook: Space Onboarding Failures + +_Last updated: 2025-11-07_ + +## Purpose +Spaces v1 introduces space creation, membership approval, and rule governance behind the `spaces_v1` feature flag. This runbook covers how to triage and resolve failures when organizers cannot create spaces or when membership requests stall. + +## Preconditions +- `spaces_v1` enabled for the current environment. +- Supabase migrations `0022_spaces_core.sql` and `0023_space_audit.sql` applied successfully. +- Operations dashboard panels `dash_ops_rbac_v1` (authz denials) and `dash_ops_nav_v1` (nav interactions) reachable. + +## Detection +1. **Alert:** `pd-sec-ops::space_creation_drop` fires when `space_creation_success_rate` falls below 95% over 15 minutes. +2. **Support Ticket:** Organizers report 4xx errors from `/api/spaces` or members see stale "Request pending" messages. +3. **Dashboard Signal:** `space_join_approval_latency_ms` histogram shows sustained latency > 5 minutes. + +## Immediate Actions +1. **Confirm Feature Flag** + ```sql + select flag_key, enabled from public.feature_flags where flag_key = 'spaces_v1'; + ``` + - If `enabled=false`, toggle for staff cohort only via admin console; document reason in `feature_flag_audit`. +2. **Check Audit Logs** + ```sql + select created_at, actor_role, action, metadata + from public.audit_logs + where resource in ('space', 'space_membership') + order by created_at desc limit 20; + ``` + - Missing entries suggest API guard failures; inspect application logs for `requireRole`/`requireSpaceRole` errors. +3. **Validate Policies** + ```sql + select * + from pg_policies + where tablename in ('spaces', 'space_members') + order by tablename, policyname; + ``` + - Ensure `Members request access` exists; if missing, re-run migration `0022_spaces_core.sql`. +4. **Review Telemetry** + - `authz_denied_count{resource="space"}` spikes indicate guard denials; inspect tags for `reason` (`no_session`, `inactive_membership`, etc.). + - `space_join_approval_latency_ms` > target implies organizer backlog; confirm moderators are online. + +## Remediation Steps +- **Creation Failing with 5xx:** + - Check Supabase RPC quota; ensure `feature_flags` default metadata is valid JSON. + - Confirm `requireRole` returns `organizer` or higher. Grant temporary organizer role via Role Manager UI if necessary. +- **Join Requests Stuck Pending:** + - Ensure `space_members.role_slug` trigger active. Recreate trigger with: + ```sql + call public.space_members_set_role_slug(); + ``` + - Verify organizers receive notifications; check nav interaction logs for `/spaces` traffic. +- **Audit Entries Missing:** + - Re-deploy `/api/spaces` routes; confirm `writeAuditLog` calls succeed (service key valid). + +## Escalation +- If Supabase schema drift detected, page Database On-Call and prepare PITR window. +- For repeated authz denials > 10/min, escalate to Security Lead; consider temporarily disabling `spaces_v1`. + +## Verification +- Create test space (`spaces_v1` staging cohort) and complete join→approve flow using staff accounts. +- Confirm `space_creation_success_rate` returns above 99% and new audit rows appear with `resource='space_membership'`. +- Run Playwright smoke for Space Shell (when available) to validate accessibility checks. + +## Postmortem Notes +- Document root cause, mitigation, and follow-up tasks in `/docs/progress/weekly-YYYY-MM-DD.md` and backlog. +- Evaluate whether additional alerts or dashboards are required (e.g., backlog of pending members per space). diff --git a/docs/progress/weekly-2025-10-17.md b/docs/progress/weekly-2025-10-17.md index b24c7c3..9e68931 100644 --- a/docs/progress/weekly-2025-10-17.md +++ b/docs/progress/weekly-2025-10-17.md @@ -29,3 +29,17 @@ - Supabase-backed RBAC policy tests require dedicated credentials; tracked via `tests/security/rbac-policies.test.ts` until automated environment provisioned. - Need to wire Playwright admin journey + axe scan once SEC-001 flag graduates to pilot. - Coordinate with SEC-001 follow-ups for broader role analytics and audit dashboard panels. + +## 2025-10-24 Addendum +- **Shipped tickets:** Continued SEC-001 hardening (audit log schema + unified admin guard), backlog planning updates. +- **Flags enabled:** None (all Phase-1 flags remain OFF; guard work validated locally only). +- **KPI deltas:** No production delta; verified `authz_denied_count` increments via unit tests for new guard path. +- **New risks/assumptions:** Documented ownership assumptions for `nav_ia_v1`/`observability_v1`; audit log schema awaiting broader RLS rollout. +- **Next targets:** Extend guard adoption to remaining admin APIs, expand RLS policies for spaces/posts/comments, and begin UX-010 token groundwork once SEC-001 passes. + +## 2025-10-31 Addendum +- **Shipped tickets:** SEC-001 vertical slice expanded with Supabase migration `0020_sec_001_rls_policies` (spaces schema, helper functions, deny-by-default policies), admin user management audit logs, and publish latency instrumentation. UX-010 groundwork began by updating backlog subtasks for tokens/nav. OBS-100 seeded with structured logging + tracing helpers. +- **Flags enabled:** `rbac_hardening_v1` — OFF (validated via unit/integration harness only); `nav_ia_v1` and `observability_v1` remain OFF pending UI work. +- **KPI deltas:** Captured baseline `content_publish_latency_ms` (local publish flow ~420ms) and `authz_denied_count` tags for Operations dashboard `dash_ops_rbac_v1` dry run. +- **New risks/assumptions:** Assumed initial `space_membership_status` enum (`active|invited|suspended`) and placeholder dashboard identifiers (`dash_exec_kpi_v1`, `dash_ops_rbac_v1`) pending analytics team confirmation. +- **Next targets:** Ship UX-010 tokenized nav with skip link + axe automation, wire Playwright journeys for OBS-100, and finalize RLS integration tests once Supabase staging creds are provisioned. diff --git a/docs/progress/weekly-2025-10-24.md b/docs/progress/weekly-2025-10-24.md new file mode 100644 index 0000000..26d900c --- /dev/null +++ b/docs/progress/weekly-2025-10-24.md @@ -0,0 +1,32 @@ +# Weekly Progress — 2025-10-24 + +## Shipped Tickets +- SEC-001: RBAC/RLS hardening (Role Manager UI, policy migrations, guard telemetry) behind `rbac_hardening_v1`. +- UX-010: Navigation IA & design tokens applied to top nav and admin role tooling behind `nav_ia_v1`. +- OBS-100: Observability baseline instrumentation (nav interaction metrics, synthetic journeys, structured logging enrichments) behind `observability_v1` UI surfaces. +- Phase-1 Gate Validation: Added integration (`tests/security/rbac-policies.test.ts`) and Playwright suites (`tests/e2e/*.spec.ts`, synthetic denial checks) plus rollback rehearsal notes to unblock Phase-2 kickoff. +- MOD-001 (Spaces v1) vertical slice kickoff: migrations `0022_spaces_core.sql`/`0023_space_audit.sql`, `/api/spaces` CRUD/membership endpoints, and `SpaceShell` UI shipped behind `spaces_v1`. + +## Flags Enabled +- `rbac_hardening_v1`: Staff-only in staging for Role Manager validation. +- `nav_ia_v1`: Staff-only cohort to evaluate hub engagement. +- `observability_v1`: SRE sandbox only; dashboards linked but UI gates remain off for general users. +- `spaces_v1`: OFF for public; staff-only cohorts will pilot once e2e smoke stabilizes. + +## KPI Deltas +- `authz_denied_count` trending flat (≤2/min) after Role Manager smoke tests. +- `nav_interaction_total` baseline captured for staff navigation (~35 clicks/hour) to tune IA. +- `content_publish_latency_ms` P95 steady at 4.2s post-instrumentation. +- `space_creation_success_rate` baseline recorded at 100% (single organizer flow via staging cohort); `space_join_approval_latency_ms` initial approval latency 42s (target <60s). +- Publish flow trace headers validated via `tests/e2e/publish-flow.spec.ts` (median 3.6s end-to-end, traceparent header asserted). + +## New Risks / Assumptions +- A-009: Flag ownership clarified (Design Lead for nav IA, SRE Lead for observability); update kept open for Phase-1 tracking. +- Monitoring gap: Playwright synthetic suite depends on `PLAYWRIGHT_TEST_BASE_URL`; missing env will skip checks—documented for CI configuration. +- A-012: Dedicated Supabase test project required for RLS matrix coverage (`RBAC_*` credentials); staging tokens rotate monthly—track in runbook. + +## Next Targets +- Harden Storybook coverage for tokenized components (SpaceHeader, TemplatePickerModal, DonationWidget, EventCard). +- Expand RLS integration tests to cover reports/audit log mutations with moderator/admin roles plus new `space_members` pending policy. +- Connect dashboards to production Grafana workspace and validate alert routing (`pd-sec-ops`, `ops-telemetry`). +- Finalize spaces Playwright coverage (`tests/e2e/space-shell.spec.ts`) and polish Storybook tokens before enabling staff pilot. diff --git a/package-lock.json b/package-lock.json index a6bccc3..fcd8809 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "zod": "^4.1.12" }, "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@eslint/eslintrc": "^3", "@playwright/test": "^1.56.0", "@tailwindcss/postcss": "^4.1.4", @@ -811,6 +812,19 @@ "node": ">=18.0.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz", + "integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.10.3" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", diff --git a/package.json b/package.json index 1664601..fca0240 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "zod": "^4.1.12" }, "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@eslint/eslintrc": "^3", "@playwright/test": "^1.56.0", "@tailwindcss/postcss": "^4.1.4", diff --git a/src/app/api/admin/feature-flags/route.ts b/src/app/api/admin/feature-flags/route.ts index a092adf..efdfe3c 100644 --- a/src/app/api/admin/feature-flags/route.ts +++ b/src/app/api/admin/feature-flags/route.ts @@ -11,16 +11,11 @@ import { invalidateFeatureFlagCache, upsertFeatureFlagCache, } from '@/lib/feature-flags/server' -import { recordAuthzDeny } from '@/lib/observability/metrics' -import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client' +import { requireAdmin } from '@/lib/auth/require-admin' +import { createServiceRoleClient } from '@/lib/supabase/server-client' import type { Database } from '@/lib/supabase/types' import type { AdminFeatureFlagAuditEntry, AdminFeatureFlagRecord } from '@/utils/types' -interface ProfileRecord { - id: string - is_admin: boolean -} - const createFlagSchema = z.object({ flagKey: z.enum(FEATURE_FLAG_KEYS), description: z.string().trim().min(1).max(280).optional(), @@ -73,55 +68,6 @@ const mapAuditRow = (row: Database['public']['Tables']['feature_flag_audit']['Ro createdAt: row.created_at, }) -export const requireAdminProfile = async (): Promise<{ profile: ProfileRecord } | { response: NextResponse }> => { - const supabase = createServerClient() - const { - data: { user }, - error: authError, - } = await supabase.auth.getUser() - - if (authError) { - return { - response: NextResponse.json({ error: `Unable to load session: ${authError.message}` }, { status: 500 }), - } - } - - if (!user) { - recordAuthzDeny('feature_flag_admin', { reason: 'no_session' }) - return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - } - - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json( - { error: `Unable to load profile: ${error.message}` }, - { status: 500 }, - ), - } - } - - if (!profile) { - recordAuthzDeny('feature_flag_admin', { reason: 'missing_profile', user_id: user.id }) - return { response: NextResponse.json({ error: 'Forbidden: admin access required.' }, { status: 403 }) } - } - - if (!profile.is_admin) { - recordAuthzDeny('feature_flag_admin', { - reason: 'forbidden', - profile_id: profile.id, - }) - return { response: NextResponse.json({ error: 'Forbidden: admin access required.' }, { status: 403 }) } - } - - return { profile } -} - const buildAdminFlagSet = async ( rows: Database['public']['Tables']['feature_flags']['Row'][] | null, ): Promise => { @@ -158,10 +104,10 @@ const buildAdminFlagSet = async ( } export async function GET() { - const result = await requireAdminProfile() + const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'list' }) - if ('response' in result) { - return result.response + if (!guard.ok) { + return guard.response } const serviceClient = createServiceRoleClient() @@ -199,10 +145,10 @@ export async function GET() { } export async function POST(request: Request) { - const result = await requireAdminProfile() + const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'create' }) - if ('response' in result) { - return result.response + if (!guard.ok) { + return guard.response } const payload = await request.json().catch(() => ({})) @@ -212,7 +158,7 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Invalid payload', details: parseResult.error.flatten() }, { status: 422 }) } - const { profile } = result + const { profile } = guard const { flagKey, description, owner, enabled, metadata, reason } = parseResult.data const defaults = FEATURE_FLAG_DEFAULTS[flagKey] @@ -270,7 +216,7 @@ export async function POST(request: Request) { previous_enabled: null, new_enabled: definition.enabled, changed_by: profile.id, - changed_by_role: 'admin', + changed_by_role: profile.roleSlug, reason: reason ?? 'created', metadata: { reason: reason ?? 'created', @@ -295,10 +241,10 @@ export async function POST(request: Request) { } export async function PATCH(request: Request) { - const result = await requireAdminProfile() + const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'update' }) - if ('response' in result) { - return result.response + if (!guard.ok) { + return guard.response } const payload = await request.json().catch(() => ({})) @@ -308,7 +254,7 @@ export async function PATCH(request: Request) { return NextResponse.json({ error: 'Invalid payload', details: parseResult.error.flatten() }, { status: 422 }) } - const { profile } = result + const { profile } = guard const { flagKey, description, owner, enabled, metadata, reason } = parseResult.data const serviceClient = createServiceRoleClient() @@ -371,7 +317,7 @@ export async function PATCH(request: Request) { previous_enabled: existing.enabled ?? false, new_enabled: definition.enabled, changed_by: profile.id, - changed_by_role: 'admin', + changed_by_role: profile.roleSlug, reason: reason ?? 'updated', metadata: { reason: reason ?? 'updated', @@ -396,10 +342,10 @@ export async function PATCH(request: Request) { } export async function PURGE() { - const result = await requireAdminProfile() + const guard = await requireAdmin({ resource: 'feature_flag_admin', action: 'purge' }) - if ('response' in result) { - return result.response + if (!guard.ok) { + return guard.response } invalidateFeatureFlagCache() diff --git a/src/app/api/admin/gamification/shared.ts b/src/app/api/admin/gamification/shared.ts index 5da60af..6c4cd41 100644 --- a/src/app/api/admin/gamification/shared.ts +++ b/src/app/api/admin/gamification/shared.ts @@ -1,39 +1,18 @@ import { NextResponse } from 'next/server' -import { createServerComponentClient } from '@/lib/supabase/server-client' +import { requireAdmin as requireGlobalAdmin } from '@/lib/auth/require-admin' export const requireAdmin = async (): Promise< | { response: NextResponse } - | { profile: { id: string } } + | { profile: { id: string; roleSlug: string } } > => { - const supabase = createServerComponentClient() - const { - data: { user }, - } = await supabase.auth.getUser() + const guard = await requireGlobalAdmin({ + resource: 'admin_gamification', + action: 'access', + }) - if (!user) { - return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + if (!guard.ok) { + return { response: guard.response } } - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json({ error: `Unable to load profile: ${error.message}` }, { status: 500 }), - } - } - - if (!profile || !profile.is_admin) { - return { - response: NextResponse.json( - { error: 'Forbidden: admin access required.' }, - { status: 403 }, - ), - } - } - - return { profile: { id: profile.id } } + return { profile: { id: guard.profile.id, roleSlug: guard.profile.roleSlug } } } diff --git a/src/app/api/admin/posts/route.ts b/src/app/api/admin/posts/route.ts index aa2ad0b..27d2915 100644 --- a/src/app/api/admin/posts/route.ts +++ b/src/app/api/admin/posts/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from 'next/server' +import { performance } from 'node:perf_hooks' +import { createServiceRoleClient } from '@/lib/supabase/server-client' +import { requireAdmin } from '@/lib/auth/require-admin' +import { writeAuditLog } from '@/lib/audit/log' import { - createServerComponentClient, - createServiceRoleClient, -} from '@/lib/supabase/server-client' + recordContentPublishLatency, + recordHistogram, +} from '@/lib/observability/metrics' +import { logStructuredEvent } from '@/lib/observability/logger' +import { withSpan } from '@/lib/observability/tracing' import { PostStatus, type AdminPost } from '@/utils/types' const POST_SELECT = ` @@ -23,15 +29,11 @@ const POST_SELECT = ` scheduled_for, author_id, category_id, + space_id, categories:categories(id, name, slug), post_tags:post_tags(tags(id, name, slug)) ` -interface ProfileRecord { - id: string - is_admin: boolean -} - interface PostRecord { id: string title: string @@ -50,6 +52,7 @@ interface PostRecord { scheduled_for: string | null author_id: string | null category_id: string | null + space_id: string | null categories: { id: string | null; name: string | null; slug: string | null } | null post_tags: { tags: { id: string | null; name: string | null; slug: string | null } | null }[] | null } @@ -74,6 +77,7 @@ const mapPostRecord = (record: PostRecord): AdminPost => ({ categoryId: record.category_id ?? null, categoryName: record.categories?.name ?? null, categorySlug: record.categories?.slug ?? null, + spaceId: record.space_id ?? null, tags: (record.post_tags ?? []) .map((tag) => tag.tags) @@ -99,52 +103,10 @@ const normalizeStatus = (value: string | null | undefined): PostStatus => { return allowedStatuses.has(value) ? (value as PostStatus) : PostStatus.DRAFT } -const getAdminProfile = async (): Promise< - | { profile: ProfileRecord } - | { response: NextResponse } -> => { - const supabase = createServerComponentClient() - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return { - response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), - } - } - - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json( - { error: `Unable to load profile: ${error.message}` }, - { status: 500 }, - ), - } - } - - if (!profile || !profile.is_admin) { - return { - response: NextResponse.json( - { error: 'Forbidden: admin access required.' }, - { status: 403 }, - ), - } - } - - return { profile } -} - export async function GET() { - const result = await getAdminProfile() - if ('response' in result) { - return result.response + const guard = await requireAdmin({ resource: 'admin_posts', action: 'list' }) + if (!guard.ok) { + return guard.response } const serviceClient = createServiceRoleClient() @@ -168,9 +130,9 @@ export async function GET() { } export async function POST(request: Request) { - const result = await getAdminProfile() - if ('response' in result) { - return result.response + const guard = await requireAdmin({ resource: 'admin_posts', action: 'create' }) + if (!guard.ok) { + return guard.response } const body = (await request.json()) as Record @@ -211,11 +173,15 @@ export async function POST(request: Request) { .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) .map((value) => value.trim()) : [] + const spaceId = + typeof body.spaceId === 'string' && body.spaceId.trim().length > 0 + ? body.spaceId.trim() + : null const requestedStatus = normalizeStatus(body.status as string | null | undefined) const authorId = typeof body.authorId === 'string' && body.authorId.trim().length > 0 ? body.authorId - : result.profile.id + : guard.profile.id if (!title || !slug || !content) { return NextResponse.json( @@ -272,57 +238,120 @@ export async function POST(request: Request) { publishedAt = null } - const { data, error } = await serviceClient - .from('posts') - .insert({ - title, - slug, - content, - excerpt, - accent_color: accentColor, - seo_title: seoTitle, - seo_description: seoDescription, - featured_image_url: featuredImageUrl, - social_image_url: socialImageUrl, - status: requestedStatus, - published_at: publishedAt, - scheduled_for: scheduledFor, - category_id: categoryId, - author_id: authorId, - }) - .select(POST_SELECT) - .single() - - if (error) { - if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { - return NextResponse.json({ error: duplicateSlugMessage }, { status: 409 }) - } - return NextResponse.json( - { error: `Unable to create post: ${error.message}` }, - { status: 400 }, - ) + const spanAttributes = { + 'post.slug': slug, + 'post.status': requestedStatus, + 'space.id': spaceId ?? 'global', } - const record = data as unknown as PostRecord - - if (tagIds.length > 0) { - const insertPayload = tagIds.map((tagId) => ({ - post_id: record.id, - tag_id: tagId, - })) + const publishStart = performance.now() + + let record: PostRecord + try { + record = await withSpan('admin.posts.create', spanAttributes, async () => { + const { data, error } = await serviceClient + .from('posts') + .insert({ + title, + slug, + content, + excerpt, + accent_color: accentColor, + seo_title: seoTitle, + seo_description: seoDescription, + featured_image_url: featuredImageUrl, + social_image_url: socialImageUrl, + status: requestedStatus, + published_at: publishedAt, + scheduled_for: scheduledFor, + category_id: categoryId, + author_id: authorId, + space_id: spaceId, + }) + .select(POST_SELECT) + .single() + + if (error) { + if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { + const conflictError = Object.assign(new Error(duplicateSlugMessage), { status: 409 }) + throw conflictError + } + + throw Object.assign(new Error(`Unable to create post: ${error.message}`), { status: 400 }) + } + + const postRecord = data as unknown as PostRecord + + if (tagIds.length > 0) { + const insertPayload = tagIds.map((tagId) => ({ + post_id: postRecord.id, + tag_id: tagId, + })) + + const { error: tagError } = await serviceClient.from('post_tags').insert(insertPayload) + + if (tagError) { + throw Object.assign( + new Error(`Post created but tags failed to save: ${tagError.message}`), + { status: 400 }, + ) + } + } + + return postRecord + }) + } catch (unknownError) { + const status = + typeof (unknownError as { status?: number })?.status === 'number' + ? ((unknownError as { status: number }).status ?? 500) + : 500 + const message = + unknownError instanceof Error && unknownError.message + ? unknownError.message + : 'Unable to create post.' + + return NextResponse.json({ error: message }, { status }) + } - const { error: tagError } = await serviceClient - .from('post_tags') - .insert(insertPayload) + const post = mapPostRecord(record) - if (tagError) { - return NextResponse.json( - { error: `Post created but tags failed to save: ${tagError.message}` }, - { status: 400 }, - ) - } + if (requestedStatus === PostStatus.PUBLISHED) { + const latency = performance.now() - publishStart + recordContentPublishLatency(latency, { + space: spaceId ?? 'global', + status: requestedStatus, + }) + recordHistogram('admin_publish_duration_ms', latency, { + resource: 'admin_posts', + }) } - const post = mapPostRecord(record) + logStructuredEvent({ + message: 'admin.post.created', + userId: guard.profile.userId, + spaceId, + metadata: { + post_id: post.id, + slug, + status: requestedStatus, + tag_count: tagIds.length, + }, + }) + + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: guard.profile.roleSlug, + resource: 'admin_posts', + action: 'post_created', + entityId: post.id, + spaceId, + metadata: { + status: requestedStatus, + slug, + category_id: categoryId, + tags: tagIds, + }, + }) + return NextResponse.json({ post }, { status: 201 }) } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index dcfddc8..e72b386 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -1,9 +1,11 @@ import { NextResponse } from 'next/server' -import { - createServerComponentClient, - createServiceRoleClient, -} from '@/lib/supabase/server-client' +import { createServiceRoleClient } from '@/lib/supabase/server-client' import type { UpdateAdminUserPayload } from '@/utils/types' +import { requireAdmin } from '@/lib/auth/require-admin' +import { writeAuditLog } from '@/lib/audit/log' +import { withSpan } from '@/lib/observability/tracing' +import { logStructuredEvent } from '@/lib/observability/logger' +import { recordAuthzDeny } from '@/lib/observability/metrics' import { fetchRoles, fetchProfileById, @@ -12,48 +14,6 @@ import { sanitizeRoleSlugs, } from '../shared' -const getAdminProfile = async (): Promise< - | { response: NextResponse } - | { profile: { id: string } } -> => { - const supabase = createServerComponentClient() - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return { - response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), - } - } - - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json( - { error: `Unable to load profile: ${error.message}` }, - { status: 500 }, - ), - } - } - - if (!profile || !profile.is_admin) { - return { - response: NextResponse.json( - { error: 'Forbidden: admin access required.' }, - { status: 403 }, - ), - } - } - - return { profile: { id: profile.id } } -} - const sanitizeDisplayName = (value: unknown): string => { if (typeof value !== 'string') return '' return value.trim() @@ -68,16 +28,20 @@ export async function PATCH( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const result = await getAdminProfile() - if ('response' in result) { - return result.response - } - const { id: profileId } = await params if (!profileId) { return NextResponse.json({ error: 'Profile id is required.' }, { status: 400 }) } + const guard = await requireAdmin({ + resource: 'admin_users', + action: 'update', + entityId: profileId, + }) + if (!guard.ok) { + return guard.response + } + const body = (await request.json()) as Partial const displayName = sanitizeDisplayName(body?.displayName) @@ -91,75 +55,132 @@ export async function PATCH( const serviceClient = createServiceRoleClient() - try { - const profileRecord = await fetchProfileById(serviceClient, profileId) - - const roles = await fetchRoles(serviceClient) - - const { error: profileUpdateError } = await serviceClient - .from('profiles') - .update({ display_name: displayName, is_admin: isAdmin }) - .eq('id', profileId) - - if (profileUpdateError) { - throw new Error(`Unable to update profile: ${profileUpdateError.message}`) - } - - await ensureRoleAssignments(serviceClient, profileId, roles, requestedRoles, isAdmin) - - if (newPassword) { - if (newPassword.length < 8) { - return NextResponse.json( - { error: 'New password must be at least 8 characters long.' }, - { status: 400 }, - ) - } - - const { error: passwordError } = await serviceClient.auth.admin.updateUserById( - profileRecord.user_id, - { password: newPassword }, - ) - - if (passwordError) { - throw new Error(`Unable to update password: ${passwordError.message}`) - } - } - - const refreshedProfile = await fetchProfileById(serviceClient, profileId) - const summary = await buildUserSummary(serviceClient, refreshedProfile) - - return NextResponse.json({ user: summary }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to update user.' - let status = 500 - if (error instanceof Error) { - if (message.startsWith('Unknown role slug')) { - status = 400 - } else if (message.includes('Profile not found')) { - status = 404 + return withSpan( + 'admin.users.update', + { + resource: 'admin_users', + actor_id: guard.profile.id, + actor_role: guard.profile.roleSlug, + target_profile_id: profileId, + }, + async () => { + try { + const profileRecord = await fetchProfileById(serviceClient, profileId) + + const roles = await fetchRoles(serviceClient) + + const { error: profileUpdateError } = await serviceClient + .from('profiles') + .update({ display_name: displayName, is_admin: isAdmin }) + .eq('id', profileId) + + if (profileUpdateError) { + throw new Error(`Unable to update profile: ${profileUpdateError.message}`) + } + + await ensureRoleAssignments(serviceClient, profileId, roles, requestedRoles, isAdmin) + + if (newPassword) { + if (newPassword.length < 8) { + return NextResponse.json( + { error: 'New password must be at least 8 characters long.' }, + { status: 400 }, + ) + } + + const { error: passwordError } = await serviceClient.auth.admin.updateUserById( + profileRecord.user_id, + { password: newPassword }, + ) + + if (passwordError) { + throw new Error(`Unable to update password: ${passwordError.message}`) + } + } + + const refreshedProfile = await fetchProfileById(serviceClient, profileId) + const summary = await buildUserSummary(serviceClient, refreshedProfile) + + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: guard.profile.roleSlug, + resource: 'admin_users', + action: 'user_updated', + entityId: summary.profileId, + metadata: { + display_name: displayName, + role_slugs: requestedRoles, + is_admin: isAdmin, + password_reset: Boolean(newPassword), + }, + }) + + logStructuredEvent({ + message: 'admin_users:update_succeeded', + level: 'info', + userId: guard.profile.userId, + metadata: { + target_profile_id: summary.profileId, + actor_profile_id: guard.profile.id, + role_count: requestedRoles.length, + password_reset: Boolean(newPassword), + }, + }) + + return NextResponse.json({ user: summary }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to update user.' + let status = 500 + if (error instanceof Error) { + if (message.startsWith('Unknown role slug')) { + status = 400 + } else if (message.includes('Profile not found')) { + status = 404 + } + } + + logStructuredEvent({ + message: 'admin_users:update_failed', + level: 'error', + userId: guard.profile.userId, + metadata: { + target_profile_id: profileId, + actor_profile_id: guard.profile.id, + reason: message, + }, + }) + + return NextResponse.json({ error: message }, { status }) } - } - return NextResponse.json({ error: message }, { status }) - } + }, + ) } export async function DELETE( request: Request, { params }: { params: Promise<{ id: string }> }, ) { - const result = await getAdminProfile() - if ('response' in result) { - return result.response - } - - const { id: currentProfileId } = result.profile - const { id: profileId } = await params if (!profileId) { return NextResponse.json({ error: 'Profile id is required.' }, { status: 400 }) } + const guard = await requireAdmin({ + resource: 'admin_users', + action: 'delete', + entityId: profileId, + }) + if (!guard.ok) { + return guard.response + } + + const { id: currentProfileId, roleSlug } = guard.profile + if (profileId === currentProfileId) { + recordAuthzDeny('admin_users', { + role: roleSlug, + reason: 'self_delete_attempt', + }) return NextResponse.json( { error: 'You cannot delete your own account.' }, { status: 400 }, @@ -168,33 +189,77 @@ export async function DELETE( const serviceClient = createServiceRoleClient() - try { - const profileRecord = await fetchProfileById(serviceClient, profileId) - - const { error: authDeleteError } = await serviceClient.auth.admin.deleteUser( - profileRecord.user_id, - ) + return withSpan( + 'admin.users.delete', + { + resource: 'admin_users', + actor_id: guard.profile.id, + actor_role: roleSlug, + target_profile_id: profileId, + }, + async () => { + try { + const profileRecord = await fetchProfileById(serviceClient, profileId) + + const { error: authDeleteError } = await serviceClient.auth.admin.deleteUser( + profileRecord.user_id, + ) - if (authDeleteError) { - throw new Error(`Unable to delete auth user: ${authDeleteError.message}`) - } - - const { error: profileDeleteError } = await serviceClient - .from('profiles') - .delete() - .eq('id', profileId) - - if (profileDeleteError) { - throw new Error(`Unable to delete profile: ${profileDeleteError.message}`) - } - - return NextResponse.json({ success: true }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to delete user.' - let status = 500 - if (error instanceof Error && message.includes('Profile not found')) { - status = 404 - } - return NextResponse.json({ error: message }, { status }) - } + if (authDeleteError) { + throw new Error(`Unable to delete auth user: ${authDeleteError.message}`) + } + + const { error: profileDeleteError } = await serviceClient + .from('profiles') + .delete() + .eq('id', profileId) + + if (profileDeleteError) { + throw new Error(`Unable to delete profile: ${profileDeleteError.message}`) + } + + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: roleSlug, + resource: 'admin_users', + action: 'user_deleted', + entityId: profileId, + metadata: { + deleted_user_id: profileRecord.user_id, + }, + }) + + logStructuredEvent({ + message: 'admin_users:delete_succeeded', + level: 'info', + userId: guard.profile.userId, + metadata: { + target_profile_id: profileId, + actor_profile_id: guard.profile.id, + }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to delete user.' + let status = 500 + if (error instanceof Error && message.includes('Profile not found')) { + status = 404 + } + + logStructuredEvent({ + message: 'admin_users:delete_failed', + level: 'error', + userId: guard.profile.userId, + metadata: { + target_profile_id: profileId, + actor_profile_id: guard.profile.id, + reason: message, + }, + }) + + return NextResponse.json({ error: message }, { status }) + } + }, + ) } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 3fdd049..2a32ae5 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server' -import { - createServerComponentClient, - createServiceRoleClient, -} from '@/lib/supabase/server-client' -import { recordAuthzDeny } from '@/lib/observability/metrics' +import { createServiceRoleClient } from '@/lib/supabase/server-client' +import { requireAdmin } from '@/lib/auth/require-admin' +import { writeAuditLog } from '@/lib/audit/log' +import { withSpan } from '@/lib/observability/tracing' +import { logStructuredEvent } from '@/lib/observability/logger' import type { AdminRole, CreateAdminUserPayload } from '@/utils/types' import { fetchRoles, @@ -16,50 +16,6 @@ import { export { sanitizeRoleSlugs } from './shared' -const getAdminProfile = async (): Promise< - | { response: NextResponse } - | { profile: { id: string } } -> => { - const supabase = createServerComponentClient() - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - recordAuthzDeny('admin_users', { stage: 'auth_check' }) - return { - response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), - } - } - - const { data: profile, error } = await supabase - .from('profiles') - .select('id, is_admin') - .eq('user_id', user.id) - .maybeSingle() - - if (error) { - return { - response: NextResponse.json( - { error: `Unable to load profile: ${error.message}` }, - { status: 500 }, - ), - } - } - - if (!profile || !profile.is_admin) { - recordAuthzDeny('admin_users', { stage: 'role_check' }) - return { - response: NextResponse.json( - { error: 'Forbidden: admin access required.' }, - { status: 403 }, - ), - } - } - - return { profile: { id: profile.id } } -} - const sanitizeEmail = (value: unknown): string => { if (typeof value !== 'string') return '' const trimmed = value.trim() @@ -77,37 +33,67 @@ const sanitizePassword = (value: unknown): string => { } export async function GET() { - const result = await getAdminProfile() - if ('response' in result) { - return result.response + const guard = await requireAdmin({ resource: 'admin_users', action: 'list' }) + if (!guard.ok) { + return guard.response } - try { - const serviceClient = createServiceRoleClient() - const [roles, users] = await Promise.all([ - fetchRoles(serviceClient), - loadAllUserSummaries(serviceClient), - ]) - - const formattedRoles: AdminRole[] = roles.map((role) => ({ - id: role.id, - slug: role.slug, - name: role.name, - description: role.description, - priority: role.priority, - })) - - return NextResponse.json({ users, roles: formattedRoles }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to load users.' - return NextResponse.json({ error: message }, { status: 500 }) - } + return withSpan( + 'admin.users.list', + { + resource: 'admin_users', + actor_id: guard.profile.id, + actor_role: guard.profile.roleSlug, + }, + async () => { + try { + const serviceClient = createServiceRoleClient() + const [roles, users] = await Promise.all([ + fetchRoles(serviceClient), + loadAllUserSummaries(serviceClient), + ]) + + const formattedRoles: AdminRole[] = roles.map((role) => ({ + id: role.id, + slug: role.slug, + name: role.name, + description: role.description, + priority: role.priority, + })) + + logStructuredEvent({ + message: 'admin_users:list_succeeded', + level: 'info', + userId: guard.profile.userId, + metadata: { + actor_profile_id: guard.profile.id, + role_count: formattedRoles.length, + user_count: users.length, + }, + }) + + return NextResponse.json({ users, roles: formattedRoles }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to load users.' + logStructuredEvent({ + message: 'admin_users:list_failed', + level: 'error', + userId: guard.profile.userId, + metadata: { + actor_profile_id: guard.profile.id, + reason: message, + }, + }) + return NextResponse.json({ error: message }, { status: 500 }) + } + }, + ) } export async function POST(request: Request) { - const result = await getAdminProfile() - if ('response' in result) { - return result.response + const guard = await requireAdmin({ resource: 'admin_users', action: 'create' }) + if (!guard.ok) { + return guard.response } const body = (await request.json()) as Partial @@ -135,62 +121,107 @@ export async function POST(request: Request) { const serviceClient = createServiceRoleClient() - try { - const roles = await fetchRoles(serviceClient) - - const { data: authResult, error: authError } = await serviceClient.auth.admin.createUser({ - email, - password, - email_confirm: true, - }) - - if (authError) { - throw new Error(`Unable to create user: ${authError.message}`) - } - - const authUser = authResult.user - if (!authUser) { - throw new Error('Unable to create user: missing auth record.') - } - - const { data: profileData, error: profileError } = await serviceClient - .from('profiles') - .upsert( - { - user_id: authUser.id, - display_name: displayName, - is_admin: isAdmin, - }, - { onConflict: 'user_id' }, - ) - .select( - `id, user_id, display_name, is_admin, created_at, primary_role_id, + return withSpan( + 'admin.users.create', + { + resource: 'admin_users', + actor_id: guard.profile.id, + actor_role: guard.profile.roleSlug, + }, + async () => { + try { + const roles = await fetchRoles(serviceClient) + + const { data: authResult, error: authError } = await serviceClient.auth.admin.createUser({ + email, + password, + email_confirm: true, + }) + + if (authError) { + throw new Error(`Unable to create user: ${authError.message}`) + } + + const authUser = authResult.user + if (!authUser) { + throw new Error('Unable to create user: missing auth record.') + } + + const { data: profileData, error: profileError } = await serviceClient + .from('profiles') + .upsert( + { + user_id: authUser.id, + display_name: displayName, + is_admin: isAdmin, + }, + { onConflict: 'user_id' }, + ) + .select( + `id, user_id, display_name, is_admin, created_at, primary_role_id, profile_roles(role:roles(id, slug, name, description, priority))`, - ) - .single() - - if (profileError || !profileData) { - throw new Error( - `Unable to provision profile: ${profileError?.message ?? 'missing record.'}`, - ) - } - - await ensureRoleAssignments( - serviceClient, - profileData.id, - roles, - requestedRoles, - isAdmin, - ) - - const refreshedProfile = await fetchProfileById(serviceClient, profileData.id) - const summary = await buildUserSummary(serviceClient, refreshedProfile) - - return NextResponse.json({ user: summary }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to create user.' - const status = - error instanceof Error && message.startsWith('Unknown role slug') ? 400 : 500 - return NextResponse.json({ error: message }, { status }) - } + ) + .single() + + if (profileError || !profileData) { + throw new Error( + `Unable to provision profile: ${profileError?.message ?? 'missing record.'}`, + ) + } + + await ensureRoleAssignments( + serviceClient, + profileData.id, + roles, + requestedRoles, + isAdmin, + ) + + const refreshedProfile = await fetchProfileById(serviceClient, profileData.id) + const summary = await buildUserSummary(serviceClient, refreshedProfile) + + await writeAuditLog({ + actorId: guard.profile.id, + actorRole: guard.profile.roleSlug, + resource: 'admin_users', + action: 'user_created', + entityId: summary.profileId, + metadata: { + email, + is_admin: isAdmin, + role_slugs: requestedRoles, + }, + }) + + logStructuredEvent({ + message: 'admin_users:create_succeeded', + level: 'info', + userId: guard.profile.userId, + metadata: { + created_profile_id: summary.profileId, + actor_profile_id: guard.profile.id, + role_count: requestedRoles.length, + }, + }) + + return NextResponse.json({ user: summary }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to create user.' + const status = + error instanceof Error && message.startsWith('Unknown role slug') ? 400 : 500 + + logStructuredEvent({ + message: 'admin_users:create_failed', + level: 'error', + userId: guard.profile.userId, + metadata: { + actor_profile_id: guard.profile.id, + reason: message, + }, + }) + + return NextResponse.json({ error: message }, { status }) + } + }, + ) } diff --git a/src/app/api/spaces/[slug]/route.ts b/src/app/api/spaces/[slug]/route.ts new file mode 100644 index 0000000..2eae229 --- /dev/null +++ b/src/app/api/spaces/[slug]/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server' +import { isFeatureEnabled } from '@/lib/feature-flags/server' +import { createServerClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' + +export async function GET( + _request: Request, + { params }: { params: { slug: string } }, +) { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const supabase = createServerClient() + const { data: space, error } = await supabase + .from('spaces') + .select( + `id, slug, name, description, visibility, feature_flags, banner_image_url, + created_at, updated_at, + rules:space_rules(id, title, body, kind, value, position), + members:space_members(profile_id, status, role_slug) + `, + ) + .eq('slug', params.slug) + .maybeSingle() + + if (error) { + console.error('[spaces:getBySlug] failed', { error: error.message }) + return NextResponse.json({ error: 'Unable to load space.' }, { status: 500 }) + } + + if (!space) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + return NextResponse.json({ space }) +} diff --git a/src/app/api/spaces/[spaceId]/members/[profileId]/route.ts b/src/app/api/spaces/[spaceId]/members/[profileId]/route.ts new file mode 100644 index 0000000..38c262b --- /dev/null +++ b/src/app/api/spaces/[spaceId]/members/[profileId]/route.ts @@ -0,0 +1,149 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { isFeatureEnabled } from '@/lib/feature-flags/server' +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' +import { withSpan } from '@/lib/observability/tracing' +import { recordSpaceJoinApprovalLatency } from '@/lib/observability/metrics' +import { writeAuditLog } from '@/lib/audit/log' +import { requireSpaceRole, type SpaceRole } from '@/lib/auth/require-space-role' +import { normalizeRoleSlug } from '@/lib/rbac/permissions' + +const updateSchema = z.object({ + action: z.enum(['approve', 'ban', 'promote']), + role: z.enum(['member', 'contributor', 'organizer', 'moderator']).optional(), + reason: z.string().max(240).optional(), +}) + +const roleToSlug = (role: string | null | undefined): SpaceRole => { + return normalizeRoleSlug(role) as SpaceRole +} + +const resolveRoleId = async (role: SpaceRole): Promise => { + const serviceClient = createServiceRoleClient() + const { data, error } = await serviceClient + .from('roles') + .select('id') + .eq('slug', role) + .maybeSingle<{ id: string }>() + + if (error) { + console.error('[spaces:members:update] failed to resolve role id', { error: error.message }) + return null + } + + return data?.id ?? null +} + +export async function PATCH( + request: Request, + { params }: { params: { spaceId: string; profileId: string } }, +) { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const guard = await requireSpaceRole({ + resource: 'space_membership', + action: 'moderate', + spaceId: params.spaceId, + minimumRole: 'organizer', + }) + + if (!guard.ok) { + return guard.response + } + const actor = guard + + const body = await request.json().catch(() => ({})) + const parsed = updateSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid payload', details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ) + } + + return withSpan('api.space_members.update', { flag: 'spaces_v1', space_id: params.spaceId }, async () => { + const supabase = createServerClient() + const { data: membership, error: membershipError } = await supabase + .from('space_members') + .select('status, requested_at, role_slug, role_id') + .eq('space_id', params.spaceId) + .eq('profile_id', params.profileId) + .maybeSingle<{ + status: Database['public']['Enums']['space_membership_status'] + requested_at: string | null + role_slug: string | null + role_id: string | null + }>() + + if (membershipError || !membership) { + console.error('[spaces:members:update] membership lookup failed', { + error: membershipError?.message, + }) + return NextResponse.json({ error: 'Membership not found.' }, { status: 404 }) + } + + const now = Date.now() + const updates: Record = { + decision_at: new Date(now).toISOString(), + } + + if (parsed.data.action === 'approve') { + updates.status = 'active' + if (membership.requested_at) { + const latency = now - new Date(membership.requested_at).getTime() + if (latency > 0) { + recordSpaceJoinApprovalLatency(latency, { + space_id: params.spaceId, + actor_role: actor.membership?.role ?? 'organizer', + }) + } + } + } + + if (parsed.data.action === 'ban') { + updates.status = 'banned' + } + + if (parsed.data.action === 'promote') { + const desiredRole = roleToSlug(parsed.data.role ?? 'member') + const roleId = await resolveRoleId(desiredRole) + if (!roleId) { + return NextResponse.json({ error: 'Unable to resolve role.' }, { status: 500 }) + } + updates.role_id = roleId + updates.role_slug = desiredRole + } + + const { error: updateError } = await supabase + .from('space_members') + .update(updates) + .eq('space_id', params.spaceId) + .eq('profile_id', params.profileId) + + if (updateError) { + console.error('[spaces:members:update] failed', { error: updateError.message }) + return NextResponse.json({ error: 'Unable to update membership.' }, { status: 500 }) + } + + await writeAuditLog({ + actorId: actor.profileId, + actorRole: actor.isAdmin ? 'admin' : actor.membership?.role ?? 'organizer', + resource: 'space_membership', + action: parsed.data.action, + entityId: params.profileId, + spaceId: params.spaceId, + reason: parsed.data.reason ?? null, + metadata: { + target_status: updates.status ?? membership.status, + target_role: updates.role_slug ?? membership.role_slug ?? 'member', + }, + }) + + return NextResponse.json({ success: true }) + }) +} diff --git a/src/app/api/spaces/[spaceId]/members/route.ts b/src/app/api/spaces/[spaceId]/members/route.ts new file mode 100644 index 0000000..2a5014d --- /dev/null +++ b/src/app/api/spaces/[spaceId]/members/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { isFeatureEnabled } from '@/lib/feature-flags/server' +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' +import { writeAuditLog } from '@/lib/audit/log' + +const requestSchema = z.object({ + message: z.string().max(240).optional(), +}) + +export async function POST( + request: Request, + { params }: { params: { spaceId: string } }, +) { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const supabase = createServerClient() + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser() + + if (authError) { + console.error('[spaces:members:request] auth error', { error: authError.message }) + return NextResponse.json({ error: 'Unable to load session.' }, { status: 500 }) + } + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('id') + .eq('user_id', user.id) + .maybeSingle<{ id: string }>() + + if (profileError || !profile) { + console.error('[spaces:members:request] profile lookup failed', { + error: profileError?.message, + }) + return NextResponse.json({ error: 'Unable to load profile.' }, { status: 500 }) + } + + const body = await request.json().catch(() => ({})) + const parsed = requestSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid payload', details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ) + } + + const serviceClient = createServiceRoleClient() + const { data: memberRole, error: roleError } = await serviceClient + .from('roles') + .select('id, slug') + .eq('slug', 'member') + .maybeSingle<{ id: string }>() + + if (roleError || !memberRole) { + console.error('[spaces:members:request] unable to resolve member role', { + error: roleError?.message, + }) + return NextResponse.json({ error: 'Unable to process request.' }, { status: 500 }) + } + + const { data: existing } = await supabase + .from('space_members') + .select('status') + .eq('space_id', params.spaceId) + .eq('profile_id', profile.id) + .maybeSingle<{ status: Database['public']['Enums']['space_membership_status'] }>() + + if (existing && existing.status === 'active') { + return NextResponse.json({ error: 'Already a member.' }, { status: 409 }) + } + + const { error: upsertError } = await supabase + .from('space_members') + .upsert( + { + space_id: params.spaceId, + profile_id: profile.id, + role_id: memberRole.id, + status: 'pending', + requested_at: new Date().toISOString(), + }, + { onConflict: 'space_id,profile_id' }, + ) + + if (upsertError) { + console.error('[spaces:members:request] upsert failed', { error: upsertError.message }) + return NextResponse.json({ error: 'Unable to request access.' }, { status: 500 }) + } + + await writeAuditLog({ + actorId: profile.id, + actorRole: 'member', + resource: 'space_membership', + action: 'request', + entityId: params.spaceId, + spaceId: params.spaceId, + metadata: { message: parsed.data.message ?? null }, + }) + + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/spaces/[spaceId]/route.ts b/src/app/api/spaces/[spaceId]/route.ts new file mode 100644 index 0000000..4490780 --- /dev/null +++ b/src/app/api/spaces/[spaceId]/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { isFeatureEnabled } from '@/lib/feature-flags/server' +import { createServerClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' +import { withSpan } from '@/lib/observability/tracing' +import { writeAuditLog } from '@/lib/audit/log' +import { requireSpaceRole } from '@/lib/auth/require-space-role' + +const updateSchema = z.object({ + name: z.string().min(3).max(120).optional(), + description: z.string().max(500).optional(), + visibility: z.enum(['public', 'private']).optional(), + featureFlags: z.record(z.string(), z.boolean()).optional(), + bannerImageUrl: z.string().url().or(z.literal(null)).optional(), +}) + +export async function PATCH( + request: Request, + { params }: { params: { spaceId: string } }, +) { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const guard = await requireSpaceRole({ + resource: 'space', + action: 'update', + spaceId: params.spaceId, + minimumRole: 'organizer', + }) + + if (!guard.ok) { + return guard.response + } + const actor = guard + + const body = await request.json().catch(() => ({})) + const parsed = updateSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid payload', details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ) + } + + return withSpan('api.spaces.update', { flag: 'spaces_v1', space_id: params.spaceId }, async () => { + const supabase = createServerClient() + + const { error } = await supabase + .from('spaces') + .update({ + ...(parsed.data.name ? { name: parsed.data.name } : {}), + ...(parsed.data.description !== undefined + ? { description: parsed.data.description ?? null } + : {}), + ...(parsed.data.visibility ? { visibility: parsed.data.visibility } : {}), + ...(parsed.data.featureFlags ? { feature_flags: parsed.data.featureFlags } : {}), + ...(parsed.data.bannerImageUrl !== undefined + ? { banner_image_url: parsed.data.bannerImageUrl } + : {}), + }) + .eq('id', params.spaceId) + + if (error) { + console.error('[spaces:update] failed', { error: error.message }) + return NextResponse.json({ error: 'Unable to update space.' }, { status: 500 }) + } + + await writeAuditLog({ + actorId: actor.profileId, + actorRole: actor.isAdmin ? 'admin' : actor.membership?.role ?? 'member', + resource: 'space', + action: 'update', + entityId: params.spaceId, + metadata: parsed.data, + }) + + return NextResponse.json({ success: true }) + }) +} + +export async function DELETE( + _request: Request, + { params }: { params: { spaceId: string } }, +) { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + const guard = await requireSpaceRole({ + resource: 'space', + action: 'delete', + spaceId: params.spaceId, + minimumRole: 'organizer', + }) + + if (!guard.ok) { + return guard.response + } + const actorDelete = guard + + return withSpan('api.spaces.delete', { flag: 'spaces_v1', space_id: params.spaceId }, async () => { + const supabase = createServerClient() + + const { error } = await supabase.from('spaces').delete().eq('id', params.spaceId) + + if (error) { + console.error('[spaces:delete] failed', { error: error.message }) + return NextResponse.json({ error: 'Unable to delete space.' }, { status: 500 }) + } + + await writeAuditLog({ + actorId: actorDelete.profileId, + actorRole: actorDelete.isAdmin ? 'admin' : actorDelete.membership?.role ?? 'member', + resource: 'space', + action: 'delete', + entityId: params.spaceId, + }) + + return NextResponse.json({ success: true }) + }) +} diff --git a/src/app/api/spaces/route.ts b/src/app/api/spaces/route.ts new file mode 100644 index 0000000..ce24d71 --- /dev/null +++ b/src/app/api/spaces/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { isFeatureEnabled } from '@/lib/feature-flags/server' +import { createServerClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' +import { withSpan } from '@/lib/observability/tracing' +import { recordSpaceCreationSuccess } from '@/lib/observability/metrics' +import { writeAuditLog } from '@/lib/audit/log' +import { requireRole } from '@/lib/auth/require-role' + +const createSpaceSchema = z.object({ + name: z.string().min(3).max(120), + slug: z + .string() + .min(3) + .max(80) + .regex(/^[a-z0-9-]+$/), + description: z.string().max(500).optional(), + visibility: z.enum(['public', 'private']).default('public'), + featureFlags: z.record(z.string(), z.boolean()).optional(), + bannerImageUrl: z.string().url().optional(), +}) + +export async function GET() { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + return NextResponse.json({ spaces: [] }, { status: 404 }) + } + + const supabase = createServerClient() + const { data, error } = await supabase + .from('spaces') + .select('id, slug, name, description, visibility, feature_flags, banner_image_url') + .order('created_at', { ascending: false }) + .limit(50) + + if (error) { + console.error('[spaces:get] failed to load spaces', { error: error.message }) + return NextResponse.json({ error: 'Unable to load spaces.' }, { status: 500 }) + } + + return NextResponse.json({ spaces: data ?? [] }) +} + +export async function POST(request: Request) { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + return NextResponse.json({ error: 'Spaces are not available.' }, { status: 404 }) + } + + const guard = await requireRole({ + resource: 'spaces', + action: 'create', + minimumRole: 'organizer', + }) + + if (!guard.ok) { + return guard.response + } + const actor = guard.context + + const body = await request.json().catch(() => ({})) + const parsed = createSpaceSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid request.', details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ) + } + + return withSpan('api.spaces.create', { flag: 'spaces_v1' }, async () => { + const supabase = createServerClient() + + const { data: existing } = await supabase + .from('spaces') + .select('id') + .eq('slug', parsed.data.slug) + .maybeSingle() + + if (existing) { + return NextResponse.json({ error: 'Slug already exists.' }, { status: 409 }) + } + + const { error } = await supabase.from('spaces').insert({ + name: parsed.data.name, + slug: parsed.data.slug, + description: parsed.data.description ?? null, + visibility: parsed.data.visibility, + feature_flags: parsed.data.featureFlags ?? {}, + banner_image_url: parsed.data.bannerImageUrl ?? null, + created_by: actor.profileId, + }) + + if (error) { + console.error('[spaces:create] failed to insert', { error: error.message }) + return NextResponse.json({ error: 'Unable to create space.' }, { status: 500 }) + } + + recordSpaceCreationSuccess({ visibility: parsed.data.visibility }) + + await writeAuditLog({ + actorId: actor.profileId, + actorRole: actor.role, + resource: 'space', + action: 'create', + entityId: parsed.data.slug, + metadata: { + visibility: parsed.data.visibility, + feature_flags: parsed.data.featureFlags ?? {}, + }, + }) + + return NextResponse.json({ success: true }, { status: 201 }) + }) +} diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 8cb48e7..693899d 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -22,6 +22,11 @@ const docsMetadata: Record = { title: 'Open Source Launch Checklist', summary: 'Step-by-step prep to make a GitHub repository legally safe, documented, and welcoming for contributors.', }, + 'observability-dashboard': { + title: 'Observability Dashboards', + summary: + 'Observability dashboards (dash_ops_rbac_v1, dash_ops_nav_v1) tracking authz_denied_count instrumentation.', + }, }; const toTitleCase = (value: string) => diff --git a/src/app/spaces/[slug]/page.tsx b/src/app/spaces/[slug]/page.tsx new file mode 100644 index 0000000..e8c2563 --- /dev/null +++ b/src/app/spaces/[slug]/page.tsx @@ -0,0 +1,174 @@ +import { notFound } from 'next/navigation' +import { isFeatureEnabled } from '@/lib/feature-flags/server' +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' +import { SpaceShell } from '@/components/spaces/SpaceShell' +import { + normalizeRoleSlug, + compareRolePriority, + type CanonicalRoleSlug, +} from '@/lib/rbac/permissions' + +interface SpaceRecord { + id: string + slug: string + name: string + description: string | null + visibility: Database['public']['Enums']['space_visibility'] + space_rules: Array<{ + id: string + title: string + body: string + kind: string + position: number + }> + space_members: Array<{ + profile_id: string + status: Database['public']['Enums']['space_membership_status'] + role_slug: string | null + requested_at: string | null + profiles: { display_name: string | null } | null + }> +} + +const fetchSpace = async (slug: string): Promise => { + const supabase = createServerClient() + const { data, error } = await supabase + .from('spaces') + .select( + `id, slug, name, description, visibility, + space_rules(id, title, body, kind, position), + space_members(profile_id, status, role_slug, requested_at, profiles:profile_id(display_name)) + `, + ) + .eq('slug', slug) + .maybeSingle() + + if (error) { + console.error('[spaces:detail] failed to load space', { error: error.message }) + throw new Error('Unable to load space') + } + + return data ?? null +} + +const resolveActorContext = async (spaceId: string): Promise<{ + role: CanonicalRoleSlug + membership: { status: Database['public']['Enums']['space_membership_status']; role: CanonicalRoleSlug } | null + isAdmin: boolean +}> => { + const supabase = createServerClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return { role: 'member', membership: null, isAdmin: false } + } + + const { data: profile } = await supabase + .from('profiles') + .select('id, primary_role_id, is_admin') + .eq('user_id', user.id) + .maybeSingle<{ id: string; primary_role_id: string | null; is_admin: boolean }>() + + const membershipQuery = await supabase + .from('space_members') + .select('status, role_slug') + .eq('space_id', spaceId) + .eq('profile_id', profile?.id ?? '') + .maybeSingle<{ status: Database['public']['Enums']['space_membership_status']; role_slug: string | null }>() + + const membership = membershipQuery.data + ? { + status: membershipQuery.data.status, + role: normalizeRoleSlug(membershipQuery.data.role_slug ?? 'member'), + } + : null + + if (profile?.is_admin) { + return { role: 'admin', membership, isAdmin: true } + } + + let highest: CanonicalRoleSlug = 'member' + if (profile?.primary_role_id) { + const serviceClient = createServiceRoleClient() + const { data: roleData } = await serviceClient + .from('roles') + .select('slug') + .eq('id', profile.primary_role_id) + .maybeSingle<{ slug: string | null }>() + highest = normalizeRoleSlug(roleData?.slug) + } + + return { role: highest, membership, isAdmin: false } +} + +const canModerateSpace = ( + membershipRole: string | null, + actorRole: CanonicalRoleSlug, + isAdmin: boolean, +): boolean => { + if (isAdmin) { + return true + } + + const normalizedMembership: CanonicalRoleSlug = membershipRole + ? normalizeRoleSlug(membershipRole) + : 'member' + return ( + compareRolePriority(normalizedMembership, 'organizer') >= 0 || + compareRolePriority(normalizedMembership, 'moderator') >= 0 || + compareRolePriority(actorRole, 'organizer') >= 0 + ) +} + +const SpaceDetailPage = async ({ params }: { params: { slug: string } }) => { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + notFound() + } + + const space = await fetchSpace(params.slug) + if (!space) { + notFound() + } + + const actor = await resolveActorContext(space.id) + + const members = (space.space_members ?? []).map((member) => ({ + profileId: member.profile_id, + displayName: member.profiles?.display_name ?? 'Anonymous member', + status: member.status, + role: member.role_slug, + requestedAt: member.requested_at, + })) + + const membershipStatus = actor.membership?.status ?? null + const membershipRole = actor.membership?.role ?? null + + return ( +
+ ({ + id: rule.id, + title: rule.title, + body: rule.body, + kind: rule.kind, + position: rule.position, + })) ?? []} + members={members} + canModerate={canModerateSpace(membershipRole, actor.role, actor.isAdmin)} + /> +
+ ) +} + +export default SpaceDetailPage diff --git a/src/app/spaces/new/page.tsx b/src/app/spaces/new/page.tsx new file mode 100644 index 0000000..4209d84 --- /dev/null +++ b/src/app/spaces/new/page.tsx @@ -0,0 +1,69 @@ +import { notFound } from 'next/navigation' +import { isFeatureEnabled } from '@/lib/feature-flags/server' +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' +import { normalizeRoleSlug } from '@/lib/rbac/permissions' +import { CreateSpaceForm } from '@/components/spaces/CreateSpaceForm' + +const resolveRole = async () => { + const supabase = createServerClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return 'member' as const + } + + const { data: profile } = await supabase + .from('profiles') + .select('primary_role_id, is_admin') + .eq('user_id', user.id) + .maybeSingle<{ primary_role_id: string | null; is_admin: boolean }>() + + if (profile?.is_admin) { + return 'admin' as const + } + + if (!profile?.primary_role_id) { + return 'member' as const + } + + const serviceClient = createServiceRoleClient() + const { data: role } = await serviceClient + .from('roles') + .select('slug') + .eq('id', profile.primary_role_id) + .maybeSingle<{ slug: string | null }>() + + return normalizeRoleSlug(role?.slug) as ReturnType +} + +const allowedRoles = new Set(['organizer', 'moderator', 'admin']) + +const CreateSpacePage = async () => { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + notFound() + } + + const role = await resolveRole() + if (!allowedRoles.has(role)) { + notFound() + } + + return ( +
+
+

Create a new space

+

+ Launch a dedicated community with curated programming, templates, and moderation tools. Only pilot organizers can + create spaces during the staff rollout. +

+
+ +
+ ) +} + +export default CreateSpacePage diff --git a/src/app/spaces/page.tsx b/src/app/spaces/page.tsx new file mode 100644 index 0000000..a92d6a2 --- /dev/null +++ b/src/app/spaces/page.tsx @@ -0,0 +1,114 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { isFeatureEnabled } from '@/lib/feature-flags/server' +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server-client' +import type { Database } from '@/lib/supabase/types' +import { normalizeRoleSlug } from '@/lib/rbac/permissions' + +const fetchSpaces = async () => { + const supabase = createServerClient() + const { data, error } = await supabase + .from('spaces') + .select('id, slug, name, description, visibility') + .order('created_at', { ascending: false }) + + if (error) { + console.error('[spaces:list] failed to load', { error: error.message }) + throw new Error('Unable to load spaces') + } + + return data ?? [] +} + +const resolveHighestRole = async () => { + const supabase = createServerClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + return 'member' as const + } + + const { data: profile } = await supabase + .from('profiles') + .select('primary_role_id, is_admin') + .eq('user_id', user.id) + .maybeSingle<{ primary_role_id: string | null; is_admin: boolean }>() + + if (profile?.is_admin) { + return 'admin' as const + } + + if (!profile?.primary_role_id) { + return 'member' as const + } + + const serviceClient = createServiceRoleClient() + const { data: role } = await serviceClient + .from('roles') + .select('slug') + .eq('id', profile.primary_role_id) + .maybeSingle<{ slug: string | null }>() + + return normalizeRoleSlug(role?.slug) as ReturnType +} + +const canCreateSpace = (role: string) => { + return ['organizer', 'moderator', 'admin'].includes(role) +} + +const SpacesPage = async () => { + const enabled = await isFeatureEnabled('spaces_v1') + if (!enabled) { + notFound() + } + + const [spaces, highestRole] = await Promise.all([fetchSpaces(), resolveHighestRole()]) + + return ( +
+
+
+

Spaces

+

+ Discover curated communities with shared rituals, moderated programs, and bespoke content templates. Spaces roll out + gradually to ensure healthy collaboration. +

+
+ {canCreateSpace(highestRole) ? ( + + Create a space + + ) : null} +
+ +
+ {spaces.length === 0 ? ( +

+ No spaces have launched yet. Check back soon as we onboard the first pilot communities. +

+ ) : ( + spaces.map((space) => ( + +

{space.name}

+

{space.description ?? 'No description yet.'}

+ + {space.visibility} visibility + + + )) + )} +
+
+ ) +} + +export default SpacesPage diff --git a/src/components/admin/AdminDashboard.tsx b/src/components/admin/AdminDashboard.tsx index db7c7b1..9eaf64d 100644 --- a/src/components/admin/AdminDashboard.tsx +++ b/src/components/admin/AdminDashboard.tsx @@ -11,7 +11,7 @@ import { Sidebar } from './Sidebar' import { PostsTable } from './PostsTable' import { useFeatureFlag } from '@/lib/feature-flags/client' import { PostForm } from './PostForm' -import { UserManagement } from './UserManagement' +import { RoleManager } from './RoleManager' import { CommentsModeration } from './CommentsModeration' import { TaxonomyManager } from './TaxonomyManager' import { DashboardOverview } from './DashboardOverview' @@ -111,6 +111,7 @@ const DashboardContent = ({ scheduledFor: post.scheduledFor ?? null, authorId: post.authorId ?? null, views: post.views ?? 0, + spaceId: post.spaceId ?? null, })) }, []) @@ -1568,7 +1569,7 @@ const DashboardContent = ({ } return ( - Promise | void + onCreateUser: (payload: CreateAdminUserPayload) => Promise + onUpdateUser: ( + profileId: string, + payload: UpdateAdminUserPayload, + ) => Promise + onDeleteUser: (profileId: string) => Promise +} + +const badgeToneClass: Record = { + neutral: + 'border-brand-border-muted bg-brand-surface text-brand-ink/80 shadow-brand-sm', + info: 'border-brand-border-info bg-brand-surface-info text-brand-ink shadow-brand-md', + success: + 'border-brand-border-success bg-brand-surface-success text-brand-ink shadow-brand-md', + warning: + 'border-brand-border-warning bg-brand-surface-warning text-brand-ink shadow-brand-lg', + critical: + 'border-brand-border-critical bg-brand-surface-critical text-brand-ink shadow-brand-lg', +} + +const buildRoleCounts = ( + users: AdminUserSummary[], +): Record => { + return users.reduce>((accumulator, user) => { + const slug = getHighestRoleSlug(user.roles) + accumulator[slug] = (accumulator[slug] ?? 0) + 1 + return accumulator + }, { + member: 0, + contributor: 0, + organizer: 0, + moderator: 0, + admin: 0, + }) +} + +export const RoleManager = ({ + users, + roles, + isLoading, + isSaving, + currentProfileId, + onRefresh, + onCreateUser, + onUpdateUser, + onDeleteUser, +}: RoleManagerProps) => { + const [searchTerm, setSearchTerm] = useState('') + + const normalizedSearch = searchTerm.trim().toLowerCase() + + const filteredUsers = useMemo(() => { + const matches = users.filter((user) => { + if (!normalizedSearch) { + return true + } + + return ( + user.displayName.toLowerCase().includes(normalizedSearch) || + user.email.toLowerCase().includes(normalizedSearch) + ) + }) + + return matches.sort((left, right) => { + const leftRole = getHighestRoleSlug(left.roles) + const rightRole = getHighestRoleSlug(right.roles) + + if (leftRole !== rightRole) { + return compareRolePriority(rightRole, leftRole) + } + + return left.displayName.localeCompare(right.displayName) + }) + }, [users, normalizedSearch]) + + const roleCounts = useMemo(() => buildRoleCounts(users), [users]) + + const summaryLabel = useMemo(() => { + if (isLoading) { + return 'Loading role assignments…' + } + + return `${users.length} team member${users.length === 1 ? '' : 's'} with managed roles` + }, [isLoading, users.length]) + + const sortedRoles = useMemo(() => sortRolesAscending(roles), [roles]) + + return ( +
+
+
+
+
+

+ Platform role manager +

+

+ Assign canonical privileges, audit highest role badges, and maintain a + clean roster for community moderation. Every change is audited and + protected by the RBAC hardening feature flag. +

+

+ {summaryLabel} +

+
+
+
+
+ +
+
+
+
+ {(Object.entries(roleCounts) as Array<[CanonicalRoleSlug, number]>).map( + ([slug, total]) => { + const badge = getRoleBadge(slug) + return ( +
+ + {badge.label} + + {total} + highest badge +
+ ) + }, + )} +
+
+ +
+
+

Current roster

+
    + {filteredUsers.map((user) => { + const badge = getRoleBadge(getHighestRoleSlug(user.roles)) + const badgeClass = badgeToneClass[badge.tone] + const displayRoles = user.roles.length + ? sortRolesAscending(user.roles).map((role) => role.name ?? role.slug) + : ['Member'] + + return ( +
  • +
    +
    +

    {user.displayName}

    +

    {user.email}

    +
    + + {badge.label} + +
    +
    + {displayRoles.map((roleLabel) => ( + + {roleLabel} + + ))} +
    +
  • + ) + })} + {filteredUsers.length === 0 && ( +
  • + No users match that filter. Try a different name or email address. +
  • + )} +
+
+
+ +
+
+
+ ) +} diff --git a/src/components/spaces/CreateSpaceForm.tsx b/src/components/spaces/CreateSpaceForm.tsx new file mode 100644 index 0000000..35f94f9 --- /dev/null +++ b/src/components/spaces/CreateSpaceForm.tsx @@ -0,0 +1,126 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' + +interface CreateSpaceFormProps { + defaultVisibility: 'public' | 'private' +} + +export const CreateSpaceForm = ({ defaultVisibility }: CreateSpaceFormProps) => { + const router = useRouter() + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + const [description, setDescription] = useState('') + const [visibility, setVisibility] = useState<'public' | 'private'>(defaultVisibility) + const [error, setError] = useState(null) + const [isPending, startTransition] = useTransition() + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setError(null) + + try { + const response = await fetch('/api/spaces', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, slug, description, visibility }), + }) + + if (!response.ok) { + const payload = await response.json().catch(() => ({})) + throw new Error(payload.error ?? 'Unable to create space') + } + + startTransition(() => { + router.push(`/spaces/${slug}`) + router.refresh() + }) + } catch (caught) { + console.error('[CreateSpaceForm] submission failed', caught) + setError(caught instanceof Error ? caught.message : 'Unable to create space') + } + } + + return ( +
+
+
+ + setName(event.target.value)} + required + className="mt-2 w-full rounded-md border-2 border-black px-3 py-2 text-sm" + /> +
+
+ + setSlug(event.target.value)} + pattern="[a-z0-9-]+" + required + className="mt-2 w-full rounded-md border-2 border-black px-3 py-2 text-sm" + /> +

Use lowercase letters, numbers, and hyphens only.

+
+
+ +