@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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit Q4/Q5 — the concurrent-solve guarantee for share-link caps.
|
|
3
|
+
*
|
|
4
|
+
* `tryIncrementSolveCount` is the load-bearing race-sensitive method: the
|
|
5
|
+
* `/api/compute` endpoint calls it BEFORE solving and turns a `null` return
|
|
6
|
+
* (cap hit) into HTTP 429. Under concurrent solves against one capped link, the
|
|
7
|
+
* cap must hold exactly — no overshoot, no lost update handing two solves the
|
|
8
|
+
* same slot. Supabase provides this via the SECURITY DEFINER
|
|
9
|
+
* `try_increment_share_link_solve_count` RPC, which does the check-and-increment
|
|
10
|
+
* in a single statement.
|
|
11
|
+
*
|
|
12
|
+
* This lives in the Supabase suite (not the shared conformance suite or the app
|
|
13
|
+
* route tests) because it is the ONLY backend that actually provides the
|
|
14
|
+
* guarantee: the local provider's whole-file read-modify-write is a documented
|
|
15
|
+
* dev-scale tradeoff and is not concurrency-safe. Skipped without a live stack,
|
|
16
|
+
* matching the other conformance tests here.
|
|
17
|
+
*
|
|
18
|
+
* Complements the sequential cap/count tests at
|
|
19
|
+
* `packages/selva/src/routes/api/compute/__tests__/solve-cap-and-count.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
23
|
+
import type { RequestContext, ShareLink } from '@selvajs/platform';
|
|
24
|
+
import { SupabaseShareLinkStore } from '../SupabaseShareLinkStore.js';
|
|
25
|
+
import { readEnv, resetAllData, seedUser } from './test-helpers.js';
|
|
26
|
+
|
|
27
|
+
const envCtx = readEnv();
|
|
28
|
+
|
|
29
|
+
if (!envCtx) {
|
|
30
|
+
describe.skip('SupabaseShareLinkStore concurrency (skipped: no live stack)', () => {
|
|
31
|
+
it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => {});
|
|
32
|
+
});
|
|
33
|
+
} else {
|
|
34
|
+
const ctx: RequestContext = { userId: '', system: true } as RequestContext;
|
|
35
|
+
|
|
36
|
+
/** Seed org → project → definition → share link, all via service-role. */
|
|
37
|
+
async function seedCappedLink(maxSolves: number): Promise<{ linkId: string }> {
|
|
38
|
+
const { userId: ownerId } = await seedUser(envCtx!, '');
|
|
39
|
+
const admin = envCtx!.adminClient;
|
|
40
|
+
const orgId = crypto.randomUUID();
|
|
41
|
+
const projectId = crypto.randomUUID();
|
|
42
|
+
const definitionId = crypto.randomUUID();
|
|
43
|
+
const linkId = crypto.randomUUID();
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
|
|
46
|
+
const { error: orgErr } = await admin.from('orgs').insert({
|
|
47
|
+
id: orgId,
|
|
48
|
+
name: 'Concurrency Org',
|
|
49
|
+
slug: `conc-${orgId.slice(0, 8)}`,
|
|
50
|
+
owner_id: ownerId,
|
|
51
|
+
created_at: now,
|
|
52
|
+
updated_at: now
|
|
53
|
+
});
|
|
54
|
+
if (orgErr) throw orgErr;
|
|
55
|
+
const { error: projErr } = await admin.from('projects').insert({
|
|
56
|
+
id: projectId,
|
|
57
|
+
org_id: orgId,
|
|
58
|
+
name: 'Project',
|
|
59
|
+
slug: `proj-${projectId.slice(0, 8)}`,
|
|
60
|
+
visibility: 'public',
|
|
61
|
+
owner_id: ownerId,
|
|
62
|
+
created_at: now,
|
|
63
|
+
updated_at: now
|
|
64
|
+
});
|
|
65
|
+
if (projErr) throw projErr;
|
|
66
|
+
const { error: defErr } = await admin.from('definitions').insert({
|
|
67
|
+
guid: definitionId,
|
|
68
|
+
project_id: projectId,
|
|
69
|
+
owner_id: ownerId,
|
|
70
|
+
display_name: 'Def',
|
|
71
|
+
status: 'published',
|
|
72
|
+
created_at: now,
|
|
73
|
+
updated_at: now
|
|
74
|
+
});
|
|
75
|
+
if (defErr) throw defErr;
|
|
76
|
+
|
|
77
|
+
const link: ShareLink = {
|
|
78
|
+
id: linkId,
|
|
79
|
+
definitionId,
|
|
80
|
+
channel: 'live',
|
|
81
|
+
tokenHash: crypto.randomUUID().replace(/-/g, ''),
|
|
82
|
+
createdBy: ownerId,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
expiresAt: null,
|
|
85
|
+
revokedAt: null,
|
|
86
|
+
allowSolve: true,
|
|
87
|
+
maxSolves,
|
|
88
|
+
solveCount: 0
|
|
89
|
+
};
|
|
90
|
+
const store = new SupabaseShareLinkStore(envCtx!.bundle);
|
|
91
|
+
await store.create(ctx, link);
|
|
92
|
+
return { linkId };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('SupabaseShareLinkStore — concurrent solve-cap enforcement', () => {
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
await resetAllData(envCtx!);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('N concurrent solves against a cap-K link admit exactly K, refuse the rest', async () => {
|
|
101
|
+
const CAP = 5;
|
|
102
|
+
const ATTEMPTS = 25;
|
|
103
|
+
const { linkId } = await seedCappedLink(CAP);
|
|
104
|
+
const store = new SupabaseShareLinkStore(envCtx!.bundle);
|
|
105
|
+
|
|
106
|
+
const outcomes = await Promise.all(
|
|
107
|
+
Array.from({ length: ATTEMPTS }, () => store.tryIncrementSolveCount(ctx, linkId))
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const admitted = outcomes.filter((n): n is number => n !== null);
|
|
111
|
+
const refused = outcomes.filter((n) => n === null);
|
|
112
|
+
// Exactly CAP admitted — the atomic RPC never lets a concurrent burst
|
|
113
|
+
// overshoot the cap.
|
|
114
|
+
expect(admitted).toHaveLength(CAP);
|
|
115
|
+
expect(refused).toHaveLength(ATTEMPTS - CAP);
|
|
116
|
+
// Each admitted solve got a distinct slot 1..CAP — no lost update.
|
|
117
|
+
expect([...admitted].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
|
|
118
|
+
|
|
119
|
+
// The persisted count settles at exactly the cap.
|
|
120
|
+
const finalLink = await store.getById(ctx, linkId);
|
|
121
|
+
expect(finalLink?.solveCount).toBe(CAP);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/data/client.ts
CHANGED
|
@@ -10,6 +10,27 @@ import type { RequestContext } from '@selvajs/platform';
|
|
|
10
10
|
*/
|
|
11
11
|
export type SelvaSchemaClient = SupabaseClient<any, 'public', 'selva'>;
|
|
12
12
|
|
|
13
|
+
/** Default schema every engine store targets. */
|
|
14
|
+
export const DEFAULT_SCHEMA = 'selva' as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A client pinned to a caller-chosen schema. When the schema is `'selva'` this
|
|
18
|
+
* is the narrow `SelvaSchemaClient`; for an app-owned schema (e.g. `'public'`)
|
|
19
|
+
* the schema generic is opaque — the store surface (`.from` / `.rpc`) is the
|
|
20
|
+
* same either way.
|
|
21
|
+
*/
|
|
22
|
+
export type SchemaClient = SupabaseClient<any, string, any>;
|
|
23
|
+
|
|
24
|
+
export interface ForRequestOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Postgres schema this client targets. Defaults to `'selva'` (the engine's
|
|
27
|
+
* tables). Pass `'public'` (or another app schema) to build stores on the
|
|
28
|
+
* consuming app's own tables while keeping the same fail-closed RLS
|
|
29
|
+
* dispatch as the engine stores.
|
|
30
|
+
*/
|
|
31
|
+
schema?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
/**
|
|
14
35
|
* Build a Supabase client for a given `RequestContext`.
|
|
15
36
|
*
|
|
@@ -35,15 +56,20 @@ export type SelvaSchemaClient = SupabaseClient<any, 'public', 'selva'>;
|
|
|
35
56
|
* fetch connection without leaking auth across requests.
|
|
36
57
|
*/
|
|
37
58
|
export interface ClientBundle {
|
|
38
|
-
/** Long-lived service-role client. Bypasses RLS. */
|
|
59
|
+
/** Long-lived service-role client, pinned to the `'selva'` schema. Bypasses RLS. */
|
|
39
60
|
serviceClient: SelvaSchemaClient;
|
|
40
61
|
/**
|
|
41
|
-
* Produce (or reuse) a client scoped to this request's identity
|
|
62
|
+
* Produce (or reuse) a client scoped to this request's identity, targeting
|
|
63
|
+
* `opts.schema` (default `'selva'`).
|
|
42
64
|
* - `ctx.system === true` → service-role (RLS bypassed).
|
|
43
65
|
* - `ctx.adapterContext.sessionToken` set → user-scoped (RLS active).
|
|
44
66
|
* - neither → anon client (RLS active, no `auth.uid()`).
|
|
67
|
+
*
|
|
68
|
+
* The schema pin and the RLS dispatch travel together: an app building
|
|
69
|
+
* stores on its own `'public'` tables gets the same fail-closed behavior as
|
|
70
|
+
* engine stores, just aimed at a different schema.
|
|
45
71
|
*/
|
|
46
|
-
forRequest(ctx: RequestContext):
|
|
72
|
+
forRequest(ctx: RequestContext, opts?: ForRequestOptions): SchemaClient;
|
|
47
73
|
}
|
|
48
74
|
|
|
49
75
|
export interface BuildClientOptions {
|
|
@@ -53,44 +79,93 @@ export interface BuildClientOptions {
|
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
export function buildClientBundle(opts: BuildClientOptions): ClientBundle {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
// Long-lived service-role + anon clients, one per schema. Both are stateless
|
|
83
|
+
// w.r.t. request identity, so a single instance is safe to share across all
|
|
84
|
+
// requests targeting the same schema.
|
|
85
|
+
const serviceBySchema = new Map<string, SchemaClient>();
|
|
86
|
+
const anonBySchema = new Map<string, SchemaClient>();
|
|
87
|
+
|
|
88
|
+
function serviceFor(schema: string): SchemaClient {
|
|
89
|
+
let client = serviceBySchema.get(schema);
|
|
90
|
+
if (!client) {
|
|
91
|
+
client = createClient(opts.supabaseUrl, opts.serviceRoleKey, {
|
|
92
|
+
db: { schema },
|
|
93
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
94
|
+
});
|
|
95
|
+
serviceBySchema.set(schema, client);
|
|
96
|
+
}
|
|
97
|
+
return client;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function anonFor(schema: string): SchemaClient {
|
|
101
|
+
let client = anonBySchema.get(schema);
|
|
102
|
+
if (!client) {
|
|
103
|
+
client = createClient(opts.supabaseUrl, opts.anonKey, {
|
|
104
|
+
db: { schema },
|
|
105
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
106
|
+
});
|
|
107
|
+
anonBySchema.set(schema, client);
|
|
108
|
+
}
|
|
109
|
+
return client;
|
|
110
|
+
}
|
|
60
111
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
db: { schema: 'selva' },
|
|
65
|
-
auth: { persistSession: false, autoRefreshToken: false }
|
|
66
|
-
});
|
|
112
|
+
// The `'selva'`-pinned service client is exposed directly on the bundle for
|
|
113
|
+
// engine code that reaches for RLS-bypassing reads without a request context.
|
|
114
|
+
const serviceClient = serviceFor(DEFAULT_SCHEMA) as SelvaSchemaClient;
|
|
67
115
|
|
|
68
|
-
|
|
116
|
+
// Per-request user-scoped clients, keyed first by context then by schema so
|
|
117
|
+
// a handler that touches both `'selva'` and an app schema reuses one client
|
|
118
|
+
// per (request, schema) without leaking auth across requests.
|
|
119
|
+
const perRequestCache = new WeakMap<RequestContext, Map<string, SchemaClient>>();
|
|
69
120
|
|
|
70
121
|
return {
|
|
71
122
|
serviceClient,
|
|
72
|
-
forRequest(ctx: RequestContext):
|
|
73
|
-
|
|
123
|
+
forRequest(ctx: RequestContext, forOpts?: ForRequestOptions): SchemaClient {
|
|
124
|
+
const schema = forOpts?.schema ?? DEFAULT_SCHEMA;
|
|
125
|
+
if (ctx.system) return serviceFor(schema);
|
|
74
126
|
|
|
75
127
|
const token = extractSessionToken(ctx.adapterContext);
|
|
76
|
-
if (!token) return
|
|
128
|
+
if (!token) return anonFor(schema);
|
|
77
129
|
|
|
78
|
-
|
|
130
|
+
let bySchema = perRequestCache.get(ctx);
|
|
131
|
+
if (!bySchema) {
|
|
132
|
+
bySchema = new Map();
|
|
133
|
+
perRequestCache.set(ctx, bySchema);
|
|
134
|
+
}
|
|
135
|
+
const cached = bySchema.get(schema);
|
|
79
136
|
if (cached) return cached;
|
|
80
137
|
|
|
81
138
|
const client = createClient(opts.supabaseUrl, opts.anonKey, {
|
|
82
|
-
db: { schema
|
|
139
|
+
db: { schema },
|
|
83
140
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
84
141
|
global: {
|
|
85
142
|
headers: { Authorization: `Bearer ${token}` }
|
|
86
143
|
}
|
|
87
144
|
});
|
|
88
|
-
|
|
145
|
+
bySchema.set(schema, client);
|
|
89
146
|
return client;
|
|
90
147
|
}
|
|
91
148
|
};
|
|
92
149
|
}
|
|
93
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Build a `ClientBundle` straight from environment variables — the
|
|
153
|
+
* `SUPABASE_URL` / `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` triple,
|
|
154
|
+
* nothing else. Deliberately does NOT require `SELVA_AT_REST_KEY`: that key
|
|
155
|
+
* only matters for compute-server secret encryption, which a consuming app
|
|
156
|
+
* building its own `'public'` stores never touches. `SupabaseDataProvider.fromEnv`
|
|
157
|
+
* layers the at-rest-key requirement on top of this.
|
|
158
|
+
*/
|
|
159
|
+
export function clientBundleFromEnv(env: Record<string, string | undefined>): ClientBundle {
|
|
160
|
+
const supabaseUrl = env.SUPABASE_URL;
|
|
161
|
+
const anonKey = env.SUPABASE_ANON_KEY;
|
|
162
|
+
const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
|
|
163
|
+
if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
|
|
164
|
+
if (!anonKey) throw new Error('Missing required env var: SUPABASE_ANON_KEY');
|
|
165
|
+
if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
|
|
166
|
+
return buildClientBundle({ supabaseUrl, anonKey, serviceRoleKey });
|
|
167
|
+
}
|
|
168
|
+
|
|
94
169
|
function extractSessionToken(adapterContext: unknown): string | undefined {
|
|
95
170
|
if (!adapterContext || typeof adapterContext !== 'object') return undefined;
|
|
96
171
|
const maybe = (adapterContext as { sessionToken?: unknown }).sessionToken;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ProviderError } from '@selvajs/platform';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical PostgREST/Postgres error translation shared by every Supabase
|
|
5
|
+
* store. Replaces the six near-identical private `mapError` copies that had
|
|
6
|
+
* drifted into two variants (a "full" one preserving `details`/`hint`, and a
|
|
7
|
+
* slimmer message-only one) and none of which handled `PGRST116`.
|
|
8
|
+
*
|
|
9
|
+
* Mapping:
|
|
10
|
+
* - `PGRST116` (single-row selector matched zero rows) → 404. PostgREST
|
|
11
|
+
* raises this when `.single()` finds nothing; treating it as "not found"
|
|
12
|
+
* is almost always what a store's caller wants.
|
|
13
|
+
* - `23505` (unique violation) / `23503` (foreign-key violation) → 409.
|
|
14
|
+
* - Anything else → a plain `Error` (500 downstream via `api-errors`), with
|
|
15
|
+
* `details`/`hint`/`code` preserved on the error object for logs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The subset of a `PostgrestError` this mapper inspects. */
|
|
19
|
+
interface PostgrestErrorShape {
|
|
20
|
+
code?: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
details?: string;
|
|
23
|
+
hint?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function mapPostgrestError(e: unknown): Error {
|
|
27
|
+
const pg = e as PostgrestErrorShape;
|
|
28
|
+
switch (pg?.code) {
|
|
29
|
+
case 'PGRST116':
|
|
30
|
+
return new ProviderError(pg.message ?? 'Not found', 404);
|
|
31
|
+
case '23505':
|
|
32
|
+
return new ProviderError(pg.message ?? 'Duplicate record', 409);
|
|
33
|
+
case '23503':
|
|
34
|
+
return new ProviderError(pg.message ?? 'Foreign key violation', 409);
|
|
35
|
+
}
|
|
36
|
+
if (e instanceof Error) return e;
|
|
37
|
+
if (e && typeof e === 'object') {
|
|
38
|
+
const obj = e as PostgrestErrorShape;
|
|
39
|
+
const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
|
|
40
|
+
const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
|
|
41
|
+
Object.assign(err, obj);
|
|
42
|
+
return err;
|
|
43
|
+
}
|
|
44
|
+
return new Error(String(e));
|
|
45
|
+
}
|
package/src/data/index.ts
CHANGED
|
@@ -8,5 +8,22 @@ export { SupabaseDataProvider } from './SupabaseDataProvider.js';
|
|
|
8
8
|
export { SupabaseEventSink } from './SupabaseEventSink.js';
|
|
9
9
|
export { SupabaseSolveMetricSink } from './SupabaseSolveMetricSink.js';
|
|
10
10
|
export { SupabaseAuditQuery } from './SupabaseAuditQuery.js';
|
|
11
|
-
export { buildClientBundle } from './client.js';
|
|
12
|
-
export type {
|
|
11
|
+
export { buildClientBundle, clientBundleFromEnv, DEFAULT_SCHEMA } from './client.js';
|
|
12
|
+
export type {
|
|
13
|
+
ClientBundle,
|
|
14
|
+
BuildClientOptions,
|
|
15
|
+
ForRequestOptions,
|
|
16
|
+
SchemaClient,
|
|
17
|
+
SelvaSchemaClient
|
|
18
|
+
} from './client.js';
|
|
19
|
+
export { mapPostgrestError } from './errors.js';
|
|
20
|
+
export { stampUpdate, stampSoftDelete } from './rowStamp.js';
|
|
21
|
+
export { notDeleted } from './query.js';
|
|
22
|
+
export {
|
|
23
|
+
toRange,
|
|
24
|
+
orderColumn,
|
|
25
|
+
nextCursorFromRange,
|
|
26
|
+
encodeCursor,
|
|
27
|
+
decodeCursor
|
|
28
|
+
} from './pagination.js';
|
|
29
|
+
export type { RangeSpec } from './pagination.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small query-builder helpers shared across Supabase stores.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The slice of a PostgREST filter builder these helpers touch: an `.is()`
|
|
7
|
+
* method that returns the same builder for chaining. Generic over the builder
|
|
8
|
+
* type so the caller's concrete builder (and its row generic) flows through.
|
|
9
|
+
*/
|
|
10
|
+
interface DeletableQuery {
|
|
11
|
+
is(column: string, value: null): this;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Apply the soft-delete filter `deleted_at IS NULL`. The `.is('deleted_at', null)`
|
|
16
|
+
* idiom is repeated across every store that soft-deletes; this names it.
|
|
17
|
+
*
|
|
18
|
+
* let q = notDeleted(client.from('projects').select(COLS));
|
|
19
|
+
*/
|
|
20
|
+
export function notDeleted<Q extends DeletableQuery>(query: Q): Q {
|
|
21
|
+
return query.is('deleted_at', null);
|
|
22
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { RequestContext } from '@selvajs/platform';
|
|
2
|
+
import { auditSoftDelete } from '@selvajs/platform';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Row-level (snake_case) audit-stamp helpers for Supabase stores.
|
|
6
|
+
*
|
|
7
|
+
* Platform's `auditUpdate` / `auditSoftDelete` emit camelCase *domain* fields
|
|
8
|
+
* (`updatedAt` / `updatedBy`), but Postgres rows are snake_case, so every store
|
|
9
|
+
* hand-translated them. These helpers own that translation once. Row-column
|
|
10
|
+
* naming is a provider concern, so they live here rather than in platform.
|
|
11
|
+
*
|
|
12
|
+
* Two deliberate rules, matched to how the engine's stores already write rows:
|
|
13
|
+
* - `updated_by` is written ONLY when `ctx.userId` is set. On system contexts
|
|
14
|
+
* it's the empty string, and the column FKs `auth.users(id)` with
|
|
15
|
+
* `ON DELETE SET NULL` — writing `''` would violate the FK, so it's omitted
|
|
16
|
+
* and the existing value is left untouched.
|
|
17
|
+
* - Plain updates DON'T stamp `updated_at`: a per-table `updated_at` trigger
|
|
18
|
+
* already sets it. Only the soft-delete stamp writes `updated_at` explicitly,
|
|
19
|
+
* so it matches `deleted_at` (a single timestamp for the deletion event).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stamp for a plain UPDATE: `{ updated_by }` when the context carries a user,
|
|
24
|
+
* `{}` otherwise (the DB trigger handles `updated_at`). Spread into the row:
|
|
25
|
+
* `const row = { ...patch, ...stampUpdate(ctx) }`.
|
|
26
|
+
*/
|
|
27
|
+
export function stampUpdate(ctx: RequestContext): { updated_by?: string } {
|
|
28
|
+
return ctx.userId ? { updated_by: ctx.userId } : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stamp for a soft-delete: `{ deleted_at, updated_at, updated_by? }`. `updated_at`
|
|
33
|
+
* is set explicitly to equal `deleted_at`; `updated_by` follows the same
|
|
34
|
+
* omit-when-empty rule as {@link stampUpdate}. `fallbackActor` defaults to
|
|
35
|
+
* `ctx.userId` (matching the engine stores' `auditSoftDelete(ctx, ctx.userId)`).
|
|
36
|
+
*/
|
|
37
|
+
export function stampSoftDelete(
|
|
38
|
+
ctx: RequestContext,
|
|
39
|
+
fallbackActor: string | null | undefined = ctx.userId
|
|
40
|
+
): { deleted_at: string; updated_at: string; updated_by?: string } {
|
|
41
|
+
const stamp = auditSoftDelete(ctx, fallbackActor);
|
|
42
|
+
const row: { deleted_at: string; updated_at: string; updated_by?: string } = {
|
|
43
|
+
deleted_at: stamp.deletedAt,
|
|
44
|
+
updated_at: stamp.updatedAt
|
|
45
|
+
};
|
|
46
|
+
if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
|
|
47
|
+
return row;
|
|
48
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,9 +12,27 @@ export {
|
|
|
12
12
|
SupabaseEventSink,
|
|
13
13
|
SupabaseSolveMetricSink,
|
|
14
14
|
SupabaseAuditQuery,
|
|
15
|
-
buildClientBundle
|
|
15
|
+
buildClientBundle,
|
|
16
|
+
clientBundleFromEnv,
|
|
17
|
+
DEFAULT_SCHEMA,
|
|
18
|
+
mapPostgrestError,
|
|
19
|
+
stampUpdate,
|
|
20
|
+
stampSoftDelete,
|
|
21
|
+
notDeleted,
|
|
22
|
+
toRange,
|
|
23
|
+
orderColumn,
|
|
24
|
+
nextCursorFromRange,
|
|
25
|
+
encodeCursor,
|
|
26
|
+
decodeCursor
|
|
27
|
+
} from './data/index.js';
|
|
28
|
+
export type {
|
|
29
|
+
ClientBundle,
|
|
30
|
+
BuildClientOptions,
|
|
31
|
+
ForRequestOptions,
|
|
32
|
+
SchemaClient,
|
|
33
|
+
SelvaSchemaClient,
|
|
34
|
+
RangeSpec
|
|
16
35
|
} from './data/index.js';
|
|
17
|
-
export type { ClientBundle, BuildClientOptions } from './data/index.js';
|
|
18
36
|
|
|
19
37
|
export { SupabaseUserProfileProvider } from './userProfile/index.js';
|
|
20
38
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
2
|
import { createClient } from '@supabase/supabase-js';
|
|
3
|
-
import { transcodeImageIfNeeded } from '@selvajs/platform/storage';
|
|
3
|
+
import { transcodeImageIfNeeded, classifyAssetPath } from '@selvajs/platform/storage';
|
|
4
4
|
import type { IStorageProvider } from '@selvajs/platform/storage';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -74,14 +74,19 @@ export class SupabaseStorageProvider implements IStorageProvider {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
|
-
* Route a storage path to a bucket.
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
77
|
+
* Route a storage path to a bucket. A path is private when either:
|
|
78
|
+
* - it's a `.gh`/`.ghx` source file — confidentiality by extension, so a
|
|
79
|
+
* path-scheme rename can't silently move sources into the public bucket;
|
|
80
|
+
* - it classifies as a non-`public` asset class (`classifyAssetPath`) —
|
|
81
|
+
* e.g. `orgs/{id}/private/*` pricing sheets are members-only and must
|
|
82
|
+
* never land in the CDN-readable bucket.
|
|
83
|
+
* Everything else (branding, covers, archives, thumbnails) is public.
|
|
82
84
|
*/
|
|
83
85
|
private bucketFor(storagePath: string): string {
|
|
84
|
-
|
|
86
|
+
if (/\.(gh|ghx)$/i.test(storagePath)) return this.privateBucket;
|
|
87
|
+
const cls = classifyAssetPath(storagePath);
|
|
88
|
+
if (cls && cls.class.visibility !== 'public') return this.privateBucket;
|
|
89
|
+
return this.publicBucket;
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
async get(storagePath: string): Promise<Uint8Array | null> {
|
package/supabase/config.toml
CHANGED
|
@@ -7,7 +7,7 @@ project_id = "selva-supabase-provider"
|
|
|
7
7
|
|
|
8
8
|
[api]
|
|
9
9
|
enabled = true
|
|
10
|
-
port =
|
|
10
|
+
port = 54421
|
|
11
11
|
# Exposed REST schemas. `selva` (where all Selva tables live, isolated from a
|
|
12
12
|
# consuming app's `public`) is intentionally NOT listed here: PostgREST loads
|
|
13
13
|
# this config at stack boot, *before* migrations run, so naming a not-yet-created
|
|
@@ -20,8 +20,8 @@ extra_search_path = ["public", "extensions"]
|
|
|
20
20
|
max_rows = 1000
|
|
21
21
|
|
|
22
22
|
[db]
|
|
23
|
-
port =
|
|
24
|
-
shadow_port =
|
|
23
|
+
port = 54422
|
|
24
|
+
shadow_port = 54420
|
|
25
25
|
major_version = 15
|
|
26
26
|
|
|
27
27
|
[db.pooler]
|
|
@@ -33,11 +33,11 @@ sql_paths = ["./seed.sql"]
|
|
|
33
33
|
|
|
34
34
|
[studio]
|
|
35
35
|
enabled = true
|
|
36
|
-
port =
|
|
36
|
+
port = 54423
|
|
37
37
|
|
|
38
38
|
[inbucket]
|
|
39
39
|
enabled = true
|
|
40
|
-
port =
|
|
40
|
+
port = 54424
|
|
41
41
|
|
|
42
42
|
[storage]
|
|
43
43
|
enabled = true
|
|
@@ -45,7 +45,7 @@ file_size_limit = "50MiB"
|
|
|
45
45
|
|
|
46
46
|
[auth]
|
|
47
47
|
enabled = true
|
|
48
|
-
site_url = "http://127.0.0.1:
|
|
48
|
+
site_url = "http://127.0.0.1:54421"
|
|
49
49
|
additional_redirect_urls = ["http://127.0.0.1:5173"]
|
|
50
50
|
jwt_expiry = 3600
|
|
51
51
|
enable_signup = true
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Add org branding assets
|
|
3
|
+
--
|
|
4
|
+
-- A JSONB `assets` map on selva.orgs holds public URLs of the org's branding
|
|
5
|
+
-- assets, keyed by kind (`logo`, `favicon`, …) — e.g. {"logo": "https://…"}.
|
|
6
|
+
-- Each blob lives in the public storage bucket at `orgs/{id}/{kind}.webp`;
|
|
7
|
+
-- every upload (including SVG) is rasterized to WebP by the shared transcoder,
|
|
8
|
+
-- so no vector blob is ever stored and the served bytes carry no XSS surface.
|
|
9
|
+
--
|
|
10
|
+
-- One JSONB column instead of a column-per-asset: adding a new asset kind is an
|
|
11
|
+
-- app-layer enum change, never another migration.
|
|
12
|
+
--
|
|
13
|
+
-- No new RLS policy: the existing `orgs` UPDATE policy
|
|
14
|
+
-- (instance_admin or owner_id = auth.uid()) already governs every column on the
|
|
15
|
+
-- row; asset edits are additionally gated to `manage_org_members` at the app
|
|
16
|
+
-- layer.
|
|
17
|
+
-- ============================================================================
|
|
18
|
+
|
|
19
|
+
alter table selva.orgs add column if not exists assets jsonb not null default '{}'::jsonb;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- Expose the cached schema's format version as a queryable column (ADR 0005).
|
|
3
|
+
--
|
|
4
|
+
-- The stored `schema` blob is a disposable cache of the compute-extracted UI
|
|
5
|
+
-- schema; its embedded `schemaVersion` says which schema format it was written
|
|
6
|
+
-- in. A GENERATED column keeps that version queryable for ops ("how many
|
|
7
|
+
-- versions are stale?") with zero write-path involvement — it can never drift
|
|
8
|
+
-- from the blob, and rows whose schema is NULL (pre-caching uploads) yield
|
|
9
|
+
-- NULL. The web render path re-extracts from compute on version mismatch; it
|
|
10
|
+
-- reads the version from the blob itself, not from this column.
|
|
11
|
+
-- ============================================================================
|
|
12
|
+
|
|
13
|
+
alter table selva.definition_versions
|
|
14
|
+
add column if not exists schema_version text
|
|
15
|
+
generated always as (schema ->> 'schemaVersion') stored;
|
|
16
|
+
|
|
17
|
+
comment on column selva.definition_versions.schema_version is
|
|
18
|
+
'Schema format version of the cached `schema` blob (generated from schema->>''schemaVersion''). NULL when no schema is cached. Ops/diagnostics only — the app reads the version from the blob.';
|