@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
|
@@ -8,9 +8,18 @@ import type {
|
|
|
8
8
|
ListOptions,
|
|
9
9
|
Page
|
|
10
10
|
} from '@selvajs/platform';
|
|
11
|
-
import { ProviderError,
|
|
11
|
+
import { ProviderError, actorFrom, NoopEventSink } from '@selvajs/platform';
|
|
12
12
|
import type { ClientBundle } from './client.js';
|
|
13
|
+
import { mapPostgrestError } from './errors.js';
|
|
13
14
|
import { nextCursorFromRange, orderColumn, toRange } from './pagination.js';
|
|
15
|
+
import { stampSoftDelete, stampUpdate } from './rowStamp.js';
|
|
16
|
+
|
|
17
|
+
/** Explicit column list for `projects` — every field `rowToProject` consumes. */
|
|
18
|
+
const PROJECT_COLUMNS =
|
|
19
|
+
'id, org_id, name, slug, description, visibility, owner_id, created_by, updated_by, auto_join_on_upload, created_at, updated_at, deleted_at';
|
|
20
|
+
/** Explicit column list for `project_members` — every field `rowToProjectMember` consumes. */
|
|
21
|
+
const PROJECT_MEMBER_COLUMNS =
|
|
22
|
+
'project_id, user_id, role, joined_at, updated_at, updated_by, deleted_at';
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
25
|
* Project + project-membership store backed by Postgres. Visibility semantics
|
|
@@ -42,12 +51,12 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
42
51
|
const { data, error, count } = await this.clients
|
|
43
52
|
.forRequest(ctx)
|
|
44
53
|
.from('projects')
|
|
45
|
-
.select(
|
|
54
|
+
.select(PROJECT_COLUMNS, { count: 'exact' })
|
|
46
55
|
.eq('org_id', orgId)
|
|
47
56
|
.is('deleted_at', null)
|
|
48
57
|
.order(orderColumn(opts?.orderBy), { ascending: direction === 'asc' })
|
|
49
58
|
.range(range.from, range.to);
|
|
50
|
-
if (error) throw
|
|
59
|
+
if (error) throw mapPostgrestError(error);
|
|
51
60
|
const items = (data ?? []).map(rowToProject);
|
|
52
61
|
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
53
62
|
}
|
|
@@ -56,11 +65,11 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
56
65
|
const { data, error } = await this.clients
|
|
57
66
|
.forRequest(ctx)
|
|
58
67
|
.from('projects')
|
|
59
|
-
.select(
|
|
68
|
+
.select(PROJECT_COLUMNS)
|
|
60
69
|
.eq('id', id)
|
|
61
70
|
.is('deleted_at', null)
|
|
62
71
|
.maybeSingle();
|
|
63
|
-
if (error) throw
|
|
72
|
+
if (error) throw mapPostgrestError(error);
|
|
64
73
|
return data ? rowToProject(data) : null;
|
|
65
74
|
}
|
|
66
75
|
|
|
@@ -72,19 +81,19 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
72
81
|
const { data, error } = await this.clients
|
|
73
82
|
.forRequest(ctx)
|
|
74
83
|
.from('projects')
|
|
75
|
-
.select(
|
|
84
|
+
.select(PROJECT_COLUMNS)
|
|
76
85
|
.eq('org_id', orgId)
|
|
77
86
|
.eq('slug', slug)
|
|
78
87
|
.is('deleted_at', null)
|
|
79
88
|
.maybeSingle();
|
|
80
|
-
if (error) throw
|
|
89
|
+
if (error) throw mapPostgrestError(error);
|
|
81
90
|
return data ? rowToProject(data) : null;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
async createProject(ctx: RequestContext, project: Project): Promise<void> {
|
|
85
94
|
const client = this.clients.forRequest(ctx);
|
|
86
95
|
const { error } = await client.from('projects').insert(projectToRow(project));
|
|
87
|
-
if (error) throw
|
|
96
|
+
if (error) throw mapPostgrestError(error);
|
|
88
97
|
|
|
89
98
|
// Seed owner membership so user-scoped reads see the project post-create.
|
|
90
99
|
// Upsert so a stale soft-deleted row from a prior project with the same id
|
|
@@ -99,7 +108,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
99
108
|
},
|
|
100
109
|
{ onConflict: 'project_id,user_id' }
|
|
101
110
|
);
|
|
102
|
-
if (memberError) throw
|
|
111
|
+
if (memberError) throw mapPostgrestError(memberError);
|
|
103
112
|
await this.events.emit({
|
|
104
113
|
type: 'project.created',
|
|
105
114
|
projectId: project.id,
|
|
@@ -122,7 +131,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
122
131
|
if (patch.visibility !== undefined) row.visibility = patch.visibility;
|
|
123
132
|
if (patch.autoJoinOnUpload !== undefined) row.auto_join_on_upload = patch.autoJoinOnUpload;
|
|
124
133
|
if (Object.keys(row).length === 0) return;
|
|
125
|
-
|
|
134
|
+
Object.assign(row, stampUpdate(ctx));
|
|
126
135
|
|
|
127
136
|
const { data, error } = await this.clients
|
|
128
137
|
.forRequest(ctx)
|
|
@@ -131,7 +140,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
131
140
|
.eq('id', id)
|
|
132
141
|
.is('deleted_at', null)
|
|
133
142
|
.select('id');
|
|
134
|
-
if (error) throw
|
|
143
|
+
if (error) throw mapPostgrestError(error);
|
|
135
144
|
if (!data || data.length === 0) throw new ProviderError(`Project '${id}' not found`, 404);
|
|
136
145
|
}
|
|
137
146
|
|
|
@@ -140,8 +149,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
140
149
|
// project_members, definitions. FK CASCADE doesn't fire on soft-delete,
|
|
141
150
|
// so the cascade is in app code.
|
|
142
151
|
const client = this.clients.forRequest(ctx);
|
|
143
|
-
const
|
|
144
|
-
const stampRow = stampToRow(stamp);
|
|
152
|
+
const stampRow = stampSoftDelete(ctx);
|
|
145
153
|
|
|
146
154
|
const { data, error } = await client
|
|
147
155
|
.from('projects')
|
|
@@ -149,7 +157,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
149
157
|
.eq('id', id)
|
|
150
158
|
.is('deleted_at', null)
|
|
151
159
|
.select('id');
|
|
152
|
-
if (error) throw
|
|
160
|
+
if (error) throw mapPostgrestError(error);
|
|
153
161
|
if (!data || data.length === 0) throw new ProviderError(`Project '${id}' not found`, 404);
|
|
154
162
|
|
|
155
163
|
const { error: pmErr } = await client
|
|
@@ -157,14 +165,28 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
157
165
|
.update(stampRow)
|
|
158
166
|
.eq('project_id', id)
|
|
159
167
|
.is('deleted_at', null);
|
|
160
|
-
if (pmErr) throw
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
if (pmErr) throw mapPostgrestError(pmErr);
|
|
169
|
+
|
|
170
|
+
// Cascade to definitions. `select('guid')` returns the tombstoned rows so
|
|
171
|
+
// we emit one `definition.deleted` per record — parity with the single
|
|
172
|
+
// `delete` path and with `SupabaseDefinitionStore.deleteByProject` /
|
|
173
|
+
// `LocalProjectStore.deleteProject`. (Kept inline rather than delegating to
|
|
174
|
+
// the definition store to avoid a cross-store constructor dependency; the
|
|
175
|
+
// behavior must stay identical to `deleteByProject`.)
|
|
176
|
+
const { data: deletedDefs, error: defErr } = await client
|
|
163
177
|
.from('definitions')
|
|
164
178
|
.update(stampRow)
|
|
165
179
|
.eq('project_id', id)
|
|
166
|
-
.is('deleted_at', null)
|
|
167
|
-
|
|
180
|
+
.is('deleted_at', null)
|
|
181
|
+
.select('guid');
|
|
182
|
+
if (defErr) throw mapPostgrestError(defErr);
|
|
183
|
+
for (const row of deletedDefs ?? []) {
|
|
184
|
+
await this.events.emit({
|
|
185
|
+
type: 'definition.deleted',
|
|
186
|
+
definitionId: row.guid,
|
|
187
|
+
actorId: actorFrom(ctx)
|
|
188
|
+
});
|
|
189
|
+
}
|
|
168
190
|
|
|
169
191
|
await this.events.emit({ type: 'project.deleted', projectId: id, actorId: actorFrom(ctx) });
|
|
170
192
|
}
|
|
@@ -185,9 +207,9 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
185
207
|
.eq('org_id', orgId)
|
|
186
208
|
.eq('slug', slug)
|
|
187
209
|
.not('deleted_at', 'is', null)
|
|
188
|
-
.select(
|
|
210
|
+
.select(PROJECT_COLUMNS)
|
|
189
211
|
.maybeSingle();
|
|
190
|
-
if (error) throw
|
|
212
|
+
if (error) throw mapPostgrestError(error);
|
|
191
213
|
if (!data) return null;
|
|
192
214
|
|
|
193
215
|
// Reactivate the owner's project_members row (deleteProject cascades to it).
|
|
@@ -197,7 +219,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
197
219
|
.eq('project_id', data.id)
|
|
198
220
|
.eq('user_id', data.owner_id)
|
|
199
221
|
.not('deleted_at', 'is', null);
|
|
200
|
-
if (pmErr) throw
|
|
222
|
+
if (pmErr) throw mapPostgrestError(pmErr);
|
|
201
223
|
|
|
202
224
|
const project = rowToProject(data);
|
|
203
225
|
await this.events.emit({
|
|
@@ -221,12 +243,12 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
221
243
|
const { data, error, count } = await this.clients
|
|
222
244
|
.forRequest(ctx)
|
|
223
245
|
.from('project_members')
|
|
224
|
-
.select(
|
|
246
|
+
.select(PROJECT_MEMBER_COLUMNS, { count: 'exact' })
|
|
225
247
|
.eq('project_id', projectId)
|
|
226
248
|
.is('deleted_at', null)
|
|
227
249
|
.order('joined_at', { ascending: (opts?.orderDir ?? 'desc') === 'asc' })
|
|
228
250
|
.range(range.from, range.to);
|
|
229
|
-
if (error) throw
|
|
251
|
+
if (error) throw mapPostgrestError(error);
|
|
230
252
|
const items = (data ?? []).map(rowToProjectMember);
|
|
231
253
|
return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
|
|
232
254
|
}
|
|
@@ -239,12 +261,12 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
239
261
|
const { data, error } = await this.clients
|
|
240
262
|
.forRequest(ctx)
|
|
241
263
|
.from('project_members')
|
|
242
|
-
.select(
|
|
264
|
+
.select(PROJECT_MEMBER_COLUMNS)
|
|
243
265
|
.eq('project_id', projectId)
|
|
244
266
|
.eq('user_id', userId)
|
|
245
267
|
.is('deleted_at', null)
|
|
246
268
|
.maybeSingle();
|
|
247
|
-
if (error) throw
|
|
269
|
+
if (error) throw mapPostgrestError(error);
|
|
248
270
|
return data ? rowToProjectMember(data) : null;
|
|
249
271
|
}
|
|
250
272
|
|
|
@@ -253,14 +275,14 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
253
275
|
// duplicate-key. Mirrors LocalProjectStore.addProjectMember.
|
|
254
276
|
const row: Record<string, unknown> = {
|
|
255
277
|
...projectMemberToRow(member),
|
|
256
|
-
deleted_at: null
|
|
278
|
+
deleted_at: null,
|
|
279
|
+
...stampUpdate(ctx)
|
|
257
280
|
};
|
|
258
|
-
if (ctx.userId) row.updated_by = ctx.userId;
|
|
259
281
|
const { error } = await this.clients
|
|
260
282
|
.forRequest(ctx)
|
|
261
283
|
.from('project_members')
|
|
262
284
|
.upsert(row, { onConflict: 'project_id,user_id' });
|
|
263
|
-
if (error) throw
|
|
285
|
+
if (error) throw mapPostgrestError(error);
|
|
264
286
|
await this.events.emit({
|
|
265
287
|
type: 'project_member.added',
|
|
266
288
|
projectId: member.projectId,
|
|
@@ -275,8 +297,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
275
297
|
userId: string,
|
|
276
298
|
role: ProjectRole
|
|
277
299
|
): Promise<void> {
|
|
278
|
-
const row: Record<string, unknown> = { role };
|
|
279
|
-
if (ctx.userId) row.updated_by = ctx.userId;
|
|
300
|
+
const row: Record<string, unknown> = { role, ...stampUpdate(ctx) };
|
|
280
301
|
const { data, error } = await this.clients
|
|
281
302
|
.forRequest(ctx)
|
|
282
303
|
.from('project_members')
|
|
@@ -285,7 +306,7 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
285
306
|
.eq('user_id', userId)
|
|
286
307
|
.is('deleted_at', null)
|
|
287
308
|
.select('user_id');
|
|
288
|
-
if (error) throw
|
|
309
|
+
if (error) throw mapPostgrestError(error);
|
|
289
310
|
if (!data || data.length === 0)
|
|
290
311
|
throw new ProviderError(`Project member '${userId}' not found`, 404);
|
|
291
312
|
await this.events.emit({
|
|
@@ -298,15 +319,14 @@ export class SupabaseProjectStore implements IProjectStore {
|
|
|
298
319
|
}
|
|
299
320
|
|
|
300
321
|
async removeProjectMember(ctx: RequestContext, projectId: string, userId: string): Promise<void> {
|
|
301
|
-
const stamp = auditSoftDelete(ctx, ctx.userId);
|
|
302
322
|
const { error } = await this.clients
|
|
303
323
|
.forRequest(ctx)
|
|
304
324
|
.from('project_members')
|
|
305
|
-
.update(
|
|
325
|
+
.update(stampSoftDelete(ctx))
|
|
306
326
|
.eq('project_id', projectId)
|
|
307
327
|
.eq('user_id', userId)
|
|
308
328
|
.is('deleted_at', null);
|
|
309
|
-
if (error) throw
|
|
329
|
+
if (error) throw mapPostgrestError(error);
|
|
310
330
|
await this.events.emit({
|
|
311
331
|
type: 'project_member.removed',
|
|
312
332
|
projectId,
|
|
@@ -409,46 +429,3 @@ function projectMemberToRow(m: ProjectMember): ProjectMemberRow {
|
|
|
409
429
|
joined_at: m.joinedAt
|
|
410
430
|
};
|
|
411
431
|
}
|
|
412
|
-
|
|
413
|
-
// ============================================================================
|
|
414
|
-
// Error translation
|
|
415
|
-
// ============================================================================
|
|
416
|
-
interface PostgrestError {
|
|
417
|
-
code?: string;
|
|
418
|
-
message?: string;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Build a row payload from an audit-soft-delete stamp. `updated_by` only goes
|
|
423
|
-
* into the row when ctx.userId is set — passing `''` would violate the
|
|
424
|
-
* `references auth.users(id)` FK on system contexts. The DB trigger sets
|
|
425
|
-
* `updated_at`, but for soft-delete we set it explicitly so it matches
|
|
426
|
-
* `deleted_at` (single timestamp for the deletion event).
|
|
427
|
-
*/
|
|
428
|
-
function stampToRow(stamp: {
|
|
429
|
-
updatedAt: string;
|
|
430
|
-
updatedBy: string;
|
|
431
|
-
deletedAt: string;
|
|
432
|
-
}): Record<string, unknown> {
|
|
433
|
-
const row: Record<string, unknown> = {
|
|
434
|
-
deleted_at: stamp.deletedAt,
|
|
435
|
-
updated_at: stamp.updatedAt
|
|
436
|
-
};
|
|
437
|
-
if (stamp.updatedBy) row.updated_by = stamp.updatedBy;
|
|
438
|
-
return row;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function mapError(e: unknown): Error {
|
|
442
|
-
const pg = e as PostgrestError;
|
|
443
|
-
if (pg?.code === '23505') return new ProviderError(pg.message ?? 'Duplicate record', 409);
|
|
444
|
-
if (pg?.code === '23503') return new ProviderError(pg.message ?? 'Foreign key violation', 409);
|
|
445
|
-
if (e instanceof Error) return e;
|
|
446
|
-
if (e && typeof e === 'object') {
|
|
447
|
-
const obj = e as { message?: string; details?: string; hint?: string; code?: string };
|
|
448
|
-
const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
|
|
449
|
-
const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
|
|
450
|
-
Object.assign(err, obj);
|
|
451
|
-
return err;
|
|
452
|
-
}
|
|
453
|
-
return new Error(String(e));
|
|
454
|
-
}
|
|
@@ -6,10 +6,15 @@ import type {
|
|
|
6
6
|
ListOptions,
|
|
7
7
|
Page
|
|
8
8
|
} from '@selvajs/platform';
|
|
9
|
-
import {
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|