@selvajs/supabase-provider 0.11.0

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 (173) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +271 -0
  3. package/dist/auth/SupabaseAuthProvider.d.ts +81 -0
  4. package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -0
  5. package/dist/auth/SupabaseAuthProvider.js +383 -0
  6. package/dist/auth/SupabaseAuthProvider.js.map +1 -0
  7. package/dist/auth/__tests__/conformance.test.d.ts +2 -0
  8. package/dist/auth/__tests__/conformance.test.d.ts.map +1 -0
  9. package/dist/auth/__tests__/conformance.test.js +76 -0
  10. package/dist/auth/__tests__/conformance.test.js.map +1 -0
  11. package/dist/auth/index.d.ts +3 -0
  12. package/dist/auth/index.d.ts.map +1 -0
  13. package/dist/auth/index.js +2 -0
  14. package/dist/auth/index.js.map +1 -0
  15. package/dist/data/SupabaseAuditQuery.d.ts +16 -0
  16. package/dist/data/SupabaseAuditQuery.d.ts.map +1 -0
  17. package/dist/data/SupabaseAuditQuery.js +83 -0
  18. package/dist/data/SupabaseAuditQuery.js.map +1 -0
  19. package/dist/data/SupabaseComputeServerStore.d.ts +26 -0
  20. package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -0
  21. package/dist/data/SupabaseComputeServerStore.js +227 -0
  22. package/dist/data/SupabaseComputeServerStore.js.map +1 -0
  23. package/dist/data/SupabaseDataProvider.d.ts +55 -0
  24. package/dist/data/SupabaseDataProvider.d.ts.map +1 -0
  25. package/dist/data/SupabaseDataProvider.js +114 -0
  26. package/dist/data/SupabaseDataProvider.js.map +1 -0
  27. package/dist/data/SupabaseDefinitionStore.d.ts +39 -0
  28. package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -0
  29. package/dist/data/SupabaseDefinitionStore.js +419 -0
  30. package/dist/data/SupabaseDefinitionStore.js.map +1 -0
  31. package/dist/data/SupabaseEventSink.d.ts +17 -0
  32. package/dist/data/SupabaseEventSink.d.ts.map +1 -0
  33. package/dist/data/SupabaseEventSink.js +31 -0
  34. package/dist/data/SupabaseEventSink.js.map +1 -0
  35. package/dist/data/SupabaseInviteStore.d.ts +22 -0
  36. package/dist/data/SupabaseInviteStore.d.ts.map +1 -0
  37. package/dist/data/SupabaseInviteStore.js +159 -0
  38. package/dist/data/SupabaseInviteStore.js.map +1 -0
  39. package/dist/data/SupabaseOrgStore.d.ts +33 -0
  40. package/dist/data/SupabaseOrgStore.d.ts.map +1 -0
  41. package/dist/data/SupabaseOrgStore.js +455 -0
  42. package/dist/data/SupabaseOrgStore.js.map +1 -0
  43. package/dist/data/SupabaseProjectStore.d.ts +29 -0
  44. package/dist/data/SupabaseProjectStore.d.ts.map +1 -0
  45. package/dist/data/SupabaseProjectStore.js +328 -0
  46. package/dist/data/SupabaseProjectStore.js.map +1 -0
  47. package/dist/data/SupabaseShareLinkStore.d.ts +26 -0
  48. package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -0
  49. package/dist/data/SupabaseShareLinkStore.js +150 -0
  50. package/dist/data/SupabaseShareLinkStore.js.map +1 -0
  51. package/dist/data/__tests__/compute-server-conformance.test.d.ts +2 -0
  52. package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +1 -0
  53. package/dist/data/__tests__/compute-server-conformance.test.js +40 -0
  54. package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -0
  55. package/dist/data/__tests__/definition-conformance.test.d.ts +2 -0
  56. package/dist/data/__tests__/definition-conformance.test.d.ts.map +1 -0
  57. package/dist/data/__tests__/definition-conformance.test.js +85 -0
  58. package/dist/data/__tests__/definition-conformance.test.js.map +1 -0
  59. package/dist/data/__tests__/event-sink-conformance.test.d.ts +2 -0
  60. package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +1 -0
  61. package/dist/data/__tests__/event-sink-conformance.test.js +27 -0
  62. package/dist/data/__tests__/event-sink-conformance.test.js.map +1 -0
  63. package/dist/data/__tests__/invite-conformance.test.d.ts +2 -0
  64. package/dist/data/__tests__/invite-conformance.test.d.ts.map +1 -0
  65. package/dist/data/__tests__/invite-conformance.test.js +45 -0
  66. package/dist/data/__tests__/invite-conformance.test.js.map +1 -0
  67. package/dist/data/__tests__/org-conformance.test.d.ts +2 -0
  68. package/dist/data/__tests__/org-conformance.test.d.ts.map +1 -0
  69. package/dist/data/__tests__/org-conformance.test.js +29 -0
  70. package/dist/data/__tests__/org-conformance.test.js.map +1 -0
  71. package/dist/data/__tests__/project-conformance.test.d.ts +2 -0
  72. package/dist/data/__tests__/project-conformance.test.d.ts.map +1 -0
  73. package/dist/data/__tests__/project-conformance.test.js +57 -0
  74. package/dist/data/__tests__/project-conformance.test.js.map +1 -0
  75. package/dist/data/__tests__/share-link-conformance.test.d.ts +2 -0
  76. package/dist/data/__tests__/share-link-conformance.test.d.ts.map +1 -0
  77. package/dist/data/__tests__/share-link-conformance.test.js +88 -0
  78. package/dist/data/__tests__/share-link-conformance.test.js.map +1 -0
  79. package/dist/data/__tests__/test-helpers.d.ts +53 -0
  80. package/dist/data/__tests__/test-helpers.d.ts.map +1 -0
  81. package/dist/data/__tests__/test-helpers.js +175 -0
  82. package/dist/data/__tests__/test-helpers.js.map +1 -0
  83. package/dist/data/client.d.ts +44 -0
  84. package/dist/data/client.d.ts.map +1 -0
  85. package/dist/data/client.js +40 -0
  86. package/dist/data/client.js.map +1 -0
  87. package/dist/data/index.d.ts +12 -0
  88. package/dist/data/index.d.ts.map +1 -0
  89. package/dist/data/index.js +11 -0
  90. package/dist/data/index.js.map +1 -0
  91. package/dist/data/pagination.d.ts +30 -0
  92. package/dist/data/pagination.d.ts.map +1 -0
  93. package/dist/data/pagination.js +53 -0
  94. package/dist/data/pagination.js.map +1 -0
  95. package/dist/index.d.ts +9 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +6 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/permissions/SupabasePlatformPermissionStore.d.ts +25 -0
  100. package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -0
  101. package/dist/permissions/SupabasePlatformPermissionStore.js +153 -0
  102. package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -0
  103. package/dist/permissions/__tests__/conformance.test.d.ts +2 -0
  104. package/dist/permissions/__tests__/conformance.test.d.ts.map +1 -0
  105. package/dist/permissions/__tests__/conformance.test.js +27 -0
  106. package/dist/permissions/__tests__/conformance.test.js.map +1 -0
  107. package/dist/permissions/index.d.ts +2 -0
  108. package/dist/permissions/index.d.ts.map +1 -0
  109. package/dist/permissions/index.js +2 -0
  110. package/dist/permissions/index.js.map +1 -0
  111. package/dist/storage/SupabaseStorageProvider.d.ts +67 -0
  112. package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -0
  113. package/dist/storage/SupabaseStorageProvider.js +136 -0
  114. package/dist/storage/SupabaseStorageProvider.js.map +1 -0
  115. package/dist/storage/__tests__/conformance.test.d.ts +2 -0
  116. package/dist/storage/__tests__/conformance.test.d.ts.map +1 -0
  117. package/dist/storage/__tests__/conformance.test.js +149 -0
  118. package/dist/storage/__tests__/conformance.test.js.map +1 -0
  119. package/dist/storage/index.d.ts +3 -0
  120. package/dist/storage/index.d.ts.map +1 -0
  121. package/dist/storage/index.js +2 -0
  122. package/dist/storage/index.js.map +1 -0
  123. package/dist/userProfile/SupabaseUserProfileProvider.d.ts +16 -0
  124. package/dist/userProfile/SupabaseUserProfileProvider.d.ts.map +1 -0
  125. package/dist/userProfile/SupabaseUserProfileProvider.js +148 -0
  126. package/dist/userProfile/SupabaseUserProfileProvider.js.map +1 -0
  127. package/dist/userProfile/__tests__/conformance.test.d.ts +2 -0
  128. package/dist/userProfile/__tests__/conformance.test.d.ts.map +1 -0
  129. package/dist/userProfile/__tests__/conformance.test.js +38 -0
  130. package/dist/userProfile/__tests__/conformance.test.js.map +1 -0
  131. package/dist/userProfile/index.d.ts +2 -0
  132. package/dist/userProfile/index.d.ts.map +1 -0
  133. package/dist/userProfile/index.js +2 -0
  134. package/dist/userProfile/index.js.map +1 -0
  135. package/package.json +75 -0
  136. package/src/auth/SupabaseAuthProvider.ts +464 -0
  137. package/src/auth/__tests__/conformance.test.ts +78 -0
  138. package/src/auth/index.ts +2 -0
  139. package/src/data/SupabaseAuditQuery.ts +103 -0
  140. package/src/data/SupabaseComputeServerStore.ts +262 -0
  141. package/src/data/SupabaseDataProvider.ts +125 -0
  142. package/src/data/SupabaseDefinitionStore.ts +504 -0
  143. package/src/data/SupabaseEventSink.ts +30 -0
  144. package/src/data/SupabaseInviteStore.ts +183 -0
  145. package/src/data/SupabaseOrgStore.ts +547 -0
  146. package/src/data/SupabaseProjectStore.ts +414 -0
  147. package/src/data/SupabaseShareLinkStore.ts +191 -0
  148. package/src/data/__tests__/compute-server-conformance.test.ts +40 -0
  149. package/src/data/__tests__/definition-conformance.test.ts +89 -0
  150. package/src/data/__tests__/event-sink-conformance.test.ts +32 -0
  151. package/src/data/__tests__/invite-conformance.test.ts +46 -0
  152. package/src/data/__tests__/org-conformance.test.ts +30 -0
  153. package/src/data/__tests__/project-conformance.test.ts +57 -0
  154. package/src/data/__tests__/share-link-conformance.test.ts +87 -0
  155. package/src/data/__tests__/test-helpers.ts +186 -0
  156. package/src/data/client.ts +86 -0
  157. package/src/data/index.ts +11 -0
  158. package/src/data/pagination.ts +68 -0
  159. package/src/index.ts +23 -0
  160. package/src/permissions/SupabasePlatformPermissionStore.ts +161 -0
  161. package/src/permissions/__tests__/conformance.test.ts +28 -0
  162. package/src/permissions/index.ts +1 -0
  163. package/src/storage/SupabaseStorageProvider.ts +177 -0
  164. package/src/storage/__tests__/conformance.test.ts +152 -0
  165. package/src/storage/index.ts +2 -0
  166. package/src/userProfile/SupabaseUserProfileProvider.ts +177 -0
  167. package/src/userProfile/__tests__/conformance.test.ts +39 -0
  168. package/src/userProfile/index.ts +1 -0
  169. package/supabase/.branches/_current_branch +1 -0
  170. package/supabase/.temp/cli-latest +1 -0
  171. package/supabase/config.toml +68 -0
  172. package/supabase/migrations/0001_initial.sql +1084 -0
  173. package/supabase/seed.sql +13 -0
@@ -0,0 +1,1084 @@
1
+ -- ============================================================================
2
+ -- Selva — initial schema (consolidated bootstrap).
3
+ --
4
+ -- This is the only migration in the repository. It produces the same
5
+ -- end-state that 0001–0010 produced incrementally, with the dropped /
6
+ -- replaced columns and policies omitted entirely. Apply once on a fresh
7
+ -- DB; future schema changes go in numbered files (0002_…, 0003_…).
8
+ --
9
+ -- Layered top-to-bottom so dependencies always resolve:
10
+ -- 1. set_updated_at trigger function
11
+ -- 2. user_profiles + auto-seed trigger + RLS
12
+ -- 3. orgs, org_members, projects, project_members tables + indexes
13
+ -- 4. updated_at triggers + name uniqueness index
14
+ -- 5. RLS helper functions (depend on all of the above)
15
+ -- 6. Org/project RLS policies
16
+ -- 7. definitions + definition_versions + RPC + RLS
17
+ -- 8. invites + RPC + RLS
18
+ -- 9. compute_servers + per-org/instance defaults + RLS
19
+ -- 10. share_links (spec §7) + atomic-increment RPC + RLS
20
+ -- 11. Storage bucket policies (selva-public, selva-private)
21
+ -- 12. audit_events (spec §9 domain-event sink)
22
+ -- ============================================================================
23
+
24
+
25
+ -- ============================================================================
26
+ -- 1. set_updated_at trigger function
27
+ -- Maintains updated_at on every UPDATE without adapter involvement.
28
+ -- ============================================================================
29
+
30
+ create or replace function public.set_updated_at()
31
+ returns trigger
32
+ language plpgsql
33
+ as $$
34
+ begin
35
+ new.updated_at := now();
36
+ return new;
37
+ end;
38
+ $$;
39
+
40
+
41
+ -- ============================================================================
42
+ -- 2. user_profiles
43
+ -- Selva-specific extension of auth.users (display name, platform perms,
44
+ -- starred definitions, recent runs). Auto-seeded by trigger on signup.
45
+ -- ============================================================================
46
+
47
+ create table if not exists public.user_profiles (
48
+ user_id uuid primary key references auth.users(id) on delete cascade,
49
+ display_name text,
50
+ platform_permissions text[] not null default '{}',
51
+ starred_definitions uuid[] not null default '{}',
52
+ recent_runs jsonb not null default '[]',
53
+ created_at timestamptz not null default now(),
54
+ last_login_at timestamptz
55
+ );
56
+
57
+ create or replace function public.handle_new_auth_user()
58
+ returns trigger
59
+ language plpgsql
60
+ security definer
61
+ set search_path = public
62
+ as $$
63
+ begin
64
+ insert into public.user_profiles (user_id)
65
+ values (new.id)
66
+ on conflict (user_id) do nothing;
67
+ return new;
68
+ end;
69
+ $$;
70
+
71
+ drop trigger if exists trg_auth_user_created on auth.users;
72
+ create trigger trg_auth_user_created
73
+ after insert on auth.users
74
+ for each row execute function public.handle_new_auth_user();
75
+
76
+ alter table public.user_profiles enable row level security;
77
+
78
+ create policy "user_profiles: any authenticated can read"
79
+ on public.user_profiles for select
80
+ to authenticated
81
+ using (true);
82
+
83
+ create policy "user_profiles: users can update own"
84
+ on public.user_profiles for update
85
+ to authenticated
86
+ using (user_id = auth.uid())
87
+ with check (
88
+ user_id = auth.uid()
89
+ -- Users cannot escalate their own platform permissions; only service-role
90
+ -- writes (which bypass RLS) may change that column.
91
+ and platform_permissions is not distinct from (
92
+ select platform_permissions from public.user_profiles where user_id = auth.uid()
93
+ )
94
+ );
95
+
96
+
97
+ -- ============================================================================
98
+ -- 3. orgs, org_members, projects, project_members
99
+ -- Tenant-owned hierarchy. All carry created_by/updated_by/deleted_at;
100
+ -- reads filter deleted_at IS NULL.
101
+ -- ============================================================================
102
+
103
+ create table if not exists public.orgs (
104
+ id uuid primary key,
105
+ name text not null,
106
+ slug text not null unique,
107
+ owner_id uuid not null references auth.users(id) on delete cascade,
108
+ created_by uuid references auth.users(id) on delete set null,
109
+ updated_by uuid references auth.users(id) on delete set null,
110
+ created_at timestamptz not null default now(),
111
+ updated_at timestamptz not null default now(),
112
+ deleted_at timestamptz
113
+ );
114
+ create index if not exists idx_orgs_live on public.orgs(id) where deleted_at is null;
115
+
116
+ create table if not exists public.org_members (
117
+ org_id uuid not null references public.orgs(id) on delete cascade,
118
+ user_id uuid not null references auth.users(id) on delete cascade,
119
+ role text not null check (role in ('owner', 'admin', 'member')),
120
+ permissions text[] not null default '{}',
121
+ joined_at timestamptz not null default now(),
122
+ updated_at timestamptz not null default now(),
123
+ updated_by uuid references auth.users(id) on delete set null,
124
+ deleted_at timestamptz,
125
+ primary key (org_id, user_id)
126
+ );
127
+ create index if not exists idx_org_members_user
128
+ on public.org_members(user_id) where deleted_at is null;
129
+
130
+ create table if not exists public.projects (
131
+ id uuid primary key,
132
+ org_id uuid not null references public.orgs(id) on delete cascade,
133
+ name text not null,
134
+ slug text not null,
135
+ description text,
136
+ visibility text not null check (visibility in ('public', 'org', 'private')),
137
+ auto_join_on_upload boolean not null default false,
138
+ owner_id uuid not null references auth.users(id) on delete cascade,
139
+ created_by uuid references auth.users(id) on delete set null,
140
+ updated_by uuid references auth.users(id) on delete set null,
141
+ created_at timestamptz not null default now(),
142
+ updated_at timestamptz not null default now(),
143
+ deleted_at timestamptz,
144
+ unique (org_id, slug),
145
+ -- Spec §4: commons mode requires visibility=public.
146
+ constraint projects_commons_requires_public check (
147
+ auto_join_on_upload = false or visibility = 'public'
148
+ )
149
+ );
150
+ create index if not exists idx_projects_org
151
+ on public.projects(org_id) where deleted_at is null;
152
+ -- Case-insensitive unique name per org (defense beyond slug uniqueness).
153
+ create unique index if not exists projects_org_name_unique
154
+ on public.projects (org_id, lower(name));
155
+
156
+ create table if not exists public.project_members (
157
+ project_id uuid not null references public.projects(id) on delete cascade,
158
+ user_id uuid not null references auth.users(id) on delete cascade,
159
+ role text not null check (role in ('owner', 'editor', 'viewer')),
160
+ joined_at timestamptz not null default now(),
161
+ updated_at timestamptz not null default now(),
162
+ updated_by uuid references auth.users(id) on delete set null,
163
+ deleted_at timestamptz,
164
+ primary key (project_id, user_id)
165
+ );
166
+ create index if not exists idx_project_members_user
167
+ on public.project_members(user_id) where deleted_at is null;
168
+
169
+
170
+ -- ============================================================================
171
+ -- 4. updated_at triggers
172
+ -- ============================================================================
173
+
174
+ create trigger trg_orgs_updated_at before update on public.orgs
175
+ for each row execute function public.set_updated_at();
176
+ create trigger trg_projects_updated_at before update on public.projects
177
+ for each row execute function public.set_updated_at();
178
+ create trigger trg_org_members_updated_at before update on public.org_members
179
+ for each row execute function public.set_updated_at();
180
+ create trigger trg_project_members_updated_at before update on public.project_members
181
+ for each row execute function public.set_updated_at();
182
+
183
+
184
+ -- ============================================================================
185
+ -- 5. RLS helper functions
186
+ -- All SECURITY DEFINER so they bypass RLS on the tables they read (avoids
187
+ -- recursion). All filter deleted_at IS NULL.
188
+ -- ============================================================================
189
+
190
+ create or replace function public.is_instance_admin()
191
+ returns boolean
192
+ language sql stable security definer set search_path = public
193
+ as $$
194
+ select exists (
195
+ select 1 from public.user_profiles
196
+ where user_id = auth.uid() and 'instance_admin' = any(platform_permissions)
197
+ );
198
+ $$;
199
+
200
+ create or replace function public.is_org_member(o uuid)
201
+ returns boolean
202
+ language sql stable security definer set search_path = public
203
+ as $$
204
+ select exists (
205
+ select 1 from public.org_members
206
+ where org_id = o and user_id = auth.uid() and deleted_at is null
207
+ );
208
+ $$;
209
+
210
+ create or replace function public.is_org_admin(o uuid)
211
+ returns boolean
212
+ language sql stable security definer set search_path = public
213
+ as $$
214
+ select exists (
215
+ select 1 from public.org_members
216
+ where org_id = o and user_id = auth.uid() and deleted_at is null
217
+ and role in ('owner', 'admin')
218
+ );
219
+ $$;
220
+
221
+ create or replace function public.is_org_owner(o uuid)
222
+ returns boolean
223
+ language sql stable security definer set search_path = public
224
+ as $$
225
+ select exists (
226
+ select 1 from public.org_members
227
+ where org_id = o and user_id = auth.uid() and deleted_at is null
228
+ and role = 'owner'
229
+ );
230
+ $$;
231
+
232
+ create or replace function public.has_org_permission(o uuid, perm text)
233
+ returns boolean
234
+ language sql stable security definer set search_path = public
235
+ as $$
236
+ select public.is_instance_admin() or exists (
237
+ select 1 from public.org_members
238
+ where org_id = o and user_id = auth.uid() and deleted_at is null
239
+ and perm = any(permissions)
240
+ );
241
+ $$;
242
+
243
+ create or replace function public.is_project_member(p uuid)
244
+ returns boolean
245
+ language sql stable security definer set search_path = public
246
+ as $$
247
+ select exists (
248
+ select 1 from public.project_members
249
+ where project_id = p and user_id = auth.uid() and deleted_at is null
250
+ );
251
+ $$;
252
+
253
+ -- Spec §5 canView: private → member, org → org member, public → everyone.
254
+ create or replace function public.visible_project(p uuid)
255
+ returns boolean
256
+ language sql stable security definer set search_path = public
257
+ as $$
258
+ select public.is_instance_admin() or exists (
259
+ select 1
260
+ from public.projects
261
+ where id = p
262
+ and deleted_at is null
263
+ and (
264
+ visibility = 'public'
265
+ or (visibility = 'org' and public.is_org_member(org_id))
266
+ or public.is_project_member(id)
267
+ )
268
+ );
269
+ $$;
270
+
271
+
272
+ -- ============================================================================
273
+ -- 6. Org / project RLS policies
274
+ -- ============================================================================
275
+
276
+ alter table public.orgs enable row level security;
277
+
278
+ -- RLS gates "may this principal touch this row?", not "is the row in a state
279
+ -- worth showing?". Soft-delete filtering (`deleted_at is null`) belongs at the
280
+ -- query layer — every store already issues `.is('deleted_at', null)` on
281
+ -- user-facing reads. Putting the lifecycle check in the USING clause breaks
282
+ -- the soft-delete write itself: `UPDATE ... SET deleted_at = now() RETURNING`
283
+ -- triggers a PostgREST re-read against the SELECT policy, which then filters
284
+ -- out the just-tombstoned row and surfaces as "new row violates RLS". Same
285
+ -- trap for `revoked_at` on share_links below.
286
+ create policy "orgs: members and instance admins can read"
287
+ on public.orgs for select
288
+ to authenticated
289
+ using (public.is_instance_admin() or public.is_org_member(id));
290
+
291
+ create policy "orgs: authenticated can create their own"
292
+ on public.orgs for insert
293
+ to authenticated
294
+ with check (public.is_instance_admin() or owner_id = auth.uid());
295
+
296
+ create policy "orgs: owners and instance admins can update"
297
+ on public.orgs for update
298
+ to authenticated
299
+ using (public.is_instance_admin() or public.is_org_owner(id))
300
+ with check (public.is_instance_admin() or public.is_org_owner(id));
301
+
302
+ create policy "orgs: owners and instance admins can delete"
303
+ on public.orgs for delete
304
+ to authenticated
305
+ using (public.is_instance_admin() or public.is_org_owner(id));
306
+
307
+ alter table public.org_members enable row level security;
308
+
309
+ create policy "org_members: org members can read roster"
310
+ on public.org_members for select
311
+ to authenticated
312
+ using (public.is_instance_admin() or public.is_org_member(org_id));
313
+
314
+ create policy "org_members: admins can insert"
315
+ on public.org_members for insert
316
+ to authenticated
317
+ with check (public.is_instance_admin() or public.is_org_admin(org_id));
318
+
319
+ create policy "org_members: admins can update"
320
+ on public.org_members for update
321
+ to authenticated
322
+ using (public.is_instance_admin() or public.is_org_admin(org_id))
323
+ with check (public.is_instance_admin() or public.is_org_admin(org_id));
324
+
325
+ create policy "org_members: admins can delete"
326
+ on public.org_members for delete
327
+ to authenticated
328
+ using (public.is_instance_admin() or public.is_org_admin(org_id));
329
+
330
+ alter table public.projects enable row level security;
331
+
332
+ create policy "projects: visible to members"
333
+ on public.projects for select
334
+ to authenticated
335
+ using (public.visible_project(id));
336
+
337
+ create policy "projects: manage_projects can create"
338
+ on public.projects for insert
339
+ to authenticated
340
+ with check (public.has_org_permission(org_id, 'manage_projects'));
341
+
342
+ -- Project settings (name, slug, description, visibility, flags) are owner-only.
343
+ create policy "projects: owners can update"
344
+ on public.projects for update
345
+ to authenticated
346
+ using (public.is_instance_admin() or owner_id = auth.uid())
347
+ with check (public.is_instance_admin() or owner_id = auth.uid());
348
+
349
+ create policy "projects: owners can delete"
350
+ on public.projects for delete
351
+ to authenticated
352
+ using (public.is_instance_admin() or owner_id = auth.uid());
353
+
354
+ alter table public.project_members enable row level security;
355
+
356
+ create policy "project_members: visible to project members"
357
+ on public.project_members for select
358
+ to authenticated
359
+ using (public.is_instance_admin() or public.visible_project(project_id));
360
+
361
+ -- canManage (§5) collapses to owner-only; org admins no longer silently
362
+ -- manage project members.
363
+ create policy "project_members: managers can insert"
364
+ on public.project_members for insert
365
+ to authenticated
366
+ with check (
367
+ public.is_instance_admin()
368
+ or exists (
369
+ select 1 from public.projects p
370
+ where p.id = project_id and p.deleted_at is null and p.owner_id = auth.uid()
371
+ )
372
+ );
373
+
374
+ create policy "project_members: managers can update"
375
+ on public.project_members for update
376
+ to authenticated
377
+ using (
378
+ public.is_instance_admin()
379
+ or exists (
380
+ select 1 from public.projects p
381
+ where p.id = project_id and p.deleted_at is null and p.owner_id = auth.uid()
382
+ )
383
+ )
384
+ with check (
385
+ public.is_instance_admin()
386
+ or exists (
387
+ select 1 from public.projects p
388
+ where p.id = project_id and p.deleted_at is null and p.owner_id = auth.uid()
389
+ )
390
+ );
391
+
392
+ create policy "project_members: managers can delete"
393
+ on public.project_members for delete
394
+ to authenticated
395
+ using (
396
+ public.is_instance_admin()
397
+ or exists (
398
+ select 1 from public.projects p
399
+ where p.id = project_id and p.deleted_at is null and p.owner_id = auth.uid()
400
+ )
401
+ );
402
+
403
+
404
+ -- ============================================================================
405
+ -- 7. definitions + definition_versions (spec §6)
406
+ --
407
+ -- Versioning model: each upload creates an immutable definition_versions row.
408
+ -- definitions.live_version_id / draft_version_id point at versions; both are
409
+ -- ON DELETE RESTRICT so a referenced version cannot be deleted (deletion
410
+ -- protection enforced at the DB layer).
411
+ --
412
+ -- Per-version columns (file_ext, original_filename) live on definition_versions,
413
+ -- not on definitions — different versions can carry different uploaded shapes.
414
+ -- ============================================================================
415
+
416
+ create table if not exists public.definitions (
417
+ guid uuid primary key,
418
+ project_id uuid not null references public.projects(id) on delete cascade,
419
+ owner_id uuid not null references auth.users(id) on delete cascade,
420
+ created_by uuid references auth.users(id) on delete set null,
421
+ updated_by uuid references auth.users(id) on delete set null,
422
+ -- FKs to definition_versions added below once that table exists.
423
+ live_version_id uuid,
424
+ draft_version_id uuid,
425
+ compute_server_id uuid,
426
+ display_name text not null,
427
+ description text,
428
+ category text,
429
+ tags text[] not null default '{}',
430
+ cover_image text,
431
+ status text not null check (status in ('pending', 'draft', 'review', 'published', 'archived')),
432
+ run_count bigint not null default 0,
433
+ created_at timestamptz not null default now(),
434
+ updated_at timestamptz not null default now(),
435
+ deleted_at timestamptz
436
+ );
437
+
438
+ create index if not exists idx_definitions_project
439
+ on public.definitions(project_id) where deleted_at is null;
440
+ create index if not exists idx_definitions_status
441
+ on public.definitions(status) where deleted_at is null;
442
+ create index if not exists idx_definitions_pending_updated
443
+ on public.definitions(updated_at)
444
+ where status = 'pending' and deleted_at is null;
445
+
446
+ create trigger trg_definitions_updated_at before update on public.definitions
447
+ for each row execute function public.set_updated_at();
448
+
449
+ create table if not exists public.definition_versions (
450
+ id uuid primary key,
451
+ definition_guid uuid not null references public.definitions(guid) on delete cascade,
452
+ version_number integer not null check (version_number >= 1),
453
+ file_ext text not null check (file_ext in ('gh', 'ghx')),
454
+ file_key text not null,
455
+ original_filename text,
456
+ uploaded_by uuid not null references auth.users(id) on delete cascade,
457
+ uploaded_at timestamptz not null default now(),
458
+ unique (definition_guid, version_number)
459
+ );
460
+
461
+ create index if not exists idx_definition_versions_def
462
+ on public.definition_versions(definition_guid, version_number desc);
463
+
464
+ -- Spec §6 deletion protection: cannot delete a version while it's serving
465
+ -- either channel. Postgres raises 23503; the store maps that to 409.
466
+ alter table public.definitions
467
+ add constraint fk_definitions_live_version
468
+ foreign key (live_version_id) references public.definition_versions(id)
469
+ on delete restrict;
470
+
471
+ alter table public.definitions
472
+ add constraint fk_definitions_draft_version
473
+ foreign key (draft_version_id) references public.definition_versions(id)
474
+ on delete restrict;
475
+
476
+ -- Atomic run-count increment. Called after each successful solve. No-op on
477
+ -- missing or soft-deleted rows.
478
+ create or replace function public.increment_run_count(g uuid)
479
+ returns void
480
+ language sql security definer set search_path = public
481
+ as $$
482
+ update public.definitions
483
+ set run_count = run_count + 1
484
+ where guid = g and deleted_at is null;
485
+ $$;
486
+ grant execute on function public.increment_run_count(uuid) to authenticated, service_role;
487
+
488
+ alter table public.definitions enable row level security;
489
+
490
+ create policy "definitions: visible via project"
491
+ on public.definitions for select
492
+ to authenticated
493
+ using (public.visible_project(project_id));
494
+
495
+ create policy "definitions: editors can insert"
496
+ on public.definitions for insert
497
+ to authenticated
498
+ with check (
499
+ public.is_instance_admin()
500
+ -- Container mode: project editor/owner required.
501
+ or exists (
502
+ select 1 from public.project_members m
503
+ where m.project_id = definitions.project_id
504
+ and m.user_id = auth.uid()
505
+ and m.deleted_at is null
506
+ and m.role in ('owner', 'editor')
507
+ )
508
+ -- Commons mode: any authenticated user may create on a public + commons
509
+ -- project; they become its owner_id.
510
+ or exists (
511
+ select 1 from public.projects p
512
+ where p.id = definitions.project_id
513
+ and p.deleted_at is null
514
+ and p.visibility = 'public'
515
+ and p.auto_join_on_upload = true
516
+ and definitions.owner_id = auth.uid()
517
+ )
518
+ );
519
+
520
+ create policy "definitions: editors can update"
521
+ on public.definitions for update
522
+ to authenticated
523
+ using (
524
+ public.is_instance_admin()
525
+ or exists (
526
+ select 1 from public.project_members m
527
+ where m.project_id = definitions.project_id
528
+ and m.user_id = auth.uid()
529
+ and m.deleted_at is null
530
+ and m.role in ('owner', 'editor')
531
+ )
532
+ or exists (
533
+ select 1 from public.projects p
534
+ where p.id = definitions.project_id
535
+ and p.deleted_at is null
536
+ and p.auto_join_on_upload = true
537
+ and definitions.owner_id = auth.uid()
538
+ )
539
+ )
540
+ with check (
541
+ public.is_instance_admin()
542
+ or exists (
543
+ select 1 from public.project_members m
544
+ where m.project_id = definitions.project_id
545
+ and m.user_id = auth.uid()
546
+ and m.deleted_at is null
547
+ and m.role in ('owner', 'editor')
548
+ )
549
+ or exists (
550
+ select 1 from public.projects p
551
+ where p.id = definitions.project_id
552
+ and p.deleted_at is null
553
+ and p.auto_join_on_upload = true
554
+ and definitions.owner_id = auth.uid()
555
+ )
556
+ );
557
+
558
+ create policy "definitions: editors can delete"
559
+ on public.definitions for delete
560
+ to authenticated
561
+ using (
562
+ public.is_instance_admin()
563
+ or exists (
564
+ select 1 from public.project_members m
565
+ where m.project_id = definitions.project_id
566
+ and m.user_id = auth.uid()
567
+ and m.deleted_at is null
568
+ and m.role in ('owner', 'editor')
569
+ )
570
+ or exists (
571
+ select 1 from public.projects p
572
+ where p.id = definitions.project_id
573
+ and p.deleted_at is null
574
+ and p.auto_join_on_upload = true
575
+ and definitions.owner_id = auth.uid()
576
+ )
577
+ );
578
+
579
+ alter table public.definition_versions enable row level security;
580
+
581
+ -- Versions inherit visibility from the parent definition (which inherits
582
+ -- from the parent project). Versions are immutable — no UPDATE policy.
583
+ create policy "definition_versions: visible via parent"
584
+ on public.definition_versions for select
585
+ to authenticated
586
+ using (
587
+ exists (
588
+ select 1 from public.definitions d
589
+ where d.guid = definition_versions.definition_guid
590
+ and d.deleted_at is null
591
+ and public.visible_project(d.project_id)
592
+ )
593
+ );
594
+
595
+ create policy "definition_versions: editors can insert"
596
+ on public.definition_versions for insert
597
+ to authenticated
598
+ with check (
599
+ public.is_instance_admin()
600
+ or exists (
601
+ select 1
602
+ from public.definitions d
603
+ join public.project_members m on m.project_id = d.project_id
604
+ where d.guid = definition_guid
605
+ and d.deleted_at is null
606
+ and m.user_id = auth.uid()
607
+ and m.deleted_at is null
608
+ and m.role in ('owner', 'editor')
609
+ )
610
+ or exists (
611
+ select 1
612
+ from public.definitions d
613
+ join public.projects p on p.id = d.project_id
614
+ where d.guid = definition_guid
615
+ and d.deleted_at is null
616
+ and p.deleted_at is null
617
+ and p.auto_join_on_upload = true
618
+ and d.owner_id = auth.uid()
619
+ and definition_versions.uploaded_by = auth.uid()
620
+ )
621
+ );
622
+
623
+ -- Without an explicit DELETE policy, RLS silently affects 0 rows on every
624
+ -- `delete from definition_versions` — `SupabaseDefinitionStore.deleteVersion`
625
+ -- returns success without removing anything, and the FK ON DELETE RESTRICT on
626
+ -- `definitions.live_version_id` / `draft_version_id` never gets a chance to
627
+ -- raise the 23503 the store maps to a 409. Same authority as INSERT.
628
+ create policy "definition_versions: editors can delete"
629
+ on public.definition_versions for delete
630
+ to authenticated
631
+ using (
632
+ public.is_instance_admin()
633
+ or exists (
634
+ select 1
635
+ from public.definitions d
636
+ join public.project_members m on m.project_id = d.project_id
637
+ where d.guid = definition_versions.definition_guid
638
+ and d.deleted_at is null
639
+ and m.user_id = auth.uid()
640
+ and m.deleted_at is null
641
+ and m.role in ('owner', 'editor')
642
+ )
643
+ or exists (
644
+ select 1
645
+ from public.definitions d
646
+ join public.projects p on p.id = d.project_id
647
+ where d.guid = definition_versions.definition_guid
648
+ and d.deleted_at is null
649
+ and p.deleted_at is null
650
+ and p.auto_join_on_upload = true
651
+ and d.owner_id = auth.uid()
652
+ )
653
+ );
654
+
655
+
656
+ -- ============================================================================
657
+ -- 8. invites
658
+ -- ============================================================================
659
+
660
+ -- `org_id ... on delete cascade` only fires when the org is HARD-deleted.
661
+ -- The standard `deleteOrg` path is a soft-delete (UPDATE deleted_at), so the
662
+ -- SupabaseOrgStore.deleteOrg cascade explicitly DELETEs invites for the org;
663
+ -- see SupabaseOrgStore.ts. Same pattern for compute_servers below.
664
+ -- Invite tokens are HMAC-hashed at the route layer (the selva app's
665
+ -- `lib/server/invites/token.server.ts`) before reaching the store — the raw
666
+ -- token is shown to the admin once and never persisted. A DB-only leak
667
+ -- therefore can't be replayed without the instance's INVITE_TOKEN_SECRET.
668
+ -- Mirrors the share-link design (`share_links.token_hash`).
669
+ create table if not exists public.invites (
670
+ id uuid primary key,
671
+ token_hash text not null unique,
672
+ email text not null,
673
+ org_id uuid not null references public.orgs(id) on delete cascade,
674
+ org_role text not null check (org_role in ('owner', 'admin', 'member')),
675
+ org_permissions text[] not null default '{}',
676
+ invited_by uuid not null references auth.users(id) on delete cascade,
677
+ created_at timestamptz not null default now(),
678
+ expires_at timestamptz not null,
679
+ accepted_at timestamptz,
680
+ accepted_by_user_id uuid references auth.users(id) on delete set null
681
+ );
682
+
683
+ create index if not exists idx_invites_org on public.invites(org_id, created_at desc);
684
+
685
+ alter table public.invites enable row level security;
686
+
687
+ create policy "invites: manage_org_members can read org invites"
688
+ on public.invites for select
689
+ to authenticated
690
+ using (public.has_org_permission(org_id, 'manage_org_members'));
691
+
692
+ create policy "invites: manage_org_members can insert"
693
+ on public.invites for insert
694
+ to authenticated
695
+ with check (public.has_org_permission(org_id, 'manage_org_members'));
696
+
697
+ create policy "invites: manage_org_members can update"
698
+ on public.invites for update
699
+ to authenticated
700
+ using (public.has_org_permission(org_id, 'manage_org_members'))
701
+ with check (public.has_org_permission(org_id, 'manage_org_members'));
702
+
703
+ create policy "invites: manage_org_members can delete"
704
+ on public.invites for delete
705
+ to authenticated
706
+ using (public.has_org_permission(org_id, 'manage_org_members'));
707
+
708
+ -- Hash-gated read via SECURITY DEFINER so the token itself is the capability.
709
+ -- Caller hashes the raw URL token at the route layer and passes the digest;
710
+ -- the function just looks up the row. Returns SETOF so a missing match
711
+ -- becomes an empty array (not a composite of nulls).
712
+ create or replace function public.get_invite_by_token_hash(h text)
713
+ returns setof public.invites
714
+ language sql stable security definer set search_path = public
715
+ as $$
716
+ select *
717
+ from public.invites
718
+ where token_hash = h
719
+ and accepted_at is null
720
+ and expires_at > now()
721
+ limit 1;
722
+ $$;
723
+ grant execute on function public.get_invite_by_token_hash(text) to anon, authenticated, service_role;
724
+
725
+
726
+ -- ============================================================================
727
+ -- 9. compute_servers (spec §3 — platform vs. org-private)
728
+ --
729
+ -- Servers are discriminated by `scope`:
730
+ -- * scope = 'platform' → managed by `manage_compute`. `sharedWith = 'all'`
731
+ -- (db: shared_with_all = true) exposes to every org; otherwise the
732
+ -- per-org allowlist lives in `compute_server_shares`. The global
733
+ -- default is always visible regardless (the "baseline" floor).
734
+ -- * scope = 'org' → org-private. `owner_org_id` is required.
735
+ -- Visible only to members of that org. Gated by the platform flag
736
+ -- ALLOW_ORG_COMPUTE_OVERRIDE at the TS layer; DB policies still accept
737
+ -- writes so flipping the flag on doesn't require a policy change.
738
+ --
739
+ -- Defaults are layered:
740
+ -- * compute_server_platform_default (singleton) — global default. Must
741
+ -- reference a platform row.
742
+ -- * compute_server_org_defaults — per-org override. Must reference a
743
+ -- server visible to that org (validated at the TS layer).
744
+ --
745
+ -- Cascade note: the `owner_org_id` FK is `on delete cascade`, but
746
+ -- `deleteOrg` is a soft-delete and never triggers it. SupabaseOrgStore
747
+ -- explicitly DELETEs from compute_server_org_defaults, compute_server_shares,
748
+ -- and compute_servers (where owner_org_id matches) for the org. The FK
749
+ -- cascade only matters for the eventual hard-delete janitor.
750
+ -- ============================================================================
751
+
752
+ -- compute_servers — discriminated by `scope`.
753
+ -- * scope = 'platform': managed by `manage_compute`. `owner_org_id` must
754
+ -- be null. `shared_with_all = true` exposes the server to every org;
755
+ -- otherwise the per-org allowlist lives in `compute_server_shares`. The
756
+ -- global default in `compute_server_platform_default` is *always*
757
+ -- visible regardless of share state (spec §3 — baseline floor).
758
+ -- * scope = 'org': managed by an org member with `manage_org_compute`.
759
+ -- `owner_org_id` is required and visibility is implicit (only members
760
+ -- of `owner_org_id` can see it). Gated by the platform flag
761
+ -- ALLOW_ORG_COMPUTE_OVERRIDE at the TS layer.
762
+ create table if not exists public.compute_servers (
763
+ id uuid primary key,
764
+ scope text not null check (scope in ('platform', 'org')),
765
+ owner_org_id uuid references public.orgs(id) on delete cascade,
766
+ shared_with_all boolean not null default false,
767
+ label text not null,
768
+ server_url text not null,
769
+ api_key text,
770
+ timeout_ms integer,
771
+ retry_count integer,
772
+ created_at timestamptz not null default now(),
773
+ -- Scope-specific invariants.
774
+ check (
775
+ (scope = 'platform' and owner_org_id is null)
776
+ or (scope = 'org' and owner_org_id is not null and shared_with_all = false)
777
+ )
778
+ );
779
+ create index if not exists idx_compute_servers_owner_org on public.compute_servers(owner_org_id);
780
+
781
+ -- Per-org share allowlist for platform servers. Empty rows = dormant
782
+ -- platform server (admin-only) unless it's the global default.
783
+ create table if not exists public.compute_server_shares (
784
+ server_id uuid not null references public.compute_servers(id) on delete cascade,
785
+ org_id uuid not null references public.orgs(id) on delete cascade,
786
+ primary key (server_id, org_id)
787
+ );
788
+ create index if not exists idx_compute_server_shares_org on public.compute_server_shares(org_id);
789
+
790
+ -- Per-org default override (orgDefaults[orgId]).
791
+ create table if not exists public.compute_server_org_defaults (
792
+ org_id uuid primary key references public.orgs(id) on delete cascade,
793
+ default_server_id uuid references public.compute_servers(id) on delete set null
794
+ );
795
+
796
+ -- Single-row sentinel for the global default.
797
+ create table if not exists public.compute_server_platform_default (
798
+ singleton boolean primary key default true,
799
+ default_server_id uuid references public.compute_servers(id) on delete set null,
800
+ check (singleton)
801
+ );
802
+ insert into public.compute_server_platform_default (singleton, default_server_id)
803
+ values (true, null)
804
+ on conflict (singleton) do nothing;
805
+
806
+ alter table public.compute_servers enable row level security;
807
+ alter table public.compute_server_shares enable row level security;
808
+ alter table public.compute_server_org_defaults enable row level security;
809
+ alter table public.compute_server_platform_default enable row level security;
810
+
811
+ -- Read: instance_admin sees everything. Otherwise visibility follows §3:
812
+ -- * platform server with shared_with_all = true → all authenticated users
813
+ -- * platform server with a row in compute_server_shares for an org the
814
+ -- user belongs to → that user
815
+ -- * platform server that is the current global default → all (floor)
816
+ -- * org server → only members of owner_org_id
817
+ create policy "compute_servers: visibility-scoped read"
818
+ on public.compute_servers for select
819
+ to authenticated
820
+ using (
821
+ public.is_instance_admin()
822
+ or (scope = 'platform' and shared_with_all)
823
+ or (scope = 'platform' and id = (
824
+ select default_server_id from public.compute_server_platform_default where singleton
825
+ ))
826
+ or (scope = 'platform' and exists (
827
+ select 1 from public.compute_server_shares s
828
+ where s.server_id = compute_servers.id and public.is_org_member(s.org_id)
829
+ ))
830
+ or (scope = 'org' and public.is_org_member(owner_org_id))
831
+ );
832
+
833
+ -- Writes:
834
+ -- * scope = 'platform' → instance_admin only.
835
+ -- * scope = 'org' → manage_org_compute on owner_org_id.
836
+ create policy "compute_servers: scoped write"
837
+ on public.compute_servers for all
838
+ to authenticated
839
+ using (
840
+ public.is_instance_admin()
841
+ or (scope = 'org' and owner_org_id is not null
842
+ and public.has_org_permission(owner_org_id, 'manage_org_compute'))
843
+ )
844
+ with check (
845
+ public.is_instance_admin()
846
+ or (scope = 'org' and owner_org_id is not null
847
+ and public.has_org_permission(owner_org_id, 'manage_org_compute'))
848
+ );
849
+
850
+ create policy "compute_server_shares: read"
851
+ on public.compute_server_shares for select
852
+ to authenticated
853
+ using (public.is_instance_admin() or public.is_org_member(org_id));
854
+
855
+ create policy "compute_server_shares: admin write"
856
+ on public.compute_server_shares for all
857
+ to authenticated
858
+ using (public.is_instance_admin())
859
+ with check (public.is_instance_admin());
860
+
861
+ create policy "compute_server_org_defaults: members can read"
862
+ on public.compute_server_org_defaults for select
863
+ to authenticated
864
+ using (public.is_instance_admin() or public.is_org_member(org_id));
865
+
866
+ create policy "compute_server_org_defaults: manage_org_compute can write"
867
+ on public.compute_server_org_defaults for all
868
+ to authenticated
869
+ using (public.is_instance_admin() or public.has_org_permission(org_id, 'manage_org_compute'))
870
+ with check (public.is_instance_admin() or public.has_org_permission(org_id, 'manage_org_compute'));
871
+
872
+ create policy "compute_server_platform_default: read all"
873
+ on public.compute_server_platform_default for select
874
+ to authenticated
875
+ using (true);
876
+
877
+ create policy "compute_server_platform_default: admin write"
878
+ on public.compute_server_platform_default for all
879
+ to authenticated
880
+ using (public.is_instance_admin())
881
+ with check (public.is_instance_admin());
882
+
883
+
884
+ -- ============================================================================
885
+ -- 10. share_links (spec §7)
886
+ --
887
+ -- Per-definition tokens granting unauthenticated access to one
888
+ -- (definitionId, channel). token_hash is HMAC(SHARE_LINK_SECRET, raw); the
889
+ -- raw token is shown to the minter once at creation.
890
+ -- ============================================================================
891
+
892
+ create table if not exists public.share_links (
893
+ id uuid primary key,
894
+ definition_guid uuid not null references public.definitions(guid) on delete cascade,
895
+ channel text not null check (channel in ('live', 'draft')),
896
+ token_hash text not null unique,
897
+ name text,
898
+ created_by uuid not null references auth.users(id) on delete cascade,
899
+ created_at timestamptz not null default now(),
900
+ expires_at timestamptz,
901
+ revoked_at timestamptz,
902
+ allow_solve boolean not null default true,
903
+ max_solves integer check (max_solves is null or max_solves >= 1),
904
+ solve_count integer not null default 0
905
+ );
906
+
907
+ create index if not exists idx_share_links_definition
908
+ on public.share_links(definition_guid, created_at desc)
909
+ where revoked_at is null;
910
+
911
+ -- Atomic check-and-increment. Returns the new solve_count on success, NULL
912
+ -- when the cap was reached (no row updated) or the link is revoked /
913
+ -- expired / missing. Single-statement = no race even under concurrent solves.
914
+ create or replace function public.try_increment_share_link_solve_count(link_id uuid)
915
+ returns integer
916
+ language sql security definer set search_path = public
917
+ as $$
918
+ update public.share_links
919
+ set solve_count = solve_count + 1
920
+ where id = link_id
921
+ and revoked_at is null
922
+ and (expires_at is null or expires_at > now())
923
+ and (max_solves is null or solve_count < max_solves)
924
+ returning solve_count;
925
+ $$;
926
+ grant execute on function public.try_increment_share_link_solve_count(uuid)
927
+ to authenticated, anon, service_role;
928
+
929
+ alter table public.share_links enable row level security;
930
+
931
+ -- Read/insert/revoke: same authority that can edit the parent definition
932
+ -- (project owner/editor or commons-mode definition owner). Token resolution
933
+ -- uses the SECURITY DEFINER RPC + service-role and bypasses RLS — the token
934
+ -- IS the credential there.
935
+
936
+ create policy "share_links: editors can read"
937
+ on public.share_links for select
938
+ to authenticated
939
+ using (
940
+ public.is_instance_admin()
941
+ or exists (
942
+ select 1
943
+ from public.definitions d
944
+ join public.project_members m on m.project_id = d.project_id
945
+ where d.guid = share_links.definition_guid
946
+ and d.deleted_at is null
947
+ and m.user_id = auth.uid()
948
+ and m.deleted_at is null
949
+ and m.role in ('owner', 'editor')
950
+ )
951
+ or exists (
952
+ select 1
953
+ from public.definitions d
954
+ join public.projects p on p.id = d.project_id
955
+ where d.guid = share_links.definition_guid
956
+ and d.deleted_at is null
957
+ and p.deleted_at is null
958
+ and p.auto_join_on_upload = true
959
+ and d.owner_id = auth.uid()
960
+ )
961
+ );
962
+
963
+ create policy "share_links: editors can insert"
964
+ on public.share_links for insert
965
+ to authenticated
966
+ with check (
967
+ public.is_instance_admin()
968
+ or exists (
969
+ select 1
970
+ from public.definitions d
971
+ join public.project_members m on m.project_id = d.project_id
972
+ where d.guid = share_links.definition_guid
973
+ and d.deleted_at is null
974
+ and m.user_id = auth.uid()
975
+ and m.deleted_at is null
976
+ and m.role in ('owner', 'editor')
977
+ )
978
+ or exists (
979
+ select 1
980
+ from public.definitions d
981
+ join public.projects p on p.id = d.project_id
982
+ where d.guid = share_links.definition_guid
983
+ and d.deleted_at is null
984
+ and p.deleted_at is null
985
+ and p.auto_join_on_upload = true
986
+ and d.owner_id = auth.uid()
987
+ )
988
+ );
989
+
990
+ -- UPDATE policy covers `revoke` (set revoked_at). Same authority as insert.
991
+ create policy "share_links: editors can revoke"
992
+ on public.share_links for update
993
+ to authenticated
994
+ using (
995
+ public.is_instance_admin()
996
+ or exists (
997
+ select 1
998
+ from public.definitions d
999
+ join public.project_members m on m.project_id = d.project_id
1000
+ where d.guid = share_links.definition_guid
1001
+ and d.deleted_at is null
1002
+ and m.user_id = auth.uid()
1003
+ and m.deleted_at is null
1004
+ and m.role in ('owner', 'editor')
1005
+ )
1006
+ )
1007
+ with check (
1008
+ public.is_instance_admin()
1009
+ or exists (
1010
+ select 1
1011
+ from public.definitions d
1012
+ join public.project_members m on m.project_id = d.project_id
1013
+ where d.guid = share_links.definition_guid
1014
+ and d.deleted_at is null
1015
+ and m.user_id = auth.uid()
1016
+ and m.deleted_at is null
1017
+ and m.role in ('owner', 'editor')
1018
+ )
1019
+ );
1020
+
1021
+
1022
+ -- ============================================================================
1023
+ -- 11. Storage bucket policies
1024
+ --
1025
+ -- All mutations on both buckets go through the app server, which holds the
1026
+ -- service-role key (SupabaseStorageProvider) — service-role bypasses RLS.
1027
+ -- User-scoped JWTs (the `authenticated` role) get NO direct access to
1028
+ -- storage REST: a logged-in user could otherwise extract their cookie JWT,
1029
+ -- pair it with the public anon key, and call /storage/v1/object/... directly
1030
+ -- to read/overwrite/delete any tenant's objects, since storage.objects has
1031
+ -- no project/ownership column to filter on. Application-layer access is
1032
+ -- enforced server-side: private reads flow through /api/files (which uses
1033
+ -- the service-role client and runs project-membership checks), and public
1034
+ -- reads use the CDN endpoint via the anon role below.
1035
+ -- ============================================================================
1036
+
1037
+ -- selva-public is served directly from the CDN (cover images, brand assets);
1038
+ -- anonymous reads are required. Writes are service-role only.
1039
+ create policy "selva-public: anyone can read"
1040
+ on storage.objects for select
1041
+ to anon, authenticated
1042
+ using (bucket_id = 'selva-public');
1043
+
1044
+ -- selva-private is never accessed directly by users. No policies for the
1045
+ -- authenticated role are defined; service-role bypasses RLS for app writes
1046
+ -- and proxied reads through /api/files.
1047
+
1048
+
1049
+ -- ============================================================================
1050
+ -- 12. audit_events
1051
+ -- Persistent sink for the domain events defined in `@selvajs/platform/events`.
1052
+ -- Every successful mutation in a data store emits one row here via
1053
+ -- `SupabaseEventSink`. Read-only from the application's point of view —
1054
+ -- mutations come exclusively from the sink writer.
1055
+ --
1056
+ -- Schema is intentionally generic: type + actor + timestamp + JSONB payload
1057
+ -- (the entire DomainEvent serialized). New event variants don't require a
1058
+ -- column migration — only the union in events/interface.ts.
1059
+ --
1060
+ -- The UI for viewing this remains deferred (Permissions.md §12); the table
1061
+ -- exists today so audit data is captured from the moment the system goes live.
1062
+ -- ============================================================================
1063
+
1064
+ create table if not exists public.audit_events (
1065
+ id uuid primary key default gen_random_uuid(),
1066
+ type text not null,
1067
+ actor_id text not null,
1068
+ occurred_at timestamptz not null default now(),
1069
+ data jsonb not null
1070
+ );
1071
+
1072
+ create index if not exists audit_events_occurred_at_idx
1073
+ on public.audit_events (occurred_at desc);
1074
+
1075
+ create index if not exists audit_events_type_occurred_at_idx
1076
+ on public.audit_events (type, occurred_at desc);
1077
+
1078
+ create index if not exists audit_events_actor_occurred_at_idx
1079
+ on public.audit_events (actor_id, occurred_at desc);
1080
+
1081
+ -- RLS: writes always go through service-role (the sink uses the service
1082
+ -- client). Authenticated users have NO read access today — the audit-log
1083
+ -- viewer UI will land later with its own instance_admin-only policy.
1084
+ alter table public.audit_events enable row level security;