Commit Graph
11 Commits
Author SHA1 Message Date
Moheeb ZaraandClaude Opus 4.8 2ee929658e fix(invites): close single-use invite max_uses TOCTOU (H1)
Redeeming an invite read uses_count, checked the cap in JS, then did a
non-atomic uses_count = uses_count + 1 write AFTER creating the membership.
Two concurrent redemptions of a max_uses=1 invite both passed the check and
both created a member, and the counter under-counted.

Fix: claim a use ATOMICALLY before creating the membership via a
compare-and-swap on uses_count (lib/invite-claim.ts) — the UPDATE only writes
when uses_count still equals the value the cap decision was based on, so of two
racing claims exactly one wins. The loser re-reads and retries while capacity
remains (multi-use invites unaffected). On a failed membership insert the claim
is rolled back so a slot is not burned.

Extracted to lib/invite-claim.ts (out of the 'use server' surface, unit-tested).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-01 21:16:12 -07:00
Moheeb ZaraandClaude Opus 4.8 1766780bca types: restore Supabase row typing, fix parseInput inference (586→76 tsc errors)
The installed supabase-js (2.99, 5 generics) is incompatible with
@supabase/[email protected] (calls it with 3), collapsing the server client's
schema to `never` — the root cause of ~450 of the 586 hidden type errors
(next.config typescript.ignoreBuildErrors masks them all).

- lib/supabase/server.ts: annotate the factory as SupabaseClient<Database>
  and bridge the ssr/js impedance mismatch at the one documented boundary;
  type the setAll cookie params.
- lib/supabase/proxy.ts, proxy.ts: type the setAll cookie params.
- lib/auth-helpers.ts: parseInput now infers its result from the schema via
  z.infer (was a fragile ZodSchema<T> that widened outputs to {}).
- lib/validations.ts: annotate flexibleDateTime's output type.
- types/database.ts: add class_signup_tx / class_cancel_tx RPC signatures
  (called but absent; pending full regeneration).
- tsconfig.json: target ES2020 (door-log-logic uses BigInt literals).
- vitest.setup.ts: import beforeAll/afterAll instead of relying on globals.

No runtime behavior change; 639 tests still green. ignoreBuildErrors stays
on until the remaining errors reach zero.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-08-01 18:40:33 -07:00
Moheeb Zara c0d6d496f8 audit: fix onboarding payment_url XSS + forum comment tenant check + joinSpace rate limit
Diversified audit across public/API, XSS/rendering, and legacy authz
surfaces (three independent reviews). No P0. Fixes:

- Onboarding payment_url (P1 stored XSS): rendered into an href with no
  protocol check; an admin could store javascript:. Sanitize at the sink
  with sanitizeUrl (http(s) only). Same class as the dues-url fix.
- forum.addComment (P2): only forum_thread targets were space-verified;
  proposal/incident/policy entity_ids were not, so a member could attach
  a comment to another space's entity. Verify the target exists in the
  caller's space (RLS-scoped) for every entity type before insert.
- joinSpace (P2): add a per-user rate limit (10/hr) for parity with
  createSpace/signIn, bounding invite-code guessing.
2026-05-20 02:22:50 -07:00
Moheeb Zara 2239e7a92d fix(auth): unverified members cannot exercise roles; single-membership join (D2)
require_approval was silently void: redeeming an admin-role invite into
a require_approval space yielded role=admin,status=unverified and
requireMemberWithRole never checked status -> instant admin pre-approval.
- PRIVILEGE_STATUSES/isPrivilegeEligible (current|late only; excludes
  unverified/inactive). requireMemberWithRole now rejects non-privilege-
  eligible status with 'pending approval' before the role check; Member
  carries status (getAuthMember selects it). requireMember stays
  permissive so unverified members still see their own /me.
- joinSpace rejects a second active membership (single-space model;
  prevents the .single() self-DoS).
Deferred (HANDOFF): SECURITY DEFINER helpers user_has_role_in_space/
user_has_permission still ignore status (RLS defense-in-depth, own
careful pass). 524 tests; build clean.
2026-05-18 18:30:20 -07:00
Moheeb Zara df6951c323 feat(invites): role-granting + usage-capped invites; audit hardening (P7)
Invites
- migration 029: space_invites.role (member_role enum, default
  'member' — legacy invites and the spaces.invite_code path keep
  granting member). schema.sql + types/database.ts mirrored
- lib/invite-logic.ts (pure, tested): canAssignInviteRole — admin may
  grant any role; board may grant anything except admin; nobody else.
  Privilege-escalation guard on top of the existing admin/board gate
- createInvite/updateInvite take + enforce role via that guard;
  createInviteSchema/updateInviteSchema validate the enum
- joinSpace applies the invite's granted role (validated), default
  member; uses_count/max_uses/expiry already enforced
- invites panel: role picker (filtered to what the creator may grant),
  one-click "single use", per-invite role badge
- space-scoped invite link /join/<space>?code=… via a presentational
  app/join/[space] landing that hands off to the proven
  /signup?invite= flow; /join added to middleware PUBLIC_ROUTES

Audit hardening (from this pass's review)
- /login now honors a validated same-origin ?next= (was hardcoded to
  /dashboard) so the public_auth form sign-in round-trips; OAuth
  threads next through the callback (which already honored it)
- submitForm requires formId; submitFormSchema drops the now-ambiguous
  slug path (form slugs are per-space after 028). Both callers already
  pass formId. Tests updated

337 tests; build verified (Compiled successfully, exit 0). No deploy.
2026-05-16 17:34:45 -07:00
Moheeb Zara 40d4cdad98 feat(forms): verified-email retro-link of anonymous submissions (Phase 5)
- claimMyAnonymousSubmissions(): exported but safe — no trusted params,
  resolves member + email from the caller's session, gated on
  email_confirmed_at, idempotent. Links the caller's own prior
  anonymous submissions to their member row
- hooked best-effort from joinSpace (member insert now returns id;
  links prior anon submissions for the verified email) and
  finishOnboarding
- addMember/importMembers deliberately NOT auto-linked: those emails
  are admin-asserted, not verified; the locked decision forbids
  auto-link there, so the forms.manage linkSubmissionsForMember stays
  the admin manual-link path
- also repairs a build break committed in 295d220: finishOnboarding
  declared `const admin` twice (Phase 4 added a second). The probe and
  the completion update now share one admin client
- docs: API_REFERENCE / ARCHITECTURE / HANDOFF

287/287 tests, build compiles clean (verified Compiled successfully). No deploy.
2026-05-16 16:29:32 -07:00
Moheeb Zara f5d670477b fix(security): close member self-escalation + harden after full audit
Full-application audit (5 parallel read-only audits). Two agent
"blockers" were false alarms and verified as already-correct. Real
findings fixed:

- Self-escalation (BLOCKER): migration 024 + schema.sql section 16
  extend prevent_member_self_role_change to also block a
  non-privileged member changing their own tier_id (self tier
  upgrade) and onboarding_completed_at (skipping required
  code-of-conduct via a raw PostgREST call). finishOnboarding and
  skipOnboarding now set onboarding_completed_at through the service
  client AFTER their server-side required-steps check, so the
  legitimate path is unaffected.
- Cross-tenant write scoping: added .eq('space_id', member.space_id)
  to updateForumThread, deleteForumThread, editComment, deleteComment,
  deleteChannel, renameChannel (RLS-only -> defence-in-depth).
- generateInviteCode: crypto.getRandomValues, widened to 8 chars
  (was Math.random, ~21 bits, enumerable under the join rate limit).
- SafeMarkdown: dropped iframe and the global style attribute
  (no unsandboxed embeds / UI-redress); markdown + safe HTML stay.
- /settings: server-side admin redirect before fetching space and
  integration config (was client-hidden only).
- app/(app)/layout.tsx: export const dynamic = 'force-dynamic'.
- Bounded queries: loadComments .limit(500), tasks .limit(2000).

Backlog from the audit is tracked in HANDOFF (import Zod schemas,
types/database.ts + docs regen, email lowercasing, unauthorized-
handling consistency, KB-modal ACL hookup).

271/271 tests pass. Build clean. Migrations 023 + 024 idempotent,
apply via deploy.sh on next deploy.
2026-05-15 17:35:11 -07:00
Moheeb Zara 30bba045f8 feat: configurable member onboarding + UX/persona research
A new member who joins via invite code now walks through an
admin-defined onboarding sequence before reaching the app.

Schema (migration 022, folded into schema.sql Section 14):
- space_onboarding_steps: step_key, step_type
  (welcome|code_of_conduct|profile|payment|content), title, body,
  config jsonb, is_enabled, is_required, is_system, sort_order,
  unique per (space, step_key). Full RLS: members read, admin/board
  write, admin delete non-system.
- space_members.onboarding_completed_at + onboarding_progress jsonb.
- Trigger seeds four default steps per space (welcome, code of
  conduct [required], profile, dues). Backfill seeds existing spaces
  and marks all existing members complete so the new gate never
  traps them.

Flow:
- /onboarding (top-level route, outside the (app) group so no
  redirect loop). Progress bar, step counter, back/continue, per-type
  rendering. Profile step calls updateMyProfile; payment step is a
  non-blocking nudge with optional link and "Remind me later".
- (app)/layout.tsx redirects to /onboarding while
  onboarding_completed_at is null.
- createSpace marks the founder complete: founders configure the
  space, they do not run the member flow (matches admin-vs-end-user
  persona research).
- finishOnboarding re-checks required steps server-side so a tampered
  client cannot skip a required code-of-conduct acknowledgement.
  skipOnboarding only works when nothing is required.

Admin config:
- Settings -> Onboarding tab: reorder, edit title/body/payment link,
  toggle enabled/required, add custom content steps, delete
  non-system steps.

Safety:
- components/safe-markdown.tsx renders admin-authored markdown and a
  sanitized subset of HTML (rehype-raw + rehype-sanitize). Added
  those two deps.

Also:
- bio added to updateMyProfileSchema (members can self-set it; not
  blocked by the migration-015 trigger).
- docs/UX_AND_PERSONAS.md: 4 personas and prioritized UX audit
  findings. Onboarding was finding #1; 2-6 deferred and tracked.

266/266 unit tests pass. Production build clean, /onboarding in the
route table. Migration 022 applies idempotently on the next deploy.
2026-05-15 15:38:40 -07:00
Moheeb Zara de51000584 feat: forum, polymorphic comments, custom tiers, multi-invite, secrets encryption
Big feature batch driven by the super-audit findings and the user's
follow-up requests.

Schema migration 021 (folded into schema.sql Section 13):
- forum_threads + polymorphic comments table keyed on (entity_type,
  entity_id). Entity types: forum_thread, proposal, incident, policy.
  Trigger maintains forum_threads.comment_count and last_comment_at.
- space_tiers per-space with monthly_price_cents, billing_cadence,
  is_system, sort_order. Trigger seeds plus/basic/associate on every
  space INSERT and backfills existing spaces. space_members.tier_id FK
  added and backfilled from the legacy enum value.
- space_role_labels (rename/recolor built-in roles per space).
  space_custom_roles + space_member_custom_roles for non-privileged
  extra labels. RLS gates by admin/board.
- space_invites: multi-code invites with expires_at, max_uses,
  uses_count, is_enabled. Legacy spaces.invite_code backfilled as a
  permanent enabled invite.
- secrets.encrypted_value bytea + encryption_version. Secrets RLS
  tightened. New comms_channels INSERT policy: any member can create;
  default channels are protected from deletion.
- knowledge_base.render_markdown boolean (default true).

Secrets encryption-at-rest:
- AES-256-GCM in lib/secrets/crypto.ts. Master key from
  SECRETS_ENCRYPTION_KEY env (64 hex chars).
- New revealSecret(id) server action: only path to plaintext, writes
  secret.revealed to activity_log.
- /ops/page.tsx no longer selects value or encrypted_value on the list
  endpoint - the original screenshot bug where ciphertext was already
  in the page bundle before "Reveal" is fixed.

UI:
- /forum index, /forum/new, /forum/[id]. Pin/lock/delete actions for
  admin/board.
- CommentThread component embedded on /proposals/[id], /incidents/[id],
  /policies/[slug]. Markdown rendering via react-markdown + remark-gfm.
- /comms sidebar: inline "+ New channel" form.
- /ops KB: row click opens markdown-rendering viewer modal.
- /settings: new Tiers tab and Invites tab. Inline price edit and
  archive/delete on tiers; copy/enable/disable/delete on invites.
- AtlasLogo replaces the terminal icon on the landing nav and footer.
  public/logo.svg wired as the favicon in app/layout.tsx metadata.
- Sidebar has a Forum nav link.

Server actions added:
- lib/actions/forum.ts, comms.ts, tiers.ts, roles.ts, invites.ts.
- lib/actions/secrets.ts rewritten with updateSecret + revealSecret.

Auth flow:
- joinSpace honors codes from space_invites first (enabled, expiry,
  use cap), falls back to legacy spaces.invite_code, increments
  uses_count on success.

Env:
- SECRETS_ENCRYPTION_KEY documented in .env.example and set on the
  Droplet's /opt/hackerspace-ops/.env.production.

README: live site link added (https://hackerspace.sh).

266/266 unit tests pass. Production build clean. Migration 021 will be
applied on the next deploy via /opt/hackerspace-ops/deploy.sh's
idempotent migration runner.
2026-05-14 17:47:52 -07:00
v0andMoheeb Zara f5697f577f feat: comprehensive application audit and fixes
Audited codebase, fixed input validation, OAuth, error handling, type casts, and security hardening

Co-authored-by: Moheeb Zara <[email protected]>
2026-03-11 08:18:57 +00:00
v0 296c5dd78d Initial commit from v0 2026-03-11 04:44:48 +00:00