@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.
Files changed (94) 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 +1 -1
  25. package/dist/data/SupabaseOrgStore.d.ts.map +1 -1
  26. package/dist/data/SupabaseOrgStore.js +54 -86
  27. package/dist/data/SupabaseOrgStore.js.map +1 -1
  28. package/dist/data/SupabaseProjectStore.d.ts.map +1 -1
  29. package/dist/data/SupabaseProjectStore.js +51 -70
  30. package/dist/data/SupabaseProjectStore.js.map +1 -1
  31. package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -1
  32. package/dist/data/SupabaseShareLinkStore.js +13 -27
  33. package/dist/data/SupabaseShareLinkStore.js.map +1 -1
  34. package/dist/data/__tests__/compute-server-conformance.test.js +6 -1
  35. package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -1
  36. package/dist/data/__tests__/compute-server-encryption.test.d.ts +12 -0
  37. package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +1 -0
  38. package/dist/data/__tests__/compute-server-encryption.test.js +143 -0
  39. package/dist/data/__tests__/compute-server-encryption.test.js.map +1 -0
  40. package/dist/data/__tests__/share-link-concurrency.test.d.ts +22 -0
  41. package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +1 -0
  42. package/dist/data/__tests__/share-link-concurrency.test.js +115 -0
  43. package/dist/data/__tests__/share-link-concurrency.test.js.map +1 -0
  44. package/dist/data/client.d.ts +35 -3
  45. package/dist/data/client.d.ts.map +1 -1
  46. package/dist/data/client.js +67 -16
  47. package/dist/data/client.js.map +1 -1
  48. package/dist/data/errors.d.ts +2 -0
  49. package/dist/data/errors.d.ts.map +1 -0
  50. package/dist/data/errors.js +23 -0
  51. package/dist/data/errors.js.map +1 -0
  52. package/dist/data/index.d.ts +7 -2
  53. package/dist/data/index.d.ts.map +1 -1
  54. package/dist/data/index.js +5 -1
  55. package/dist/data/index.js.map +1 -1
  56. package/dist/data/query.d.ts +20 -0
  57. package/dist/data/query.d.ts.map +1 -0
  58. package/dist/data/query.js +13 -0
  59. package/dist/data/query.js.map +1 -0
  60. package/dist/data/rowStamp.d.ts +38 -0
  61. package/dist/data/rowStamp.d.ts.map +1 -0
  62. package/dist/data/rowStamp.js +43 -0
  63. package/dist/data/rowStamp.js.map +1 -0
  64. package/dist/index.d.ts +2 -2
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +1 -1
  67. package/dist/index.js.map +1 -1
  68. package/dist/storage/SupabaseStorageProvider.d.ts +7 -5
  69. package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -1
  70. package/dist/storage/SupabaseStorageProvider.js +14 -7
  71. package/dist/storage/SupabaseStorageProvider.js.map +1 -1
  72. package/package.json +2 -2
  73. package/src/auth/SupabaseAuthProvider.ts +140 -4
  74. package/src/auth/__tests__/token-verification.test.ts +158 -0
  75. package/src/data/SupabaseComputeServerStore.ts +163 -43
  76. package/src/data/SupabaseDataProvider.ts +35 -15
  77. package/src/data/SupabaseDefinitionStore.ts +73 -66
  78. package/src/data/SupabaseInviteStore.ts +13 -25
  79. package/src/data/SupabaseOrgStore.ts +57 -96
  80. package/src/data/SupabaseProjectStore.ts +55 -78
  81. package/src/data/SupabaseShareLinkStore.ts +15 -30
  82. package/src/data/__tests__/compute-server-conformance.test.ts +7 -1
  83. package/src/data/__tests__/compute-server-encryption.test.ts +173 -0
  84. package/src/data/__tests__/share-link-concurrency.test.ts +124 -0
  85. package/src/data/client.ts +95 -20
  86. package/src/data/errors.ts +45 -0
  87. package/src/data/index.ts +19 -2
  88. package/src/data/query.ts +22 -0
  89. package/src/data/rowStamp.ts +48 -0
  90. package/src/index.ts +20 -2
  91. package/src/storage/SupabaseStorageProvider.ts +12 -7
  92. package/supabase/config.toml +6 -6
  93. package/supabase/migrations/20260630120000_org_assets.sql +19 -0
  94. 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
+ }
@@ -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): SelvaSchemaClient;
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
- const serviceClient = createClient(opts.supabaseUrl, opts.serviceRoleKey, {
57
- db: { schema: 'selva' },
58
- auth: { persistSession: false, autoRefreshToken: false }
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
- // Long-lived anon client — the fail-closed default for ctxes that aren't
62
- // explicitly system and don't carry a session token.
63
- const anonClient = createClient(opts.supabaseUrl, opts.anonKey, {
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
- const perRequestCache = new WeakMap<RequestContext, SelvaSchemaClient>();
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): SelvaSchemaClient {
73
- if (ctx.system) return serviceClient;
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 anonClient;
128
+ if (!token) return anonFor(schema);
77
129
 
78
- const cached = perRequestCache.get(ctx);
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: 'selva' },
139
+ db: { schema },
83
140
  auth: { persistSession: false, autoRefreshToken: false },
84
141
  global: {
85
142
  headers: { Authorization: `Bearer ${token}` }
86
143
  }
87
144
  });
88
- perRequestCache.set(ctx, client);
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 { ClientBundle, BuildClientOptions } from './client.js';
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. Any `.gh`/`.ghx` source file is
78
- * private — confidentiality is determined by extension, not location, so
79
- * a path-scheme rename (e.g. `definition.gh` → `versions/v1.gh`) can't
80
- * silently move source files into the public bucket. Everything else
81
- * (covers, archives, thumbnails) is public.
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
- return /\.(gh|ghx)$/i.test(storagePath) ? this.privateBucket : this.publicBucket;
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> {
@@ -7,7 +7,7 @@ project_id = "selva-supabase-provider"
7
7
 
8
8
  [api]
9
9
  enabled = true
10
- port = 54321
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 = 54322
24
- shadow_port = 54320
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 = 54323
36
+ port = 54423
37
37
 
38
38
  [inbucket]
39
39
  enabled = true
40
- port = 54324
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:54321"
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.';