@nextsparkjs/core 0.1.0-beta.166 → 0.1.0-beta.168

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/hooks/useAuth.d.ts +2 -1
  2. package/dist/hooks/useAuth.d.ts.map +1 -1
  3. package/dist/hooks/useAuth.js +13 -8
  4. package/dist/lib/api/entity/generic-handler.d.ts.map +1 -1
  5. package/dist/lib/api/entity/generic-handler.js +23 -3
  6. package/dist/lib/auth-context.d.ts +5 -0
  7. package/dist/lib/auth-context.d.ts.map +1 -1
  8. package/dist/lib/auth.d.ts.map +1 -1
  9. package/dist/lib/auth.js +28 -5
  10. package/dist/lib/config/app.config.d.ts.map +1 -1
  11. package/dist/lib/config/app.config.js +9 -1
  12. package/dist/lib/config/types.d.ts +17 -0
  13. package/dist/lib/config/types.d.ts.map +1 -1
  14. package/dist/lib/db.d.ts +28 -5
  15. package/dist/lib/db.d.ts.map +1 -1
  16. package/dist/lib/db.js +38 -16
  17. package/dist/lib/entities/types.d.ts +20 -3
  18. package/dist/lib/entities/types.d.ts.map +1 -1
  19. package/dist/lib/services/team-member.service.d.ts.map +1 -1
  20. package/dist/lib/services/team-member.service.js +2 -1
  21. package/dist/lib/services/team.service.d.ts.map +1 -1
  22. package/dist/lib/services/team.service.js +2 -2
  23. package/dist/lib/teams/actions.d.ts.map +1 -1
  24. package/dist/lib/teams/actions.js +4 -3
  25. package/dist/migrations/001_better_auth_and_functions.sql +70 -0
  26. package/dist/migrations/002_auth_tables.sql +90 -10
  27. package/dist/migrations/007_teams_table.sql +10 -4
  28. package/dist/migrations/008_team_members_table.sql +1 -1
  29. package/dist/migrations/009_team_invitations_table.sql +1 -1
  30. package/dist/migrations/010_teams_functions_triggers.sql +6 -48
  31. package/dist/migrations/013_billing_subscriptions.sql +61 -31
  32. package/dist/migrations/016_billing_events.sql +17 -3
  33. package/dist/migrations/017_scheduled_actions_table.sql +9 -7
  34. package/dist/migrations/022_rls_runtime_roles.sql +87 -0
  35. package/dist/styles/classes.json +1 -1
  36. package/dist/templates/app/api/auth/[...all]/route.ts +14 -1
  37. package/migrations/001_better_auth_and_functions.sql +70 -0
  38. package/migrations/002_auth_tables.sql +90 -10
  39. package/migrations/007_teams_table.sql +10 -4
  40. package/migrations/008_team_members_table.sql +1 -1
  41. package/migrations/009_team_invitations_table.sql +1 -1
  42. package/migrations/010_teams_functions_triggers.sql +6 -48
  43. package/migrations/013_billing_subscriptions.sql +61 -31
  44. package/migrations/016_billing_events.sql +17 -3
  45. package/migrations/017_scheduled_actions_table.sql +9 -7
  46. package/migrations/022_rls_runtime_roles.sql +87 -0
  47. package/package.json +2 -2
  48. package/scripts/db/run-migrations.mjs +13 -2
  49. package/templates/app/api/auth/[...all]/route.ts +14 -1
  50. package/templates/next.config.mjs +1 -4
@@ -0,0 +1,87 @@
1
+ -- ============================================================================
2
+ -- 022 · RLS runtime roles — nextspark_app (non-owner runtime role) + grants
3
+ -- ============================================================================
4
+ -- Generic framework migration: creates the non-owner runtime role the app
5
+ -- connects as so RLS is actually evaluated on the app path.
6
+ --
7
+ -- WHY: by default the app connects to Postgres as the table OWNER, so RLS
8
+ -- policies are never evaluated on the app path (owner skips RLS unless FORCE).
9
+ -- This migration creates the non-owner runtime role the app connects as after
10
+ -- the runtime cutover. Until the cutover nothing changes for the running app:
11
+ -- migrations and seeds keep running as the owner.
12
+ --
13
+ -- Design decisions:
14
+ -- - nextspark_app is NOLOGIN here; the LOGIN credential is created per environment
15
+ -- at cutover time (deploy-time secret), never in a migration.
16
+ -- - nextspark_app is a member of `authenticated` (INHERIT): every existing policy
17
+ -- declared `TO authenticated` applies to it without rewriting.
18
+ -- - NO `FORCE ROW LEVEL SECURITY`: nextspark_app is not the owner so plain ENABLE
19
+ -- is enough, and FORCE would break owner-run seeds/sample-data on Supabase
20
+ -- (where `postgres` is owner but not superuser).
21
+ -- - NO `BYPASSRLS` role here: that attribute requires superuser (not available
22
+ -- on Supabase). The service context for machine actors (webhooks, scheduled
23
+ -- actions) is a CORE/environment workstream: the app uses a separate service
24
+ -- connection (DATABASE_SERVICE_URL) for system operations.
25
+ -- - `anon` gets an explicit REVOKE + default privileges revoke: on Supabase the
26
+ -- default privileges grant to anon automatically; abstaining is not enough.
27
+ --
28
+ -- Ordering: runs after all core table DDL (<= 021) so `GRANT ... ON ALL TABLES`
29
+ -- covers every existing table; `ALTER DEFAULT PRIVILEGES` covers tables created
30
+ -- later (theme/entity migrations) by the same migration owner.
31
+
32
+ -- ----------------------------------------------------------------------------
33
+ -- 1. Runtime role
34
+ -- ----------------------------------------------------------------------------
35
+ DO $$
36
+ BEGIN
37
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nextspark_app') THEN
38
+ CREATE ROLE nextspark_app NOLOGIN NOINHERIT;
39
+ END IF;
40
+ END $$;
41
+
42
+ -- INHERIT membership in `authenticated` so policies `TO authenticated` apply.
43
+ ALTER ROLE nextspark_app INHERIT;
44
+ GRANT authenticated TO nextspark_app;
45
+
46
+ -- Allow the migration/validation user to SET ROLE nextspark_app (e.g. an RLS
47
+ -- isolation test suite can run its checks as this role without a LOGIN credential).
48
+ DO $$
49
+ BEGIN
50
+ EXECUTE format('GRANT nextspark_app TO %I', current_user);
51
+ EXCEPTION WHEN OTHERS THEN
52
+ RAISE NOTICE 'GRANT nextspark_app TO current_user skipped: %', SQLERRM;
53
+ END $$;
54
+
55
+ -- ----------------------------------------------------------------------------
56
+ -- 2. Grants for nextspark_app (RLS does the row filtering; grants gate the tables)
57
+ -- ----------------------------------------------------------------------------
58
+ GRANT USAGE ON SCHEMA public TO nextspark_app;
59
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO nextspark_app;
60
+ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO nextspark_app;
61
+ GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO nextspark_app;
62
+
63
+ -- Future objects created by the migration owner inherit the same grants.
64
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
65
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO nextspark_app;
66
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
67
+ GRANT USAGE, SELECT ON SEQUENCES TO nextspark_app;
68
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public
69
+ GRANT EXECUTE ON FUNCTIONS TO nextspark_app;
70
+
71
+ -- ----------------------------------------------------------------------------
72
+ -- 3. anon: explicit lockdown (defense for PostgREST/Data API surfaces)
73
+ -- ----------------------------------------------------------------------------
74
+ DO $$
75
+ BEGIN
76
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
77
+ REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon;
78
+ REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM anon;
79
+ REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM anon;
80
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM anon;
81
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON SEQUENCES FROM anon;
82
+ ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM anon;
83
+ END IF;
84
+ END $$;
85
+
86
+ COMMENT ON ROLE nextspark_app IS
87
+ 'Non-owner runtime role for the NextSpark app. Member of authenticated (policies TO authenticated apply). RLS is evaluated for every query once DATABASE_URL connects as this role instead of the table owner.';
@@ -1,5 +1,5 @@
1
1
  {
2
- "generated": "2026-06-10T14:55:15.602Z",
2
+ "generated": "2026-06-19T20:37:03.117Z",
3
3
  "totalClasses": 1081,
4
4
  "classes": [
5
5
  "!text-2xl",
@@ -8,6 +8,7 @@ import { isPublicSignupRestricted } from "@nextsparkjs/core/lib/teams/helpers";
8
8
  import { TeamService } from "@nextsparkjs/core/lib/services";
9
9
  import { wrapAuthHandlerWithCors, handleCorsPreflightRequest, addCorsHeaders } from "@nextsparkjs/core/lib/api/helpers";
10
10
  import { checkDistributedRateLimit } from "@nextsparkjs/core/lib/api/rate-limit";
11
+ import { withSignupContext } from "@nextsparkjs/core/lib/auth-context";
11
12
 
12
13
  const handlers = toNextJsHandler(auth);
13
14
 
@@ -137,6 +138,18 @@ export async function POST(req: NextRequest) {
137
138
  }
138
139
  }
139
140
 
141
+ // Read the optional signup intent (`x-signup-intent` header) and run the signup
142
+ // within request-scoped context so the user.create.after hook can map it to an
143
+ // initial team role (AUTH_CONFIG.signupIntent).
144
+ const signupIntent = isSignupAttempt
145
+ ? (req.headers.get('x-signup-intent') || undefined)
146
+ : undefined;
147
+
140
148
  // Wrap with CORS headers for cross-origin requests (mobile apps, etc.)
141
- return wrapAuthHandlerWithCors(() => handlers.POST(req), req);
149
+ return wrapAuthHandlerWithCors(
150
+ signupIntent
151
+ ? () => withSignupContext({ signupIntent }, () => handlers.POST(req))
152
+ : () => handlers.POST(req),
153
+ req
154
+ );
142
155
  }
@@ -47,6 +47,76 @@ $$;
47
47
  -- function with the alias, causing infinite recursion. All RLS policies should
48
48
  -- use `public.get_auth_user_id()` with the explicit schema qualifier.
49
49
 
50
+ -- =============================================================================
51
+ -- RLS BYPASS / VISIBILITY PRIMITIVES
52
+ -- Defined here (migration 001) so EVERY later migration's policies can use them,
53
+ -- including the auth/identity tables in 002. These are plpgsql functions, so the
54
+ -- tables they reference (users, team_members) are resolved at CALL time, not at
55
+ -- function-creation time — it is safe to define them before those tables exist.
56
+ -- They are SECURITY DEFINER so they read team_members/users without re-triggering
57
+ -- RLS (avoids recursive policy evaluation).
58
+ -- =============================================================================
59
+
60
+ -- Can the current request user bypass RLS? (superadmin always; developer if a
61
+ -- member of the System Admin Team). Mirrors app-level bypass in dual-auth.ts.
62
+ CREATE OR REPLACE FUNCTION public.can_bypass_rls()
63
+ RETURNS BOOLEAN AS $$
64
+ DECLARE
65
+ current_user_id TEXT;
66
+ user_role TEXT;
67
+ is_system_admin_member BOOLEAN;
68
+ BEGIN
69
+ current_user_id := public.get_auth_user_id();
70
+
71
+ SELECT role INTO user_role
72
+ FROM public."users"
73
+ WHERE id = current_user_id;
74
+
75
+ IF user_role = 'superadmin' THEN
76
+ RETURN TRUE;
77
+ END IF;
78
+
79
+ IF user_role = 'developer' THEN
80
+ SELECT EXISTS(
81
+ SELECT 1 FROM public."team_members"
82
+ WHERE "userId" = current_user_id
83
+ AND "teamId" = 'team-nextspark-001'
84
+ ) INTO is_system_admin_member;
85
+
86
+ RETURN is_system_admin_member;
87
+ END IF;
88
+
89
+ RETURN FALSE;
90
+ END;
91
+ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
92
+
93
+ -- Backward-compatible alias (deprecated, use can_bypass_rls).
94
+ CREATE OR REPLACE FUNCTION public.is_superadmin()
95
+ RETURNS BOOLEAN AS $$
96
+ BEGIN
97
+ RETURN public.can_bypass_rls();
98
+ END;
99
+ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
100
+
101
+ -- May the current request user SEE the row of `target_user_id`? True when they
102
+ -- share a team where either side is staff (owner/admin). Used by the `users`
103
+ -- SELECT policy (002) without a create-time dependency on team_members.
104
+ CREATE OR REPLACE FUNCTION public.auth_user_can_see_user(target_user_id TEXT)
105
+ RETURNS BOOLEAN AS $$
106
+ BEGIN
107
+ RETURN EXISTS (
108
+ SELECT 1 FROM public."team_members" me
109
+ JOIN public."team_members" them ON them."teamId" = me."teamId"
110
+ WHERE me."userId" = public.get_auth_user_id()
111
+ AND them."userId" = target_user_id
112
+ AND (
113
+ me.role IN ('owner','admin') -- I am staff of this team -> I see everyone in it
114
+ OR them.role IN ('owner','admin') -- the target is staff of my team -> I see them
115
+ )
116
+ );
117
+ END;
118
+ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
119
+
50
120
  -- Utilidad: updatedAt (si no existe ya en otra migration)
51
121
  CREATE OR REPLACE FUNCTION public.set_updated_at()
52
122
  RETURNS trigger
@@ -96,16 +96,96 @@ ALTER TABLE "verification" ENABLE ROW LEVEL SECURITY;
96
96
  -- POLICIES
97
97
  -- ============================================
98
98
 
99
- -- Mantener comportamiento actual (abierto). No es lo ideal, pero respeta "que siga funcionando".
100
- CREATE POLICY "users_allow_all" ON "users" FOR ALL USING (true) WITH CHECK (true);
101
- CREATE POLICY "session_allow_all" ON "session" FOR ALL USING (true) WITH CHECK (true);
102
- CREATE POLICY "account_allow_all" ON "account" FOR ALL USING (true) WITH CHECK (true);
103
- CREATE POLICY "verification_allow_all" ON "verification" FOR ALL USING (true) WITH CHECK (true);
104
-
105
- -- VERSIÓN MÁS SEGURA (recomendada) comentar lo de arriba y descomentar esto cuando ordenes acceso:
106
- -- Nota: el role service_role bypassa RLS. Si solo accede backend, podrías no necesitar policies.
107
- -- CREATE POLICY "auth_tables_readonly_auth" ON "users" FOR SELECT TO authenticated USING (true);
108
- -- Repite granular por tabla según necesidad real.
99
+ -- ============================================================================
100
+ -- Per-user RLS on the auth/identity tables (hardened defaults).
101
+ --
102
+ -- Uses ONLY core primitives: public.can_bypass_rls() (010) and
103
+ -- public.get_auth_user_id() (001), and public."team_members". "Staff of a team"
104
+ -- is the base elevated set the core enum knows: ('owner','admin'). A theme that
105
+ -- extends team roles widens the staff set through its own config-derived
106
+ -- mechanism; the core ships the base elevated set only.
107
+ --
108
+ -- WHY: under real RLS, the previous `USING(true)` let ANY authenticated user read
109
+ -- every row of users (PII), account (Better Auth credentials/providers) and
110
+ -- session (other users' sessions/tokens).
111
+ --
112
+ -- SERVICE DEPENDENCY (not a data hole): Better Auth reads these tables WITHOUT a
113
+ -- user GUC during login/verification, so it runs under the SERVICE connection
114
+ -- (DATABASE_SERVICE_URL, bypass). With it, login works AND these policies stay
115
+ -- active.
116
+ -- ============================================================================
117
+
118
+ -- ----------------------------------------------------------------------------
119
+ -- users — self + staff visibility (no user sees unrelated users)
120
+ -- ----------------------------------------------------------------------------
121
+ DROP POLICY IF EXISTS "users_allow_all" ON "users";
122
+ DROP POLICY IF EXISTS "Users self and staff read" ON "users";
123
+ DROP POLICY IF EXISTS "Users service insert" ON "users";
124
+ DROP POLICY IF EXISTS "Users self update" ON "users";
125
+ DROP POLICY IF EXISTS "Users service delete" ON "users";
126
+
127
+ -- Staff visibility is resolved by public.auth_user_can_see_user() (defined in
128
+ -- 001) so this policy has no create-time dependency on team_members (created in
129
+ -- a later migration).
130
+ CREATE POLICY "Users self and staff read"
131
+ ON "users"
132
+ FOR SELECT TO authenticated
133
+ USING (
134
+ public.can_bypass_rls()
135
+ OR id = public.get_auth_user_id()
136
+ OR public.auth_user_can_see_user(id)
137
+ );
138
+
139
+ -- Identity is created/managed by the auth service; a user may edit ITS OWN row.
140
+ CREATE POLICY "Users service insert" ON "users"
141
+ FOR INSERT TO authenticated WITH CHECK (public.can_bypass_rls());
142
+ CREATE POLICY "Users self update" ON "users"
143
+ FOR UPDATE TO authenticated
144
+ USING (public.can_bypass_rls() OR id = public.get_auth_user_id())
145
+ WITH CHECK (public.can_bypass_rls() OR id = public.get_auth_user_id());
146
+ CREATE POLICY "Users service delete" ON "users"
147
+ FOR DELETE TO authenticated USING (public.can_bypass_rls());
148
+
149
+ -- ----------------------------------------------------------------------------
150
+ -- account — only the owner sees/uses its own auth accounts; writes are service
151
+ -- ----------------------------------------------------------------------------
152
+ DROP POLICY IF EXISTS "account_allow_all" ON "account";
153
+ DROP POLICY IF EXISTS "Account self read" ON "account";
154
+ DROP POLICY IF EXISTS "Account service write" ON "account";
155
+
156
+ CREATE POLICY "Account self read" ON "account"
157
+ FOR SELECT TO authenticated
158
+ USING (public.can_bypass_rls() OR "userId" = public.get_auth_user_id());
159
+ CREATE POLICY "Account service write" ON "account"
160
+ FOR ALL TO authenticated
161
+ USING (public.can_bypass_rls())
162
+ WITH CHECK (public.can_bypass_rls());
163
+
164
+ -- ----------------------------------------------------------------------------
165
+ -- session — only the owner sees its own sessions; writes are service (login)
166
+ -- ----------------------------------------------------------------------------
167
+ DROP POLICY IF EXISTS "session_allow_all" ON "session";
168
+ DROP POLICY IF EXISTS "Session self read" ON "session";
169
+ DROP POLICY IF EXISTS "Session service write" ON "session";
170
+
171
+ CREATE POLICY "Session self read" ON "session"
172
+ FOR SELECT TO authenticated
173
+ USING (public.can_bypass_rls() OR "userId" = public.get_auth_user_id());
174
+ CREATE POLICY "Session service write" ON "session"
175
+ FOR ALL TO authenticated
176
+ USING (public.can_bypass_rls())
177
+ WITH CHECK (public.can_bypass_rls());
178
+
179
+ -- ----------------------------------------------------------------------------
180
+ -- verification — email/reset tokens (no userId); service-only under RLS
181
+ -- ----------------------------------------------------------------------------
182
+ DROP POLICY IF EXISTS "verification_allow_all" ON "verification";
183
+ DROP POLICY IF EXISTS "Verification service all" ON "verification";
184
+
185
+ CREATE POLICY "Verification service all" ON "verification"
186
+ FOR ALL TO authenticated
187
+ USING (public.can_bypass_rls())
188
+ WITH CHECK (public.can_bypass_rls());
109
189
 
110
190
  -- ============================================
111
191
  -- CONSTRAINTS
@@ -7,10 +7,16 @@
7
7
  -- ENUM TYPES (must be defined first)
8
8
  -- ============================================
9
9
 
10
- -- Team member roles: owner (creator), admin, member, viewer
11
- DO $$ BEGIN
12
- CREATE TYPE team_role AS ENUM ('owner', 'admin', 'member', 'viewer');
13
- EXCEPTION WHEN duplicate_object THEN NULL; END $$;
10
+ -- Team member roles are stored as TEXT (NOT a Postgres ENUM).
11
+ --
12
+ -- WHY: themes extend team roles via config (app.config.availableTeamRoles /
13
+ -- permissions.config.ts). A Postgres ENUM would force every theme to patch the
14
+ -- type with `ALTER TYPE team_role ADD VALUE ...`. Using TEXT lets a theme store
15
+ -- any role string without DB DDL. No privilege boundary is lost: RLS policies
16
+ -- compare against explicit literals ('owner','admin') and an unknown role fails
17
+ -- closed (matches no elevated tier). Value integrity is enforced at the app
18
+ -- layer (zod derived from availableTeamRoles + the permissions registry), so no
19
+ -- CHECK constraint is added (a CHECK with the base set would block theme roles).
14
20
 
15
21
  -- Invitation status lifecycle
16
22
  DO $$ BEGIN
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS public."team_members" (
10
10
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
11
11
  "teamId" TEXT NOT NULL REFERENCES public."teams"(id) ON DELETE CASCADE,
12
12
  "userId" TEXT NOT NULL REFERENCES public."users"(id) ON DELETE CASCADE,
13
- role team_role NOT NULL DEFAULT 'member',
13
+ role TEXT NOT NULL DEFAULT 'member', -- TEXT (not ENUM) so themes extend roles via config; see 007
14
14
  "invitedBy" TEXT REFERENCES public."users"(id),
15
15
  "joinedAt" TIMESTAMPTZ DEFAULT now(),
16
16
  "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS public."team_invitations" (
10
10
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
11
11
  "teamId" TEXT NOT NULL REFERENCES public."teams"(id) ON DELETE CASCADE,
12
12
  email TEXT NOT NULL,
13
- role team_role NOT NULL DEFAULT 'member',
13
+ role TEXT NOT NULL DEFAULT 'member', -- TEXT (not ENUM) so themes extend roles via config; see 007
14
14
  status invitation_status NOT NULL DEFAULT 'pending',
15
15
  token TEXT UNIQUE NOT NULL DEFAULT gen_random_uuid()::text,
16
16
  "invitedBy" TEXT NOT NULL REFERENCES public."users"(id),
@@ -13,7 +13,7 @@ RETURNS TABLE (
13
13
  team_id TEXT,
14
14
  team_name TEXT,
15
15
  team_slug TEXT,
16
- user_role team_role,
16
+ user_role TEXT, -- team roles are TEXT (not ENUM); see 007
17
17
  joined_at TIMESTAMPTZ,
18
18
  member_count BIGINT
19
19
  ) AS $$
@@ -39,11 +39,11 @@ $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
39
39
  CREATE OR REPLACE FUNCTION has_team_permission(
40
40
  user_id_param TEXT,
41
41
  team_id_param TEXT,
42
- required_roles team_role[]
42
+ required_roles TEXT[] -- team roles are TEXT (not ENUM); see 007
43
43
  )
44
44
  RETURNS BOOLEAN AS $$
45
45
  DECLARE
46
- user_role team_role;
46
+ user_role TEXT;
47
47
  BEGIN
48
48
  SELECT role INTO user_role
49
49
  FROM public."team_members"
@@ -108,51 +108,9 @@ BEGIN
108
108
  END;
109
109
  $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
110
110
 
111
- -- Check if current user can bypass RLS (Row Level Security)
112
- -- Returns true if user is superadmin OR developer in System Admin Team
113
- -- Used for RLS policies to grant elevated users full cross-team access
114
- -- Matches app-level bypass logic in dual-auth.ts
115
- CREATE OR REPLACE FUNCTION public.can_bypass_rls()
116
- RETURNS BOOLEAN AS $$
117
- DECLARE
118
- current_user_id TEXT;
119
- user_role TEXT;
120
- is_system_admin_member BOOLEAN;
121
- BEGIN
122
- current_user_id := public.get_auth_user_id();
123
-
124
- -- Get user role
125
- SELECT role INTO user_role
126
- FROM public."users"
127
- WHERE id = current_user_id;
128
-
129
- -- Superadmin always bypasses
130
- IF user_role = 'superadmin' THEN
131
- RETURN TRUE;
132
- END IF;
133
-
134
- -- Developer can bypass if member of System Admin Team (team-nextspark-001)
135
- IF user_role = 'developer' THEN
136
- SELECT EXISTS(
137
- SELECT 1 FROM public."team_members"
138
- WHERE "userId" = current_user_id
139
- AND "teamId" = 'team-nextspark-001'
140
- ) INTO is_system_admin_member;
141
-
142
- RETURN is_system_admin_member;
143
- END IF;
144
-
145
- RETURN FALSE;
146
- END;
147
- $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
148
-
149
- -- Alias for backward compatibility (deprecated, use can_bypass_rls)
150
- CREATE OR REPLACE FUNCTION public.is_superadmin()
151
- RETURNS BOOLEAN AS $$
152
- BEGIN
153
- RETURN public.can_bypass_rls();
154
- END;
155
- $$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
111
+ -- NOTE: public.can_bypass_rls() and public.is_superadmin() are defined in
112
+ -- migration 001 (foundational RLS primitives) so that the auth/identity table
113
+ -- policies in 002 can use them. They are intentionally NOT redefined here.
156
114
 
157
115
  -- ============================================
158
116
  -- TEAMS RLS POLICIES
@@ -90,59 +90,89 @@ WHERE status IN ('active', 'trialing', 'past_due');
90
90
  -- ============================================
91
91
  ALTER TABLE public."subscriptions" ENABLE ROW LEVEL SECURITY;
92
92
 
93
- -- Cleanup existing policies
94
- DROP POLICY IF EXISTS "Subscriptions team read" ON public."subscriptions";
95
- DROP POLICY IF EXISTS "Subscriptions team write" ON public."subscriptions";
96
- DROP POLICY IF EXISTS "Subscriptions superadmin" ON public."subscriptions";
93
+ -- ============================================================================
94
+ -- Per-user RLS on subscriptions (hardened).
95
+ --
96
+ -- WHY: a team's "team read" let ANY team member read the team's subscriptions.
97
+ -- When subscriptions are per (userId, teamId) and a team holds many users,
98
+ -- team-wide read means every user reads every other user's membership row.
99
+ -- Uses ONLY core primitives: can_bypass_rls() (superadmin/developer/service
100
+ -- bypass), get_auth_user_id(), team_members. Elevated tier = ('owner','admin').
101
+ --
102
+ -- NOTE on subscriptions."userId": NULLable by design (team-level subscriptions).
103
+ -- The own-row branch evaluates NULL -> false, so team-level subscriptions are
104
+ -- visible only to the elevated tier. Correct and intended.
105
+ -- ============================================================================
97
106
 
98
- -- Team members can read their team's subscription
99
- CREATE POLICY "Subscriptions team read"
107
+ -- Cleanup existing policies
108
+ DROP POLICY IF EXISTS "Subscriptions team read" ON public."subscriptions";
109
+ DROP POLICY IF EXISTS "Subscriptions team write" ON public."subscriptions";
110
+ DROP POLICY IF EXISTS "Subscriptions superadmin" ON public."subscriptions";
111
+ DROP POLICY IF EXISTS "Subscriptions per-user select" ON public."subscriptions";
112
+ DROP POLICY IF EXISTS "Subscriptions elevated insert" ON public."subscriptions";
113
+ DROP POLICY IF EXISTS "Subscriptions elevated update" ON public."subscriptions";
114
+ DROP POLICY IF EXISTS "Subscriptions elevated delete" ON public."subscriptions";
115
+
116
+ CREATE POLICY "Subscriptions per-user select"
100
117
  ON public."subscriptions"
101
118
  FOR SELECT TO authenticated
102
119
  USING (
103
- EXISTS (
120
+ public.can_bypass_rls()
121
+ OR "userId" = public.get_auth_user_id()
122
+ OR EXISTS (
123
+ SELECT 1 FROM public."team_members" tm
124
+ WHERE tm."teamId" = public."subscriptions"."teamId"
125
+ AND tm."userId" = public.get_auth_user_id()
126
+ AND tm.role IN ('owner','admin')
127
+ )
128
+ );
129
+
130
+ -- Writes: elevated tier or bypass only — NEVER an own-row branch (a user must
131
+ -- not be able to grant themselves a membership).
132
+ CREATE POLICY "Subscriptions elevated insert"
133
+ ON public."subscriptions"
134
+ FOR INSERT TO authenticated
135
+ WITH CHECK (
136
+ public.can_bypass_rls()
137
+ OR EXISTS (
104
138
  SELECT 1 FROM public."team_members" tm
105
- WHERE tm."teamId" = "subscriptions"."teamId"
139
+ WHERE tm."teamId" = public."subscriptions"."teamId"
106
140
  AND tm."userId" = public.get_auth_user_id()
141
+ AND tm.role IN ('owner','admin')
107
142
  )
108
143
  );
109
144
 
110
- -- Team owner/admin can modify subscription
111
- CREATE POLICY "Subscriptions team write"
145
+ CREATE POLICY "Subscriptions elevated update"
112
146
  ON public."subscriptions"
113
- FOR ALL TO authenticated
147
+ FOR UPDATE TO authenticated
114
148
  USING (
115
- EXISTS (
149
+ public.can_bypass_rls()
150
+ OR EXISTS (
116
151
  SELECT 1 FROM public."team_members" tm
117
- WHERE tm."teamId" = "subscriptions"."teamId"
152
+ WHERE tm."teamId" = public."subscriptions"."teamId"
118
153
  AND tm."userId" = public.get_auth_user_id()
119
- AND tm.role IN ('owner', 'admin')
154
+ AND tm.role IN ('owner','admin')
120
155
  )
121
156
  )
122
157
  WITH CHECK (
123
- EXISTS (
158
+ public.can_bypass_rls()
159
+ OR EXISTS (
124
160
  SELECT 1 FROM public."team_members" tm
125
- WHERE tm."teamId" = "subscriptions"."teamId"
161
+ WHERE tm."teamId" = public."subscriptions"."teamId"
126
162
  AND tm."userId" = public.get_auth_user_id()
127
- AND tm.role IN ('owner', 'admin')
163
+ AND tm.role IN ('owner','admin')
128
164
  )
129
165
  );
130
166
 
131
- -- Superadmin has full access
132
- CREATE POLICY "Subscriptions superadmin"
167
+ CREATE POLICY "Subscriptions elevated delete"
133
168
  ON public."subscriptions"
134
- FOR ALL TO authenticated
169
+ FOR DELETE TO authenticated
135
170
  USING (
136
- EXISTS (
137
- SELECT 1 FROM public."users" u
138
- WHERE u.id = public.get_auth_user_id()
139
- AND u.role = 'superadmin'
140
- )
141
- )
142
- WITH CHECK (
143
- EXISTS (
144
- SELECT 1 FROM public."users" u
145
- WHERE u.id = public.get_auth_user_id()
146
- AND u.role = 'superadmin'
171
+ public.can_bypass_rls()
172
+ OR EXISTS (
173
+ SELECT 1 FROM public."team_members" tm
174
+ WHERE tm."teamId" = public."subscriptions"."teamId"
175
+ AND tm."userId" = public.get_auth_user_id()
176
+ AND tm.role IN ('owner','admin')
147
177
  )
148
178
  );
@@ -80,8 +80,22 @@ USING (
80
80
  )
81
81
  );
82
82
 
83
- -- System can write (webhooks from payment providers)
84
- CREATE POLICY "Billing events system write"
83
+ -- INSERT: bypass or team owner/admin only. Payment-provider webhooks insert via
84
+ -- the SERVICE connection (DATABASE_SERVICE_URL, bypass). The previous
85
+ -- WITH CHECK (true) let ANY authenticated user forge billing events.
86
+ DROP POLICY IF EXISTS "Billing events elevated insert" ON public."billing_events";
87
+ CREATE POLICY "Billing events elevated insert"
85
88
  ON public."billing_events"
86
89
  FOR INSERT TO authenticated
87
- WITH CHECK (true);
90
+ WITH CHECK (
91
+ public.can_bypass_rls()
92
+ OR "subscriptionId" IN (
93
+ SELECT s.id FROM public."subscriptions" s
94
+ WHERE EXISTS (
95
+ SELECT 1 FROM public."team_members" tm
96
+ WHERE tm."teamId" = s."teamId"
97
+ AND tm."userId" = public.get_auth_user_id()
98
+ AND tm.role IN ('owner','admin')
99
+ )
100
+ )
101
+ );
@@ -116,12 +116,14 @@ ALTER TABLE public."scheduled_actions" ENABLE ROW LEVEL SECURITY;
116
116
  -- Cleanup existing policies
117
117
  DROP POLICY IF EXISTS "scheduled_actions auth can select" ON public."scheduled_actions";
118
118
  DROP POLICY IF EXISTS "scheduled_actions system can do all" ON public."scheduled_actions";
119
+ DROP POLICY IF EXISTS "Scheduled actions service all" ON public."scheduled_actions";
119
120
 
120
- -- Authenticated users can view all actions (for DevTools)
121
- CREATE POLICY "scheduled_actions auth can select"
121
+ -- Service-only under real RLS. Payloads may carry provisioning PII, so the
122
+ -- previous `USING(true)` SELECT is removed. Reads go to bypass (DevTools runs as
123
+ -- the developer role -> can_bypass_rls()); the in-request scheduler and the
124
+ -- processor INSERT/UPDATE under the SERVICE connection (DATABASE_SERVICE_URL).
125
+ CREATE POLICY "Scheduled actions service all"
122
126
  ON public."scheduled_actions"
123
- FOR SELECT TO authenticated
124
- USING (true);
125
-
126
- -- No INSERT/UPDATE/DELETE policies for regular users
127
- -- System operations are handled via service role or direct API with CRON_SECRET
127
+ FOR ALL TO authenticated
128
+ USING (public.can_bypass_rls())
129
+ WITH CHECK (public.can_bypass_rls());