feat(tenant): setup CLI, CD glue, bundled compose, docs + security fix

Tier 2 productization for single-tenant instances, plus fixes from an
adversarial review pass.

New:
- scripts/setup.mjs: 'pnpm setup' CLI (doctor/provision/create-admin/all),
  idempotent, creates the first admin via the Supabase Admin API (email_confirm),
  flag > env > .env.local precedence, masked interactive prompts, --yes/--force.
- scripts/apply-migrations.sh + deploy/deploy.sh: reproducible, idempotent CD
  glue (previously only on the prod droplet), tracked via public._migrations_applied.
- docker-compose.full.yml + .env.full.example: one-command app + self-hosted
  Supabase bundle (compose config validated).
- docs/SINGLE_TENANT.md (deploy + CLI guide) and docs/CUSTOMIZE.md (the
  documented lib/tenant.ts config pattern for white-labeling); README pointer.
- __tests__/tenant.test.ts: 19 unit tests for resolveTenantConfig + isMarketingPath.

Security (found by review, pre-existing):
- Drop the stale handle_space_signup() auth trigger, which derived member role
  and space from client-controlled raw_user_meta_data and could self-insert an
  admin. Membership is created only by the vetted server actions.
  Migration scripts/056_drop_stale_signup_trigger.sql + folded into schema.sql.

Fixes:
- setup.mjs masked-password prompt hung (callback passed to readline/promises
  question); use the awaited form.
- notifications-enqueue.test: base-URL default is now localhost (platform-domain
  fallback removed), not hackerspace.sh.
- docs accuracy (logo/BrandMark, apply-migrations does not restart PostgREST, --yes).

Gates: tsc 0, lint 0 errors, 664 unit tests, build ok.
This commit is contained in:
Moheeb Zara
2026-08-05 13:02:18 -07:00
parent 646d2e542c
commit 7b8d2ae167
12 changed files with 1989 additions and 49 deletions
+14 -47
View File
@@ -634,54 +634,21 @@ CREATE TRIGGER on_space_created
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.create_default_channels();
-- Auth signup hook
-- Called when a new user is created in auth.users.
-- Reads metadata from supabase.auth.signUp({ data: { space_id, role, display_name } })
-- and inserts the corresponding space_members row.
CREATE OR REPLACE FUNCTION public.handle_space_signup()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_space_id uuid;
v_role public.member_role;
v_display_name text;
BEGIN
v_space_id := (NEW.raw_user_meta_data->>'space_id')::uuid;
-- Default to 'member' if role is missing or invalid
v_role := COALESCE(
NULLIF(NEW.raw_user_meta_data->>'role', '')::public.member_role,
'member'
);
-- Prefer display_name, fall back to full_name, then email
v_display_name := COALESCE(
NULLIF(NEW.raw_user_meta_data->>'display_name', ''),
NULLIF(NEW.raw_user_meta_data->>'full_name', ''),
NEW.email
);
-- No space_id means this is not a hackerspace signup flow
IF v_space_id IS NULL THEN
RETURN NEW;
END IF;
INSERT INTO public.space_members
(space_id, user_id, role, status, display_name, email, approved)
VALUES
(v_space_id, NEW.id, v_role, 'unverified', v_display_name, NEW.email, true)
ON CONFLICT (space_id, user_id) DO NOTHING;
RETURN NEW;
END;
$$;
-- Auth signup hook — REMOVED (was a privilege-escalation vector). See
-- scripts/056_drop_stale_signup_trigger.sql for the incremental drop on
-- existing databases.
--
-- The old handle_space_signup() trigger derived the new member's role and space
-- from auth.users.raw_user_meta_data, which is fully client-controlled: the
-- browser calls supabase.auth.signUp({ options: { data: {...} } }) with the anon
-- key and GoTrue stores that verbatim. A crafted signup with
-- data:{ role:'admin', space_id:'<known-uuid>' } would self-insert an admin
-- space_members row (approved=true), bypassing the createSpace/joinSpace guards
-- entirely. The legitimate signup flow never sets space_id/role in metadata
-- (membership is created by the vetted server actions in lib/auth-actions.ts),
-- so the trigger was stale as well as dangerous. Dropped, not fixed.
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_space_signup();
DROP FUNCTION IF EXISTS public.handle_space_signup();
-- Privilege-escalation guard.
-- RLS lets a member update their own space_members row (so they can fix their