@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.
- 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.map +1 -1
- package/dist/data/SupabaseOrgStore.js +49 -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/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 +51 -95
- 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/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 {
|
|
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
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit Q4/Q5 — the concurrent-solve guarantee for share-link caps.
|
|
3
|
+
*
|
|
4
|
+
* `tryIncrementSolveCount` is the load-bearing race-sensitive method: the
|
|
5
|
+
* `/api/compute` endpoint calls it BEFORE solving and turns a `null` return
|
|
6
|
+
* (cap hit) into HTTP 429. Under concurrent solves against one capped link, the
|
|
7
|
+
* cap must hold exactly — no overshoot, no lost update handing two solves the
|
|
8
|
+
* same slot. Supabase provides this via the SECURITY DEFINER
|
|
9
|
+
* `try_increment_share_link_solve_count` RPC, which does the check-and-increment
|
|
10
|
+
* in a single statement.
|
|
11
|
+
*
|
|
12
|
+
* This lives in the Supabase suite (not the shared conformance suite or the app
|
|
13
|
+
* route tests) because it is the ONLY backend that actually provides the
|
|
14
|
+
* guarantee: the local provider's whole-file read-modify-write is a documented
|
|
15
|
+
* dev-scale tradeoff and is not concurrency-safe. Skipped without a live stack,
|
|
16
|
+
* matching the other conformance tests here.
|
|
17
|
+
*
|
|
18
|
+
* Complements the sequential cap/count tests at
|
|
19
|
+
* `packages/selva/src/routes/api/compute/__tests__/solve-cap-and-count.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
23
|
+
import type { RequestContext, ShareLink } from '@selvajs/platform';
|
|
24
|
+
import { SupabaseShareLinkStore } from '../SupabaseShareLinkStore.js';
|
|
25
|
+
import { readEnv, resetAllData, seedUser } from './test-helpers.js';
|
|
26
|
+
|
|
27
|
+
const envCtx = readEnv();
|
|
28
|
+
|
|
29
|
+
if (!envCtx) {
|
|
30
|
+
describe.skip('SupabaseShareLinkStore concurrency (skipped: no live stack)', () => {
|
|
31
|
+
it('populate packages/providers/supabase/.env.test with Supabase creds to run these tests', () => {});
|
|
32
|
+
});
|
|
33
|
+
} else {
|
|
34
|
+
const ctx: RequestContext = { userId: '', system: true } as RequestContext;
|
|
35
|
+
|
|
36
|
+
/** Seed org → project → definition → share link, all via service-role. */
|
|
37
|
+
async function seedCappedLink(maxSolves: number): Promise<{ linkId: string }> {
|
|
38
|
+
const { userId: ownerId } = await seedUser(envCtx!, '');
|
|
39
|
+
const admin = envCtx!.adminClient;
|
|
40
|
+
const orgId = crypto.randomUUID();
|
|
41
|
+
const projectId = crypto.randomUUID();
|
|
42
|
+
const definitionId = crypto.randomUUID();
|
|
43
|
+
const linkId = crypto.randomUUID();
|
|
44
|
+
const now = new Date().toISOString();
|
|
45
|
+
|
|
46
|
+
const { error: orgErr } = await admin.from('orgs').insert({
|
|
47
|
+
id: orgId,
|
|
48
|
+
name: 'Concurrency Org',
|
|
49
|
+
slug: `conc-${orgId.slice(0, 8)}`,
|
|
50
|
+
owner_id: ownerId,
|
|
51
|
+
created_at: now,
|
|
52
|
+
updated_at: now
|
|
53
|
+
});
|
|
54
|
+
if (orgErr) throw orgErr;
|
|
55
|
+
const { error: projErr } = await admin.from('projects').insert({
|
|
56
|
+
id: projectId,
|
|
57
|
+
org_id: orgId,
|
|
58
|
+
name: 'Project',
|
|
59
|
+
slug: `proj-${projectId.slice(0, 8)}`,
|
|
60
|
+
visibility: 'public',
|
|
61
|
+
owner_id: ownerId,
|
|
62
|
+
created_at: now,
|
|
63
|
+
updated_at: now
|
|
64
|
+
});
|
|
65
|
+
if (projErr) throw projErr;
|
|
66
|
+
const { error: defErr } = await admin.from('definitions').insert({
|
|
67
|
+
guid: definitionId,
|
|
68
|
+
project_id: projectId,
|
|
69
|
+
owner_id: ownerId,
|
|
70
|
+
display_name: 'Def',
|
|
71
|
+
status: 'published',
|
|
72
|
+
created_at: now,
|
|
73
|
+
updated_at: now
|
|
74
|
+
});
|
|
75
|
+
if (defErr) throw defErr;
|
|
76
|
+
|
|
77
|
+
const link: ShareLink = {
|
|
78
|
+
id: linkId,
|
|
79
|
+
definitionId,
|
|
80
|
+
channel: 'live',
|
|
81
|
+
tokenHash: crypto.randomUUID().replace(/-/g, ''),
|
|
82
|
+
createdBy: ownerId,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
expiresAt: null,
|
|
85
|
+
revokedAt: null,
|
|
86
|
+
allowSolve: true,
|
|
87
|
+
maxSolves,
|
|
88
|
+
solveCount: 0
|
|
89
|
+
};
|
|
90
|
+
const store = new SupabaseShareLinkStore(envCtx!.bundle);
|
|
91
|
+
await store.create(ctx, link);
|
|
92
|
+
return { linkId };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
describe('SupabaseShareLinkStore — concurrent solve-cap enforcement', () => {
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
await resetAllData(envCtx!);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('N concurrent solves against a cap-K link admit exactly K, refuse the rest', async () => {
|
|
101
|
+
const CAP = 5;
|
|
102
|
+
const ATTEMPTS = 25;
|
|
103
|
+
const { linkId } = await seedCappedLink(CAP);
|
|
104
|
+
const store = new SupabaseShareLinkStore(envCtx!.bundle);
|
|
105
|
+
|
|
106
|
+
const outcomes = await Promise.all(
|
|
107
|
+
Array.from({ length: ATTEMPTS }, () => store.tryIncrementSolveCount(ctx, linkId))
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const admitted = outcomes.filter((n): n is number => n !== null);
|
|
111
|
+
const refused = outcomes.filter((n) => n === null);
|
|
112
|
+
// Exactly CAP admitted — the atomic RPC never lets a concurrent burst
|
|
113
|
+
// overshoot the cap.
|
|
114
|
+
expect(admitted).toHaveLength(CAP);
|
|
115
|
+
expect(refused).toHaveLength(ATTEMPTS - CAP);
|
|
116
|
+
// Each admitted solve got a distinct slot 1..CAP — no lost update.
|
|
117
|
+
expect([...admitted].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
|
|
118
|
+
|
|
119
|
+
// The persisted count settles at exactly the cap.
|
|
120
|
+
const finalLink = await store.getById(ctx, linkId);
|
|
121
|
+
expect(finalLink?.solveCount).toBe(CAP);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/data/client.ts
CHANGED
|
@@ -10,6 +10,27 @@ import type { RequestContext } from '@selvajs/platform';
|
|
|
10
10
|
*/
|
|
11
11
|
export type SelvaSchemaClient = SupabaseClient<any, 'public', 'selva'>;
|
|
12
12
|
|
|
13
|
+
/** Default schema every engine store targets. */
|
|
14
|
+
export const DEFAULT_SCHEMA = 'selva' as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A client pinned to a caller-chosen schema. When the schema is `'selva'` this
|
|
18
|
+
* is the narrow `SelvaSchemaClient`; for an app-owned schema (e.g. `'public'`)
|
|
19
|
+
* the schema generic is opaque — the store surface (`.from` / `.rpc`) is the
|
|
20
|
+
* same either way.
|
|
21
|
+
*/
|
|
22
|
+
export type SchemaClient = SupabaseClient<any, string, any>;
|
|
23
|
+
|
|
24
|
+
export interface ForRequestOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Postgres schema this client targets. Defaults to `'selva'` (the engine's
|
|
27
|
+
* tables). Pass `'public'` (or another app schema) to build stores on the
|
|
28
|
+
* consuming app's own tables while keeping the same fail-closed RLS
|
|
29
|
+
* dispatch as the engine stores.
|
|
30
|
+
*/
|
|
31
|
+
schema?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
/**
|
|
14
35
|
* Build a Supabase client for a given `RequestContext`.
|
|
15
36
|
*
|
|
@@ -35,15 +56,20 @@ export type SelvaSchemaClient = SupabaseClient<any, 'public', 'selva'>;
|
|
|
35
56
|
* fetch connection without leaking auth across requests.
|
|
36
57
|
*/
|
|
37
58
|
export interface ClientBundle {
|
|
38
|
-
/** Long-lived service-role client. Bypasses RLS. */
|
|
59
|
+
/** Long-lived service-role client, pinned to the `'selva'` schema. Bypasses RLS. */
|
|
39
60
|
serviceClient: SelvaSchemaClient;
|
|
40
61
|
/**
|
|
41
|
-
* Produce (or reuse) a client scoped to this request's identity
|
|
62
|
+
* Produce (or reuse) a client scoped to this request's identity, targeting
|
|
63
|
+
* `opts.schema` (default `'selva'`).
|
|
42
64
|
* - `ctx.system === true` → service-role (RLS bypassed).
|
|
43
65
|
* - `ctx.adapterContext.sessionToken` set → user-scoped (RLS active).
|
|
44
66
|
* - neither → anon client (RLS active, no `auth.uid()`).
|
|
67
|
+
*
|
|
68
|
+
* The schema pin and the RLS dispatch travel together: an app building
|
|
69
|
+
* stores on its own `'public'` tables gets the same fail-closed behavior as
|
|
70
|
+
* engine stores, just aimed at a different schema.
|
|
45
71
|
*/
|
|
46
|
-
forRequest(ctx: RequestContext):
|
|
72
|
+
forRequest(ctx: RequestContext, opts?: ForRequestOptions): SchemaClient;
|
|
47
73
|
}
|
|
48
74
|
|
|
49
75
|
export interface BuildClientOptions {
|
|
@@ -53,44 +79,93 @@ export interface BuildClientOptions {
|
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
export function buildClientBundle(opts: BuildClientOptions): ClientBundle {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
82
|
+
// Long-lived service-role + anon clients, one per schema. Both are stateless
|
|
83
|
+
// w.r.t. request identity, so a single instance is safe to share across all
|
|
84
|
+
// requests targeting the same schema.
|
|
85
|
+
const serviceBySchema = new Map<string, SchemaClient>();
|
|
86
|
+
const anonBySchema = new Map<string, SchemaClient>();
|
|
87
|
+
|
|
88
|
+
function serviceFor(schema: string): SchemaClient {
|
|
89
|
+
let client = serviceBySchema.get(schema);
|
|
90
|
+
if (!client) {
|
|
91
|
+
client = createClient(opts.supabaseUrl, opts.serviceRoleKey, {
|
|
92
|
+
db: { schema },
|
|
93
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
94
|
+
});
|
|
95
|
+
serviceBySchema.set(schema, client);
|
|
96
|
+
}
|
|
97
|
+
return client;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function anonFor(schema: string): SchemaClient {
|
|
101
|
+
let client = anonBySchema.get(schema);
|
|
102
|
+
if (!client) {
|
|
103
|
+
client = createClient(opts.supabaseUrl, opts.anonKey, {
|
|
104
|
+
db: { schema },
|
|
105
|
+
auth: { persistSession: false, autoRefreshToken: false }
|
|
106
|
+
});
|
|
107
|
+
anonBySchema.set(schema, client);
|
|
108
|
+
}
|
|
109
|
+
return client;
|
|
110
|
+
}
|
|
60
111
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
db: { schema: 'selva' },
|
|
65
|
-
auth: { persistSession: false, autoRefreshToken: false }
|
|
66
|
-
});
|
|
112
|
+
// The `'selva'`-pinned service client is exposed directly on the bundle for
|
|
113
|
+
// engine code that reaches for RLS-bypassing reads without a request context.
|
|
114
|
+
const serviceClient = serviceFor(DEFAULT_SCHEMA) as SelvaSchemaClient;
|
|
67
115
|
|
|
68
|
-
|
|
116
|
+
// Per-request user-scoped clients, keyed first by context then by schema so
|
|
117
|
+
// a handler that touches both `'selva'` and an app schema reuses one client
|
|
118
|
+
// per (request, schema) without leaking auth across requests.
|
|
119
|
+
const perRequestCache = new WeakMap<RequestContext, Map<string, SchemaClient>>();
|
|
69
120
|
|
|
70
121
|
return {
|
|
71
122
|
serviceClient,
|
|
72
|
-
forRequest(ctx: RequestContext):
|
|
73
|
-
|
|
123
|
+
forRequest(ctx: RequestContext, forOpts?: ForRequestOptions): SchemaClient {
|
|
124
|
+
const schema = forOpts?.schema ?? DEFAULT_SCHEMA;
|
|
125
|
+
if (ctx.system) return serviceFor(schema);
|
|
74
126
|
|
|
75
127
|
const token = extractSessionToken(ctx.adapterContext);
|
|
76
|
-
if (!token) return
|
|
128
|
+
if (!token) return anonFor(schema);
|
|
77
129
|
|
|
78
|
-
|
|
130
|
+
let bySchema = perRequestCache.get(ctx);
|
|
131
|
+
if (!bySchema) {
|
|
132
|
+
bySchema = new Map();
|
|
133
|
+
perRequestCache.set(ctx, bySchema);
|
|
134
|
+
}
|
|
135
|
+
const cached = bySchema.get(schema);
|
|
79
136
|
if (cached) return cached;
|
|
80
137
|
|
|
81
138
|
const client = createClient(opts.supabaseUrl, opts.anonKey, {
|
|
82
|
-
db: { schema
|
|
139
|
+
db: { schema },
|
|
83
140
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
84
141
|
global: {
|
|
85
142
|
headers: { Authorization: `Bearer ${token}` }
|
|
86
143
|
}
|
|
87
144
|
});
|
|
88
|
-
|
|
145
|
+
bySchema.set(schema, client);
|
|
89
146
|
return client;
|
|
90
147
|
}
|
|
91
148
|
};
|
|
92
149
|
}
|
|
93
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Build a `ClientBundle` straight from environment variables — the
|
|
153
|
+
* `SUPABASE_URL` / `SUPABASE_ANON_KEY` / `SUPABASE_SERVICE_ROLE_KEY` triple,
|
|
154
|
+
* nothing else. Deliberately does NOT require `SELVA_AT_REST_KEY`: that key
|
|
155
|
+
* only matters for compute-server secret encryption, which a consuming app
|
|
156
|
+
* building its own `'public'` stores never touches. `SupabaseDataProvider.fromEnv`
|
|
157
|
+
* layers the at-rest-key requirement on top of this.
|
|
158
|
+
*/
|
|
159
|
+
export function clientBundleFromEnv(env: Record<string, string | undefined>): ClientBundle {
|
|
160
|
+
const supabaseUrl = env.SUPABASE_URL;
|
|
161
|
+
const anonKey = env.SUPABASE_ANON_KEY;
|
|
162
|
+
const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
|
|
163
|
+
if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
|
|
164
|
+
if (!anonKey) throw new Error('Missing required env var: SUPABASE_ANON_KEY');
|
|
165
|
+
if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
|
|
166
|
+
return buildClientBundle({ supabaseUrl, anonKey, serviceRoleKey });
|
|
167
|
+
}
|
|
168
|
+
|
|
94
169
|
function extractSessionToken(adapterContext: unknown): string | undefined {
|
|
95
170
|
if (!adapterContext || typeof adapterContext !== 'object') return undefined;
|
|
96
171
|
const maybe = (adapterContext as { sessionToken?: unknown }).sessionToken;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ProviderError } from '@selvajs/platform';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical PostgREST/Postgres error translation shared by every Supabase
|
|
5
|
+
* store. Replaces the six near-identical private `mapError` copies that had
|
|
6
|
+
* drifted into two variants (a "full" one preserving `details`/`hint`, and a
|
|
7
|
+
* slimmer message-only one) and none of which handled `PGRST116`.
|
|
8
|
+
*
|
|
9
|
+
* Mapping:
|
|
10
|
+
* - `PGRST116` (single-row selector matched zero rows) → 404. PostgREST
|
|
11
|
+
* raises this when `.single()` finds nothing; treating it as "not found"
|
|
12
|
+
* is almost always what a store's caller wants.
|
|
13
|
+
* - `23505` (unique violation) / `23503` (foreign-key violation) → 409.
|
|
14
|
+
* - Anything else → a plain `Error` (500 downstream via `api-errors`), with
|
|
15
|
+
* `details`/`hint`/`code` preserved on the error object for logs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** The subset of a `PostgrestError` this mapper inspects. */
|
|
19
|
+
interface PostgrestErrorShape {
|
|
20
|
+
code?: string;
|
|
21
|
+
message?: string;
|
|
22
|
+
details?: string;
|
|
23
|
+
hint?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function mapPostgrestError(e: unknown): Error {
|
|
27
|
+
const pg = e as PostgrestErrorShape;
|
|
28
|
+
switch (pg?.code) {
|
|
29
|
+
case 'PGRST116':
|
|
30
|
+
return new ProviderError(pg.message ?? 'Not found', 404);
|
|
31
|
+
case '23505':
|
|
32
|
+
return new ProviderError(pg.message ?? 'Duplicate record', 409);
|
|
33
|
+
case '23503':
|
|
34
|
+
return new ProviderError(pg.message ?? 'Foreign key violation', 409);
|
|
35
|
+
}
|
|
36
|
+
if (e instanceof Error) return e;
|
|
37
|
+
if (e && typeof e === 'object') {
|
|
38
|
+
const obj = e as PostgrestErrorShape;
|
|
39
|
+
const msg = obj.message ?? obj.details ?? obj.hint ?? 'Unknown Postgres error';
|
|
40
|
+
const err = new Error(obj.code ? `[${obj.code}] ${msg}` : msg);
|
|
41
|
+
Object.assign(err, obj);
|
|
42
|
+
return err;
|
|
43
|
+
}
|
|
44
|
+
return new Error(String(e));
|
|
45
|
+
}
|
package/src/data/index.ts
CHANGED
|
@@ -8,5 +8,22 @@ export { SupabaseDataProvider } from './SupabaseDataProvider.js';
|
|
|
8
8
|
export { SupabaseEventSink } from './SupabaseEventSink.js';
|
|
9
9
|
export { SupabaseSolveMetricSink } from './SupabaseSolveMetricSink.js';
|
|
10
10
|
export { SupabaseAuditQuery } from './SupabaseAuditQuery.js';
|
|
11
|
-
export { buildClientBundle } from './client.js';
|
|
12
|
-
export type {
|
|
11
|
+
export { buildClientBundle, clientBundleFromEnv, DEFAULT_SCHEMA } from './client.js';
|
|
12
|
+
export type {
|
|
13
|
+
ClientBundle,
|
|
14
|
+
BuildClientOptions,
|
|
15
|
+
ForRequestOptions,
|
|
16
|
+
SchemaClient,
|
|
17
|
+
SelvaSchemaClient
|
|
18
|
+
} from './client.js';
|
|
19
|
+
export { mapPostgrestError } from './errors.js';
|
|
20
|
+
export { stampUpdate, stampSoftDelete } from './rowStamp.js';
|
|
21
|
+
export { notDeleted } from './query.js';
|
|
22
|
+
export {
|
|
23
|
+
toRange,
|
|
24
|
+
orderColumn,
|
|
25
|
+
nextCursorFromRange,
|
|
26
|
+
encodeCursor,
|
|
27
|
+
decodeCursor
|
|
28
|
+
} from './pagination.js';
|
|
29
|
+
export type { RangeSpec } from './pagination.js';
|