@selvajs/supabase-provider 0.14.4-beta.0 → 0.15.0-beta.2

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 (158) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
  4. package/dist/auth/SupabaseAuthProvider.js +7 -14
  5. package/dist/auth/SupabaseAuthProvider.js.map +1 -1
  6. package/dist/data/SupabaseComputeServerStore.d.ts +14 -3
  7. package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
  8. package/dist/data/SupabaseComputeServerStore.js +67 -16
  9. package/dist/data/SupabaseComputeServerStore.js.map +1 -1
  10. package/dist/data/SupabaseDataProvider.d.ts +36 -8
  11. package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
  12. package/dist/data/SupabaseDataProvider.js +119 -15
  13. package/dist/data/SupabaseDataProvider.js.map +1 -1
  14. package/dist/data/SupabaseDefinitionStore.d.ts +1 -0
  15. package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
  16. package/dist/data/SupabaseDefinitionStore.js +41 -4
  17. package/dist/data/SupabaseDefinitionStore.js.map +1 -1
  18. package/dist/data/SupabaseEventSink.d.ts +5 -2
  19. package/dist/data/SupabaseEventSink.d.ts.map +1 -1
  20. package/dist/data/SupabaseEventSink.js +20 -3
  21. package/dist/data/SupabaseEventSink.js.map +1 -1
  22. package/dist/data/SupabaseSolveMetricSink.d.ts +49 -4
  23. package/dist/data/SupabaseSolveMetricSink.d.ts.map +1 -1
  24. package/dist/data/SupabaseSolveMetricSink.js +109 -19
  25. package/dist/data/SupabaseSolveMetricSink.js.map +1 -1
  26. package/dist/data/index.d.ts +1 -0
  27. package/dist/data/index.d.ts.map +1 -1
  28. package/dist/data/index.js +1 -0
  29. package/dist/data/index.js.map +1 -1
  30. package/dist/data/migrationHead.d.ts +14 -0
  31. package/dist/data/migrationHead.d.ts.map +1 -0
  32. package/dist/data/migrationHead.js +14 -0
  33. package/dist/data/migrationHead.js.map +1 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/permissions/SupabasePlatformPermissionStore.d.ts +5 -3
  39. package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -1
  40. package/dist/permissions/SupabasePlatformPermissionStore.js +17 -30
  41. package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -1
  42. package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -1
  43. package/dist/storage/SupabaseStorageProvider.js +3 -0
  44. package/dist/storage/SupabaseStorageProvider.js.map +1 -1
  45. package/package.json +23 -14
  46. package/supabase/migrations/20260712120000_user_profiles_disabled_and_gin.sql +95 -0
  47. package/supabase/migrations/20260712123000_selva_migration_head_fn.sql +38 -0
  48. package/supabase/migrations/20260713120000_selva_next_version_number.sql +59 -0
  49. package/supabase/migrations/20260713140000_selva_solve_cache_limit.sql +15 -0
  50. package/supabase/migrations/20260716120000_definition_status_and_audit_version.sql +36 -0
  51. package/supabase/migrations/20260716130000_erase_user_audit_email.sql +36 -0
  52. package/supabase/migrations/20260717120000_compute_server_has_api_key.sql +20 -0
  53. package/dist/auth/__tests__/conformance.test.d.ts +0 -2
  54. package/dist/auth/__tests__/conformance.test.d.ts.map +0 -1
  55. package/dist/auth/__tests__/conformance.test.js +0 -76
  56. package/dist/auth/__tests__/conformance.test.js.map +0 -1
  57. package/dist/auth/__tests__/token-verification.test.d.ts +0 -13
  58. package/dist/auth/__tests__/token-verification.test.d.ts.map +0 -1
  59. package/dist/auth/__tests__/token-verification.test.js +0 -135
  60. package/dist/auth/__tests__/token-verification.test.js.map +0 -1
  61. package/dist/data/__tests__/compute-server-conformance.test.d.ts +0 -2
  62. package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +0 -1
  63. package/dist/data/__tests__/compute-server-conformance.test.js +0 -45
  64. package/dist/data/__tests__/compute-server-conformance.test.js.map +0 -1
  65. package/dist/data/__tests__/compute-server-encryption.test.d.ts +0 -12
  66. package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +0 -1
  67. package/dist/data/__tests__/compute-server-encryption.test.js +0 -143
  68. package/dist/data/__tests__/compute-server-encryption.test.js.map +0 -1
  69. package/dist/data/__tests__/definition-conformance.test.d.ts +0 -2
  70. package/dist/data/__tests__/definition-conformance.test.d.ts.map +0 -1
  71. package/dist/data/__tests__/definition-conformance.test.js +0 -85
  72. package/dist/data/__tests__/definition-conformance.test.js.map +0 -1
  73. package/dist/data/__tests__/event-sink-conformance.test.d.ts +0 -2
  74. package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +0 -1
  75. package/dist/data/__tests__/event-sink-conformance.test.js +0 -27
  76. package/dist/data/__tests__/event-sink-conformance.test.js.map +0 -1
  77. package/dist/data/__tests__/invite-conformance.test.d.ts +0 -2
  78. package/dist/data/__tests__/invite-conformance.test.d.ts.map +0 -1
  79. package/dist/data/__tests__/invite-conformance.test.js +0 -45
  80. package/dist/data/__tests__/invite-conformance.test.js.map +0 -1
  81. package/dist/data/__tests__/org-conformance.test.d.ts +0 -2
  82. package/dist/data/__tests__/org-conformance.test.d.ts.map +0 -1
  83. package/dist/data/__tests__/org-conformance.test.js +0 -29
  84. package/dist/data/__tests__/org-conformance.test.js.map +0 -1
  85. package/dist/data/__tests__/project-conformance.test.d.ts +0 -2
  86. package/dist/data/__tests__/project-conformance.test.d.ts.map +0 -1
  87. package/dist/data/__tests__/project-conformance.test.js +0 -57
  88. package/dist/data/__tests__/project-conformance.test.js.map +0 -1
  89. package/dist/data/__tests__/share-link-concurrency.test.d.ts +0 -22
  90. package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +0 -1
  91. package/dist/data/__tests__/share-link-concurrency.test.js +0 -115
  92. package/dist/data/__tests__/share-link-concurrency.test.js.map +0 -1
  93. package/dist/data/__tests__/share-link-conformance.test.d.ts +0 -2
  94. package/dist/data/__tests__/share-link-conformance.test.d.ts.map +0 -1
  95. package/dist/data/__tests__/share-link-conformance.test.js +0 -88
  96. package/dist/data/__tests__/share-link-conformance.test.js.map +0 -1
  97. package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts +0 -2
  98. package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts.map +0 -1
  99. package/dist/data/__tests__/solve-metric-sink-conformance.test.js +0 -52
  100. package/dist/data/__tests__/solve-metric-sink-conformance.test.js.map +0 -1
  101. package/dist/data/__tests__/test-helpers.d.ts +0 -52
  102. package/dist/data/__tests__/test-helpers.d.ts.map +0 -1
  103. package/dist/data/__tests__/test-helpers.js +0 -177
  104. package/dist/data/__tests__/test-helpers.js.map +0 -1
  105. package/dist/permissions/__tests__/conformance.test.d.ts +0 -2
  106. package/dist/permissions/__tests__/conformance.test.d.ts.map +0 -1
  107. package/dist/permissions/__tests__/conformance.test.js +0 -27
  108. package/dist/permissions/__tests__/conformance.test.js.map +0 -1
  109. package/dist/storage/__tests__/conformance.test.d.ts +0 -2
  110. package/dist/storage/__tests__/conformance.test.d.ts.map +0 -1
  111. package/dist/storage/__tests__/conformance.test.js +0 -149
  112. package/dist/storage/__tests__/conformance.test.js.map +0 -1
  113. package/dist/userProfile/__tests__/conformance.test.d.ts +0 -2
  114. package/dist/userProfile/__tests__/conformance.test.d.ts.map +0 -1
  115. package/dist/userProfile/__tests__/conformance.test.js +0 -38
  116. package/dist/userProfile/__tests__/conformance.test.js.map +0 -1
  117. package/src/auth/SupabaseAuthProvider.ts +0 -600
  118. package/src/auth/__tests__/conformance.test.ts +0 -78
  119. package/src/auth/__tests__/token-verification.test.ts +0 -158
  120. package/src/auth/index.ts +0 -2
  121. package/src/cli/sync-migrations.ts +0 -133
  122. package/src/data/SupabaseAuditQuery.ts +0 -103
  123. package/src/data/SupabaseComputeServerStore.ts +0 -382
  124. package/src/data/SupabaseDataProvider.ts +0 -153
  125. package/src/data/SupabaseDefinitionStore.ts +0 -527
  126. package/src/data/SupabaseEventSink.ts +0 -30
  127. package/src/data/SupabaseInviteStore.ts +0 -171
  128. package/src/data/SupabaseOrgStore.ts +0 -508
  129. package/src/data/SupabaseProjectStore.ts +0 -431
  130. package/src/data/SupabaseShareLinkStore.ts +0 -176
  131. package/src/data/SupabaseSolveMetricSink.ts +0 -38
  132. package/src/data/__tests__/compute-server-conformance.test.ts +0 -46
  133. package/src/data/__tests__/compute-server-encryption.test.ts +0 -173
  134. package/src/data/__tests__/definition-conformance.test.ts +0 -89
  135. package/src/data/__tests__/event-sink-conformance.test.ts +0 -32
  136. package/src/data/__tests__/invite-conformance.test.ts +0 -46
  137. package/src/data/__tests__/org-conformance.test.ts +0 -30
  138. package/src/data/__tests__/project-conformance.test.ts +0 -57
  139. package/src/data/__tests__/share-link-concurrency.test.ts +0 -124
  140. package/src/data/__tests__/share-link-conformance.test.ts +0 -87
  141. package/src/data/__tests__/solve-metric-sink-conformance.test.ts +0 -59
  142. package/src/data/__tests__/test-helpers.ts +0 -188
  143. package/src/data/client.ts +0 -173
  144. package/src/data/errors.ts +0 -45
  145. package/src/data/index.ts +0 -29
  146. package/src/data/pagination.ts +0 -68
  147. package/src/data/query.ts +0 -22
  148. package/src/data/rowStamp.ts +0 -48
  149. package/src/index.ts +0 -42
  150. package/src/permissions/SupabasePlatformPermissionStore.ts +0 -161
  151. package/src/permissions/__tests__/conformance.test.ts +0 -28
  152. package/src/permissions/index.ts +0 -1
  153. package/src/storage/SupabaseStorageProvider.ts +0 -182
  154. package/src/storage/__tests__/conformance.test.ts +0 -152
  155. package/src/storage/index.ts +0 -2
  156. package/src/userProfile/SupabaseUserProfileProvider.ts +0 -177
  157. package/src/userProfile/__tests__/conformance.test.ts +0 -39
  158. package/src/userProfile/index.ts +0 -1
@@ -1,38 +0,0 @@
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
@@ -1 +0,0 @@
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"}
@@ -1,600 +0,0 @@
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
- * How `verifyToken` validates session JWTs (see the method for the full
54
- * rationale):
55
- * - `'hybrid'` (default): verify the JWT LOCALLY via `getClaims()` on every
56
- * request (no network call when the project uses asymmetric signing keys —
57
- * the modern default), and additionally re-check against GoTrue at most
58
- * once per `revalidateMs` per session to catch server-side sign-outs and
59
- * `disabled` flips. Bounds revocation latency to `revalidateMs`.
60
- * - `'strict'`: call `getUser()` on every request (the pre-1b behavior).
61
- * Instant revocation, one network round-trip per request. For operators
62
- * who don't accept any revocation lag.
63
- */
64
- tokenVerification?: 'hybrid' | 'strict';
65
- /**
66
- * Recheck window for `'hybrid'` verification, in ms. A verified session is
67
- * re-validated against GoTrue at most once per this interval. Default 60s.
68
- */
69
- revalidateMs?: number;
70
- }
71
-
72
- /** Default hybrid recheck window — see `revalidateMs`. */
73
- const DEFAULT_REVALIDATE_MS = 60_000;
74
-
75
- /** Parse a positive integer env var, or undefined if unset/invalid. */
76
- function parsePositiveInt(raw: string | undefined): number | undefined {
77
- if (!raw) return undefined;
78
- const n = Number.parseInt(raw, 10);
79
- return Number.isFinite(n) && n > 0 ? n : undefined;
80
- }
81
-
82
- export class SupabaseAuthProvider implements IAuthProvider {
83
- readonly name = 'Supabase Auth';
84
- readonly passwordAuth: IPasswordAuth;
85
- readonly oauth: IOAuthAuth;
86
- readonly emailLink: IEmailLinkAuth;
87
-
88
- private readonly admin: SupabaseClient;
89
- private readonly anon: SupabaseClient;
90
- private readonly anonKey: string;
91
- private readonly supabaseUrl: string;
92
- private readonly tokenVerification: 'hybrid' | 'strict';
93
- private readonly revalidateMs: number;
94
-
95
- /**
96
- * Last time a given session was re-validated against GoTrue (epoch ms),
97
- * keyed by the JWT `session_id`. Bounds the hybrid recheck to once per
98
- * `revalidateMs` per session. Swept lazily (see `verifyToken`) so a burst of
99
- * distinct sessions can't leak memory. Per-process; multi-instance drift is
100
- * harmless (each instance rechecks on its own schedule).
101
- */
102
- private readonly lastRevalidatedAt = new Map<string, number>();
103
-
104
- constructor(config: SupabaseAuthProviderConfig) {
105
- this.supabaseUrl = config.supabaseUrl;
106
- this.anonKey = config.anonKey;
107
- this.tokenVerification = config.tokenVerification ?? 'hybrid';
108
- this.revalidateMs = config.revalidateMs ?? DEFAULT_REVALIDATE_MS;
109
- this.admin = createClient(config.supabaseUrl, config.serviceRoleKey, {
110
- auth: { persistSession: false, autoRefreshToken: false }
111
- });
112
- this.anon = createClient(config.supabaseUrl, config.anonKey, {
113
- auth: { persistSession: false, autoRefreshToken: false }
114
- });
115
- this.passwordAuth = new SupabasePasswordAuth(
116
- this.admin,
117
- this.anon,
118
- (email, password) => this.signIn(email, password),
119
- config.enableSelfSignup ?? false,
120
- (user) => this.hydrate(user)
121
- );
122
- this.oauth = new SupabaseOAuthAuth(
123
- this.anon,
124
- (user) => this.hydrate(user),
125
- config.oauthProviders ?? []
126
- );
127
- this.emailLink = new SupabaseEmailLinkAuth(
128
- this.anon,
129
- (user) => this.hydrate(user),
130
- config.allowEmailLinkSignup ?? true
131
- );
132
- }
133
-
134
- static fromEnv(env: Record<string, string | undefined>): SupabaseAuthProvider {
135
- const supabaseUrl = env.SUPABASE_URL;
136
- const anonKey = env.SUPABASE_ANON_KEY;
137
- const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
138
- if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
139
- if (!anonKey) throw new Error('Missing required env var: SUPABASE_ANON_KEY');
140
- if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
141
- const oauthProviders = (env.SUPABASE_OAUTH_PROVIDERS ?? '')
142
- .split(',')
143
- .map((p) => p.trim().toLowerCase())
144
- .filter((p) => p.length > 0);
145
- // Local JWT verification is the default (fast; requires asymmetric signing
146
- // keys to skip the network — the Supabase default since 2025). Set
147
- // SUPABASE_TOKEN_VERIFICATION=strict to force a getUser() per request.
148
- const tokenVerification = env.SUPABASE_TOKEN_VERIFICATION === 'strict' ? 'strict' : 'hybrid';
149
- const revalidateMs = parsePositiveInt(env.SUPABASE_REVALIDATE_MS);
150
- return new SupabaseAuthProvider({
151
- supabaseUrl,
152
- anonKey,
153
- serviceRoleKey,
154
- enableSelfSignup: env.SUPABASE_ENABLE_SELF_SIGNUP === 'true',
155
- oauthProviders,
156
- // Default true; set SUPABASE_ALLOW_EMAIL_LINK_SIGNUP=false to lock down
157
- // to invite-only and reject magic-link signups for new addresses.
158
- allowEmailLinkSignup: env.SUPABASE_ALLOW_EMAIL_LINK_SIGNUP !== 'false',
159
- tokenVerification,
160
- ...(revalidateMs !== undefined ? { revalidateMs } : {})
161
- });
162
- }
163
-
164
- /**
165
- * Verify a session JWT and return the user, or null if invalid/disabled.
166
- * Runs on EVERY authenticated request, so the fast path avoids a network
167
- * round-trip.
168
- *
169
- * `'hybrid'` (default):
170
- * 1. `getClaims(token)` verifies the JWT. With asymmetric signing keys (the
171
- * modern Supabase default) this is LOCAL — the SDK verifies against the
172
- * cached JWKS via Web Crypto, no call to GoTrue. With a legacy symmetric
173
- * HS256 secret the SDK transparently falls back to a network verify, so
174
- * this is correct for both project types without us detecting the scheme
175
- * or hand-rolling JWKS handling (which the Supabase docs warn against —
176
- * it breaks under key rotation).
177
- * 2. Local verification can't see a server-side sign-out or a `disabled`
178
- * flip that happened AFTER the token was issued (the claims are a
179
- * snapshot). So we additionally re-check against GoTrue via `getUser`,
180
- * but at most once per `revalidateMs` per session — bounding revocation
181
- * latency to that window while eliminating ~all per-request network
182
- * calls under steady load.
183
- *
184
- * `'strict'`: `getUser` on every request — instant revocation, one round
185
- * trip per request. The pre-1b behavior, for operators who accept no lag.
186
- */
187
- async verifyToken(token: string): Promise<AuthUser | null> {
188
- if (!token) return null;
189
-
190
- if (this.tokenVerification === 'strict') {
191
- return this.verifyViaGetUser(token);
192
- }
193
-
194
- // --- hybrid: local verify first ---
195
- const claims = await this.getClaimsOrNull(token);
196
- if (!claims) return null;
197
- // `disabled` as of token issue. A flip AFTER issue is caught by the
198
- // periodic recheck below.
199
- if ((claims.user_metadata as { disabled?: boolean } | undefined)?.disabled === true) {
200
- return null;
201
- }
202
-
203
- // Periodic recheck: catch sign-out / disabled since the token was issued.
204
- const sessionId = typeof claims.session_id === 'string' ? claims.session_id : claims.sub;
205
- if (this.shouldRevalidate(sessionId)) {
206
- const fresh = await this.verifyViaGetUser(token);
207
- // getUser rejected it (signed out, disabled, revoked) → deny now.
208
- if (!fresh) {
209
- this.lastRevalidatedAt.delete(sessionId);
210
- return null;
211
- }
212
- this.markRevalidated(sessionId);
213
- return fresh;
214
- }
215
-
216
- // Build the user from claims — no network call on this path.
217
- return {
218
- id: claims.sub,
219
- email: typeof claims.email === 'string' ? claims.email : undefined,
220
- disabled: false,
221
- metadata: claims.user_metadata
222
- };
223
- }
224
-
225
- /** Full network verify against GoTrue. Also the `'strict'`-mode path. */
226
- private async verifyViaGetUser(token: string): Promise<AuthUser | null> {
227
- const { data, error } = await this.anon.auth.getUser(token);
228
- if (error || !data.user) return null;
229
- if (data.user.user_metadata?.disabled === true) return null;
230
- return this.hydrate(data.user);
231
- }
232
-
233
- /** Local JWT verification. Returns the claims, or null if invalid/expired. */
234
- private async getClaimsOrNull(token: string) {
235
- try {
236
- const { data, error } = await this.anon.auth.getClaims(token);
237
- if (error || !data) return null;
238
- return data.claims;
239
- } catch {
240
- return null;
241
- }
242
- }
243
-
244
- private shouldRevalidate(sessionId: string): boolean {
245
- const last = this.lastRevalidatedAt.get(sessionId);
246
- return last === undefined || Date.now() - last >= this.revalidateMs;
247
- }
248
-
249
- private markRevalidated(sessionId: string): void {
250
- const now = Date.now();
251
- this.lastRevalidatedAt.set(sessionId, now);
252
- // Lazy sweep so a churn of distinct sessions can't grow the map without
253
- // bound. Anything older than two windows is definitely stale (it would
254
- // revalidate on next use anyway). Cheap: only runs when the map is large.
255
- if (this.lastRevalidatedAt.size > 10_000) {
256
- const cutoff = now - this.revalidateMs * 2;
257
- for (const [id, ts] of this.lastRevalidatedAt) {
258
- if (ts < cutoff) this.lastRevalidatedAt.delete(id);
259
- }
260
- }
261
- }
262
-
263
- async getUser(id: string): Promise<AuthUser | null> {
264
- const { data, error } = await this.admin.auth.admin.getUserById(id);
265
- if (error || !data.user) return null;
266
- return this.hydrate(data.user);
267
- }
268
-
269
- async listUsers(opts?: ListOptions): Promise<Page<AuthUser> | null> {
270
- // GoTrue's admin.listUsers uses its own pagination (page/perPage).
271
- // Translate our cursor (offset) into page number.
272
- const perPage = opts?.limit ?? 50;
273
- const page = opts?.cursor ? parseInt(opts.cursor, 10) : 1;
274
- const { data, error } = await this.admin.auth.admin.listUsers({
275
- page: Number.isFinite(page) && page >= 1 ? page : 1,
276
- perPage
277
- });
278
- if (error) throw error;
279
-
280
- const users = data.users;
281
- const hydrated = users.map((u) => this.hydrate(u));
282
- // If the page was full, assume there may be more.
283
- const nextCursor = users.length >= perPage ? String(page + 1) : undefined;
284
- return { items: hydrated, nextCursor };
285
- }
286
-
287
- async createUser(email: string): Promise<AuthUser> {
288
- // Allowlist variant: create without a password. The user receives a
289
- // magic-link / OIDC handoff from the consuming app. Identity-only;
290
- // platform permissions are granted via IPlatformPermissionStore.
291
- const { data, error } = await this.admin.auth.admin.createUser({
292
- email,
293
- email_confirm: true
294
- });
295
- if (error) throw error;
296
- if (!data.user) throw new Error('createUser returned no user');
297
- return this.hydrate(data.user);
298
- }
299
-
300
- async deleteUser(id: string): Promise<UserManagementResult> {
301
- // Identity-only delete. The §2 sole-`instance_admin` invariant lives
302
- // on IPlatformPermissionStore; callers consult it before calling here.
303
- const { error } = await this.admin.auth.admin.deleteUser(id);
304
- if (error) {
305
- // 404-shaped errors from GoTrue surface as { status: 404 } or a
306
- // specific message. Normalize to 'not_found' rather than throwing.
307
- const e = error as unknown as { status?: number; message?: string };
308
- if (e.status === 404 || /not.?found/i.test(e.message ?? '')) return 'not_found';
309
- throw error;
310
- }
311
- return 'ok';
312
- }
313
-
314
- async disableUser(id: string): Promise<UserManagementResult> {
315
- const { data: existing, error: fetchError } = await this.admin.auth.admin.getUserById(id);
316
- if (fetchError || !existing.user) return 'not_found';
317
- // GoTrue's user_metadata update merges shallowly — preserve existing
318
- // fields by spreading them in.
319
- const mergedMetadata = {
320
- ...(existing.user.user_metadata ?? {}),
321
- disabled: true
322
- };
323
- const { error } = await this.admin.auth.admin.updateUserById(id, {
324
- user_metadata: mergedMetadata
325
- });
326
- if (error) {
327
- const e = error as unknown as { status?: number; message?: string };
328
- if (e.status === 404 || /not.?found/i.test(e.message ?? '')) return 'not_found';
329
- throw error;
330
- }
331
- return 'ok';
332
- }
333
-
334
- async touchLastLogin(id: string): Promise<void> {
335
- // 60-second debounce. Matches the local provider.
336
- const { data, error } = await this.admin
337
- .from('user_profiles')
338
- .select('last_login_at')
339
- .eq('user_id', id)
340
- .maybeSingle();
341
- if (error) return;
342
-
343
- if (data?.last_login_at) {
344
- const prev = Date.parse(data.last_login_at);
345
- if (Number.isFinite(prev) && Date.now() - prev < 60_000) return;
346
- }
347
- await this.admin
348
- .from('user_profiles')
349
- .update({ last_login_at: new Date().toISOString() })
350
- .eq('user_id', id);
351
- }
352
-
353
- // OAuth lives on `this.oauth` (typed `IOAuthAuth`); see `SupabaseOAuthAuth`
354
- // below. Mirrors the `passwordAuth` capability split.
355
-
356
- // ============================================================================
357
- // Internals
358
- // ============================================================================
359
- /**
360
- * Sign in via GoTrue. Returns the access token string and the hydrated
361
- * user. Called by `SupabasePasswordAuth.verifyLogin` — kept on the
362
- * provider class so it has access to the hydrate helper without plumbing.
363
- */
364
- private async signIn(
365
- email: string,
366
- password: string
367
- ): Promise<{ user: AuthUser; sessionToken: string } | null> {
368
- const { data, error } = await this.anon.auth.signInWithPassword({ email, password });
369
- if (error || !data.user || !data.session) return null;
370
- if (data.user.user_metadata?.disabled === true) return null;
371
- return { user: this.hydrate(data.user), sessionToken: data.session.access_token };
372
- }
373
-
374
- /**
375
- * Map a GoTrue `User` to our identity-only `AuthUser`. Platform permissions
376
- * live on `IPlatformPermissionStore` and profile fields (displayName,
377
- * starred, recentRuns) on `IUserProfileStore` — neither is in AuthUser.
378
- */
379
- private hydrate(user: User): AuthUser {
380
- return {
381
- id: user.id,
382
- email: user.email ?? undefined,
383
- createdAt: user.created_at ?? undefined,
384
- lastLoginAt: user.last_sign_in_at ?? undefined,
385
- disabled: user.user_metadata?.disabled === true,
386
- metadata: user.user_metadata
387
- };
388
- }
389
-
390
- /**
391
- * Expose the anon URL + key so other code (e.g. the same process's
392
- * storage provider or per-request client factories) can build user-scoped
393
- * clients without requiring a second config read.
394
- */
395
- getAnonClientConfig(): { supabaseUrl: string; anonKey: string } {
396
- return { supabaseUrl: this.supabaseUrl, anonKey: this.anonKey };
397
- }
398
- }
399
-
400
- class SupabasePasswordAuth implements IPasswordAuth {
401
- constructor(
402
- private readonly admin: SupabaseClient,
403
- private readonly anon: SupabaseClient,
404
- private readonly signIn: (
405
- email: string,
406
- password: string
407
- ) => Promise<{ user: AuthUser; sessionToken: string } | null>,
408
- private readonly enableSelfSignup: boolean,
409
- private readonly hydrate: (user: User) => AuthUser
410
- ) {}
411
-
412
- async verifyLogin(email: string, password: string): Promise<LoginResult> {
413
- const result = await this.signIn(email, password);
414
- if (!result) return { kind: 'failed', reason: 'invalid_credentials' };
415
- return { kind: 'success', user: result.user, sessionToken: result.sessionToken };
416
- }
417
-
418
- async createUserWithPassword(email: string, password: string): Promise<AuthUser> {
419
- // Identity-only — platform permissions are granted separately via
420
- // IPlatformPermissionStore.set after creation.
421
- const { data, error } = await this.admin.auth.admin.createUser({
422
- email,
423
- password,
424
- email_confirm: true
425
- });
426
- if (error) throw error;
427
- if (!data.user) throw new Error('createUserWithPassword returned no user');
428
- return this.hydrate(data.user);
429
- }
430
-
431
- async registerUser(email: string, password: string): Promise<AuthUser | null> {
432
- if (!this.enableSelfSignup) return null;
433
- const { data, error } = await this.anon.auth.signUp({ email, password });
434
- if (error || !data.user) return null;
435
- return this.hydrate(data.user);
436
- }
437
- }
438
-
439
- /**
440
- * Supabase-backed `IOAuthAuth`. Thin wrappers over GoTrue's `signInWithOAuth`
441
- * / `exchangeCodeForSession` / `refreshSession` — the failure-shape mapping
442
- * (return null vs throw) is the only logic.
443
- */
444
- class SupabaseOAuthAuth implements IOAuthAuth {
445
- constructor(
446
- private readonly anon: SupabaseClient,
447
- private readonly hydrate: (user: User) => AuthUser,
448
- private readonly providers: readonly string[]
449
- ) {}
450
-
451
- listProviders(): readonly string[] {
452
- return this.providers;
453
- }
454
-
455
- async getOAuthAuthorizationUrl(
456
- provider: 'google' | 'github' | 'azure' | 'gitlab',
457
- redirectTo: string
458
- ): Promise<string> {
459
- const { data, error } = await this.anon.auth.signInWithOAuth({
460
- provider,
461
- options: { redirectTo, skipBrowserRedirect: true }
462
- });
463
- if (error || !data.url) throw error ?? new Error('signInWithOAuth returned no URL');
464
- return data.url;
465
- }
466
-
467
- async exchangeOAuthCode(code: string): Promise<{
468
- user: AuthUser;
469
- sessionToken: string;
470
- refreshToken: string;
471
- } | null> {
472
- const { data, error } = await this.anon.auth.exchangeCodeForSession(code);
473
- if (error || !data.user || !data.session) return null;
474
- if (data.user.user_metadata?.disabled === true) return null;
475
- return {
476
- user: this.hydrate(data.user),
477
- sessionToken: data.session.access_token,
478
- refreshToken: data.session.refresh_token
479
- };
480
- }
481
-
482
- async refreshSession(refreshToken: string): Promise<{
483
- sessionToken: string;
484
- refreshToken: string;
485
- } | null> {
486
- const { data, error } = await this.anon.auth.refreshSession({ refresh_token: refreshToken });
487
- if (error || !data.session) return null;
488
- return {
489
- sessionToken: data.session.access_token,
490
- refreshToken: data.session.refresh_token
491
- };
492
- }
493
- }
494
-
495
- /**
496
- * Supabase-backed `IEmailLinkAuth`. Wraps GoTrue's `signInWithOtp` (send the
497
- * link) and `verifyOtp` with `type: 'magiclink' | 'email' | 'signup'`
498
- * (verify on click).
499
- *
500
- * Token shape: Supabase emails the user `{callbackUrl}?token_hash=…&type=…`.
501
- * `verifyMagicLink` accepts either the full URL or just the raw `token_hash`
502
- * — adapters MAY support either to give the route layer flexibility. The
503
- * `type` query param decides which OTP variant `verifyOtp` runs (signup vs
504
- * magic-link returning user vs invite acceptance), so we pass it through.
505
- */
506
- class SupabaseEmailLinkAuth implements IEmailLinkAuth {
507
- constructor(
508
- private readonly anon: SupabaseClient,
509
- private readonly hydrate: (user: User) => AuthUser,
510
- private readonly allowSignup: boolean
511
- ) {}
512
-
513
- async sendMagicLink(
514
- email: string,
515
- callbackUrl: string
516
- ): Promise<
517
- { ok: true } | { ok: false; reason: 'rate_limited' | 'signup_disabled' | 'invalid_email' }
518
- > {
519
- const { error } = await this.anon.auth.signInWithOtp({
520
- email,
521
- options: {
522
- emailRedirectTo: callbackUrl,
523
- shouldCreateUser: this.allowSignup
524
- }
525
- });
526
- if (!error) return { ok: true };
527
-
528
- // Map GoTrue's classified errors to our coarse reasons. Anything we
529
- // don't recognize we throw — the route returns 500 and we get a stack
530
- // trace, instead of swallowing a real bug as "rate limited".
531
- const status = (error as { status?: number }).status;
532
- const code = (error as { code?: string }).code;
533
- const message = error.message ?? '';
534
- if (status === 429 || /rate.?limit/i.test(message))
535
- return { ok: false, reason: 'rate_limited' };
536
- if (
537
- code === 'otp_disabled' ||
538
- code === 'signup_disabled' ||
539
- /signup.*disabled/i.test(message)
540
- ) {
541
- return { ok: false, reason: 'signup_disabled' };
542
- }
543
- if (code === 'validation_failed' || /invalid.*email|email.*invalid/i.test(message)) {
544
- return { ok: false, reason: 'invalid_email' };
545
- }
546
- throw error;
547
- }
548
-
549
- async verifyMagicLink(rawCallbackUrl: string): Promise<{
550
- user: AuthUser;
551
- sessionToken: string;
552
- refreshToken?: string;
553
- } | null> {
554
- const params = parseCallbackParams(rawCallbackUrl);
555
- if (!params) return null;
556
- const { tokenHash, type } = params;
557
-
558
- const { data, error } = await this.anon.auth.verifyOtp({
559
- token_hash: tokenHash,
560
- type
561
- });
562
- if (error || !data.user || !data.session) return null;
563
- if (data.user.user_metadata?.disabled === true) return null;
564
- return {
565
- user: this.hydrate(data.user),
566
- sessionToken: data.session.access_token,
567
- refreshToken: data.session.refresh_token
568
- };
569
- }
570
- }
571
-
572
- /**
573
- * Pull `token_hash` + `type` from either a full callback URL or a bare
574
- * `token_hash=…&type=…` query string. Tolerant of both shapes so the route
575
- * layer can pass whichever form is convenient.
576
- *
577
- * Returns null when the inputs aren't recognizable — the caller treats null
578
- * as "send the user back to /login".
579
- */
580
- function parseCallbackParams(
581
- raw: string
582
- ): { tokenHash: string; type: 'magiclink' | 'email' | 'signup' | 'invite' | 'recovery' } | null {
583
- let search: URLSearchParams;
584
- try {
585
- // Full URL path
586
- search = new URL(raw).searchParams;
587
- } catch {
588
- // Query-string-only path — treat as bare params.
589
- search = new URLSearchParams(raw.startsWith('?') ? raw.slice(1) : raw);
590
- }
591
- const tokenHash = search.get('token_hash');
592
- const type = search.get('type');
593
- if (!tokenHash) return null;
594
- const allowed = new Set(['magiclink', 'email', 'signup', 'invite', 'recovery']);
595
- if (!type || !allowed.has(type)) return null;
596
- return {
597
- tokenHash,
598
- type: type as 'magiclink' | 'email' | 'signup' | 'invite' | 'recovery'
599
- };
600
- }