@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 @@
1
+ {"version":3,"file":"conformance.test.d.ts","sourceRoot":"","sources":["../../../src/userProfile/__tests__/conformance.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,38 @@
1
+ import { describe, beforeEach, it } from 'vitest';
2
+ import { runUserProfileStoreConformance } from '@selvajs/platform/testing';
3
+ import { SupabaseUserProfileProvider } from '../SupabaseUserProfileProvider.js';
4
+ import { readEnv, resetAllData } from '../../data/__tests__/test-helpers.js';
5
+ const envCtx = readEnv();
6
+ if (!envCtx) {
7
+ describe.skip('SupabaseUserProfileProvider (skipped: no live stack)', () => {
8
+ it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => { });
9
+ });
10
+ }
11
+ else {
12
+ describe('SupabaseUserProfileProvider', () => {
13
+ beforeEach(async () => {
14
+ await resetAllData(envCtx);
15
+ });
16
+ runUserProfileStoreConformance({
17
+ name: 'SupabaseUserProfileProvider',
18
+ createStore: async () => {
19
+ const store = new SupabaseUserProfileProvider(envCtx.bundle);
20
+ const seedUser = async (email) => {
21
+ const { data, error } = await envCtx.adminClient.auth.admin.createUser({
22
+ email,
23
+ password: 'conformance-test-password-1234',
24
+ email_confirm: true
25
+ });
26
+ if (error)
27
+ throw error;
28
+ return {
29
+ id: data.user.id,
30
+ email: data.user.email
31
+ };
32
+ };
33
+ return { store, seedUser };
34
+ }
35
+ });
36
+ });
37
+ }
38
+ //# sourceMappingURL=conformance.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conformance.test.js","sourceRoot":"","sources":["../../../src/userProfile/__tests__/conformance.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EAAE,8BAA8B,EAAE,MAAM,2BAA2B,CAAC;AAE3E,OAAO,EAAE,2BAA2B,EAAE,MAAM,mCAAmC,CAAC;AAChF,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AAE7E,MAAM,MAAM,GAAG,OAAO,EAAE,CAAC;AAEzB,IAAI,CAAC,MAAM,EAAE,CAAC;IACb,QAAQ,CAAC,IAAI,CAAC,sDAAsD,EAAE,GAAG,EAAE;QAC1E,EAAE,CAAC,uFAAuF,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACvG,CAAC,CAAC,CAAC;AACJ,CAAC;KAAM,CAAC;IACP,QAAQ,CAAC,6BAA6B,EAAE,GAAG,EAAE;QAC5C,UAAU,CAAC,KAAK,IAAI,EAAE;YACrB,MAAM,YAAY,CAAC,MAAM,CAAC,CAAC;QAC5B,CAAC,CAAC,CAAC;QAEH,8BAA8B,CAAC;YAC9B,IAAI,EAAE,6BAA6B;YACnC,WAAW,EAAE,KAAK,IAAI,EAAE;gBACvB,MAAM,KAAK,GAAG,IAAI,2BAA2B,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;gBAC7D,MAAM,QAAQ,GAAG,KAAK,EAAE,KAAa,EAAqB,EAAE;oBAC3D,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC;wBACtE,KAAK;wBACL,QAAQ,EAAE,gCAAgC;wBAC1C,aAAa,EAAE,IAAI;qBACnB,CAAC,CAAC;oBACH,IAAI,KAAK;wBAAE,MAAM,KAAK,CAAC;oBACvB,OAAO;wBACN,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE;wBAChB,KAAK,EAAE,IAAI,CAAC,IAAI,CAAC,KAAK;qBACtB,CAAC;gBACH,CAAC,CAAC;gBACF,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC;YAC5B,CAAC;SACD,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;AACJ,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { SupabaseUserProfileProvider } from './SupabaseUserProfileProvider.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/userProfile/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,2BAA2B,EAAE,MAAM,kCAAkC,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { SupabaseUserProfileProvider } from './SupabaseUserProfileProvider.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/userProfile/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,2BAA2B,EAAE,MAAM,kCAAkC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@selvajs/supabase-provider",
3
+ "version": "0.11.0",
4
+ "description": "Supabase (Auth + Postgres + Storage) implementations of @selvajs/platform interfaces",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/VektorNode/selva.git",
12
+ "directory": "packages/providers/supabase"
13
+ },
14
+ "type": "module",
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "source": "./src/index.ts",
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ },
23
+ "./auth": {
24
+ "source": "./src/auth/index.ts",
25
+ "types": "./dist/auth/index.d.ts",
26
+ "import": "./dist/auth/index.js"
27
+ },
28
+ "./data": {
29
+ "source": "./src/data/index.ts",
30
+ "types": "./dist/data/index.d.ts",
31
+ "import": "./dist/data/index.js"
32
+ },
33
+ "./storage": {
34
+ "source": "./src/storage/index.ts",
35
+ "types": "./dist/storage/index.d.ts",
36
+ "import": "./dist/storage/index.js"
37
+ },
38
+ "./permissions": {
39
+ "source": "./src/permissions/index.ts",
40
+ "types": "./dist/permissions/index.d.ts",
41
+ "import": "./dist/permissions/index.js"
42
+ },
43
+ "./userProfile": {
44
+ "source": "./src/userProfile/index.ts",
45
+ "types": "./dist/userProfile/index.d.ts",
46
+ "import": "./dist/userProfile/index.js"
47
+ }
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "src",
52
+ "supabase"
53
+ ],
54
+ "dependencies": {
55
+ "@supabase/supabase-js": "^2.104.1",
56
+ "sharp": "^0.34.5",
57
+ "@selvajs/platform": "0.11.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/node": "^22.19.17",
61
+ "supabase": "^2.95.3",
62
+ "typescript": "^5.9.3",
63
+ "vitest": "^3.2.4"
64
+ },
65
+ "scripts": {
66
+ "build": "tsc",
67
+ "dev": "tsc --watch",
68
+ "type-check": "tsc --noEmit",
69
+ "test": "vitest run",
70
+ "test:watch": "vitest",
71
+ "supabase:start": "supabase start",
72
+ "supabase:stop": "supabase stop",
73
+ "supabase:reset": "supabase db reset"
74
+ }
75
+ }
@@ -0,0 +1,464 @@
1
+ import { createClient, type SupabaseClient, type User } from '@supabase/supabase-js';
2
+ import type {
3
+ IAuthProvider,
4
+ IEmailLinkAuth,
5
+ IOAuthAuth,
6
+ IPasswordAuth,
7
+ AuthUser,
8
+ LoginResult,
9
+ UserManagementResult,
10
+ ListOptions,
11
+ Page
12
+ } from '@selvajs/platform';
13
+
14
+ /**
15
+ * Auth backed by Supabase Auth (GoTrue).
16
+ *
17
+ * Identity + session lifecycle are fully delegated:
18
+ * - `verifyLogin` wraps `auth.signInWithPassword` — returns the access_token
19
+ * straight from Supabase as the session token.
20
+ * - `verifyToken` calls `auth.getUser(token)` against an anon client scoped
21
+ * to that bearer; GoTrue validates the JWT for us.
22
+ * - User management uses `auth.admin.*` with the service-role client.
23
+ *
24
+ * Identity-only — platform permissions live on `IPlatformPermissionStore`
25
+ * (`SupabasePlatformPermissionStore` reads `user_profiles.platform_permissions`)
26
+ * and profile state on `IUserProfileStore`.
27
+ */
28
+ export interface SupabaseAuthProviderConfig {
29
+ supabaseUrl: string;
30
+ anonKey: string;
31
+ serviceRoleKey: string;
32
+ /**
33
+ * Whether self-service signup is allowed via `passwordAuth.registerUser`.
34
+ * Default false — production deployments start with invite-only.
35
+ */
36
+ enableSelfSignup?: boolean;
37
+ /**
38
+ * OAuth providers enabled in the Supabase dashboard (lowercased:
39
+ * "google", "github", …). Surfaced via `oauth.listProviders()` so the
40
+ * driving layer doesn't read provider-specific env vars itself. Defaults
41
+ * to an empty list.
42
+ */
43
+ oauthProviders?: readonly string[];
44
+ /**
45
+ * Allow `emailLink.sendMagicLink` to create new users on first request.
46
+ * Default true — matches Supabase's `signInWithOtp` default and gives
47
+ * fresh installs a working signup path. Set false for invite-only
48
+ * deployments; `sendMagicLink` for an unknown email returns
49
+ * `{ ok: false, reason: 'signup_disabled' }`.
50
+ */
51
+ allowEmailLinkSignup?: boolean;
52
+ }
53
+
54
+ export class SupabaseAuthProvider implements IAuthProvider {
55
+ readonly name = 'Supabase Auth';
56
+ readonly passwordAuth: IPasswordAuth;
57
+ readonly oauth: IOAuthAuth;
58
+ readonly emailLink: IEmailLinkAuth;
59
+
60
+ private readonly admin: SupabaseClient;
61
+ private readonly anon: SupabaseClient;
62
+ private readonly anonKey: string;
63
+ private readonly supabaseUrl: string;
64
+
65
+ constructor(config: SupabaseAuthProviderConfig) {
66
+ this.supabaseUrl = config.supabaseUrl;
67
+ this.anonKey = config.anonKey;
68
+ this.admin = createClient(config.supabaseUrl, config.serviceRoleKey, {
69
+ auth: { persistSession: false, autoRefreshToken: false }
70
+ });
71
+ this.anon = createClient(config.supabaseUrl, config.anonKey, {
72
+ auth: { persistSession: false, autoRefreshToken: false }
73
+ });
74
+ this.passwordAuth = new SupabasePasswordAuth(
75
+ this.admin,
76
+ this.anon,
77
+ (email, password) => this.signIn(email, password),
78
+ config.enableSelfSignup ?? false,
79
+ (user) => this.hydrate(user)
80
+ );
81
+ this.oauth = new SupabaseOAuthAuth(
82
+ this.anon,
83
+ (user) => this.hydrate(user),
84
+ config.oauthProviders ?? []
85
+ );
86
+ this.emailLink = new SupabaseEmailLinkAuth(
87
+ this.anon,
88
+ (user) => this.hydrate(user),
89
+ config.allowEmailLinkSignup ?? true
90
+ );
91
+ }
92
+
93
+ static fromEnv(env: Record<string, string | undefined>): SupabaseAuthProvider {
94
+ const supabaseUrl = env.SUPABASE_URL;
95
+ const anonKey = env.SUPABASE_ANON_KEY;
96
+ const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
97
+ if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
98
+ if (!anonKey) throw new Error('Missing required env var: SUPABASE_ANON_KEY');
99
+ if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
100
+ const oauthProviders = (env.SUPABASE_OAUTH_PROVIDERS ?? '')
101
+ .split(',')
102
+ .map((p) => p.trim().toLowerCase())
103
+ .filter((p) => p.length > 0);
104
+ return new SupabaseAuthProvider({
105
+ supabaseUrl,
106
+ anonKey,
107
+ serviceRoleKey,
108
+ enableSelfSignup: env.SUPABASE_ENABLE_SELF_SIGNUP === 'true',
109
+ oauthProviders,
110
+ // Default true; set SUPABASE_ALLOW_EMAIL_LINK_SIGNUP=false to lock down
111
+ // to invite-only and reject magic-link signups for new addresses.
112
+ allowEmailLinkSignup: env.SUPABASE_ALLOW_EMAIL_LINK_SIGNUP !== 'false'
113
+ });
114
+ }
115
+
116
+ async verifyToken(token: string): Promise<AuthUser | null> {
117
+ if (!token) return null;
118
+ // Per-request anon client with the JWT attached. `getUser(token)` also
119
+ // works, but routing through a scoped client keeps future sub-calls
120
+ // (profile fetch, etc.) RLS-aware if we ever move them here.
121
+ const { data, error } = await this.anon.auth.getUser(token);
122
+ if (error || !data.user) return null;
123
+ if (data.user.user_metadata?.disabled === true) return null;
124
+ return this.hydrate(data.user);
125
+ }
126
+
127
+ async getUser(id: string): Promise<AuthUser | null> {
128
+ const { data, error } = await this.admin.auth.admin.getUserById(id);
129
+ if (error || !data.user) return null;
130
+ return this.hydrate(data.user);
131
+ }
132
+
133
+ async listUsers(opts?: ListOptions): Promise<Page<AuthUser> | null> {
134
+ // GoTrue's admin.listUsers uses its own pagination (page/perPage).
135
+ // Translate our cursor (offset) into page number.
136
+ const perPage = opts?.limit ?? 50;
137
+ const page = opts?.cursor ? parseInt(opts.cursor, 10) : 1;
138
+ const { data, error } = await this.admin.auth.admin.listUsers({
139
+ page: Number.isFinite(page) && page >= 1 ? page : 1,
140
+ perPage
141
+ });
142
+ if (error) throw error;
143
+
144
+ const users = data.users;
145
+ const hydrated = users.map((u) => this.hydrate(u));
146
+ // If the page was full, assume there may be more.
147
+ const nextCursor = users.length >= perPage ? String(page + 1) : undefined;
148
+ return { items: hydrated, nextCursor };
149
+ }
150
+
151
+ async createUser(email: string): Promise<AuthUser> {
152
+ // Allowlist variant: create without a password. The user receives a
153
+ // magic-link / OIDC handoff from the consuming app. Identity-only;
154
+ // platform permissions are granted via IPlatformPermissionStore.
155
+ const { data, error } = await this.admin.auth.admin.createUser({
156
+ email,
157
+ email_confirm: true
158
+ });
159
+ if (error) throw error;
160
+ if (!data.user) throw new Error('createUser returned no user');
161
+ return this.hydrate(data.user);
162
+ }
163
+
164
+ async deleteUser(id: string): Promise<UserManagementResult> {
165
+ // Identity-only delete. The §2 sole-`instance_admin` invariant lives
166
+ // on IPlatformPermissionStore; callers consult it before calling here.
167
+ const { error } = await this.admin.auth.admin.deleteUser(id);
168
+ if (error) {
169
+ // 404-shaped errors from GoTrue surface as { status: 404 } or a
170
+ // specific message. Normalize to 'not_found' rather than throwing.
171
+ const e = error as unknown as { status?: number; message?: string };
172
+ if (e.status === 404 || /not.?found/i.test(e.message ?? '')) return 'not_found';
173
+ throw error;
174
+ }
175
+ return 'ok';
176
+ }
177
+
178
+ async disableUser(id: string): Promise<UserManagementResult> {
179
+ const { data: existing, error: fetchError } = await this.admin.auth.admin.getUserById(id);
180
+ if (fetchError || !existing.user) return 'not_found';
181
+ // GoTrue's user_metadata update merges shallowly — preserve existing
182
+ // fields by spreading them in.
183
+ const mergedMetadata = {
184
+ ...(existing.user.user_metadata ?? {}),
185
+ disabled: true
186
+ };
187
+ const { error } = await this.admin.auth.admin.updateUserById(id, {
188
+ user_metadata: mergedMetadata
189
+ });
190
+ if (error) {
191
+ const e = error as unknown as { status?: number; message?: string };
192
+ if (e.status === 404 || /not.?found/i.test(e.message ?? '')) return 'not_found';
193
+ throw error;
194
+ }
195
+ return 'ok';
196
+ }
197
+
198
+ async touchLastLogin(id: string): Promise<void> {
199
+ // 60-second debounce. Matches the local provider.
200
+ const { data, error } = await this.admin
201
+ .from('user_profiles')
202
+ .select('last_login_at')
203
+ .eq('user_id', id)
204
+ .maybeSingle();
205
+ if (error) return;
206
+
207
+ if (data?.last_login_at) {
208
+ const prev = Date.parse(data.last_login_at);
209
+ if (Number.isFinite(prev) && Date.now() - prev < 60_000) return;
210
+ }
211
+ await this.admin
212
+ .from('user_profiles')
213
+ .update({ last_login_at: new Date().toISOString() })
214
+ .eq('user_id', id);
215
+ }
216
+
217
+ // OAuth lives on `this.oauth` (typed `IOAuthAuth`); see `SupabaseOAuthAuth`
218
+ // below. Mirrors the `passwordAuth` capability split.
219
+
220
+ // ============================================================================
221
+ // Internals
222
+ // ============================================================================
223
+ /**
224
+ * Sign in via GoTrue. Returns the access token string and the hydrated
225
+ * user. Called by `SupabasePasswordAuth.verifyLogin` — kept on the
226
+ * provider class so it has access to the hydrate helper without plumbing.
227
+ */
228
+ private async signIn(
229
+ email: string,
230
+ password: string
231
+ ): Promise<{ user: AuthUser; sessionToken: string } | null> {
232
+ const { data, error } = await this.anon.auth.signInWithPassword({ email, password });
233
+ if (error || !data.user || !data.session) return null;
234
+ if (data.user.user_metadata?.disabled === true) return null;
235
+ return { user: this.hydrate(data.user), sessionToken: data.session.access_token };
236
+ }
237
+
238
+ /**
239
+ * Map a GoTrue `User` to our identity-only `AuthUser`. Platform permissions
240
+ * live on `IPlatformPermissionStore` and profile fields (displayName,
241
+ * starred, recentRuns) on `IUserProfileStore` — neither is in AuthUser.
242
+ */
243
+ private hydrate(user: User): AuthUser {
244
+ return {
245
+ id: user.id,
246
+ email: user.email ?? undefined,
247
+ createdAt: user.created_at ?? undefined,
248
+ lastLoginAt: user.last_sign_in_at ?? undefined,
249
+ disabled: user.user_metadata?.disabled === true,
250
+ metadata: user.user_metadata
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Expose the anon URL + key so other code (e.g. the same process's
256
+ * storage provider or per-request client factories) can build user-scoped
257
+ * clients without requiring a second config read.
258
+ */
259
+ getAnonClientConfig(): { supabaseUrl: string; anonKey: string } {
260
+ return { supabaseUrl: this.supabaseUrl, anonKey: this.anonKey };
261
+ }
262
+ }
263
+
264
+ class SupabasePasswordAuth implements IPasswordAuth {
265
+ constructor(
266
+ private readonly admin: SupabaseClient,
267
+ private readonly anon: SupabaseClient,
268
+ private readonly signIn: (
269
+ email: string,
270
+ password: string
271
+ ) => Promise<{ user: AuthUser; sessionToken: string } | null>,
272
+ private readonly enableSelfSignup: boolean,
273
+ private readonly hydrate: (user: User) => AuthUser
274
+ ) {}
275
+
276
+ async verifyLogin(email: string, password: string): Promise<LoginResult> {
277
+ const result = await this.signIn(email, password);
278
+ if (!result) return { kind: 'failed', reason: 'invalid_credentials' };
279
+ return { kind: 'success', user: result.user, sessionToken: result.sessionToken };
280
+ }
281
+
282
+ async createUserWithPassword(email: string, password: string): Promise<AuthUser> {
283
+ // Identity-only — platform permissions are granted separately via
284
+ // IPlatformPermissionStore.set after creation.
285
+ const { data, error } = await this.admin.auth.admin.createUser({
286
+ email,
287
+ password,
288
+ email_confirm: true
289
+ });
290
+ if (error) throw error;
291
+ if (!data.user) throw new Error('createUserWithPassword returned no user');
292
+ return this.hydrate(data.user);
293
+ }
294
+
295
+ async registerUser(email: string, password: string): Promise<AuthUser | null> {
296
+ if (!this.enableSelfSignup) return null;
297
+ const { data, error } = await this.anon.auth.signUp({ email, password });
298
+ if (error || !data.user) return null;
299
+ return this.hydrate(data.user);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Supabase-backed `IOAuthAuth`. Thin wrappers over GoTrue's `signInWithOAuth`
305
+ * / `exchangeCodeForSession` / `refreshSession` — the failure-shape mapping
306
+ * (return null vs throw) is the only logic.
307
+ */
308
+ class SupabaseOAuthAuth implements IOAuthAuth {
309
+ constructor(
310
+ private readonly anon: SupabaseClient,
311
+ private readonly hydrate: (user: User) => AuthUser,
312
+ private readonly providers: readonly string[]
313
+ ) {}
314
+
315
+ listProviders(): readonly string[] {
316
+ return this.providers;
317
+ }
318
+
319
+ async getOAuthAuthorizationUrl(
320
+ provider: 'google' | 'github' | 'azure' | 'gitlab',
321
+ redirectTo: string
322
+ ): Promise<string> {
323
+ const { data, error } = await this.anon.auth.signInWithOAuth({
324
+ provider,
325
+ options: { redirectTo, skipBrowserRedirect: true }
326
+ });
327
+ if (error || !data.url) throw error ?? new Error('signInWithOAuth returned no URL');
328
+ return data.url;
329
+ }
330
+
331
+ async exchangeOAuthCode(code: string): Promise<{
332
+ user: AuthUser;
333
+ sessionToken: string;
334
+ refreshToken: string;
335
+ } | null> {
336
+ const { data, error } = await this.anon.auth.exchangeCodeForSession(code);
337
+ if (error || !data.user || !data.session) return null;
338
+ if (data.user.user_metadata?.disabled === true) return null;
339
+ return {
340
+ user: this.hydrate(data.user),
341
+ sessionToken: data.session.access_token,
342
+ refreshToken: data.session.refresh_token
343
+ };
344
+ }
345
+
346
+ async refreshSession(refreshToken: string): Promise<{
347
+ sessionToken: string;
348
+ refreshToken: string;
349
+ } | null> {
350
+ const { data, error } = await this.anon.auth.refreshSession({ refresh_token: refreshToken });
351
+ if (error || !data.session) return null;
352
+ return {
353
+ sessionToken: data.session.access_token,
354
+ refreshToken: data.session.refresh_token
355
+ };
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Supabase-backed `IEmailLinkAuth`. Wraps GoTrue's `signInWithOtp` (send the
361
+ * link) and `verifyOtp` with `type: 'magiclink' | 'email' | 'signup'`
362
+ * (verify on click).
363
+ *
364
+ * Token shape: Supabase emails the user `{callbackUrl}?token_hash=…&type=…`.
365
+ * `verifyMagicLink` accepts either the full URL or just the raw `token_hash`
366
+ * — adapters MAY support either to give the route layer flexibility. The
367
+ * `type` query param decides which OTP variant `verifyOtp` runs (signup vs
368
+ * magic-link returning user vs invite acceptance), so we pass it through.
369
+ */
370
+ class SupabaseEmailLinkAuth implements IEmailLinkAuth {
371
+ constructor(
372
+ private readonly anon: SupabaseClient,
373
+ private readonly hydrate: (user: User) => AuthUser,
374
+ private readonly allowSignup: boolean
375
+ ) {}
376
+
377
+ async sendMagicLink(
378
+ email: string,
379
+ callbackUrl: string
380
+ ): Promise<
381
+ { ok: true } | { ok: false; reason: 'rate_limited' | 'signup_disabled' | 'invalid_email' }
382
+ > {
383
+ const { error } = await this.anon.auth.signInWithOtp({
384
+ email,
385
+ options: {
386
+ emailRedirectTo: callbackUrl,
387
+ shouldCreateUser: this.allowSignup
388
+ }
389
+ });
390
+ if (!error) return { ok: true };
391
+
392
+ // Map GoTrue's classified errors to our coarse reasons. Anything we
393
+ // don't recognize we throw — the route returns 500 and we get a stack
394
+ // trace, instead of swallowing a real bug as "rate limited".
395
+ const status = (error as { status?: number }).status;
396
+ const code = (error as { code?: string }).code;
397
+ const message = error.message ?? '';
398
+ if (status === 429 || /rate.?limit/i.test(message))
399
+ return { ok: false, reason: 'rate_limited' };
400
+ if (
401
+ code === 'otp_disabled' ||
402
+ code === 'signup_disabled' ||
403
+ /signup.*disabled/i.test(message)
404
+ ) {
405
+ return { ok: false, reason: 'signup_disabled' };
406
+ }
407
+ if (code === 'validation_failed' || /invalid.*email|email.*invalid/i.test(message)) {
408
+ return { ok: false, reason: 'invalid_email' };
409
+ }
410
+ throw error;
411
+ }
412
+
413
+ async verifyMagicLink(rawCallbackUrl: string): Promise<{
414
+ user: AuthUser;
415
+ sessionToken: string;
416
+ refreshToken?: string;
417
+ } | null> {
418
+ const params = parseCallbackParams(rawCallbackUrl);
419
+ if (!params) return null;
420
+ const { tokenHash, type } = params;
421
+
422
+ const { data, error } = await this.anon.auth.verifyOtp({
423
+ token_hash: tokenHash,
424
+ type
425
+ });
426
+ if (error || !data.user || !data.session) return null;
427
+ if (data.user.user_metadata?.disabled === true) return null;
428
+ return {
429
+ user: this.hydrate(data.user),
430
+ sessionToken: data.session.access_token,
431
+ refreshToken: data.session.refresh_token
432
+ };
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Pull `token_hash` + `type` from either a full callback URL or a bare
438
+ * `token_hash=…&type=…` query string. Tolerant of both shapes so the route
439
+ * layer can pass whichever form is convenient.
440
+ *
441
+ * Returns null when the inputs aren't recognizable — the caller treats null
442
+ * as "send the user back to /login".
443
+ */
444
+ function parseCallbackParams(
445
+ raw: string
446
+ ): { tokenHash: string; type: 'magiclink' | 'email' | 'signup' | 'invite' | 'recovery' } | null {
447
+ let search: URLSearchParams;
448
+ try {
449
+ // Full URL path
450
+ search = new URL(raw).searchParams;
451
+ } catch {
452
+ // Query-string-only path — treat as bare params.
453
+ search = new URLSearchParams(raw.startsWith('?') ? raw.slice(1) : raw);
454
+ }
455
+ const tokenHash = search.get('token_hash');
456
+ const type = search.get('type');
457
+ if (!tokenHash) return null;
458
+ const allowed = new Set(['magiclink', 'email', 'signup', 'invite', 'recovery']);
459
+ if (!type || !allowed.has(type)) return null;
460
+ return {
461
+ tokenHash,
462
+ type: type as 'magiclink' | 'email' | 'signup' | 'invite' | 'recovery'
463
+ };
464
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, beforeEach, it } from 'vitest';
2
+ import { runAuthProviderConformance, runEmailLinkAuthConformance } from '@selvajs/platform/testing';
3
+ import { SupabaseAuthProvider } from '../SupabaseAuthProvider.js';
4
+ import { readEnv, resetAllData } from '../../data/__tests__/test-helpers.js';
5
+
6
+ const envCtx = readEnv();
7
+
8
+ if (!envCtx) {
9
+ describe.skip('SupabaseAuthProvider (skipped: no live stack)', () => {
10
+ it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => {});
11
+ });
12
+ } else {
13
+ const ctx = envCtx; // non-null in this branch
14
+ // The auth conformance suite expects `verifyLogin('', adminPassword)` to
15
+ // succeed. Supabase requires a non-empty email, so we seed a deterministic
16
+ // admin email and have the suite use that via the `adminEmail` option.
17
+ const ADMIN_EMAIL = 'admin@conformance.test';
18
+ const ADMIN_PASSWORD = 'conformance-admin-password-1234';
19
+
20
+ async function ensureAdmin() {
21
+ const { data: existing } = await ctx.adminClient.auth.admin.listUsers({ perPage: 1000 });
22
+ const match = existing.users.find((u) => u.email === ADMIN_EMAIL);
23
+ if (match) return match.id;
24
+ const { data, error } = await ctx.adminClient.auth.admin.createUser({
25
+ email: ADMIN_EMAIL,
26
+ password: ADMIN_PASSWORD,
27
+ email_confirm: true
28
+ });
29
+ if (error) throw error;
30
+ return data.user.id;
31
+ }
32
+
33
+ async function makeProvider() {
34
+ const provider = new SupabaseAuthProvider({
35
+ supabaseUrl: ctx.url,
36
+ anonKey: ctx.anonKey,
37
+ serviceRoleKey: ctx.serviceRoleKey
38
+ });
39
+ // Ensure the admin account exists + has instance_admin.
40
+ const id = await ensureAdmin();
41
+ const { error: upError } = await ctx.adminClient
42
+ .from('user_profiles')
43
+ .update({ platform_permissions: ['instance_admin'] })
44
+ .eq('user_id', id);
45
+ if (upError) throw upError;
46
+ return { provider, adminPassword: ADMIN_PASSWORD };
47
+ }
48
+
49
+ describe('SupabaseAuthProvider', () => {
50
+ beforeEach(async () => {
51
+ await resetAllData(ctx);
52
+ });
53
+
54
+ runAuthProviderConformance({
55
+ name: 'SupabaseAuthProvider',
56
+ createProvider: async () => {
57
+ const { provider, adminPassword } = await makeProvider();
58
+ return { provider, adminPassword, adminEmail: ADMIN_EMAIL };
59
+ },
60
+ userManagement: true
61
+ });
62
+
63
+ runEmailLinkAuthConformance({
64
+ name: 'SupabaseEmailLinkAuth',
65
+ createAdapter: async () => {
66
+ const { provider } = await makeProvider();
67
+ return {
68
+ adapter: provider.emailLink,
69
+ // `validEmail` isn't used by the suite for live mail delivery;
70
+ // it just needs to look like a real address. The malformed
71
+ // case is what we actually assert on.
72
+ validEmail: 'real-looking@conformance.test',
73
+ invalidEmail: 'not-an-email-address'
74
+ };
75
+ }
76
+ });
77
+ });
78
+ }
@@ -0,0 +1,2 @@
1
+ export { SupabaseAuthProvider } from './SupabaseAuthProvider.js';
2
+ export type { SupabaseAuthProviderConfig } from './SupabaseAuthProvider.js';