@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,68 @@
1
+ /**
2
+ * Pagination helpers shared across Supabase stores.
3
+ *
4
+ * The platform contract specifies cursor pagination (`ListOptions.cursor`).
5
+ * Because Supabase/Postgres has no built-in opaque cursor format, we encode
6
+ * an offset as the cursor — simple, correct for stable sorts, and good
7
+ * enough for the volumes Selva deals with (admin/definition lists are
8
+ * bounded; nothing is infinite scroll over millions of rows).
9
+ *
10
+ * Keyset pagination would be faster at scale if it ever matters.
11
+ */
12
+
13
+ import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '@selvajs/platform';
14
+ import type { ListOptions } from '@selvajs/platform';
15
+
16
+ export interface RangeSpec {
17
+ from: number;
18
+ to: number;
19
+ limit: number;
20
+ }
21
+
22
+ /** Decode an opaque cursor string. Returns 0 for missing/invalid cursors. */
23
+ export function decodeCursor(cursor: string | undefined): number {
24
+ if (!cursor) return 0;
25
+ const n = parseInt(cursor, 10);
26
+ return Number.isFinite(n) && n >= 0 ? n : 0;
27
+ }
28
+
29
+ export function encodeCursor(offset: number): string {
30
+ return String(offset);
31
+ }
32
+
33
+ /** Clamp limit + compute the PostgREST `range(from, to)` bounds. */
34
+ export function toRange(opts: Pick<ListOptions, 'limit' | 'cursor'> | undefined): RangeSpec {
35
+ const limit = Math.min(Math.max(opts?.limit ?? DEFAULT_PAGE_LIMIT, 1), MAX_PAGE_LIMIT);
36
+ const from = decodeCursor(opts?.cursor);
37
+ return { from, to: from + limit - 1, limit };
38
+ }
39
+
40
+ /**
41
+ * Build the `nextCursor` from the count returned by PostgREST. Returns
42
+ * undefined when there is no next page.
43
+ */
44
+ export function nextCursorFromRange(
45
+ range: RangeSpec,
46
+ returnedCount: number,
47
+ totalCount: number | null | undefined
48
+ ): string | undefined {
49
+ const consumed = range.from + returnedCount;
50
+ if (totalCount != null) {
51
+ return consumed < totalCount ? encodeCursor(consumed) : undefined;
52
+ }
53
+ // Without total, we can only infer "there might be more" if we got a full page.
54
+ return returnedCount >= range.limit ? encodeCursor(consumed) : undefined;
55
+ }
56
+
57
+ /** Map `ListOptions.orderBy` to a Postgres column name. */
58
+ export function orderColumn(orderBy: ListOptions['orderBy'] | undefined): string {
59
+ switch (orderBy) {
60
+ case 'name':
61
+ return 'name';
62
+ case 'updatedAt':
63
+ return 'updated_at';
64
+ case 'createdAt':
65
+ default:
66
+ return 'created_at';
67
+ }
68
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { SupabaseStorageProvider } from './storage/SupabaseStorageProvider.js';
2
+ export type { SupabaseStorageProviderConfig } from './storage/SupabaseStorageProvider.js';
3
+
4
+ export {
5
+ SupabaseOrgStore,
6
+ SupabaseProjectStore,
7
+ SupabaseDefinitionStore,
8
+ SupabaseInviteStore,
9
+ SupabaseComputeServerStore,
10
+ SupabaseShareLinkStore,
11
+ SupabaseDataProvider,
12
+ SupabaseEventSink,
13
+ SupabaseAuditQuery,
14
+ buildClientBundle
15
+ } from './data/index.js';
16
+ export type { ClientBundle, BuildClientOptions } from './data/index.js';
17
+
18
+ export { SupabaseUserProfileProvider } from './userProfile/index.js';
19
+
20
+ export { SupabaseAuthProvider } from './auth/index.js';
21
+ export type { SupabaseAuthProviderConfig } from './auth/index.js';
22
+
23
+ export { SupabasePlatformPermissionStore } from './permissions/index.js';
@@ -0,0 +1,161 @@
1
+ import type {
2
+ IPlatformPermissionStore,
3
+ PlatformPermission,
4
+ RequestContext,
5
+ UserManagementResult
6
+ } from '@selvajs/platform';
7
+ import { ProviderError, hasPermission, PlatformPermissionSchema } from '@selvajs/platform';
8
+ import type { ClientBundle } from '../data/client.js';
9
+
10
+ /**
11
+ * Supabase-backed platform-permission store. Reads and writes
12
+ * `public.user_profiles.platform_permissions` (auto-seeded by the
13
+ * `handle_new_auth_user` trigger). Service-role throughout — the store's own
14
+ * `assertAdmin` / `assertCanRead` enforce the auth boundary at the app level
15
+ * since RLS on `user_profiles` is coarse-grained for cross-user reads.
16
+ *
17
+ * Disabled-user state lives on `auth.users.user_metadata.disabled`, so the
18
+ * invariant counters cross-reference the auth backend to exclude disabled
19
+ * admins (matches `Permissions.md §10` "enabled instance_admin" semantics).
20
+ */
21
+ export class SupabasePlatformPermissionStore implements IPlatformPermissionStore {
22
+ constructor(private readonly clients: ClientBundle) {}
23
+
24
+ private client() {
25
+ return this.clients.serviceClient;
26
+ }
27
+
28
+ async getFor(ctx: RequestContext, userId: string): Promise<PlatformPermission[]> {
29
+ assertCanRead(ctx, userId);
30
+ const { data, error } = await this.client()
31
+ .from('user_profiles')
32
+ .select('platform_permissions')
33
+ .eq('user_id', userId)
34
+ .maybeSingle();
35
+ if (error) throw mapError(error);
36
+ return filterValid(data?.platform_permissions ?? []);
37
+ }
38
+
39
+ async getForBatch(
40
+ ctx: RequestContext,
41
+ userIds: readonly string[]
42
+ ): Promise<Map<string, PlatformPermission[]>> {
43
+ assertAdmin(ctx);
44
+ const out = new Map<string, PlatformPermission[]>();
45
+ if (userIds.length === 0) return out;
46
+ const { data, error } = await this.client()
47
+ .from('user_profiles')
48
+ .select('user_id, platform_permissions')
49
+ .in('user_id', [...userIds]);
50
+ if (error) throw mapError(error);
51
+ for (const row of data ?? []) {
52
+ out.set(row.user_id as string, filterValid((row.platform_permissions ?? []) as string[]));
53
+ }
54
+ return out;
55
+ }
56
+
57
+ async set(
58
+ ctx: RequestContext,
59
+ userId: string,
60
+ permissions: readonly PlatformPermission[]
61
+ ): Promise<UserManagementResult> {
62
+ assertAdmin(ctx);
63
+ // 1. Confirm the target exists in the auth backend (the FK + trigger
64
+ // means the user_profiles row should exist post-signup; if not,
65
+ // treat as not_found rather than auto-creating).
66
+ const { data: existing, error: fetchError } = await this.client()
67
+ .from('user_profiles')
68
+ .select('platform_permissions')
69
+ .eq('user_id', userId)
70
+ .maybeSingle();
71
+ if (fetchError) throw mapError(fetchError);
72
+ if (!existing) return 'not_found';
73
+
74
+ // 2. §2 invariant: refuse if dropping the last instance_admin.
75
+ const wasAdmin = (existing.platform_permissions ?? []).includes('instance_admin');
76
+ const willBeAdmin = permissions.includes('instance_admin');
77
+ if (wasAdmin && !willBeAdmin) {
78
+ const others = await this.countOtherEnabledAdmins(userId);
79
+ if (others === 0) return 'last_admin';
80
+ }
81
+
82
+ // 3. Apply.
83
+ const { error } = await this.client()
84
+ .from('user_profiles')
85
+ .update({ platform_permissions: [...permissions] })
86
+ .eq('user_id', userId);
87
+ if (error) throw mapError(error);
88
+ return 'ok';
89
+ }
90
+
91
+ async hasInstanceAdmin(_ctx: RequestContext): Promise<boolean> {
92
+ const { data, error } = await this.client()
93
+ .from('user_profiles')
94
+ .select('user_id')
95
+ .contains('platform_permissions', ['instance_admin']);
96
+ if (error) throw mapError(error);
97
+ const candidates = (data ?? []).map((r) => r.user_id as string);
98
+ if (candidates.length === 0) return false;
99
+ // Check at least one isn't disabled.
100
+ for (const id of candidates) {
101
+ const { data: authData } = await this.clients.serviceClient.auth.admin.getUserById(id);
102
+ if (authData.user && authData.user.user_metadata?.disabled !== true) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ async countInstanceAdminsExcluding(_ctx: RequestContext, excludeUserId: string): Promise<number> {
108
+ return this.countOtherEnabledAdmins(excludeUserId);
109
+ }
110
+
111
+ private async countOtherEnabledAdmins(excludeUserId: string): Promise<number> {
112
+ const { data, error } = await this.client()
113
+ .from('user_profiles')
114
+ .select('user_id')
115
+ .contains('platform_permissions', ['instance_admin'])
116
+ .neq('user_id', excludeUserId);
117
+ if (error) throw mapError(error);
118
+ const candidates = (data ?? []).map((r) => r.user_id as string);
119
+ if (candidates.length === 0) return 0;
120
+ let count = 0;
121
+ for (const id of candidates) {
122
+ const { data: authData } = await this.clients.serviceClient.auth.admin.getUserById(id);
123
+ if (authData.user && authData.user.user_metadata?.disabled !== true) count += 1;
124
+ }
125
+ return count;
126
+ }
127
+ }
128
+
129
+ function filterValid(raw: readonly string[]): PlatformPermission[] {
130
+ return raw.filter((p): p is PlatformPermission => PlatformPermissionSchema.safeParse(p).success);
131
+ }
132
+
133
+ function assertCanRead(ctx: RequestContext, userId: string): void {
134
+ if (ctx.system) return;
135
+ if (ctx.userId === userId) return;
136
+ if (hasPermission(ctx, 'instance_admin')) return;
137
+ throw new ProviderError('Forbidden: cannot read another user’s permissions', 403);
138
+ }
139
+
140
+ function assertAdmin(ctx: RequestContext): void {
141
+ if (ctx.system) return;
142
+ if (hasPermission(ctx, 'instance_admin')) return;
143
+ throw new ProviderError('Forbidden: instance admin required', 403);
144
+ }
145
+
146
+ interface PostgrestError {
147
+ code?: string;
148
+ message?: string;
149
+ }
150
+
151
+ function mapError(e: unknown): Error {
152
+ const pg = e as PostgrestError;
153
+ if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
154
+ if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
155
+ if (e instanceof Error) return e;
156
+ if (e && typeof e === 'object') {
157
+ const obj = e as { message?: string; code?: string };
158
+ return new Error(obj.code ? `[${obj.code}] ${obj.message ?? ''}` : (obj.message ?? String(e)));
159
+ }
160
+ return new Error(String(e));
161
+ }
@@ -0,0 +1,28 @@
1
+ import { describe, beforeEach, it } from 'vitest';
2
+ import { runPlatformPermissionStoreConformance } from '@selvajs/platform/testing';
3
+ import { SupabasePlatformPermissionStore } from '../SupabasePlatformPermissionStore.js';
4
+ import { readEnv, resetAllData, seedPlainUser } from '../../data/__tests__/test-helpers.js';
5
+
6
+ const envCtx = readEnv();
7
+
8
+ if (!envCtx) {
9
+ describe.skip('SupabasePlatformPermissionStore (skipped: no live stack)', () => {
10
+ it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => {});
11
+ });
12
+ } else {
13
+ describe('SupabasePlatformPermissionStore', () => {
14
+ beforeEach(async () => {
15
+ await resetAllData(envCtx);
16
+ });
17
+
18
+ runPlatformPermissionStoreConformance({
19
+ name: 'SupabasePlatformPermissionStore',
20
+ createStore: async () => {
21
+ return {
22
+ store: new SupabasePlatformPermissionStore(envCtx.bundle),
23
+ seedUser: async () => (await seedPlainUser(envCtx, '')).userId
24
+ };
25
+ }
26
+ });
27
+ });
28
+ }
@@ -0,0 +1 @@
1
+ export { SupabasePlatformPermissionStore } from './SupabasePlatformPermissionStore.js';
@@ -0,0 +1,177 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import { createClient } from '@supabase/supabase-js';
3
+ import { transcodeImageIfNeeded } from '@selvajs/platform/storage';
4
+ import type { IStorageProvider } from '@selvajs/platform/storage';
5
+
6
+ /**
7
+ * Storage backend for Selva on Supabase Storage.
8
+ *
9
+ * Uses two buckets:
10
+ * - {publicBucket} (public): cover images, archive thumbnails, anything
11
+ * CDN-safe. `getPublicUrl` returns the direct CDN URL.
12
+ * - {privateBucket} (private): .gh / .ghx source files. `getPublicUrl`
13
+ * returns the authenticated proxy URL (`${privateUrlPrefix}/{path}`) —
14
+ * the consuming app must have a route at that prefix that authenticates
15
+ * the request and streams the bytes via `get()`.
16
+ *
17
+ * Path routing: any path whose basename matches `definition.{ext}` is
18
+ * considered private. Covers (`cover.webp`) and archives (`archive/{ref}`)
19
+ * stay public. `definitionPaths` from @selvajs/platform produces exactly these
20
+ * names, so the routing is implicit — no caller has to think about buckets.
21
+ */
22
+ export interface SupabaseStorageProviderConfig {
23
+ /** Supabase project URL (e.g. http://127.0.0.1:54321 for local stack). */
24
+ supabaseUrl: string;
25
+ /**
26
+ * Service-role key. Required because the provider runs server-side and
27
+ * must bypass RLS for server-internal actions (admin uploads, janitor
28
+ * cleanup). The provider is never used directly from the browser.
29
+ */
30
+ serviceRoleKey: string;
31
+ /** Bucket for CDN-readable objects. Default: "selva-public". */
32
+ publicBucket?: string;
33
+ /** Bucket for authenticated-only objects. Default: "selva-private". */
34
+ privateBucket?: string;
35
+ /**
36
+ * URL prefix the app exposes for authenticated file downloads. Used by
37
+ * `getPublicUrl` for private-bucket paths. Default: "/api/files".
38
+ * The matching SvelteKit route is expected to call `get()` and stream
39
+ * bytes back after an auth check.
40
+ */
41
+ privateUrlPrefix?: string;
42
+ }
43
+
44
+ export class SupabaseStorageProvider implements IStorageProvider {
45
+ private readonly client: SupabaseClient;
46
+ private readonly publicBucket: string;
47
+ private readonly privateBucket: string;
48
+ private readonly publicBaseUrl: string;
49
+ private readonly privateUrlPrefix: string;
50
+
51
+ static fromEnv(env: Record<string, string | undefined>): SupabaseStorageProvider {
52
+ const supabaseUrl = env.SUPABASE_URL;
53
+ const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
54
+ if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
55
+ if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
56
+ return new SupabaseStorageProvider({
57
+ supabaseUrl,
58
+ serviceRoleKey,
59
+ publicBucket: env.SUPABASE_PUBLIC_BUCKET,
60
+ privateBucket: env.SUPABASE_PRIVATE_BUCKET,
61
+ privateUrlPrefix: env.SUPABASE_PRIVATE_URL_PREFIX
62
+ });
63
+ }
64
+
65
+ constructor(config: SupabaseStorageProviderConfig) {
66
+ this.client = createClient(config.supabaseUrl, config.serviceRoleKey, {
67
+ auth: { persistSession: false, autoRefreshToken: false }
68
+ });
69
+ this.publicBucket = config.publicBucket ?? 'selva-public';
70
+ this.privateBucket = config.privateBucket ?? 'selva-private';
71
+ // Direct CDN URL format: {supabaseUrl}/storage/v1/object/public/{bucket}/{path}
72
+ this.publicBaseUrl = `${config.supabaseUrl.replace(/\/$/, '')}/storage/v1/object/public/${this.publicBucket}`;
73
+ this.privateUrlPrefix = (config.privateUrlPrefix ?? '/api/files').replace(/\/$/, '');
74
+ }
75
+
76
+ /**
77
+ * Route a storage path to a bucket. Any `.gh`/`.ghx` source file is
78
+ * private — confidentiality is determined by extension, not location, so
79
+ * a path-scheme rename (e.g. `definition.gh` → `versions/v1.gh`) can't
80
+ * silently move source files into the public bucket. Everything else
81
+ * (covers, archives, thumbnails) is public.
82
+ */
83
+ private bucketFor(storagePath: string): string {
84
+ return /\.(gh|ghx)$/i.test(storagePath) ? this.privateBucket : this.publicBucket;
85
+ }
86
+
87
+ async get(storagePath: string): Promise<Uint8Array | null> {
88
+ const bucket = this.bucketFor(storagePath);
89
+ const { data, error } = await this.client.storage.from(bucket).download(storagePath);
90
+ if (error) {
91
+ // storage-js surfaces 404 as an error with message "Object not found".
92
+ // Normalize any "not found" to null — we don't have a stable status code.
93
+ const msg = (error as { message?: string }).message ?? '';
94
+ if (/not found/i.test(msg) || /no such key/i.test(msg)) return null;
95
+ throw error;
96
+ }
97
+ const buffer = await data.arrayBuffer();
98
+ return new Uint8Array(buffer);
99
+ }
100
+
101
+ async put(storagePath: string, data: Uint8Array, contentType?: string): Promise<void> {
102
+ // Normalize images through the shared helper so this provider produces
103
+ // the same bytes / extensions as LocalStorageProvider for the same input.
104
+ const transcoded = await transcodeImageIfNeeded(data, contentType, storagePath);
105
+ const bucket = this.bucketFor(transcoded.path);
106
+ const { error } = await this.client.storage
107
+ .from(bucket)
108
+ .upload(transcoded.path, transcoded.data, {
109
+ contentType: transcoded.contentType,
110
+ upsert: true
111
+ });
112
+ if (error) throw error;
113
+ }
114
+
115
+ async delete(storagePath: string): Promise<void> {
116
+ const bucket = this.bucketFor(storagePath);
117
+ const { error } = await this.client.storage.from(bucket).remove([storagePath]);
118
+ // remove() does not error for missing paths — it just returns an empty array.
119
+ if (error) throw error;
120
+ }
121
+
122
+ async deletePrefix(prefix: string): Promise<void> {
123
+ // Hit both buckets: a definition's GUID prefix may have files in each.
124
+ // Scoped by the trailing slash in the prefix, so siblings aren't touched.
125
+ await Promise.all([
126
+ this.deletePrefixInBucket(this.publicBucket, prefix),
127
+ this.deletePrefixInBucket(this.privateBucket, prefix)
128
+ ]);
129
+ }
130
+
131
+ private async deletePrefixInBucket(bucket: string, prefix: string): Promise<void> {
132
+ const normalized = prefix.replace(/\/+$/, '');
133
+ const keys = await this.listAllKeys(bucket, normalized);
134
+ if (keys.length === 0) return;
135
+ // `remove` accepts up to 1000 paths; batch just in case.
136
+ for (let i = 0; i < keys.length; i += 1000) {
137
+ const slice = keys.slice(i, i + 1000);
138
+ const { error } = await this.client.storage.from(bucket).remove(slice);
139
+ if (error) throw error;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Recursively list every key under a prefix. `storage-js`'s `list` API
145
+ * only returns the immediate children of a "folder", so we DFS.
146
+ */
147
+ private async listAllKeys(bucket: string, prefix: string): Promise<string[]> {
148
+ const out: string[] = [];
149
+ const stack: string[] = [prefix];
150
+ while (stack.length > 0) {
151
+ const dir = stack.pop()!;
152
+ const { data, error } = await this.client.storage.from(bucket).list(dir, {
153
+ limit: 1000,
154
+ sortBy: { column: 'name', order: 'asc' }
155
+ });
156
+ if (error) throw error;
157
+ for (const entry of data ?? []) {
158
+ const full = dir ? `${dir}/${entry.name}` : entry.name;
159
+ // Folder entries have `id === null` in storage-js.
160
+ if (entry.id === null) {
161
+ stack.push(full);
162
+ } else {
163
+ out.push(full);
164
+ }
165
+ }
166
+ }
167
+ return out;
168
+ }
169
+
170
+ getPublicUrl(storagePath: string): string {
171
+ if (this.bucketFor(storagePath) === this.publicBucket) {
172
+ return `${this.publicBaseUrl}/${storagePath}`;
173
+ }
174
+ // Private files: the app's authenticated proxy route. See `privateUrlPrefix`.
175
+ return `${this.privateUrlPrefix}/${storagePath}`;
176
+ }
177
+ }
@@ -0,0 +1,152 @@
1
+ import { describe, beforeEach, it, expect } from 'vitest';
2
+ import { createClient } from '@supabase/supabase-js';
3
+ import { runStorageProviderConformance } from '@selvajs/platform/testing';
4
+ import { definitionPaths } from '@selvajs/platform';
5
+ import { SupabaseStorageProvider } from '../SupabaseStorageProvider.js';
6
+
7
+ const SUPABASE_URL = process.env.SUPABASE_URL;
8
+ const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
9
+ const PUBLIC_BUCKET = process.env.SUPABASE_PUBLIC_BUCKET ?? 'selva-public';
10
+ const PRIVATE_BUCKET = process.env.SUPABASE_PRIVATE_BUCKET ?? 'selva-private';
11
+
12
+ // The conformance suite hits a live Supabase stack. If creds aren't present
13
+ // (e.g. in CI without Docker, or a clean clone pre-`supabase start`), skip
14
+ // the suite with a single explanatory test so the run surfaces the reason.
15
+ // `createClient` transitively loads `@supabase/realtime-js`, which on Node < 22
16
+ // throws at construction without native WebSocket — treat that as "no stack"
17
+ // rather than crashing the suite during collection.
18
+ function tryBuildAdminClient(): ReturnType<typeof createClient> | null {
19
+ if (!SUPABASE_URL || !SERVICE_ROLE_KEY) return null;
20
+ try {
21
+ return createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
22
+ auth: { persistSession: false, autoRefreshToken: false }
23
+ });
24
+ } catch (err) {
25
+ const msg = err instanceof Error ? err.message : String(err);
26
+ if (msg.includes('WebSocket')) return null;
27
+ throw err;
28
+ }
29
+ }
30
+ const _adminClient = tryBuildAdminClient();
31
+
32
+ if (_adminClient === null) {
33
+ describe.skip('SupabaseStorageProvider (skipped: no live stack)', () => {
34
+ it('populate packages/providers/supabase/.env.test with SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (from `npx supabase start`) to run these tests', () => {});
35
+ });
36
+ } else {
37
+ // Re-bind in this branch so closures below see the non-null narrowed type.
38
+ const adminClient = _adminClient;
39
+
40
+ /**
41
+ * Delete every object under a prefix in a bucket. The conformance suite
42
+ * assumes fresh state per test; Supabase Storage is shared across runs,
43
+ * so we reset both buckets before each test.
44
+ */
45
+ async function emptyBucket(bucket: string): Promise<void> {
46
+ const keys: string[] = [];
47
+ const stack: string[] = [''];
48
+ while (stack.length > 0) {
49
+ const dir = stack.pop()!;
50
+ const { data, error } = await adminClient.storage.from(bucket).list(dir, {
51
+ limit: 1000,
52
+ sortBy: { column: 'name', order: 'asc' }
53
+ });
54
+ if (error) throw error;
55
+ for (const entry of data ?? []) {
56
+ const full = dir ? `${dir}/${entry.name}` : entry.name;
57
+ if (entry.id === null) stack.push(full);
58
+ else keys.push(full);
59
+ }
60
+ }
61
+ if (keys.length === 0) return;
62
+ for (let i = 0; i < keys.length; i += 1000) {
63
+ const { error } = await adminClient.storage.from(bucket).remove(keys.slice(i, i + 1000));
64
+ if (error) throw error;
65
+ }
66
+ }
67
+
68
+ describe('SupabaseStorageProvider', () => {
69
+ beforeEach(async () => {
70
+ await Promise.all([emptyBucket(PUBLIC_BUCKET), emptyBucket(PRIVATE_BUCKET)]);
71
+ });
72
+
73
+ runStorageProviderConformance({
74
+ name: 'SupabaseStorageProvider',
75
+ createStorage: () =>
76
+ new SupabaseStorageProvider({
77
+ supabaseUrl: SUPABASE_URL!,
78
+ serviceRoleKey: SERVICE_ROLE_KEY!,
79
+ publicBucket: PUBLIC_BUCKET,
80
+ privateBucket: PRIVATE_BUCKET
81
+ })
82
+ });
83
+
84
+ // ============================================================================
85
+ // Bucket routing — Supabase-specific (the conformance suite is provider-
86
+ // agnostic and can't see which bucket a file lands in). Without these tests
87
+ // a regex regression in `bucketFor` could silently move .gh source files
88
+ // into the public/CDN bucket.
89
+ // ============================================================================
90
+ describe('bucket routing', () => {
91
+ const storage = new SupabaseStorageProvider({
92
+ supabaseUrl: SUPABASE_URL!,
93
+ serviceRoleKey: SERVICE_ROLE_KEY!,
94
+ publicBucket: PUBLIC_BUCKET,
95
+ privateBucket: PRIVATE_BUCKET
96
+ });
97
+
98
+ async function existsInBucket(bucket: string, path: string): Promise<boolean> {
99
+ const { data, error } = await adminClient.storage.from(bucket).download(path);
100
+ if (error) return false;
101
+ return Boolean(data);
102
+ }
103
+
104
+ it('versioned .gh files land in the private bucket', async () => {
105
+ const guid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
106
+ const path = definitionPaths.version(guid, 1, 'gh');
107
+ await storage.put(path, new TextEncoder().encode('GH_BYTES'));
108
+ expect(await existsInBucket(PRIVATE_BUCKET, path)).toBe(true);
109
+ expect(await existsInBucket(PUBLIC_BUCKET, path)).toBe(false);
110
+ });
111
+
112
+ it('versioned .ghx files land in the private bucket', async () => {
113
+ const guid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
114
+ const path = definitionPaths.version(guid, 3, 'ghx');
115
+ await storage.put(path, new TextEncoder().encode('GHX_BYTES'));
116
+ expect(await existsInBucket(PRIVATE_BUCKET, path)).toBe(true);
117
+ expect(await existsInBucket(PUBLIC_BUCKET, path)).toBe(false);
118
+ });
119
+
120
+ it('cover images land in the public bucket', async () => {
121
+ const guid = 'cccccccc-cccc-cccc-cccc-cccccccccccc';
122
+ const path = definitionPaths.image(guid);
123
+ // Real 1×1 transparent PNG. The provider runs every image through
124
+ // transcodeImageIfNeeded → sharp, so a hand-crafted byte stub
125
+ // would fail the decode. Routing (the test's actual concern) is
126
+ // independent of which format the input arrives in.
127
+ const tinyPng = Uint8Array.from([
128
+ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8,
129
+ 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 9, 112, 72, 89, 115, 0, 0, 3, 232, 0, 0, 3, 232, 1,
130
+ 181, 123, 82, 107, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 96, 96, 96, 96, 0, 0, 0, 5,
131
+ 0, 1, 165, 246, 69, 64, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130
132
+ ]);
133
+ await storage.put(path, tinyPng, 'image/png');
134
+ expect(await existsInBucket(PUBLIC_BUCKET, path)).toBe(true);
135
+ expect(await existsInBucket(PRIVATE_BUCKET, path)).toBe(false);
136
+ });
137
+
138
+ it('getPublicUrl returns the proxy prefix for private files', () => {
139
+ const path = definitionPaths.version('guid-1', 1, 'gh');
140
+ const url = storage.getPublicUrl(path);
141
+ expect(url.startsWith('/api/files/')).toBe(true);
142
+ expect(url).not.toContain('/storage/v1/object/public/');
143
+ });
144
+
145
+ it('getPublicUrl returns the CDN URL for public files', () => {
146
+ const path = definitionPaths.image('guid-1');
147
+ const url = storage.getPublicUrl(path);
148
+ expect(url).toContain(`/storage/v1/object/public/${PUBLIC_BUCKET}/`);
149
+ });
150
+ });
151
+ });
152
+ }
@@ -0,0 +1,2 @@
1
+ export { SupabaseStorageProvider } from './SupabaseStorageProvider.js';
2
+ export type { SupabaseStorageProviderConfig } from './SupabaseStorageProvider.js';