@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
@@ -4,10 +4,23 @@ import {
4
4
  type IComputeServerStore,
5
5
  type ComputeConfig,
6
6
  type ComputeServerConfig,
7
- type RequestContext
7
+ type RequestContext,
8
+ type SecretVerificationFailure,
9
+ type SecretVerificationReport
8
10
  } from '@selvajs/platform';
9
- import { ProviderError } from '@selvajs/platform';
11
+ // Secret crypto functions come from the server-only subpath (they use
12
+ // `node:crypto`, kept out of the root barrel so client bundles don't pull it in).
13
+ import { decryptSecret, encryptSecret, isEncryptedSecret } from '@selvajs/platform/computeServer';
10
14
  import type { ClientBundle } from './client.js';
15
+ import { mapPostgrestError } from './errors.js';
16
+
17
+ /** Explicit column list for `compute_servers` — every field `rowToServer` consumes. */
18
+ const SERVER_COLUMNS =
19
+ 'id, scope, owner_org_id, shared_with_all, label, server_url, api_key, timeout_ms, retry_count';
20
+ /** Columns from the share join table read in `getConfig`. */
21
+ const SHARE_COLUMNS = 'server_id, org_id';
22
+ /** Columns from the per-org default table read in `getConfig`. */
23
+ const ORG_DEFAULT_COLUMNS = 'org_id, default_server_id';
11
24
 
12
25
  /**
13
26
  * Compute-server config backed by three tables:
@@ -24,15 +37,26 @@ import type { ClientBundle } from './client.js';
24
37
  * callers (resolver, page loaders) using helpers in `@selvajs/platform`.
25
38
  */
26
39
  export class SupabaseComputeServerStore implements IComputeServerStore {
27
- constructor(private readonly clients: ClientBundle) {}
40
+ /**
41
+ * `secretKey` (from `SELVA_AT_REST_KEY`) encrypts `api_key` before it is
42
+ * written to `compute_servers.api_key` and decrypts it on read, so the
43
+ * Rhino.Compute credential never sits in the DB as plaintext — matching the
44
+ * local provider's on-disk envelope. Optional only for legacy construction
45
+ * paths (tests that never touch apiKeys); a store built without a key throws
46
+ * if asked to write or read a secret. Wire it via `SupabaseDataProvider`.
47
+ */
48
+ constructor(
49
+ private readonly clients: ClientBundle,
50
+ private readonly secretKey?: Buffer
51
+ ) {}
28
52
 
29
53
  async getConfig(ctx: RequestContext): Promise<ComputeConfig> {
30
54
  const client = this.clients.forRequest(ctx);
31
55
 
32
56
  const [serversRes, sharesRes, orgDefRes, platDefRes] = await Promise.all([
33
- client.from('compute_servers').select('*'),
34
- client.from('compute_server_shares').select('*'),
35
- client.from('compute_server_org_defaults').select('*'),
57
+ client.from('compute_servers').select(SERVER_COLUMNS),
58
+ client.from('compute_server_shares').select(SHARE_COLUMNS),
59
+ client.from('compute_server_org_defaults').select(ORG_DEFAULT_COLUMNS),
36
60
  client
37
61
  .from('compute_server_platform_default')
38
62
  .select('default_server_id')
@@ -40,10 +64,10 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
40
64
  .maybeSingle()
41
65
  ]);
42
66
 
43
- if (serversRes.error) throw mapError(serversRes.error);
44
- if (sharesRes.error) throw mapError(sharesRes.error);
45
- if (orgDefRes.error) throw mapError(orgDefRes.error);
46
- if (platDefRes.error) throw mapError(platDefRes.error);
67
+ if (serversRes.error) throw mapPostgrestError(serversRes.error);
68
+ if (sharesRes.error) throw mapPostgrestError(sharesRes.error);
69
+ if (orgDefRes.error) throw mapPostgrestError(orgDefRes.error);
70
+ if (platDefRes.error) throw mapPostgrestError(platDefRes.error);
47
71
 
48
72
  // Build per-server `sharedWith` allowlists from the join table.
49
73
  const sharedByServer = new Map<string, string[]>();
@@ -53,7 +77,9 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
53
77
  sharedByServer.set(row.server_id, list);
54
78
  }
55
79
 
56
- const servers = (serversRes.data ?? []).map((row) => rowToServer(row, sharedByServer));
80
+ const servers = (serversRes.data ?? []).map((row) =>
81
+ rowToServer(this.decryptRowApiKey(row), sharedByServer)
82
+ );
57
83
 
58
84
  const orgDefaults: Record<string, string> = {};
59
85
  for (const row of orgDefRes.data ?? []) {
@@ -77,13 +103,13 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
77
103
 
78
104
  // Replace-all of platform rows.
79
105
  const { error: delErr } = await client.from('compute_servers').delete().eq('scope', 'platform');
80
- if (delErr) throw mapError(delErr);
106
+ if (delErr) throw mapPostgrestError(delErr);
81
107
 
82
108
  if (platformOnly.length > 0) {
83
109
  const { error: insErr } = await client
84
110
  .from('compute_servers')
85
- .insert(platformOnly.map(serverToRow));
86
- if (insErr) throw mapError(insErr);
111
+ .insert(platformOnly.map((s) => serverToRow(this.encryptServerApiKey(s))));
112
+ if (insErr) throw mapPostgrestError(insErr);
87
113
 
88
114
  // Rebuild the share rows for any non-`all` platform servers.
89
115
  const shareRows = platformOnly.flatMap((s) =>
@@ -93,7 +119,7 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
93
119
  );
94
120
  if (shareRows.length > 0) {
95
121
  const { error: shErr } = await client.from('compute_server_shares').insert(shareRows);
96
- if (shErr) throw mapError(shErr);
122
+ if (shErr) throw mapPostgrestError(shErr);
97
123
  }
98
124
  }
99
125
 
@@ -101,7 +127,7 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
101
127
  .from('compute_server_platform_default')
102
128
  .update({ default_server_id: defaultServerId ?? null })
103
129
  .eq('singleton', true);
104
- if (defErr) throw mapError(defErr);
130
+ if (defErr) throw mapPostgrestError(defErr);
105
131
  }
106
132
 
107
133
  async saveOrgServers(
@@ -118,13 +144,13 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
118
144
  .delete()
119
145
  .eq('scope', 'org')
120
146
  .eq('owner_org_id', orgId);
121
- if (delErr) throw mapError(delErr);
147
+ if (delErr) throw mapPostgrestError(delErr);
122
148
 
123
149
  if (orgOnly.length > 0) {
124
150
  const { error: insErr } = await client
125
151
  .from('compute_servers')
126
- .insert(orgOnly.map(serverToRow));
127
- if (insErr) throw mapError(insErr);
152
+ .insert(orgOnly.map((s) => serverToRow(this.encryptServerApiKey(s))));
153
+ if (insErr) throw mapPostgrestError(insErr);
128
154
  }
129
155
 
130
156
  if (defaultServerId === null) {
@@ -132,12 +158,12 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
132
158
  .from('compute_server_org_defaults')
133
159
  .delete()
134
160
  .eq('org_id', orgId);
135
- if (error) throw mapError(error);
161
+ if (error) throw mapPostgrestError(error);
136
162
  } else if (typeof defaultServerId === 'string') {
137
163
  const { error } = await client
138
164
  .from('compute_server_org_defaults')
139
165
  .upsert({ org_id: orgId, default_server_id: defaultServerId });
140
- if (error) throw mapError(error);
166
+ if (error) throw mapPostgrestError(error);
141
167
  }
142
168
  }
143
169
 
@@ -148,13 +174,13 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
148
174
  .from('compute_server_org_defaults')
149
175
  .delete()
150
176
  .eq('org_id', orgId);
151
- if (error) throw mapError(error);
177
+ if (error) throw mapPostgrestError(error);
152
178
  return;
153
179
  }
154
180
  const { error } = await client
155
181
  .from('compute_server_org_defaults')
156
182
  .upsert({ org_id: orgId, default_server_id: serverId });
157
- if (error) throw mapError(error);
183
+ if (error) throw mapPostgrestError(error);
158
184
  }
159
185
 
160
186
  async deleteByOrg(ctx: RequestContext, orgId: string): Promise<void> {
@@ -166,7 +192,7 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
166
192
  .from('compute_server_org_defaults')
167
193
  .delete()
168
194
  .eq('org_id', orgId);
169
- if (defErr) throw mapError(defErr);
195
+ if (defErr) throw mapPostgrestError(defErr);
170
196
 
171
197
  // Org-private servers owned by this org.
172
198
  const { error: srvErr } = await client
@@ -174,14 +200,125 @@ export class SupabaseComputeServerStore implements IComputeServerStore {
174
200
  .delete()
175
201
  .eq('scope', 'org')
176
202
  .eq('owner_org_id', orgId);
177
- if (srvErr) throw mapError(srvErr);
203
+ if (srvErr) throw mapPostgrestError(srvErr);
178
204
 
179
205
  // Strip this org from any platform server share allowlist.
180
206
  const { error: shErr } = await client
181
207
  .from('compute_server_shares')
182
208
  .delete()
183
209
  .eq('org_id', orgId);
184
- if (shErr) throw mapError(shErr);
210
+ if (shErr) throw mapPostgrestError(shErr);
211
+ }
212
+
213
+ // ==========================================================================
214
+ // At-rest secret handling
215
+ // ==========================================================================
216
+
217
+ /**
218
+ * Encrypt a server's `apiKey` before it is written to the DB. Idempotent —
219
+ * an already-enveloped value is passed through, so a round-trip (read →
220
+ * save) never double-encrypts. Throws if the store was built without a key
221
+ * but a real secret needs writing, so we never silently persist plaintext.
222
+ */
223
+ private encryptServerApiKey(s: ComputeServerConfig): ComputeServerConfig {
224
+ if (!s.apiKey) return s;
225
+ if (isEncryptedSecret(s.apiKey)) return s;
226
+ if (!this.secretKey) {
227
+ throw new Error(
228
+ 'Cannot store a compute-server apiKey: SELVA_AT_REST_KEY is not configured. ' +
229
+ 'Set it so secrets are encrypted at rest.'
230
+ );
231
+ }
232
+ return { ...s, apiKey: encryptSecret(s.apiKey, this.secretKey) };
233
+ }
234
+
235
+ /**
236
+ * Decrypt a row's `api_key` on read. Tolerant, mirroring the local provider:
237
+ * a row whose ciphertext can't be authenticated under the current key is
238
+ * returned with `api_key: null` and a warning logged, so the page keeps
239
+ * rendering (solves against that server fail later at Rhino.Compute). A
240
+ * plaintext value that predates encryption is passed through unchanged and
241
+ * surfaced by `verifySecrets()` — never silently dropped.
242
+ */
243
+ private decryptRowApiKey(row: ServerRow): ServerRow {
244
+ if (!row.api_key) return row;
245
+ if (!isEncryptedSecret(row.api_key)) {
246
+ // Legacy/hand-inserted plaintext. Pass through so an existing key
247
+ // keeps working; verifySecrets() flags it for re-save via /admin/compute.
248
+ console.warn(
249
+ `[selva] compute_servers row "${row.label}" (${row.id}) has a plaintext api_key. ` +
250
+ 'Re-save it via /admin/compute so it is stored encrypted.'
251
+ );
252
+ return row;
253
+ }
254
+ if (!this.secretKey) {
255
+ console.warn(
256
+ `[selva] Cannot decrypt api_key for compute server "${row.label}" (${row.id}): ` +
257
+ 'SELVA_AT_REST_KEY is not configured. Returning without a key; solves will fail.'
258
+ );
259
+ return { ...row, api_key: null };
260
+ }
261
+ try {
262
+ return { ...row, api_key: decryptSecret(row.api_key, this.secretKey) };
263
+ } catch (cause) {
264
+ console.warn(
265
+ `[selva] Could not decrypt api_key for compute server "${row.label}" (${row.id}). ` +
266
+ 'The stored ciphertext does not match the current SELVA_AT_REST_KEY. ' +
267
+ 'This server will be returned without an apiKey; solves against it will fail. ' +
268
+ 'Re-enter the key via /admin/compute, or restore the original SELVA_AT_REST_KEY.',
269
+ cause
270
+ );
271
+ return { ...row, api_key: null };
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Boot-time integrity check over every `compute_servers` row. Uses the
277
+ * service client (no request context at boot) to read raw ciphertext, then
278
+ * attempts to decrypt each `api_key`. Does NOT throw — returns a structured
279
+ * report for boot health to drive `/api/health`.
280
+ */
281
+ async verifySecrets(): Promise<SecretVerificationReport> {
282
+ const { data, error } = await this.clients.serviceClient
283
+ .from('compute_servers')
284
+ .select('id, label, api_key');
285
+ if (error) throw mapPostgrestError(error);
286
+
287
+ const failures: SecretVerificationFailure[] = [];
288
+ let plaintextFound = false;
289
+
290
+ for (const row of data ?? []) {
291
+ const apiKey = (row as { api_key: string | null }).api_key;
292
+ const id = (row as { id: string }).id;
293
+ const label = (row as { label: string }).label;
294
+ if (!apiKey) continue;
295
+ if (!isEncryptedSecret(apiKey)) {
296
+ plaintextFound = true;
297
+ failures.push({ serverId: id, serverLabel: label, reason: 'plaintext_on_disk' });
298
+ continue;
299
+ }
300
+ if (!this.secretKey) {
301
+ failures.push({
302
+ serverId: id,
303
+ serverLabel: label,
304
+ reason: 'key_mismatch',
305
+ cause: 'SELVA_AT_REST_KEY is not configured'
306
+ });
307
+ continue;
308
+ }
309
+ try {
310
+ decryptSecret(apiKey, this.secretKey);
311
+ } catch (cause) {
312
+ failures.push({
313
+ serverId: id,
314
+ serverLabel: label,
315
+ reason: 'key_mismatch',
316
+ cause: cause instanceof Error ? cause.message : String(cause)
317
+ });
318
+ }
319
+ }
320
+
321
+ return { ok: failures.length === 0, failures, plaintextFound };
185
322
  }
186
323
  }
187
324
 
@@ -243,20 +380,3 @@ function serverToRow(s: ComputeServerConfig): ServerRow {
243
380
  shared_with_all: false
244
381
  };
245
382
  }
246
-
247
- interface PostgrestError {
248
- code?: string;
249
- message?: string;
250
- }
251
-
252
- function mapError(e: unknown): Error {
253
- const pg = e as PostgrestError;
254
- if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
255
- if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
256
- if (e instanceof Error) return e;
257
- if (e && typeof e === 'object') {
258
- const obj = e as { message?: string; code?: string };
259
- return new Error(obj.code ? `[${obj.code}] ${obj.message ?? ''}` : (obj.message ?? String(e)));
260
- }
261
- return new Error(String(e));
262
- }
@@ -1,7 +1,8 @@
1
1
  import type { IDataProvider, IEventSink, IPlatformProjectGrantStore } from '@selvajs/platform';
2
2
  import { ProviderError } from '@selvajs/platform';
3
+ import { decodeSecretKey } from '@selvajs/platform/computeServer';
3
4
  import type { ClientBundle, BuildClientOptions } from './client.js';
4
- import { buildClientBundle } from './client.js';
5
+ import { buildClientBundle, clientBundleFromEnv } from './client.js';
5
6
  import { SupabaseEventSink } from './SupabaseEventSink.js';
6
7
  import { SupabaseSolveMetricSink } from './SupabaseSolveMetricSink.js';
7
8
  import { SupabaseAuditQuery } from './SupabaseAuditQuery.js';
@@ -63,14 +64,15 @@ export class SupabaseDataProvider implements IDataProvider {
63
64
 
64
65
  private constructor(
65
66
  private readonly clients: ClientBundle,
66
- events: IEventSink
67
+ events: IEventSink,
68
+ secretKey?: Buffer
67
69
  ) {
68
70
  this.solveMetrics = new SupabaseSolveMetricSink(clients);
69
71
  this.orgs = new SupabaseOrgStore(clients, events);
70
72
  this.projects = new SupabaseProjectStore(clients, events);
71
73
  this.definitions = new SupabaseDefinitionStore(clients, events);
72
74
  this.invites = new SupabaseInviteStore(clients, events);
73
- this.computeServer = new SupabaseComputeServerStore(clients);
75
+ this.computeServer = new SupabaseComputeServerStore(clients, secretKey);
74
76
  this.shareLinks = new SupabaseShareLinkStore(clients, events);
75
77
  this.userProfile = new SupabaseUserProfileProvider(clients);
76
78
  this.permissions = new SupabasePlatformPermissionStore(clients);
@@ -82,27 +84,45 @@ export class SupabaseDataProvider implements IDataProvider {
82
84
  env: Record<string, string | undefined>,
83
85
  events?: IEventSink
84
86
  ): SupabaseDataProvider {
85
- const supabaseUrl = env.SUPABASE_URL;
86
- const anonKey = env.SUPABASE_ANON_KEY;
87
- const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
88
- if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
89
- if (!anonKey) throw new Error('Missing required env var: SUPABASE_ANON_KEY');
90
- if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
91
- const bundle = buildClientBundle({ supabaseUrl, anonKey, serviceRoleKey });
92
- return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle));
87
+ // Bundle-only env parse (URL + anon + service-role) is shared with
88
+ // `clientBundleFromEnv`; the full data provider additionally requires the
89
+ // at-rest key below.
90
+ const bundle = clientBundleFromEnv(env);
91
+ // Required so compute-server apiKeys are encrypted at rest in the DB —
92
+ // same guarantee (and same key) as the local provider's on-disk envelope.
93
+ if (!env.SELVA_AT_REST_KEY) {
94
+ throw new Error(
95
+ 'Missing required env var: SELVA_AT_REST_KEY (32-byte hex or base64). ' +
96
+ 'Required to encrypt compute-server API keys at rest. ' +
97
+ "Generate one with: node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\""
98
+ );
99
+ }
100
+ const secretKey = decodeSecretKey(env.SELVA_AT_REST_KEY);
101
+ return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle), secretKey);
93
102
  }
94
103
 
95
- static create(opts: BuildClientOptions, events?: IEventSink): SupabaseDataProvider {
104
+ static create(
105
+ opts: BuildClientOptions,
106
+ events?: IEventSink,
107
+ secretKey?: Buffer
108
+ ): SupabaseDataProvider {
96
109
  const bundle = buildClientBundle(opts);
97
- return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle));
110
+ return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle), secretKey);
98
111
  }
99
112
 
100
113
  /**
101
114
  * Build from a pre-existing `ClientBundle`. Useful for tests or advanced
102
115
  * cases that need to inject a custom event sink or share a bundle externally.
116
+ * `secretKey` is optional here — omit it only when the caller never touches
117
+ * compute-server apiKeys (writes without a key throw rather than persist
118
+ * plaintext).
103
119
  */
104
- static fromBundle(bundle: ClientBundle, events?: IEventSink): SupabaseDataProvider {
105
- return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle));
120
+ static fromBundle(
121
+ bundle: ClientBundle,
122
+ events?: IEventSink,
123
+ secretKey?: Buffer
124
+ ): SupabaseDataProvider {
125
+ return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle), secretKey);
106
126
  }
107
127
 
108
128
  /**