@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.
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/auth/SupabaseAuthProvider.d.ts +81 -0
- package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -0
- package/dist/auth/SupabaseAuthProvider.js +383 -0
- package/dist/auth/SupabaseAuthProvider.js.map +1 -0
- package/dist/auth/__tests__/conformance.test.d.ts +2 -0
- package/dist/auth/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/auth/__tests__/conformance.test.js +76 -0
- package/dist/auth/__tests__/conformance.test.js.map +1 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +2 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/data/SupabaseAuditQuery.d.ts +16 -0
- package/dist/data/SupabaseAuditQuery.d.ts.map +1 -0
- package/dist/data/SupabaseAuditQuery.js +83 -0
- package/dist/data/SupabaseAuditQuery.js.map +1 -0
- package/dist/data/SupabaseComputeServerStore.d.ts +26 -0
- package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -0
- package/dist/data/SupabaseComputeServerStore.js +227 -0
- package/dist/data/SupabaseComputeServerStore.js.map +1 -0
- package/dist/data/SupabaseDataProvider.d.ts +55 -0
- package/dist/data/SupabaseDataProvider.d.ts.map +1 -0
- package/dist/data/SupabaseDataProvider.js +114 -0
- package/dist/data/SupabaseDataProvider.js.map +1 -0
- package/dist/data/SupabaseDefinitionStore.d.ts +39 -0
- package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -0
- package/dist/data/SupabaseDefinitionStore.js +419 -0
- package/dist/data/SupabaseDefinitionStore.js.map +1 -0
- package/dist/data/SupabaseEventSink.d.ts +17 -0
- package/dist/data/SupabaseEventSink.d.ts.map +1 -0
- package/dist/data/SupabaseEventSink.js +31 -0
- package/dist/data/SupabaseEventSink.js.map +1 -0
- package/dist/data/SupabaseInviteStore.d.ts +22 -0
- package/dist/data/SupabaseInviteStore.d.ts.map +1 -0
- package/dist/data/SupabaseInviteStore.js +159 -0
- package/dist/data/SupabaseInviteStore.js.map +1 -0
- package/dist/data/SupabaseOrgStore.d.ts +33 -0
- package/dist/data/SupabaseOrgStore.d.ts.map +1 -0
- package/dist/data/SupabaseOrgStore.js +455 -0
- package/dist/data/SupabaseOrgStore.js.map +1 -0
- package/dist/data/SupabaseProjectStore.d.ts +29 -0
- package/dist/data/SupabaseProjectStore.d.ts.map +1 -0
- package/dist/data/SupabaseProjectStore.js +328 -0
- package/dist/data/SupabaseProjectStore.js.map +1 -0
- package/dist/data/SupabaseShareLinkStore.d.ts +26 -0
- package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -0
- package/dist/data/SupabaseShareLinkStore.js +150 -0
- package/dist/data/SupabaseShareLinkStore.js.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.js +40 -0
- package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.js +85 -0
- package/dist/data/__tests__/definition-conformance.test.js.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.js +27 -0
- package/dist/data/__tests__/event-sink-conformance.test.js.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.js +45 -0
- package/dist/data/__tests__/invite-conformance.test.js.map +1 -0
- package/dist/data/__tests__/org-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/org-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/org-conformance.test.js +29 -0
- package/dist/data/__tests__/org-conformance.test.js.map +1 -0
- package/dist/data/__tests__/project-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/project-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/project-conformance.test.js +57 -0
- package/dist/data/__tests__/project-conformance.test.js.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.js +88 -0
- package/dist/data/__tests__/share-link-conformance.test.js.map +1 -0
- package/dist/data/__tests__/test-helpers.d.ts +53 -0
- package/dist/data/__tests__/test-helpers.d.ts.map +1 -0
- package/dist/data/__tests__/test-helpers.js +175 -0
- package/dist/data/__tests__/test-helpers.js.map +1 -0
- package/dist/data/client.d.ts +44 -0
- package/dist/data/client.d.ts.map +1 -0
- package/dist/data/client.js +40 -0
- package/dist/data/client.js.map +1 -0
- package/dist/data/index.d.ts +12 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +11 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/pagination.d.ts +30 -0
- package/dist/data/pagination.d.ts.map +1 -0
- package/dist/data/pagination.js +53 -0
- package/dist/data/pagination.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts +25 -0
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -0
- package/dist/permissions/SupabasePlatformPermissionStore.js +153 -0
- package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -0
- package/dist/permissions/__tests__/conformance.test.d.ts +2 -0
- package/dist/permissions/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/permissions/__tests__/conformance.test.js +27 -0
- package/dist/permissions/__tests__/conformance.test.js.map +1 -0
- package/dist/permissions/index.d.ts +2 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +2 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/storage/SupabaseStorageProvider.d.ts +67 -0
- package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -0
- package/dist/storage/SupabaseStorageProvider.js +136 -0
- package/dist/storage/SupabaseStorageProvider.js.map +1 -0
- package/dist/storage/__tests__/conformance.test.d.ts +2 -0
- package/dist/storage/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/storage/__tests__/conformance.test.js +149 -0
- package/dist/storage/__tests__/conformance.test.js.map +1 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/userProfile/SupabaseUserProfileProvider.d.ts +16 -0
- package/dist/userProfile/SupabaseUserProfileProvider.d.ts.map +1 -0
- package/dist/userProfile/SupabaseUserProfileProvider.js +148 -0
- package/dist/userProfile/SupabaseUserProfileProvider.js.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts +2 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.js +38 -0
- package/dist/userProfile/__tests__/conformance.test.js.map +1 -0
- package/dist/userProfile/index.d.ts +2 -0
- package/dist/userProfile/index.d.ts.map +1 -0
- package/dist/userProfile/index.js +2 -0
- package/dist/userProfile/index.js.map +1 -0
- package/package.json +75 -0
- package/src/auth/SupabaseAuthProvider.ts +464 -0
- package/src/auth/__tests__/conformance.test.ts +78 -0
- package/src/auth/index.ts +2 -0
- package/src/data/SupabaseAuditQuery.ts +103 -0
- package/src/data/SupabaseComputeServerStore.ts +262 -0
- package/src/data/SupabaseDataProvider.ts +125 -0
- package/src/data/SupabaseDefinitionStore.ts +504 -0
- package/src/data/SupabaseEventSink.ts +30 -0
- package/src/data/SupabaseInviteStore.ts +183 -0
- package/src/data/SupabaseOrgStore.ts +547 -0
- package/src/data/SupabaseProjectStore.ts +414 -0
- package/src/data/SupabaseShareLinkStore.ts +191 -0
- package/src/data/__tests__/compute-server-conformance.test.ts +40 -0
- package/src/data/__tests__/definition-conformance.test.ts +89 -0
- package/src/data/__tests__/event-sink-conformance.test.ts +32 -0
- package/src/data/__tests__/invite-conformance.test.ts +46 -0
- package/src/data/__tests__/org-conformance.test.ts +30 -0
- package/src/data/__tests__/project-conformance.test.ts +57 -0
- package/src/data/__tests__/share-link-conformance.test.ts +87 -0
- package/src/data/__tests__/test-helpers.ts +186 -0
- package/src/data/client.ts +86 -0
- package/src/data/index.ts +11 -0
- package/src/data/pagination.ts +68 -0
- package/src/index.ts +23 -0
- package/src/permissions/SupabasePlatformPermissionStore.ts +161 -0
- package/src/permissions/__tests__/conformance.test.ts +28 -0
- package/src/permissions/index.ts +1 -0
- package/src/storage/SupabaseStorageProvider.ts +177 -0
- package/src/storage/__tests__/conformance.test.ts +152 -0
- package/src/storage/index.ts +2 -0
- package/src/userProfile/SupabaseUserProfileProvider.ts +177 -0
- package/src/userProfile/__tests__/conformance.test.ts +39 -0
- package/src/userProfile/index.ts +1 -0
- package/supabase/.branches/_current_branch +1 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/config.toml +68 -0
- package/supabase/migrations/0001_initial.sql +1084 -0
- 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 @@
|
|
|
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 @@
|
|
|
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
|
+
}
|