@selvajs/supabase-provider 0.14.3 → 0.14.4-beta.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 (86) hide show
  1. package/dist/auth/SupabaseAuthProvider.d.ts +57 -0
  2. package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/SupabaseAuthProvider.js +116 -4
  4. package/dist/auth/SupabaseAuthProvider.js.map +1 -1
  5. package/dist/auth/__tests__/token-verification.test.d.ts +13 -0
  6. package/dist/auth/__tests__/token-verification.test.d.ts.map +1 -0
  7. package/dist/auth/__tests__/token-verification.test.js +135 -0
  8. package/dist/auth/__tests__/token-verification.test.js.map +1 -0
  9. package/dist/data/SupabaseComputeServerStore.d.ts +34 -2
  10. package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
  11. package/dist/data/SupabaseComputeServerStore.js +146 -39
  12. package/dist/data/SupabaseComputeServerStore.js.map +1 -1
  13. package/dist/data/SupabaseDataProvider.d.ts +5 -2
  14. package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
  15. package/dist/data/SupabaseDataProvider.js +24 -18
  16. package/dist/data/SupabaseDataProvider.js.map +1 -1
  17. package/dist/data/SupabaseDefinitionStore.d.ts +2 -1
  18. package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
  19. package/dist/data/SupabaseDefinitionStore.js +68 -67
  20. package/dist/data/SupabaseDefinitionStore.js.map +1 -1
  21. package/dist/data/SupabaseInviteStore.d.ts.map +1 -1
  22. package/dist/data/SupabaseInviteStore.js +11 -22
  23. package/dist/data/SupabaseInviteStore.js.map +1 -1
  24. package/dist/data/SupabaseOrgStore.d.ts.map +1 -1
  25. package/dist/data/SupabaseOrgStore.js +49 -86
  26. package/dist/data/SupabaseOrgStore.js.map +1 -1
  27. package/dist/data/SupabaseProjectStore.d.ts.map +1 -1
  28. package/dist/data/SupabaseProjectStore.js +51 -70
  29. package/dist/data/SupabaseProjectStore.js.map +1 -1
  30. package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -1
  31. package/dist/data/SupabaseShareLinkStore.js +13 -27
  32. package/dist/data/SupabaseShareLinkStore.js.map +1 -1
  33. package/dist/data/__tests__/compute-server-conformance.test.js +6 -1
  34. package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -1
  35. package/dist/data/__tests__/compute-server-encryption.test.d.ts +12 -0
  36. package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +1 -0
  37. package/dist/data/__tests__/compute-server-encryption.test.js +143 -0
  38. package/dist/data/__tests__/compute-server-encryption.test.js.map +1 -0
  39. package/dist/data/__tests__/share-link-concurrency.test.d.ts +22 -0
  40. package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +1 -0
  41. package/dist/data/__tests__/share-link-concurrency.test.js +115 -0
  42. package/dist/data/__tests__/share-link-concurrency.test.js.map +1 -0
  43. package/dist/data/client.d.ts +35 -3
  44. package/dist/data/client.d.ts.map +1 -1
  45. package/dist/data/client.js +67 -16
  46. package/dist/data/client.js.map +1 -1
  47. package/dist/data/errors.d.ts +2 -0
  48. package/dist/data/errors.d.ts.map +1 -0
  49. package/dist/data/errors.js +23 -0
  50. package/dist/data/errors.js.map +1 -0
  51. package/dist/data/index.d.ts +7 -2
  52. package/dist/data/index.d.ts.map +1 -1
  53. package/dist/data/index.js +5 -1
  54. package/dist/data/index.js.map +1 -1
  55. package/dist/data/query.d.ts +20 -0
  56. package/dist/data/query.d.ts.map +1 -0
  57. package/dist/data/query.js +13 -0
  58. package/dist/data/query.js.map +1 -0
  59. package/dist/data/rowStamp.d.ts +38 -0
  60. package/dist/data/rowStamp.d.ts.map +1 -0
  61. package/dist/data/rowStamp.js +43 -0
  62. package/dist/data/rowStamp.js.map +1 -0
  63. package/dist/index.d.ts +2 -2
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -1
  66. package/dist/index.js.map +1 -1
  67. package/package.json +2 -2
  68. package/src/auth/SupabaseAuthProvider.ts +140 -4
  69. package/src/auth/__tests__/token-verification.test.ts +158 -0
  70. package/src/data/SupabaseComputeServerStore.ts +163 -43
  71. package/src/data/SupabaseDataProvider.ts +35 -15
  72. package/src/data/SupabaseDefinitionStore.ts +73 -66
  73. package/src/data/SupabaseInviteStore.ts +13 -25
  74. package/src/data/SupabaseOrgStore.ts +51 -95
  75. package/src/data/SupabaseProjectStore.ts +55 -78
  76. package/src/data/SupabaseShareLinkStore.ts +15 -30
  77. package/src/data/__tests__/compute-server-conformance.test.ts +7 -1
  78. package/src/data/__tests__/compute-server-encryption.test.ts +173 -0
  79. package/src/data/__tests__/share-link-concurrency.test.ts +124 -0
  80. package/src/data/client.ts +95 -20
  81. package/src/data/errors.ts +45 -0
  82. package/src/data/index.ts +19 -2
  83. package/src/data/query.ts +22 -0
  84. package/src/data/rowStamp.ts +48 -0
  85. package/src/index.ts +20 -2
  86. package/supabase/migrations/20260711120000_selva_definition_schema_version.sql +18 -0
@@ -0,0 +1,38 @@
1
+ import type { RequestContext } from '@selvajs/platform';
2
+ /**
3
+ * Row-level (snake_case) audit-stamp helpers for Supabase stores.
4
+ *
5
+ * Platform's `auditUpdate` / `auditSoftDelete` emit camelCase *domain* fields
6
+ * (`updatedAt` / `updatedBy`), but Postgres rows are snake_case, so every store
7
+ * hand-translated them. These helpers own that translation once. Row-column
8
+ * naming is a provider concern, so they live here rather than in platform.
9
+ *
10
+ * Two deliberate rules, matched to how the engine's stores already write rows:
11
+ * - `updated_by` is written ONLY when `ctx.userId` is set. On system contexts
12
+ * it's the empty string, and the column FKs `auth.users(id)` with
13
+ * `ON DELETE SET NULL` — writing `''` would violate the FK, so it's omitted
14
+ * and the existing value is left untouched.
15
+ * - Plain updates DON'T stamp `updated_at`: a per-table `updated_at` trigger
16
+ * already sets it. Only the soft-delete stamp writes `updated_at` explicitly,
17
+ * so it matches `deleted_at` (a single timestamp for the deletion event).
18
+ */
19
+ /**
20
+ * Stamp for a plain UPDATE: `{ updated_by }` when the context carries a user,
21
+ * `{}` otherwise (the DB trigger handles `updated_at`). Spread into the row:
22
+ * `const row = { ...patch, ...stampUpdate(ctx) }`.
23
+ */
24
+ export declare function stampUpdate(ctx: RequestContext): {
25
+ updated_by?: string;
26
+ };
27
+ /**
28
+ * Stamp for a soft-delete: `{ deleted_at, updated_at, updated_by? }`. `updated_at`
29
+ * is set explicitly to equal `deleted_at`; `updated_by` follows the same
30
+ * omit-when-empty rule as {@link stampUpdate}. `fallbackActor` defaults to
31
+ * `ctx.userId` (matching the engine stores' `auditSoftDelete(ctx, ctx.userId)`).
32
+ */
33
+ export declare function stampSoftDelete(ctx: RequestContext, fallbackActor?: string | null | undefined): {
34
+ deleted_at: string;
35
+ updated_at: string;
36
+ updated_by?: string;
37
+ };
38
+ //# sourceMappingURL=rowStamp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rowStamp.d.ts","sourceRoot":"","sources":["../../src/data/rowStamp.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGxD;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,cAAc,GAAG;IAAE,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CAExE;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAC9B,GAAG,EAAE,cAAc,EACnB,aAAa,GAAE,MAAM,GAAG,IAAI,GAAG,SAAsB,GACnD;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAE,CAQjE"}
@@ -0,0 +1,43 @@
1
+ import { auditSoftDelete } from '@selvajs/platform';
2
+ /**
3
+ * Row-level (snake_case) audit-stamp helpers for Supabase stores.
4
+ *
5
+ * Platform's `auditUpdate` / `auditSoftDelete` emit camelCase *domain* fields
6
+ * (`updatedAt` / `updatedBy`), but Postgres rows are snake_case, so every store
7
+ * hand-translated them. These helpers own that translation once. Row-column
8
+ * naming is a provider concern, so they live here rather than in platform.
9
+ *
10
+ * Two deliberate rules, matched to how the engine's stores already write rows:
11
+ * - `updated_by` is written ONLY when `ctx.userId` is set. On system contexts
12
+ * it's the empty string, and the column FKs `auth.users(id)` with
13
+ * `ON DELETE SET NULL` — writing `''` would violate the FK, so it's omitted
14
+ * and the existing value is left untouched.
15
+ * - Plain updates DON'T stamp `updated_at`: a per-table `updated_at` trigger
16
+ * already sets it. Only the soft-delete stamp writes `updated_at` explicitly,
17
+ * so it matches `deleted_at` (a single timestamp for the deletion event).
18
+ */
19
+ /**
20
+ * Stamp for a plain UPDATE: `{ updated_by }` when the context carries a user,
21
+ * `{}` otherwise (the DB trigger handles `updated_at`). Spread into the row:
22
+ * `const row = { ...patch, ...stampUpdate(ctx) }`.
23
+ */
24
+ export function stampUpdate(ctx) {
25
+ return ctx.userId ? { updated_by: ctx.userId } : {};
26
+ }
27
+ /**
28
+ * Stamp for a soft-delete: `{ deleted_at, updated_at, updated_by? }`. `updated_at`
29
+ * is set explicitly to equal `deleted_at`; `updated_by` follows the same
30
+ * omit-when-empty rule as {@link stampUpdate}. `fallbackActor` defaults to
31
+ * `ctx.userId` (matching the engine stores' `auditSoftDelete(ctx, ctx.userId)`).
32
+ */
33
+ export function stampSoftDelete(ctx, fallbackActor = ctx.userId) {
34
+ const stamp = auditSoftDelete(ctx, fallbackActor);
35
+ const row = {
36
+ deleted_at: stamp.deletedAt,
37
+ updated_at: stamp.updatedAt
38
+ };
39
+ if (stamp.updatedBy)
40
+ row.updated_by = stamp.updatedBy;
41
+ return row;
42
+ }
43
+ //# sourceMappingURL=rowStamp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rowStamp.js","sourceRoot":"","sources":["../../src/data/rowStamp.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEpD;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,GAAmB;IAC9C,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;AACrD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAC9B,GAAmB,EACnB,gBAA2C,GAAG,CAAC,MAAM;IAErD,MAAM,KAAK,GAAG,eAAe,CAAC,GAAG,EAAE,aAAa,CAAC,CAAC;IAClD,MAAM,GAAG,GAAoE;QAC5E,UAAU,EAAE,KAAK,CAAC,SAAS;QAC3B,UAAU,EAAE,KAAK,CAAC,SAAS;KAC3B,CAAC;IACF,IAAI,KAAK,CAAC,SAAS;QAAE,GAAG,CAAC,UAAU,GAAG,KAAK,CAAC,SAAS,CAAC;IACtD,OAAO,GAAG,CAAC;AACZ,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { SupabaseStorageProvider } from './storage/SupabaseStorageProvider.js';
2
2
  export type { SupabaseStorageProviderConfig } from './storage/SupabaseStorageProvider.js';
3
- export { SupabaseOrgStore, SupabaseProjectStore, SupabaseDefinitionStore, SupabaseInviteStore, SupabaseComputeServerStore, SupabaseShareLinkStore, SupabaseDataProvider, SupabaseEventSink, SupabaseSolveMetricSink, SupabaseAuditQuery, buildClientBundle } from './data/index.js';
4
- export type { ClientBundle, BuildClientOptions } from './data/index.js';
3
+ export { SupabaseOrgStore, SupabaseProjectStore, SupabaseDefinitionStore, SupabaseInviteStore, SupabaseComputeServerStore, SupabaseShareLinkStore, SupabaseDataProvider, SupabaseEventSink, SupabaseSolveMetricSink, SupabaseAuditQuery, buildClientBundle, clientBundleFromEnv, DEFAULT_SCHEMA, mapPostgrestError, stampUpdate, stampSoftDelete, notDeleted, toRange, orderColumn, nextCursorFromRange, encodeCursor, decodeCursor } from './data/index.js';
4
+ export type { ClientBundle, BuildClientOptions, ForRequestOptions, SchemaClient, SelvaSchemaClient, RangeSpec } from './data/index.js';
5
5
  export { SupabaseUserProfileProvider } from './userProfile/index.js';
6
6
  export { SupabaseAuthProvider } from './auth/index.js';
7
7
  export type { SupabaseAuthProviderConfig } from './auth/index.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,sCAAsC,CAAC;AAC/E,YAAY,EAAE,6BAA6B,EAAE,MAAM,sCAAsC,CAAC;AAE1F,OAAO,EACN,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,mBAAmB,EACnB,0BAA0B,EAC1B,sBAAsB,EACtB,oBAAoB,EACpB,iBAAiB,EACjB,uBAAuB,EACvB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,iBAAiB,CAAC;AACzB,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAExE,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AAErE,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACvD,YAAY,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAElE,OAAO,EAAE,+BAA+B,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,sCAAsC,CAAC;AAC/E,YAAY,EAAE,6BAA6B,EAAE,MAAM,sCAAsC,CAAC;AAE1F,OAAO,EACN,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,mBAAmB,EACnB,0BAA0B,EAC1B,sBAAsB,EACtB,oBAAoB,EACpB,iBAAiB,EACjB,uBAAuB,EACvB,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,iBAAiB,EACjB,WAAW,EACX,eAAe,EACf,UAAU,EACV,OAAO,EACP,WAAW,EACX,mBAAmB,EACnB,YAAY,EACZ,YAAY,EACZ,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACX,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,EACjB,YAAY,EACZ,iBAAiB,EACjB,SAAS,EACT,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AAErE,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AACvD,YAAY,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAElE,OAAO,EAAE,+BAA+B,EAAE,MAAM,wBAAwB,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { SupabaseStorageProvider } from './storage/SupabaseStorageProvider.js';
2
- export { SupabaseOrgStore, SupabaseProjectStore, SupabaseDefinitionStore, SupabaseInviteStore, SupabaseComputeServerStore, SupabaseShareLinkStore, SupabaseDataProvider, SupabaseEventSink, SupabaseSolveMetricSink, SupabaseAuditQuery, buildClientBundle } from './data/index.js';
2
+ export { SupabaseOrgStore, SupabaseProjectStore, SupabaseDefinitionStore, SupabaseInviteStore, SupabaseComputeServerStore, SupabaseShareLinkStore, SupabaseDataProvider, SupabaseEventSink, SupabaseSolveMetricSink, SupabaseAuditQuery, buildClientBundle, clientBundleFromEnv, DEFAULT_SCHEMA, mapPostgrestError, stampUpdate, stampSoftDelete, notDeleted, toRange, orderColumn, nextCursorFromRange, encodeCursor, decodeCursor } from './data/index.js';
3
3
  export { SupabaseUserProfileProvider } from './userProfile/index.js';
4
4
  export { SupabaseAuthProvider } from './auth/index.js';
5
5
  export { SupabasePlatformPermissionStore } from './permissions/index.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,sCAAsC,CAAC;AAG/E,OAAO,EACN,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,mBAAmB,EACnB,0BAA0B,EAC1B,sBAAsB,EACtB,oBAAoB,EACpB,iBAAiB,EACjB,uBAAuB,EACvB,kBAAkB,EAClB,iBAAiB,EACjB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AAErE,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAGvD,OAAO,EAAE,+BAA+B,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,sCAAsC,CAAC;AAG/E,OAAO,EACN,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,mBAAmB,EACnB,0BAA0B,EAC1B,sBAAsB,EACtB,oBAAoB,EACpB,iBAAiB,EACjB,uBAAuB,EACvB,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,iBAAiB,EACjB,WAAW,EACX,eAAe,EACf,UAAU,EACV,OAAO,EACP,WAAW,EACX,mBAAmB,EACnB,YAAY,EACZ,YAAY,EACZ,MAAM,iBAAiB,CAAC;AAUzB,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AAErE,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAGvD,OAAO,EAAE,+BAA+B,EAAE,MAAM,wBAAwB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/supabase-provider",
3
- "version": "0.14.3",
3
+ "version": "0.14.4-beta.0",
4
4
  "description": "Supabase (Auth + Postgres + Storage) implementations of @selvajs/platform interfaces",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -57,7 +57,7 @@
57
57
  "dependencies": {
58
58
  "@supabase/supabase-js": "^2.104.1",
59
59
  "sharp": "^0.34.5",
60
- "@selvajs/platform": "0.14.2"
60
+ "@selvajs/platform": "0.14.3-beta.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/node": "^22.19.17",
@@ -49,6 +49,34 @@ export interface SupabaseAuthProviderConfig {
49
49
  * `{ ok: false, reason: 'signup_disabled' }`.
50
50
  */
51
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;
52
80
  }
53
81
 
54
82
  export class SupabaseAuthProvider implements IAuthProvider {
@@ -61,10 +89,23 @@ export class SupabaseAuthProvider implements IAuthProvider {
61
89
  private readonly anon: SupabaseClient;
62
90
  private readonly anonKey: string;
63
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>();
64
103
 
65
104
  constructor(config: SupabaseAuthProviderConfig) {
66
105
  this.supabaseUrl = config.supabaseUrl;
67
106
  this.anonKey = config.anonKey;
107
+ this.tokenVerification = config.tokenVerification ?? 'hybrid';
108
+ this.revalidateMs = config.revalidateMs ?? DEFAULT_REVALIDATE_MS;
68
109
  this.admin = createClient(config.supabaseUrl, config.serviceRoleKey, {
69
110
  auth: { persistSession: false, autoRefreshToken: false }
70
111
  });
@@ -101,6 +142,11 @@ export class SupabaseAuthProvider implements IAuthProvider {
101
142
  .split(',')
102
143
  .map((p) => p.trim().toLowerCase())
103
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);
104
150
  return new SupabaseAuthProvider({
105
151
  supabaseUrl,
106
152
  anonKey,
@@ -109,21 +155,111 @@ export class SupabaseAuthProvider implements IAuthProvider {
109
155
  oauthProviders,
110
156
  // Default true; set SUPABASE_ALLOW_EMAIL_LINK_SIGNUP=false to lock down
111
157
  // to invite-only and reject magic-link signups for new addresses.
112
- allowEmailLinkSignup: env.SUPABASE_ALLOW_EMAIL_LINK_SIGNUP !== 'false'
158
+ allowEmailLinkSignup: env.SUPABASE_ALLOW_EMAIL_LINK_SIGNUP !== 'false',
159
+ tokenVerification,
160
+ ...(revalidateMs !== undefined ? { revalidateMs } : {})
113
161
  });
114
162
  }
115
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
+ */
116
187
  async verifyToken(token: string): Promise<AuthUser | null> {
117
188
  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.
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> {
121
227
  const { data, error } = await this.anon.auth.getUser(token);
122
228
  if (error || !data.user) return null;
123
229
  if (data.user.user_metadata?.disabled === true) return null;
124
230
  return this.hydrate(data.user);
125
231
  }
126
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
+
127
263
  async getUser(id: string): Promise<AuthUser | null> {
128
264
  const { data, error } = await this.admin.auth.admin.getUserById(id);
129
265
  if (error || !data.user) return null;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * CI-runnable (no live stack) tests for §1b hybrid token verification. Stubs
3
+ * the internal anon client's `getClaims` (local JWT verify) and `getUser`
4
+ * (network GoTrue verify) so we can assert the network-call behavior directly.
5
+ *
6
+ * Contract:
7
+ * - hybrid (default): verify locally via getClaims on every request; hit
8
+ * getUser at most once per `revalidateMs` per session; a getUser rejection
9
+ * during that recheck (sign-out / disabled) denies immediately.
10
+ * - strict: getUser on every request.
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+ import { SupabaseAuthProvider } from '../SupabaseAuthProvider.js';
15
+
16
+ const URL = 'https://example.supabase.co';
17
+ const ANON = 'anon-key';
18
+ const SERVICE = 'service-key';
19
+
20
+ interface AuthStub {
21
+ getClaims: ReturnType<typeof vi.fn>;
22
+ getUser: ReturnType<typeof vi.fn>;
23
+ }
24
+
25
+ /** Reach into the provider's private anon client and replace its auth surface. */
26
+ function stubAnonAuth(provider: SupabaseAuthProvider): AuthStub {
27
+ const stub: AuthStub = { getClaims: vi.fn(), getUser: vi.fn() };
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ (provider as any).anon.auth.getClaims = stub.getClaims;
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ (provider as any).anon.auth.getUser = stub.getUser;
32
+ return stub;
33
+ }
34
+
35
+ function okClaims(overrides: Record<string, unknown> = {}) {
36
+ return {
37
+ data: {
38
+ claims: {
39
+ sub: 'user-1',
40
+ session_id: 'sess-1',
41
+ email: 'u@example.com',
42
+ exp: Math.floor(Date.now() / 1000) + 3600,
43
+ ...overrides
44
+ },
45
+ header: {},
46
+ signature: new Uint8Array()
47
+ },
48
+ error: null
49
+ };
50
+ }
51
+
52
+ function okUser() {
53
+ return {
54
+ data: { user: { id: 'user-1', email: 'u@example.com', user_metadata: {} } },
55
+ error: null
56
+ };
57
+ }
58
+
59
+ describe('SupabaseAuthProvider — hybrid token verification', () => {
60
+ beforeEach(() => vi.restoreAllMocks());
61
+
62
+ it('verifies locally via getClaims and rechecks getUser once per window', async () => {
63
+ const provider = new SupabaseAuthProvider({
64
+ supabaseUrl: URL,
65
+ anonKey: ANON,
66
+ serviceRoleKey: SERVICE,
67
+ revalidateMs: 60_000
68
+ });
69
+ const stub = stubAnonAuth(provider);
70
+ stub.getClaims.mockResolvedValue(okClaims());
71
+ stub.getUser.mockResolvedValue(okUser());
72
+
73
+ // First call: local verify + one recheck (never seen this session).
74
+ const a = await provider.verifyToken('tok');
75
+ expect(a?.id).toBe('user-1');
76
+ expect(stub.getClaims).toHaveBeenCalledTimes(1);
77
+ expect(stub.getUser).toHaveBeenCalledTimes(1);
78
+
79
+ // Subsequent calls within the window: local only, NO more getUser.
80
+ await provider.verifyToken('tok');
81
+ await provider.verifyToken('tok');
82
+ expect(stub.getClaims).toHaveBeenCalledTimes(3);
83
+ expect(stub.getUser).toHaveBeenCalledTimes(1); // still just the first
84
+ });
85
+
86
+ it('denies immediately when the periodic getUser recheck rejects (signed out / revoked)', async () => {
87
+ const provider = new SupabaseAuthProvider({
88
+ supabaseUrl: URL,
89
+ anonKey: ANON,
90
+ serviceRoleKey: SERVICE
91
+ });
92
+ const stub = stubAnonAuth(provider);
93
+ stub.getClaims.mockResolvedValue(okClaims());
94
+ stub.getUser.mockResolvedValue({ data: { user: null }, error: { message: 'signed out' } });
95
+
96
+ const result = await provider.verifyToken('tok');
97
+ expect(result).toBeNull();
98
+ });
99
+
100
+ it('denies when the claims already mark the user disabled (no getUser needed)', async () => {
101
+ const provider = new SupabaseAuthProvider({
102
+ supabaseUrl: URL,
103
+ anonKey: ANON,
104
+ serviceRoleKey: SERVICE
105
+ });
106
+ const stub = stubAnonAuth(provider);
107
+ stub.getClaims.mockResolvedValue(okClaims({ user_metadata: { disabled: true } }));
108
+
109
+ const result = await provider.verifyToken('tok');
110
+ expect(result).toBeNull();
111
+ expect(stub.getUser).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('denies when getClaims fails (bad signature / expired)', async () => {
115
+ const provider = new SupabaseAuthProvider({
116
+ supabaseUrl: URL,
117
+ anonKey: ANON,
118
+ serviceRoleKey: SERVICE
119
+ });
120
+ const stub = stubAnonAuth(provider);
121
+ stub.getClaims.mockResolvedValue({ data: null, error: { message: 'invalid JWT' } });
122
+
123
+ expect(await provider.verifyToken('tok')).toBeNull();
124
+ expect(stub.getUser).not.toHaveBeenCalled();
125
+ });
126
+
127
+ it('returns null for an empty token without any network call', async () => {
128
+ const provider = new SupabaseAuthProvider({
129
+ supabaseUrl: URL,
130
+ anonKey: ANON,
131
+ serviceRoleKey: SERVICE
132
+ });
133
+ const stub = stubAnonAuth(provider);
134
+ expect(await provider.verifyToken('')).toBeNull();
135
+ expect(stub.getClaims).not.toHaveBeenCalled();
136
+ expect(stub.getUser).not.toHaveBeenCalled();
137
+ });
138
+ });
139
+
140
+ describe('SupabaseAuthProvider — strict token verification', () => {
141
+ beforeEach(() => vi.restoreAllMocks());
142
+
143
+ it('calls getUser on every request and never getClaims', async () => {
144
+ const provider = new SupabaseAuthProvider({
145
+ supabaseUrl: URL,
146
+ anonKey: ANON,
147
+ serviceRoleKey: SERVICE,
148
+ tokenVerification: 'strict'
149
+ });
150
+ const stub = stubAnonAuth(provider);
151
+ stub.getUser.mockResolvedValue(okUser());
152
+
153
+ await provider.verifyToken('tok');
154
+ await provider.verifyToken('tok');
155
+ expect(stub.getUser).toHaveBeenCalledTimes(2);
156
+ expect(stub.getClaims).not.toHaveBeenCalled();
157
+ });
158
+ });