@selvajs/supabase-provider 0.14.2 → 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.
- package/dist/auth/SupabaseAuthProvider.d.ts +57 -0
- package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
- package/dist/auth/SupabaseAuthProvider.js +116 -4
- package/dist/auth/SupabaseAuthProvider.js.map +1 -1
- package/dist/auth/__tests__/token-verification.test.d.ts +13 -0
- package/dist/auth/__tests__/token-verification.test.d.ts.map +1 -0
- package/dist/auth/__tests__/token-verification.test.js +135 -0
- package/dist/auth/__tests__/token-verification.test.js.map +1 -0
- package/dist/data/SupabaseComputeServerStore.d.ts +34 -2
- package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
- package/dist/data/SupabaseComputeServerStore.js +146 -39
- package/dist/data/SupabaseComputeServerStore.js.map +1 -1
- package/dist/data/SupabaseDataProvider.d.ts +5 -2
- package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
- package/dist/data/SupabaseDataProvider.js +24 -18
- package/dist/data/SupabaseDataProvider.js.map +1 -1
- package/dist/data/SupabaseDefinitionStore.d.ts +2 -1
- package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
- package/dist/data/SupabaseDefinitionStore.js +68 -67
- package/dist/data/SupabaseDefinitionStore.js.map +1 -1
- package/dist/data/SupabaseInviteStore.d.ts.map +1 -1
- package/dist/data/SupabaseInviteStore.js +11 -22
- package/dist/data/SupabaseInviteStore.js.map +1 -1
- package/dist/data/SupabaseOrgStore.d.ts +1 -1
- package/dist/data/SupabaseOrgStore.d.ts.map +1 -1
- package/dist/data/SupabaseOrgStore.js +54 -86
- package/dist/data/SupabaseOrgStore.js.map +1 -1
- package/dist/data/SupabaseProjectStore.d.ts.map +1 -1
- package/dist/data/SupabaseProjectStore.js +51 -70
- package/dist/data/SupabaseProjectStore.js.map +1 -1
- package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -1
- package/dist/data/SupabaseShareLinkStore.js +13 -27
- package/dist/data/SupabaseShareLinkStore.js.map +1 -1
- package/dist/data/__tests__/compute-server-conformance.test.js +6 -1
- package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -1
- package/dist/data/__tests__/compute-server-encryption.test.d.ts +12 -0
- package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +1 -0
- package/dist/data/__tests__/compute-server-encryption.test.js +143 -0
- package/dist/data/__tests__/compute-server-encryption.test.js.map +1 -0
- package/dist/data/__tests__/share-link-concurrency.test.d.ts +22 -0
- package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +1 -0
- package/dist/data/__tests__/share-link-concurrency.test.js +115 -0
- package/dist/data/__tests__/share-link-concurrency.test.js.map +1 -0
- package/dist/data/client.d.ts +35 -3
- package/dist/data/client.d.ts.map +1 -1
- package/dist/data/client.js +67 -16
- package/dist/data/client.js.map +1 -1
- package/dist/data/errors.d.ts +2 -0
- package/dist/data/errors.d.ts.map +1 -0
- package/dist/data/errors.js +23 -0
- package/dist/data/errors.js.map +1 -0
- package/dist/data/index.d.ts +7 -2
- package/dist/data/index.d.ts.map +1 -1
- package/dist/data/index.js +5 -1
- package/dist/data/index.js.map +1 -1
- package/dist/data/query.d.ts +20 -0
- package/dist/data/query.d.ts.map +1 -0
- package/dist/data/query.js +13 -0
- package/dist/data/query.js.map +1 -0
- package/dist/data/rowStamp.d.ts +38 -0
- package/dist/data/rowStamp.d.ts.map +1 -0
- package/dist/data/rowStamp.js +43 -0
- package/dist/data/rowStamp.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/storage/SupabaseStorageProvider.d.ts +7 -5
- package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -1
- package/dist/storage/SupabaseStorageProvider.js +14 -7
- package/dist/storage/SupabaseStorageProvider.js.map +1 -1
- package/package.json +2 -2
- package/src/auth/SupabaseAuthProvider.ts +140 -4
- package/src/auth/__tests__/token-verification.test.ts +158 -0
- package/src/data/SupabaseComputeServerStore.ts +163 -43
- package/src/data/SupabaseDataProvider.ts +35 -15
- package/src/data/SupabaseDefinitionStore.ts +73 -66
- package/src/data/SupabaseInviteStore.ts +13 -25
- package/src/data/SupabaseOrgStore.ts +57 -96
- package/src/data/SupabaseProjectStore.ts +55 -78
- package/src/data/SupabaseShareLinkStore.ts +15 -30
- package/src/data/__tests__/compute-server-conformance.test.ts +7 -1
- package/src/data/__tests__/compute-server-encryption.test.ts +173 -0
- package/src/data/__tests__/share-link-concurrency.test.ts +124 -0
- package/src/data/client.ts +95 -20
- package/src/data/errors.ts +45 -0
- package/src/data/index.ts +19 -2
- package/src/data/query.ts +22 -0
- package/src/data/rowStamp.ts +48 -0
- package/src/index.ts +20 -2
- package/src/storage/SupabaseStorageProvider.ts +12 -7
- package/supabase/config.toml +6 -6
- package/supabase/migrations/20260630120000_org_assets.sql +19 -0
- 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';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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;
|
|
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"}
|
|
@@ -45,11 +45,13 @@ export declare class SupabaseStorageProvider implements IStorageProvider {
|
|
|
45
45
|
static fromEnv(env: Record<string, string | undefined>): SupabaseStorageProvider;
|
|
46
46
|
constructor(config: SupabaseStorageProviderConfig);
|
|
47
47
|
/**
|
|
48
|
-
* Route a storage path to a bucket.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
48
|
+
* Route a storage path to a bucket. A path is private when either:
|
|
49
|
+
* - it's a `.gh`/`.ghx` source file — confidentiality by extension, so a
|
|
50
|
+
* path-scheme rename can't silently move sources into the public bucket;
|
|
51
|
+
* - it classifies as a non-`public` asset class (`classifyAssetPath`) —
|
|
52
|
+
* e.g. `orgs/{id}/private/*` pricing sheets are members-only and must
|
|
53
|
+
* never land in the CDN-readable bucket.
|
|
54
|
+
* Everything else (branding, covers, archives, thumbnails) is public.
|
|
53
55
|
*/
|
|
54
56
|
private bucketFor;
|
|
55
57
|
get(storagePath: string): Promise<Uint8Array | null>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SupabaseStorageProvider.d.ts","sourceRoot":"","sources":["../../src/storage/SupabaseStorageProvider.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAElE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,6BAA6B;IAC7C,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,qBAAa,uBAAwB,YAAW,gBAAgB;IAC/D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAE1C,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,uBAAuB;gBAcpE,MAAM,EAAE,6BAA6B;IAWjD
|
|
1
|
+
{"version":3,"file":"SupabaseStorageProvider.d.ts","sourceRoot":"","sources":["../../src/storage/SupabaseStorageProvider.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAElE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,6BAA6B;IAC7C,0EAA0E;IAC1E,WAAW,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,qBAAa,uBAAwB,YAAW,gBAAgB;IAC/D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAS;IAE1C,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,uBAAuB;gBAcpE,MAAM,EAAE,6BAA6B;IAWjD;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS;IAOX,GAAG,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAcpD,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAc/E,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAO1C,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YASnC,oBAAoB;IAYlC;;;OAGG;YACW,WAAW;IAuBzB,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM;CAOzC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createClient } from '@supabase/supabase-js';
|
|
2
|
-
import { transcodeImageIfNeeded } from '@selvajs/platform/storage';
|
|
2
|
+
import { transcodeImageIfNeeded, classifyAssetPath } from '@selvajs/platform/storage';
|
|
3
3
|
export class SupabaseStorageProvider {
|
|
4
4
|
client;
|
|
5
5
|
publicBucket;
|
|
@@ -32,14 +32,21 @@ export class SupabaseStorageProvider {
|
|
|
32
32
|
this.privateUrlPrefix = (config.privateUrlPrefix ?? '/api/files').replace(/\/$/, '');
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
* Route a storage path to a bucket.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
35
|
+
* Route a storage path to a bucket. A path is private when either:
|
|
36
|
+
* - it's a `.gh`/`.ghx` source file — confidentiality by extension, so a
|
|
37
|
+
* path-scheme rename can't silently move sources into the public bucket;
|
|
38
|
+
* - it classifies as a non-`public` asset class (`classifyAssetPath`) —
|
|
39
|
+
* e.g. `orgs/{id}/private/*` pricing sheets are members-only and must
|
|
40
|
+
* never land in the CDN-readable bucket.
|
|
41
|
+
* Everything else (branding, covers, archives, thumbnails) is public.
|
|
40
42
|
*/
|
|
41
43
|
bucketFor(storagePath) {
|
|
42
|
-
|
|
44
|
+
if (/\.(gh|ghx)$/i.test(storagePath))
|
|
45
|
+
return this.privateBucket;
|
|
46
|
+
const cls = classifyAssetPath(storagePath);
|
|
47
|
+
if (cls && cls.class.visibility !== 'public')
|
|
48
|
+
return this.privateBucket;
|
|
49
|
+
return this.publicBucket;
|
|
43
50
|
}
|
|
44
51
|
async get(storagePath) {
|
|
45
52
|
const bucket = this.bucketFor(storagePath);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SupabaseStorageProvider.js","sourceRoot":"","sources":["../../src/storage/SupabaseStorageProvider.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"SupabaseStorageProvider.js","sourceRoot":"","sources":["../../src/storage/SupabaseStorageProvider.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAyCtF,MAAM,OAAO,uBAAuB;IAClB,MAAM,CAAiB;IACvB,YAAY,CAAS;IACrB,aAAa,CAAS;IACtB,aAAa,CAAS;IACtB,gBAAgB,CAAS;IAE1C,MAAM,CAAC,OAAO,CAAC,GAAuC;QACrD,MAAM,WAAW,GAAG,GAAG,CAAC,YAAY,CAAC;QACrC,MAAM,cAAc,GAAG,GAAG,CAAC,yBAAyB,CAAC;QACrD,IAAI,CAAC,WAAW;YAAE,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;QAC5E,IAAI,CAAC,cAAc;YAAE,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;QAC5F,OAAO,IAAI,uBAAuB,CAAC;YAClC,WAAW;YACX,cAAc;YACd,YAAY,EAAE,GAAG,CAAC,sBAAsB;YACxC,aAAa,EAAE,GAAG,CAAC,uBAAuB;YAC1C,gBAAgB,EAAE,GAAG,CAAC,2BAA2B;SACjD,CAAC,CAAC;IACJ,CAAC;IAED,YAAY,MAAqC;QAChD,IAAI,CAAC,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,cAAc,EAAE;YACrE,IAAI,EAAE,EAAE,cAAc,EAAE,KAAK,EAAE,gBAAgB,EAAE,KAAK,EAAE;SACxD,CAAC,CAAC;QACH,IAAI,CAAC,YAAY,GAAG,MAAM,CAAC,YAAY,IAAI,cAAc,CAAC;QAC1D,IAAI,CAAC,aAAa,GAAG,MAAM,CAAC,aAAa,IAAI,eAAe,CAAC;QAC7D,gFAAgF;QAChF,IAAI,CAAC,aAAa,GAAG,GAAG,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,6BAA6B,IAAI,CAAC,YAAY,EAAE,CAAC;QAC9G,IAAI,CAAC,gBAAgB,GAAG,CAAC,MAAM,CAAC,gBAAgB,IAAI,YAAY,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACtF,CAAC;IAED;;;;;;;;OAQG;IACK,SAAS,CAAC,WAAmB;QACpC,IAAI,cAAc,CAAC,IAAI,CAAC,WAAW,CAAC;YAAE,OAAO,IAAI,CAAC,aAAa,CAAC;QAChE,MAAM,GAAG,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;QAC3C,IAAI,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,UAAU,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC,aAAa,CAAC;QACxE,OAAO,IAAI,CAAC,YAAY,CAAC;IAC1B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,WAAmB;QAC5B,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC;QAC3C,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;QACrF,IAAI,KAAK,EAAE,CAAC;YACX,uEAAuE;YACvE,0EAA0E;YAC1E,MAAM,GAAG,GAAI,KAA8B,CAAC,OAAO,IAAI,EAAE,CAAC;YAC1D,IAAI,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,cAAc,CAAC,IAAI,CAAC,GAAG,CAAC;gBAAE,OAAO,IAAI,CAAC;YACpE,MAAM,KAAK,CAAC;QACb,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,WAAW,EAAE,CAAC;QACxC,OAAO,IAAI,UAAU,CAAC,MAAM,CAAC,CAAC;IAC/B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,WAAmB,EAAE,IAAgB,EAAE,WAAoB;QACpE,uEAAuE;QACvE,0EAA0E;QAC1E,MAAM,UAAU,GAAG,MAAM,sBAAsB,CAAC,IAAI,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC;QAChF,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;QAC/C,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO;aACzC,IAAI,CAAC,MAAM,CAAC;aACZ,MAAM,CAAC,UAAU,CAAC,IAAI,EAAE,UAAU,CAAC,IAAI,EAAE;YACzC,WAAW,EAAE,UAAU,CAAC,WAAW;YACnC,MAAM,EAAE,IAAI;SACZ,CAAC,CAAC;QACJ,IAAI,KAAK;YAAE,MAAM,KAAK,CAAC;IACxB,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,WAAmB;QAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,CAAC;QAC3C,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC;QAC/E,8EAA8E;QAC9E,IAAI,KAAK;YAAE,MAAM,KAAK,CAAC;IACxB,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAChC,uEAAuE;QACvE,0EAA0E;QAC1E,MAAM,OAAO,CAAC,GAAG,CAAC;YACjB,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,YAAY,EAAE,MAAM,CAAC;YACpD,IAAI,CAAC,oBAAoB,CAAC,IAAI,CAAC,aAAa,EAAE,MAAM,CAAC;SACrD,CAAC,CAAC;IACJ,CAAC;IAEO,KAAK,CAAC,oBAAoB,CAAC,MAAc,EAAE,MAAc;QAChE,MAAM,UAAU,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QAC9C,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;QACxD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAC9B,yDAAyD;QACzD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC;YAC5C,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,CAAC;YACtC,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACvE,IAAI,KAAK;gBAAE,MAAM,KAAK,CAAC;QACxB,CAAC;IACF,CAAC;IAED;;;OAGG;IACK,KAAK,CAAC,WAAW,CAAC,MAAc,EAAE,MAAc;QACvD,MAAM,GAAG,GAAa,EAAE,CAAC;QACzB,MAAM,KAAK,GAAa,CAAC,MAAM,CAAC,CAAC;QACjC,OAAO,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACzB,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,EAAG,CAAC;YACzB,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE;gBACxE,KAAK,EAAE,IAAI;gBACX,MAAM,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE;aACxC,CAAC,CAAC;YACH,IAAI,KAAK;gBAAE,MAAM,KAAK,CAAC;YACvB,KAAK,MAAM,KAAK,IAAI,IAAI,IAAI,EAAE,EAAE,CAAC;gBAChC,MAAM,IAAI,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC;gBACvD,mDAAmD;gBACnD,IAAI,KAAK,CAAC,EAAE,KAAK,IAAI,EAAE,CAAC;oBACvB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAClB,CAAC;qBAAM,CAAC;oBACP,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAChB,CAAC;YACF,CAAC;QACF,CAAC;QACD,OAAO,GAAG,CAAC;IACZ,CAAC;IAED,YAAY,CAAC,WAAmB;QAC/B,IAAI,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,KAAK,IAAI,CAAC,YAAY,EAAE,CAAC;YACvD,OAAO,GAAG,IAAI,CAAC,aAAa,IAAI,WAAW,EAAE,CAAC;QAC/C,CAAC;QACD,8EAA8E;QAC9E,OAAO,GAAG,IAAI,CAAC,gBAAgB,IAAI,WAAW,EAAE,CAAC;IAClD,CAAC;CACD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/supabase-provider",
|
|
3
|
-
"version": "0.14.
|
|
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.
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
});
|