@selvajs/supabase-provider 0.14.3 → 0.14.4-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/auth/SupabaseAuthProvider.d.ts +57 -0
  2. package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
  3. package/dist/auth/SupabaseAuthProvider.js +116 -4
  4. package/dist/auth/SupabaseAuthProvider.js.map +1 -1
  5. package/dist/auth/__tests__/token-verification.test.d.ts +13 -0
  6. package/dist/auth/__tests__/token-verification.test.d.ts.map +1 -0
  7. package/dist/auth/__tests__/token-verification.test.js +135 -0
  8. package/dist/auth/__tests__/token-verification.test.js.map +1 -0
  9. package/dist/data/SupabaseComputeServerStore.d.ts +34 -2
  10. package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
  11. package/dist/data/SupabaseComputeServerStore.js +146 -39
  12. package/dist/data/SupabaseComputeServerStore.js.map +1 -1
  13. package/dist/data/SupabaseDataProvider.d.ts +5 -2
  14. package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
  15. package/dist/data/SupabaseDataProvider.js +24 -18
  16. package/dist/data/SupabaseDataProvider.js.map +1 -1
  17. package/dist/data/SupabaseDefinitionStore.d.ts +2 -1
  18. package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
  19. package/dist/data/SupabaseDefinitionStore.js +68 -67
  20. package/dist/data/SupabaseDefinitionStore.js.map +1 -1
  21. package/dist/data/SupabaseInviteStore.d.ts.map +1 -1
  22. package/dist/data/SupabaseInviteStore.js +11 -22
  23. package/dist/data/SupabaseInviteStore.js.map +1 -1
  24. package/dist/data/SupabaseOrgStore.d.ts.map +1 -1
  25. package/dist/data/SupabaseOrgStore.js +49 -86
  26. package/dist/data/SupabaseOrgStore.js.map +1 -1
  27. package/dist/data/SupabaseProjectStore.d.ts.map +1 -1
  28. package/dist/data/SupabaseProjectStore.js +51 -70
  29. package/dist/data/SupabaseProjectStore.js.map +1 -1
  30. package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -1
  31. package/dist/data/SupabaseShareLinkStore.js +13 -27
  32. package/dist/data/SupabaseShareLinkStore.js.map +1 -1
  33. package/dist/data/__tests__/compute-server-conformance.test.js +6 -1
  34. package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -1
  35. package/dist/data/__tests__/compute-server-encryption.test.d.ts +12 -0
  36. package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +1 -0
  37. package/dist/data/__tests__/compute-server-encryption.test.js +143 -0
  38. package/dist/data/__tests__/compute-server-encryption.test.js.map +1 -0
  39. package/dist/data/__tests__/share-link-concurrency.test.d.ts +22 -0
  40. package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +1 -0
  41. package/dist/data/__tests__/share-link-concurrency.test.js +115 -0
  42. package/dist/data/__tests__/share-link-concurrency.test.js.map +1 -0
  43. package/dist/data/client.d.ts +35 -3
  44. package/dist/data/client.d.ts.map +1 -1
  45. package/dist/data/client.js +67 -16
  46. package/dist/data/client.js.map +1 -1
  47. package/dist/data/errors.d.ts +2 -0
  48. package/dist/data/errors.d.ts.map +1 -0
  49. package/dist/data/errors.js +23 -0
  50. package/dist/data/errors.js.map +1 -0
  51. package/dist/data/index.d.ts +7 -2
  52. package/dist/data/index.d.ts.map +1 -1
  53. package/dist/data/index.js +5 -1
  54. package/dist/data/index.js.map +1 -1
  55. package/dist/data/query.d.ts +20 -0
  56. package/dist/data/query.d.ts.map +1 -0
  57. package/dist/data/query.js +13 -0
  58. package/dist/data/query.js.map +1 -0
  59. package/dist/data/rowStamp.d.ts +38 -0
  60. package/dist/data/rowStamp.d.ts.map +1 -0
  61. package/dist/data/rowStamp.js +43 -0
  62. package/dist/data/rowStamp.js.map +1 -0
  63. package/dist/index.d.ts +2 -2
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -1
  66. package/dist/index.js.map +1 -1
  67. package/package.json +2 -2
  68. package/src/auth/SupabaseAuthProvider.ts +140 -4
  69. package/src/auth/__tests__/token-verification.test.ts +158 -0
  70. package/src/data/SupabaseComputeServerStore.ts +163 -43
  71. package/src/data/SupabaseDataProvider.ts +35 -15
  72. package/src/data/SupabaseDefinitionStore.ts +73 -66
  73. package/src/data/SupabaseInviteStore.ts +13 -25
  74. package/src/data/SupabaseOrgStore.ts +51 -95
  75. package/src/data/SupabaseProjectStore.ts +55 -78
  76. package/src/data/SupabaseShareLinkStore.ts +15 -30
  77. package/src/data/__tests__/compute-server-conformance.test.ts +7 -1
  78. package/src/data/__tests__/compute-server-encryption.test.ts +173 -0
  79. package/src/data/__tests__/share-link-concurrency.test.ts +124 -0
  80. package/src/data/client.ts +95 -20
  81. package/src/data/errors.ts +45 -0
  82. package/src/data/index.ts +19 -2
  83. package/src/data/query.ts +22 -0
  84. package/src/data/rowStamp.ts +48 -0
  85. package/src/index.ts +20 -2
  86. package/supabase/migrations/20260711120000_selva_definition_schema_version.sql +18 -0
@@ -6,10 +6,15 @@ import type {
6
6
  ListOptions,
7
7
  Page
8
8
  } from '@selvajs/platform';
9
- import { ProviderError, actorFrom, NoopEventSink } from '@selvajs/platform';
9
+ import { actorFrom, NoopEventSink } from '@selvajs/platform';
10
10
  import type { ClientBundle } from './client.js';
11
+ import { mapPostgrestError } from './errors.js';
11
12
  import { nextCursorFromRange, toRange } from './pagination.js';
12
13
 
14
+ /** Explicit column list for `share_links` — every field `rowToLink` consumes. */
15
+ const SHARE_LINK_COLUMNS =
16
+ 'id, definition_guid, channel, token_hash, name, created_by, created_at, expires_at, revoked_at, allow_solve, max_solves, solve_count';
17
+
13
18
  /**
14
19
  * Spec §7 — share-link store backed by Postgres.
15
20
  *
@@ -37,7 +42,7 @@ export class SupabaseShareLinkStore implements IShareLinkStore {
37
42
  .forRequest(ctx)
38
43
  .from('share_links')
39
44
  .insert(linkToRow(link));
40
- if (error) throw mapError(error);
45
+ if (error) throw mapPostgrestError(error);
41
46
  await this.events.emit({
42
47
  type: 'share_link.minted',
43
48
  linkId: link.id,
@@ -55,12 +60,12 @@ export class SupabaseShareLinkStore implements IShareLinkStore {
55
60
  const { data, error, count } = await this.clients
56
61
  .forRequest(ctx)
57
62
  .from('share_links')
58
- .select('*', { count: 'exact' })
63
+ .select(SHARE_LINK_COLUMNS, { count: 'exact' })
59
64
  .eq('definition_guid', definitionId)
60
65
  .is('revoked_at', null)
61
66
  .order('created_at', { ascending: false })
62
67
  .range(range.from, range.to);
63
- if (error) throw mapError(error);
68
+ if (error) throw mapPostgrestError(error);
64
69
  const items = (data ?? []).map(rowToLink);
65
70
  return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
66
71
  }
@@ -69,10 +74,10 @@ export class SupabaseShareLinkStore implements IShareLinkStore {
69
74
  const { data, error } = await this.clients
70
75
  .forRequest(ctx)
71
76
  .from('share_links')
72
- .select('*')
77
+ .select(SHARE_LINK_COLUMNS)
73
78
  .eq('id', id)
74
79
  .maybeSingle();
75
- if (error) throw mapError(error);
80
+ if (error) throw mapPostgrestError(error);
76
81
  return data ? rowToLink(data) : null;
77
82
  }
78
83
 
@@ -82,12 +87,12 @@ export class SupabaseShareLinkStore implements IShareLinkStore {
82
87
  // — exactly the wrong semantic for token-credentialed access.
83
88
  const { data, error } = await this.clients.serviceClient
84
89
  .from('share_links')
85
- .select('*, definitions!inner(deleted_at)')
90
+ .select(`${SHARE_LINK_COLUMNS}, definitions!inner(deleted_at)`)
86
91
  .eq('token_hash', tokenHash)
87
92
  .is('revoked_at', null)
88
93
  .is('definitions.deleted_at', null)
89
94
  .maybeSingle();
90
- if (error) throw mapError(error);
95
+ if (error) throw mapPostgrestError(error);
91
96
  return data ? rowToLink(data) : null;
92
97
  }
93
98
 
@@ -101,7 +106,7 @@ export class SupabaseShareLinkStore implements IShareLinkStore {
101
106
  .update({ revoked_at: new Date().toISOString() })
102
107
  .eq('id', id)
103
108
  .is('revoked_at', null);
104
- if (error) throw mapError(error);
109
+ if (error) throw mapPostgrestError(error);
105
110
  await this.events.emit({ type: 'share_link.revoked', linkId: id, actorId: actorFrom(ctx) });
106
111
  }
107
112
 
@@ -113,7 +118,7 @@ export class SupabaseShareLinkStore implements IShareLinkStore {
113
118
  'try_increment_share_link_solve_count',
114
119
  { link_id: id }
115
120
  );
116
- if (error) throw mapError(error);
121
+ if (error) throw mapPostgrestError(error);
117
122
  return typeof data === 'number' ? data : null;
118
123
  }
119
124
  }
@@ -169,23 +174,3 @@ function linkToRow(l: ShareLink): Record<string, unknown> {
169
174
  solve_count: l.solveCount
170
175
  };
171
176
  }
172
-
173
- interface PostgrestError {
174
- code?: string;
175
- message?: string;
176
- }
177
-
178
- function mapError(e: unknown): Error {
179
- const pg = e as PostgrestError;
180
- if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
181
- if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
182
- if (e instanceof Error) return e;
183
- if (e && typeof e === 'object') {
184
- const obj = e as { message?: string; details?: string; hint?: string; code?: string };
185
- const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
186
- const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
187
- Object.assign(err, obj);
188
- return err;
189
- }
190
- return new Error(String(e));
191
- }
@@ -1,10 +1,16 @@
1
1
  import { describe, beforeEach, it } from 'vitest';
2
+ import { randomBytes } from 'node:crypto';
2
3
  import { runComputeServerStoreConformance } from '@selvajs/platform/testing';
3
4
  import { SupabaseComputeServerStore } from '../SupabaseComputeServerStore.js';
4
5
  import { readEnv, resetAllData, seedUser } from './test-helpers.js';
5
6
 
6
7
  const envCtx = readEnv();
7
8
 
9
+ // A throwaway at-rest key so the store encrypts apiKeys during the suite. The
10
+ // round-trip (encrypt on write, decrypt on read) must be transparent to the
11
+ // conformance assertions, which compare plaintext apiKeys.
12
+ const TEST_SECRET_KEY = randomBytes(32);
13
+
8
14
  if (!envCtx) {
9
15
  describe.skip('SupabaseComputeServerStore (skipped: no live stack)', () => {
10
16
  it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => {});
@@ -17,7 +23,7 @@ if (!envCtx) {
17
23
 
18
24
  runComputeServerStoreConformance({
19
25
  name: 'SupabaseComputeServerStore',
20
- createStore: () => new SupabaseComputeServerStore(envCtx.bundle),
26
+ createStore: () => new SupabaseComputeServerStore(envCtx.bundle, TEST_SECRET_KEY),
21
27
  seedOrg: async (orgId) => {
22
28
  // `compute_servers.owner_org_id` and `compute_server_org_defaults.org_id`
23
29
  // FK to `orgs(id)`; the suite invents an `orgId` per org-scoped test,
@@ -0,0 +1,173 @@
1
+ /**
2
+ * CI-runnable (no live stack) unit tests for at-rest encryption of
3
+ * `compute_servers.api_key`. Uses a minimal fake of the Supabase query builder
4
+ * that records `insert` payloads and serves canned `select` rows, so we can
5
+ * assert the exact bytes that would reach the DB without a real Postgres.
6
+ *
7
+ * The security property under test: a plaintext apiKey handed to the store is
8
+ * NEVER written verbatim — the DB only ever sees an `enc:v1:` envelope — and it
9
+ * round-trips back to plaintext on read.
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
13
+ import { randomBytes } from 'node:crypto';
14
+ import { isEncryptedSecret, decryptSecret } from '@selvajs/platform/computeServer';
15
+ import type { RequestContext } from '@selvajs/platform';
16
+ import { SupabaseComputeServerStore } from '../SupabaseComputeServerStore.js';
17
+ import type { ClientBundle } from '../client.js';
18
+
19
+ const SYSTEM_CTX = { userId: '', system: true } as unknown as RequestContext;
20
+ const KEY = randomBytes(32);
21
+
22
+ /**
23
+ * Records every `insert` payload and returns canned rows for each table's
24
+ * `select`. Only the fragments the store actually chains are implemented; the
25
+ * builder is thenable so `await client.from(...).select(...)` resolves.
26
+ */
27
+ function fakeBundle(selectRows: Record<string, unknown[]> = {}) {
28
+ const inserted: Record<string, unknown[]> = {};
29
+
30
+ function builder(table: string) {
31
+ const result = { data: selectRows[table] ?? [], error: null };
32
+ const chain: Record<string, unknown> = {
33
+ select: () => ({
34
+ ...chain,
35
+ eq: () => ({ ...chain, maybeSingle: async () => ({ data: null, error: null }) }),
36
+ then: (resolve: (v: unknown) => unknown) => resolve(result)
37
+ }),
38
+ insert: (rows: unknown) => {
39
+ inserted[table] = (inserted[table] ?? []).concat(rows as unknown[]);
40
+ return { error: null };
41
+ },
42
+ delete: () => ({ eq: () => ({ eq: () => ({ error: null }), error: null }) }),
43
+ update: () => ({ eq: () => ({ error: null }) }),
44
+ upsert: () => ({ error: null })
45
+ };
46
+ return chain;
47
+ }
48
+
49
+ const client = { from: (t: string) => builder(t) };
50
+ const bundle = {
51
+ serviceClient: client,
52
+ forRequest: () => client
53
+ } as unknown as ClientBundle;
54
+ return { bundle, inserted };
55
+ }
56
+
57
+ describe('SupabaseComputeServerStore — apiKey encryption at rest', () => {
58
+ beforeEach(() => vi.restoreAllMocks());
59
+
60
+ it('encrypts apiKey before insert — DB never sees plaintext', async () => {
61
+ const { bundle, inserted } = fakeBundle();
62
+ const store = new SupabaseComputeServerStore(bundle, KEY);
63
+
64
+ await store.savePlatformServers(
65
+ SYSTEM_CTX,
66
+ [
67
+ {
68
+ id: 's1',
69
+ label: 'prod',
70
+ serverUrl: 'https://compute.example.com',
71
+ apiKey: 'super-secret-key',
72
+ scope: 'platform',
73
+ sharedWith: 'all'
74
+ }
75
+ ],
76
+ 's1'
77
+ );
78
+
79
+ const rows = inserted['compute_servers'] as Array<{ api_key: string }>;
80
+ expect(rows).toHaveLength(1);
81
+ expect(rows[0].api_key).not.toBe('super-secret-key');
82
+ expect(isEncryptedSecret(rows[0].api_key)).toBe(true);
83
+ // And it's genuinely our plaintext under the envelope.
84
+ expect(decryptSecret(rows[0].api_key, KEY)).toBe('super-secret-key');
85
+ });
86
+
87
+ it('decrypts apiKey on read — callers see plaintext', async () => {
88
+ // Seed the "DB" with an already-encrypted row.
89
+ const { encryptSecret } = await import('@selvajs/platform/computeServer');
90
+ const envelope = encryptSecret('round-trip-key', KEY);
91
+ const { bundle } = fakeBundle({
92
+ compute_servers: [
93
+ {
94
+ id: 's1',
95
+ scope: 'platform',
96
+ owner_org_id: null,
97
+ shared_with_all: true,
98
+ label: 'prod',
99
+ server_url: 'https://compute.example.com',
100
+ api_key: envelope,
101
+ timeout_ms: null,
102
+ retry_count: null
103
+ }
104
+ ]
105
+ });
106
+ const store = new SupabaseComputeServerStore(bundle, KEY);
107
+
108
+ const config = await store.getConfig(SYSTEM_CTX);
109
+ expect(config.servers).toHaveLength(1);
110
+ expect(config.servers[0].apiKey).toBe('round-trip-key');
111
+ });
112
+
113
+ it('throws rather than persist plaintext when no key is configured', async () => {
114
+ const { bundle } = fakeBundle();
115
+ const store = new SupabaseComputeServerStore(bundle); // no key
116
+
117
+ await expect(
118
+ store.savePlatformServers(
119
+ SYSTEM_CTX,
120
+ [
121
+ {
122
+ id: 's1',
123
+ label: 'prod',
124
+ serverUrl: 'https://compute.example.com',
125
+ apiKey: 'secret',
126
+ scope: 'platform',
127
+ sharedWith: 'all'
128
+ }
129
+ ],
130
+ 's1'
131
+ )
132
+ ).rejects.toThrow(/SELVA_AT_REST_KEY/);
133
+ });
134
+
135
+ it('verifySecrets flags a plaintext row', async () => {
136
+ const { bundle } = fakeBundle({
137
+ compute_servers: [{ id: 's1', label: 'legacy', api_key: 'raw-plaintext' }]
138
+ });
139
+ const store = new SupabaseComputeServerStore(bundle, KEY);
140
+
141
+ const report = await store.verifySecrets();
142
+ expect(report.ok).toBe(false);
143
+ expect(report.plaintextFound).toBe(true);
144
+ expect(report.failures).toEqual([
145
+ { serverId: 's1', serverLabel: 'legacy', reason: 'plaintext_on_disk' }
146
+ ]);
147
+ });
148
+
149
+ it('verifySecrets reports key_mismatch when the at-rest key rotated', async () => {
150
+ const { encryptSecret } = await import('@selvajs/platform/computeServer');
151
+ const envelope = encryptSecret('secret', randomBytes(32)); // different key
152
+ const { bundle } = fakeBundle({
153
+ compute_servers: [{ id: 's1', label: 'prod', api_key: envelope }]
154
+ });
155
+ const store = new SupabaseComputeServerStore(bundle, KEY);
156
+
157
+ const report = await store.verifySecrets();
158
+ expect(report.ok).toBe(false);
159
+ expect(report.plaintextFound).toBe(false);
160
+ expect(report.failures[0]).toMatchObject({ serverId: 's1', reason: 'key_mismatch' });
161
+ });
162
+
163
+ it('verifySecrets returns ok when every apiKey decrypts', async () => {
164
+ const { encryptSecret } = await import('@selvajs/platform/computeServer');
165
+ const { bundle } = fakeBundle({
166
+ compute_servers: [{ id: 's1', label: 'prod', api_key: encryptSecret('k', KEY) }]
167
+ });
168
+ const store = new SupabaseComputeServerStore(bundle, KEY);
169
+
170
+ const report = await store.verifySecrets();
171
+ expect(report).toEqual({ ok: true, failures: [], plaintextFound: false });
172
+ });
173
+ });
@@ -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';