@selvajs/supabase-provider 0.11.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/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/auth/SupabaseAuthProvider.d.ts +81 -0
- package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -0
- package/dist/auth/SupabaseAuthProvider.js +383 -0
- package/dist/auth/SupabaseAuthProvider.js.map +1 -0
- package/dist/auth/__tests__/conformance.test.d.ts +2 -0
- package/dist/auth/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/auth/__tests__/conformance.test.js +76 -0
- package/dist/auth/__tests__/conformance.test.js.map +1 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +2 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/data/SupabaseAuditQuery.d.ts +16 -0
- package/dist/data/SupabaseAuditQuery.d.ts.map +1 -0
- package/dist/data/SupabaseAuditQuery.js +83 -0
- package/dist/data/SupabaseAuditQuery.js.map +1 -0
- package/dist/data/SupabaseComputeServerStore.d.ts +26 -0
- package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -0
- package/dist/data/SupabaseComputeServerStore.js +227 -0
- package/dist/data/SupabaseComputeServerStore.js.map +1 -0
- package/dist/data/SupabaseDataProvider.d.ts +55 -0
- package/dist/data/SupabaseDataProvider.d.ts.map +1 -0
- package/dist/data/SupabaseDataProvider.js +114 -0
- package/dist/data/SupabaseDataProvider.js.map +1 -0
- package/dist/data/SupabaseDefinitionStore.d.ts +39 -0
- package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -0
- package/dist/data/SupabaseDefinitionStore.js +419 -0
- package/dist/data/SupabaseDefinitionStore.js.map +1 -0
- package/dist/data/SupabaseEventSink.d.ts +17 -0
- package/dist/data/SupabaseEventSink.d.ts.map +1 -0
- package/dist/data/SupabaseEventSink.js +31 -0
- package/dist/data/SupabaseEventSink.js.map +1 -0
- package/dist/data/SupabaseInviteStore.d.ts +22 -0
- package/dist/data/SupabaseInviteStore.d.ts.map +1 -0
- package/dist/data/SupabaseInviteStore.js +159 -0
- package/dist/data/SupabaseInviteStore.js.map +1 -0
- package/dist/data/SupabaseOrgStore.d.ts +33 -0
- package/dist/data/SupabaseOrgStore.d.ts.map +1 -0
- package/dist/data/SupabaseOrgStore.js +455 -0
- package/dist/data/SupabaseOrgStore.js.map +1 -0
- package/dist/data/SupabaseProjectStore.d.ts +29 -0
- package/dist/data/SupabaseProjectStore.d.ts.map +1 -0
- package/dist/data/SupabaseProjectStore.js +328 -0
- package/dist/data/SupabaseProjectStore.js.map +1 -0
- package/dist/data/SupabaseShareLinkStore.d.ts +26 -0
- package/dist/data/SupabaseShareLinkStore.d.ts.map +1 -0
- package/dist/data/SupabaseShareLinkStore.js +150 -0
- package/dist/data/SupabaseShareLinkStore.js.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/compute-server-conformance.test.js +40 -0
- package/dist/data/__tests__/compute-server-conformance.test.js.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/definition-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/definition-conformance.test.js +85 -0
- package/dist/data/__tests__/definition-conformance.test.js.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/event-sink-conformance.test.js +27 -0
- package/dist/data/__tests__/event-sink-conformance.test.js.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/invite-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/invite-conformance.test.js +45 -0
- package/dist/data/__tests__/invite-conformance.test.js.map +1 -0
- package/dist/data/__tests__/org-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/org-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/org-conformance.test.js +29 -0
- package/dist/data/__tests__/org-conformance.test.js.map +1 -0
- package/dist/data/__tests__/project-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/project-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/project-conformance.test.js +57 -0
- package/dist/data/__tests__/project-conformance.test.js.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts +2 -0
- package/dist/data/__tests__/share-link-conformance.test.d.ts.map +1 -0
- package/dist/data/__tests__/share-link-conformance.test.js +88 -0
- package/dist/data/__tests__/share-link-conformance.test.js.map +1 -0
- package/dist/data/__tests__/test-helpers.d.ts +53 -0
- package/dist/data/__tests__/test-helpers.d.ts.map +1 -0
- package/dist/data/__tests__/test-helpers.js +175 -0
- package/dist/data/__tests__/test-helpers.js.map +1 -0
- package/dist/data/client.d.ts +44 -0
- package/dist/data/client.d.ts.map +1 -0
- package/dist/data/client.js +40 -0
- package/dist/data/client.js.map +1 -0
- package/dist/data/index.d.ts +12 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +11 -0
- package/dist/data/index.js.map +1 -0
- package/dist/data/pagination.d.ts +30 -0
- package/dist/data/pagination.d.ts.map +1 -0
- package/dist/data/pagination.js +53 -0
- package/dist/data/pagination.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts +25 -0
- package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -0
- package/dist/permissions/SupabasePlatformPermissionStore.js +153 -0
- package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -0
- package/dist/permissions/__tests__/conformance.test.d.ts +2 -0
- package/dist/permissions/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/permissions/__tests__/conformance.test.js +27 -0
- package/dist/permissions/__tests__/conformance.test.js.map +1 -0
- package/dist/permissions/index.d.ts +2 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +2 -0
- package/dist/permissions/index.js.map +1 -0
- package/dist/storage/SupabaseStorageProvider.d.ts +67 -0
- package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -0
- package/dist/storage/SupabaseStorageProvider.js +136 -0
- package/dist/storage/SupabaseStorageProvider.js.map +1 -0
- package/dist/storage/__tests__/conformance.test.d.ts +2 -0
- package/dist/storage/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/storage/__tests__/conformance.test.js +149 -0
- package/dist/storage/__tests__/conformance.test.js.map +1 -0
- package/dist/storage/index.d.ts +3 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/userProfile/SupabaseUserProfileProvider.d.ts +16 -0
- package/dist/userProfile/SupabaseUserProfileProvider.d.ts.map +1 -0
- package/dist/userProfile/SupabaseUserProfileProvider.js +148 -0
- package/dist/userProfile/SupabaseUserProfileProvider.js.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts +2 -0
- package/dist/userProfile/__tests__/conformance.test.d.ts.map +1 -0
- package/dist/userProfile/__tests__/conformance.test.js +38 -0
- package/dist/userProfile/__tests__/conformance.test.js.map +1 -0
- package/dist/userProfile/index.d.ts +2 -0
- package/dist/userProfile/index.d.ts.map +1 -0
- package/dist/userProfile/index.js +2 -0
- package/dist/userProfile/index.js.map +1 -0
- package/package.json +75 -0
- package/src/auth/SupabaseAuthProvider.ts +464 -0
- package/src/auth/__tests__/conformance.test.ts +78 -0
- package/src/auth/index.ts +2 -0
- package/src/data/SupabaseAuditQuery.ts +103 -0
- package/src/data/SupabaseComputeServerStore.ts +262 -0
- package/src/data/SupabaseDataProvider.ts +125 -0
- package/src/data/SupabaseDefinitionStore.ts +504 -0
- package/src/data/SupabaseEventSink.ts +30 -0
- package/src/data/SupabaseInviteStore.ts +183 -0
- package/src/data/SupabaseOrgStore.ts +547 -0
- package/src/data/SupabaseProjectStore.ts +414 -0
- package/src/data/SupabaseShareLinkStore.ts +191 -0
- package/src/data/__tests__/compute-server-conformance.test.ts +40 -0
- package/src/data/__tests__/definition-conformance.test.ts +89 -0
- package/src/data/__tests__/event-sink-conformance.test.ts +32 -0
- package/src/data/__tests__/invite-conformance.test.ts +46 -0
- package/src/data/__tests__/org-conformance.test.ts +30 -0
- package/src/data/__tests__/project-conformance.test.ts +57 -0
- package/src/data/__tests__/share-link-conformance.test.ts +87 -0
- package/src/data/__tests__/test-helpers.ts +186 -0
- package/src/data/client.ts +86 -0
- package/src/data/index.ts +11 -0
- package/src/data/pagination.ts +68 -0
- package/src/index.ts +23 -0
- package/src/permissions/SupabasePlatformPermissionStore.ts +161 -0
- package/src/permissions/__tests__/conformance.test.ts +28 -0
- package/src/permissions/index.ts +1 -0
- package/src/storage/SupabaseStorageProvider.ts +177 -0
- package/src/storage/__tests__/conformance.test.ts +152 -0
- package/src/storage/index.ts +2 -0
- package/src/userProfile/SupabaseUserProfileProvider.ts +177 -0
- package/src/userProfile/__tests__/conformance.test.ts +39 -0
- package/src/userProfile/index.ts +1 -0
- package/supabase/.branches/_current_branch +1 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/config.toml +68 -0
- package/supabase/migrations/0001_initial.sql +1084 -0
- package/supabase/seed.sql +13 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuditEventRow,
|
|
3
|
+
AuditQueryFilters,
|
|
4
|
+
AuditQueryResult,
|
|
5
|
+
DomainEvent,
|
|
6
|
+
IAuditQuery,
|
|
7
|
+
RequestContext
|
|
8
|
+
} from '@selvajs/platform';
|
|
9
|
+
import type { ClientBundle } from './client.js';
|
|
10
|
+
|
|
11
|
+
const MAX_LIMIT = 200;
|
|
12
|
+
const DEFAULT_LIMIT = 100;
|
|
13
|
+
|
|
14
|
+
// Cursor values are interpolated into PostgREST filter strings — both formats
|
|
15
|
+
// must be validated before any string concatenation reaches the query.
|
|
16
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
17
|
+
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read-side for `audit_events`. Always uses the service-role client — RLS
|
|
21
|
+
* blocks all read access by design, so the route layer is the single trust
|
|
22
|
+
* boundary (callers must gate on `instance_admin`).
|
|
23
|
+
*
|
|
24
|
+
* Pagination is keyset on `(occurred_at desc, id desc)`. The `id` tie-breaker
|
|
25
|
+
* prevents skipped rows when multiple events land in the same millisecond.
|
|
26
|
+
*/
|
|
27
|
+
export class SupabaseAuditQuery implements IAuditQuery {
|
|
28
|
+
constructor(private readonly clients: ClientBundle) {}
|
|
29
|
+
|
|
30
|
+
async list(_ctx: RequestContext, filters: AuditQueryFilters): Promise<AuditQueryResult> {
|
|
31
|
+
const limit = clampLimit(filters.limit);
|
|
32
|
+
|
|
33
|
+
let q = this.clients.serviceClient
|
|
34
|
+
.from('audit_events')
|
|
35
|
+
.select('id, type, actor_id, occurred_at, data')
|
|
36
|
+
.order('occurred_at', { ascending: false })
|
|
37
|
+
.order('id', { ascending: false })
|
|
38
|
+
// Fetch one extra row to detect "has next page" without a count query.
|
|
39
|
+
.limit(limit + 1);
|
|
40
|
+
|
|
41
|
+
if (filters.types && filters.types.length > 0) {
|
|
42
|
+
q = q.in('type', [...filters.types]);
|
|
43
|
+
}
|
|
44
|
+
if (filters.actorId) {
|
|
45
|
+
q = q.eq('actor_id', filters.actorId);
|
|
46
|
+
}
|
|
47
|
+
if (filters.sinceIso) {
|
|
48
|
+
q = q.gte('occurred_at', filters.sinceIso);
|
|
49
|
+
}
|
|
50
|
+
if (filters.untilIso) {
|
|
51
|
+
q = q.lte('occurred_at', filters.untilIso);
|
|
52
|
+
}
|
|
53
|
+
if (filters.cursor) {
|
|
54
|
+
// `(occurred_at, id) < (cursorTs, cursorId)` expressed for PostgREST:
|
|
55
|
+
// strictly older OR same timestamp with a strictly smaller id.
|
|
56
|
+
//
|
|
57
|
+
// Both values are interpolated into a `.or()` filter string. Validate
|
|
58
|
+
// shape before interpolation — without these guards a malformed cursor
|
|
59
|
+
// (or a hand-crafted URL by a route caller) could break out of the
|
|
60
|
+
// filter and combine with arbitrary PostgREST operators.
|
|
61
|
+
const ts = filters.cursor.occurredAt;
|
|
62
|
+
const id = filters.cursor.id;
|
|
63
|
+
if (!ISO_RE.test(ts) || !UUID_RE.test(id)) {
|
|
64
|
+
throw new Error('audit_events query: malformed cursor');
|
|
65
|
+
}
|
|
66
|
+
q = q.or(`occurred_at.lt.${ts},and(occurred_at.eq.${ts},id.lt.${id})`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { data, error } = await q;
|
|
70
|
+
if (error) {
|
|
71
|
+
throw new Error(`audit_events query failed: ${error.message}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const fetched = (data ?? []) as Array<{
|
|
75
|
+
id: string;
|
|
76
|
+
type: string;
|
|
77
|
+
actor_id: string;
|
|
78
|
+
occurred_at: string;
|
|
79
|
+
data: DomainEvent;
|
|
80
|
+
}>;
|
|
81
|
+
|
|
82
|
+
const hasMore = fetched.length > limit;
|
|
83
|
+
const page = hasMore ? fetched.slice(0, limit) : fetched;
|
|
84
|
+
const last = page[page.length - 1];
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
rows: page.map((r) => ({
|
|
88
|
+
id: r.id,
|
|
89
|
+
type: r.type as DomainEvent['type'],
|
|
90
|
+
actorId: r.actor_id,
|
|
91
|
+
occurredAt: r.occurred_at,
|
|
92
|
+
data: r.data
|
|
93
|
+
})) satisfies AuditEventRow[],
|
|
94
|
+
nextCursor: hasMore && last ? { occurredAt: last.occurred_at, id: last.id } : null
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function clampLimit(requested: number | undefined): number {
|
|
100
|
+
if (!requested || requested <= 0) return DEFAULT_LIMIT;
|
|
101
|
+
if (requested > MAX_LIMIT) return MAX_LIMIT;
|
|
102
|
+
return Math.floor(requested);
|
|
103
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isOrgServer,
|
|
3
|
+
isPlatformServer,
|
|
4
|
+
type IComputeServerStore,
|
|
5
|
+
type ComputeConfig,
|
|
6
|
+
type ComputeServerConfig,
|
|
7
|
+
type RequestContext
|
|
8
|
+
} from '@selvajs/platform';
|
|
9
|
+
import { ProviderError } from '@selvajs/platform';
|
|
10
|
+
import type { ClientBundle } from './client.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Compute-server config backed by three tables:
|
|
14
|
+
* - `compute_servers`: one row per server. `scope = 'platform' | 'org'`.
|
|
15
|
+
* Platform rows carry a `shared_with_all` flag + `compute_server_shares`
|
|
16
|
+
* allowlist; org rows carry `owner_org_id`.
|
|
17
|
+
* - `compute_server_shares`: many-to-many for platform servers shared
|
|
18
|
+
* with specific orgs (when `shared_with_all = false`).
|
|
19
|
+
* - `compute_server_org_defaults`: per-org `orgDefaults[orgId]` choice.
|
|
20
|
+
* - `compute_server_platform_default`: single-row sentinel for the global
|
|
21
|
+
* `defaultServerId`.
|
|
22
|
+
*
|
|
23
|
+
* `getConfig` returns the full doc; the visibility predicate is applied by
|
|
24
|
+
* callers (resolver, page loaders) using helpers in `@selvajs/platform`.
|
|
25
|
+
*/
|
|
26
|
+
export class SupabaseComputeServerStore implements IComputeServerStore {
|
|
27
|
+
constructor(private readonly clients: ClientBundle) {}
|
|
28
|
+
|
|
29
|
+
async getConfig(ctx: RequestContext): Promise<ComputeConfig> {
|
|
30
|
+
const client = this.clients.forRequest(ctx);
|
|
31
|
+
|
|
32
|
+
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('*'),
|
|
36
|
+
client
|
|
37
|
+
.from('compute_server_platform_default')
|
|
38
|
+
.select('default_server_id')
|
|
39
|
+
.eq('singleton', true)
|
|
40
|
+
.maybeSingle()
|
|
41
|
+
]);
|
|
42
|
+
|
|
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);
|
|
47
|
+
|
|
48
|
+
// Build per-server `sharedWith` allowlists from the join table.
|
|
49
|
+
const sharedByServer = new Map<string, string[]>();
|
|
50
|
+
for (const row of sharesRes.data ?? []) {
|
|
51
|
+
const list = sharedByServer.get(row.server_id) ?? [];
|
|
52
|
+
list.push(row.org_id);
|
|
53
|
+
sharedByServer.set(row.server_id, list);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const servers = (serversRes.data ?? []).map((row) => rowToServer(row, sharedByServer));
|
|
57
|
+
|
|
58
|
+
const orgDefaults: Record<string, string> = {};
|
|
59
|
+
for (const row of orgDefRes.data ?? []) {
|
|
60
|
+
if (row.default_server_id) orgDefaults[row.org_id] = row.default_server_id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
servers,
|
|
65
|
+
defaultServerId: platDefRes.data?.default_server_id ?? undefined,
|
|
66
|
+
orgDefaults
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async savePlatformServers(
|
|
71
|
+
ctx: RequestContext,
|
|
72
|
+
servers: ComputeServerConfig[],
|
|
73
|
+
defaultServerId: string | undefined
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
const client = this.clients.forRequest(ctx);
|
|
76
|
+
const platformOnly = servers.filter(isPlatformServer);
|
|
77
|
+
|
|
78
|
+
// Replace-all of platform rows.
|
|
79
|
+
const { error: delErr } = await client.from('compute_servers').delete().eq('scope', 'platform');
|
|
80
|
+
if (delErr) throw mapError(delErr);
|
|
81
|
+
|
|
82
|
+
if (platformOnly.length > 0) {
|
|
83
|
+
const { error: insErr } = await client
|
|
84
|
+
.from('compute_servers')
|
|
85
|
+
.insert(platformOnly.map(serverToRow));
|
|
86
|
+
if (insErr) throw mapError(insErr);
|
|
87
|
+
|
|
88
|
+
// Rebuild the share rows for any non-`all` platform servers.
|
|
89
|
+
const shareRows = platformOnly.flatMap((s) =>
|
|
90
|
+
s.sharedWith === 'all'
|
|
91
|
+
? []
|
|
92
|
+
: s.sharedWith.map((orgId) => ({ server_id: s.id, org_id: orgId }))
|
|
93
|
+
);
|
|
94
|
+
if (shareRows.length > 0) {
|
|
95
|
+
const { error: shErr } = await client.from('compute_server_shares').insert(shareRows);
|
|
96
|
+
if (shErr) throw mapError(shErr);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { error: defErr } = await client
|
|
101
|
+
.from('compute_server_platform_default')
|
|
102
|
+
.update({ default_server_id: defaultServerId ?? null })
|
|
103
|
+
.eq('singleton', true);
|
|
104
|
+
if (defErr) throw mapError(defErr);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async saveOrgServers(
|
|
108
|
+
ctx: RequestContext,
|
|
109
|
+
orgId: string,
|
|
110
|
+
servers: ComputeServerConfig[],
|
|
111
|
+
defaultServerId?: string | null
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
const client = this.clients.forRequest(ctx);
|
|
114
|
+
const orgOnly = servers.filter(isOrgServer).map((s) => ({ ...s, ownerOrgId: orgId }));
|
|
115
|
+
|
|
116
|
+
const { error: delErr } = await client
|
|
117
|
+
.from('compute_servers')
|
|
118
|
+
.delete()
|
|
119
|
+
.eq('scope', 'org')
|
|
120
|
+
.eq('owner_org_id', orgId);
|
|
121
|
+
if (delErr) throw mapError(delErr);
|
|
122
|
+
|
|
123
|
+
if (orgOnly.length > 0) {
|
|
124
|
+
const { error: insErr } = await client
|
|
125
|
+
.from('compute_servers')
|
|
126
|
+
.insert(orgOnly.map(serverToRow));
|
|
127
|
+
if (insErr) throw mapError(insErr);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (defaultServerId === null) {
|
|
131
|
+
const { error } = await client
|
|
132
|
+
.from('compute_server_org_defaults')
|
|
133
|
+
.delete()
|
|
134
|
+
.eq('org_id', orgId);
|
|
135
|
+
if (error) throw mapError(error);
|
|
136
|
+
} else if (typeof defaultServerId === 'string') {
|
|
137
|
+
const { error } = await client
|
|
138
|
+
.from('compute_server_org_defaults')
|
|
139
|
+
.upsert({ org_id: orgId, default_server_id: defaultServerId });
|
|
140
|
+
if (error) throw mapError(error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async setOrgDefault(ctx: RequestContext, orgId: string, serverId: string | null): Promise<void> {
|
|
145
|
+
const client = this.clients.forRequest(ctx);
|
|
146
|
+
if (serverId === null) {
|
|
147
|
+
const { error } = await client
|
|
148
|
+
.from('compute_server_org_defaults')
|
|
149
|
+
.delete()
|
|
150
|
+
.eq('org_id', orgId);
|
|
151
|
+
if (error) throw mapError(error);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const { error } = await client
|
|
155
|
+
.from('compute_server_org_defaults')
|
|
156
|
+
.upsert({ org_id: orgId, default_server_id: serverId });
|
|
157
|
+
if (error) throw mapError(error);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async deleteByOrg(ctx: RequestContext, orgId: string): Promise<void> {
|
|
161
|
+
const client = this.clients.forRequest(ctx);
|
|
162
|
+
|
|
163
|
+
// orgDefaults entry — FK on org_id cascades from orgs(id), but org
|
|
164
|
+
// soft-delete doesn't trigger the cascade, so do it explicitly.
|
|
165
|
+
const { error: defErr } = await client
|
|
166
|
+
.from('compute_server_org_defaults')
|
|
167
|
+
.delete()
|
|
168
|
+
.eq('org_id', orgId);
|
|
169
|
+
if (defErr) throw mapError(defErr);
|
|
170
|
+
|
|
171
|
+
// Org-private servers owned by this org.
|
|
172
|
+
const { error: srvErr } = await client
|
|
173
|
+
.from('compute_servers')
|
|
174
|
+
.delete()
|
|
175
|
+
.eq('scope', 'org')
|
|
176
|
+
.eq('owner_org_id', orgId);
|
|
177
|
+
if (srvErr) throw mapError(srvErr);
|
|
178
|
+
|
|
179
|
+
// Strip this org from any platform server share allowlist.
|
|
180
|
+
const { error: shErr } = await client
|
|
181
|
+
.from('compute_server_shares')
|
|
182
|
+
.delete()
|
|
183
|
+
.eq('org_id', orgId);
|
|
184
|
+
if (shErr) throw mapError(shErr);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface ServerRow {
|
|
189
|
+
id: string;
|
|
190
|
+
scope: 'platform' | 'org';
|
|
191
|
+
owner_org_id: string | null;
|
|
192
|
+
shared_with_all: boolean;
|
|
193
|
+
label: string;
|
|
194
|
+
server_url: string;
|
|
195
|
+
api_key: string | null;
|
|
196
|
+
timeout_ms: number | null;
|
|
197
|
+
retry_count: number | null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function rowToServer(row: ServerRow, sharedByServer: Map<string, string[]>): ComputeServerConfig {
|
|
201
|
+
const common = {
|
|
202
|
+
id: row.id,
|
|
203
|
+
label: row.label,
|
|
204
|
+
serverUrl: row.server_url,
|
|
205
|
+
apiKey: row.api_key ?? undefined,
|
|
206
|
+
timeoutMs: row.timeout_ms ?? undefined,
|
|
207
|
+
retryCount: row.retry_count ?? undefined
|
|
208
|
+
};
|
|
209
|
+
if (row.scope === 'platform') {
|
|
210
|
+
return {
|
|
211
|
+
...common,
|
|
212
|
+
scope: 'platform',
|
|
213
|
+
sharedWith: row.shared_with_all ? 'all' : (sharedByServer.get(row.id) ?? [])
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (!row.owner_org_id) {
|
|
217
|
+
throw new Error(`compute_servers row ${row.id} has scope='org' but null owner_org_id`);
|
|
218
|
+
}
|
|
219
|
+
return { ...common, scope: 'org', ownerOrgId: row.owner_org_id };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function serverToRow(s: ComputeServerConfig): ServerRow {
|
|
223
|
+
const base = {
|
|
224
|
+
id: s.id,
|
|
225
|
+
label: s.label,
|
|
226
|
+
server_url: s.serverUrl,
|
|
227
|
+
api_key: s.apiKey ?? null,
|
|
228
|
+
timeout_ms: s.timeoutMs ?? null,
|
|
229
|
+
retry_count: s.retryCount ?? null
|
|
230
|
+
};
|
|
231
|
+
if (isPlatformServer(s)) {
|
|
232
|
+
return {
|
|
233
|
+
...base,
|
|
234
|
+
scope: 'platform',
|
|
235
|
+
owner_org_id: null,
|
|
236
|
+
shared_with_all: s.sharedWith === 'all'
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
...base,
|
|
241
|
+
scope: 'org',
|
|
242
|
+
owner_org_id: s.ownerOrgId,
|
|
243
|
+
shared_with_all: false
|
|
244
|
+
};
|
|
245
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { IDataProvider, IEventSink, IPlatformProjectGrantStore } from '@selvajs/platform';
|
|
2
|
+
import { ProviderError } from '@selvajs/platform';
|
|
3
|
+
import type { ClientBundle, BuildClientOptions } from './client.js';
|
|
4
|
+
import { buildClientBundle } from './client.js';
|
|
5
|
+
import { SupabaseEventSink } from './SupabaseEventSink.js';
|
|
6
|
+
import { SupabaseAuditQuery } from './SupabaseAuditQuery.js';
|
|
7
|
+
import { SupabaseOrgStore } from './SupabaseOrgStore.js';
|
|
8
|
+
import { SupabaseProjectStore } from './SupabaseProjectStore.js';
|
|
9
|
+
import { SupabaseDefinitionStore } from './SupabaseDefinitionStore.js';
|
|
10
|
+
import { SupabaseInviteStore } from './SupabaseInviteStore.js';
|
|
11
|
+
import { SupabaseComputeServerStore } from './SupabaseComputeServerStore.js';
|
|
12
|
+
import { SupabaseShareLinkStore } from './SupabaseShareLinkStore.js';
|
|
13
|
+
import { SupabaseUserProfileProvider } from '../userProfile/SupabaseUserProfileProvider.js';
|
|
14
|
+
import { SupabasePlatformPermissionStore } from '../permissions/SupabasePlatformPermissionStore.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stub until the Supabase implementation lands. Reads and mutations surface a
|
|
18
|
+
* clear 501; cascade hooks are silent no-ops so deleting an org or project on
|
|
19
|
+
* a Supabase deployment without the feature wired does not blow up.
|
|
20
|
+
*/
|
|
21
|
+
const NOT_IMPLEMENTED_MSG = 'Platform projects are not supported on this deployment yet.';
|
|
22
|
+
const notImplementedGrantStore: IPlatformProjectGrantStore = {
|
|
23
|
+
listByProject: async () => {
|
|
24
|
+
throw new ProviderError(NOT_IMPLEMENTED_MSG, 501);
|
|
25
|
+
},
|
|
26
|
+
create: async () => {
|
|
27
|
+
throw new ProviderError(NOT_IMPLEMENTED_MSG, 501);
|
|
28
|
+
},
|
|
29
|
+
delete: async () => {
|
|
30
|
+
throw new ProviderError(NOT_IMPLEMENTED_MSG, 501);
|
|
31
|
+
},
|
|
32
|
+
deleteByProject: async () => {
|
|
33
|
+
// No-op: nothing to clean up on a deployment that never accepted grants.
|
|
34
|
+
},
|
|
35
|
+
deleteByGranteeOrg: async () => {
|
|
36
|
+
// No-op: same reasoning.
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Composition of every data store for the Supabase backend. Instantiates one
|
|
42
|
+
* `ClientBundle` (service-role + per-request factory) and wires each store
|
|
43
|
+
* against it so every store in a single process shares the same pooled clients.
|
|
44
|
+
*/
|
|
45
|
+
export class SupabaseDataProvider implements IDataProvider {
|
|
46
|
+
readonly orgs: SupabaseOrgStore;
|
|
47
|
+
readonly projects: SupabaseProjectStore;
|
|
48
|
+
readonly definitions: SupabaseDefinitionStore;
|
|
49
|
+
readonly invites: SupabaseInviteStore;
|
|
50
|
+
readonly computeServer: SupabaseComputeServerStore;
|
|
51
|
+
readonly shareLinks: SupabaseShareLinkStore;
|
|
52
|
+
readonly userProfile: SupabaseUserProfileProvider;
|
|
53
|
+
readonly permissions: SupabasePlatformPermissionStore;
|
|
54
|
+
readonly platformProjectGrants: IPlatformProjectGrantStore;
|
|
55
|
+
readonly auditQuery: SupabaseAuditQuery;
|
|
56
|
+
|
|
57
|
+
private constructor(
|
|
58
|
+
private readonly clients: ClientBundle,
|
|
59
|
+
events: IEventSink
|
|
60
|
+
) {
|
|
61
|
+
this.orgs = new SupabaseOrgStore(clients, events);
|
|
62
|
+
this.projects = new SupabaseProjectStore(clients, events);
|
|
63
|
+
this.definitions = new SupabaseDefinitionStore(clients, events);
|
|
64
|
+
this.invites = new SupabaseInviteStore(clients, events);
|
|
65
|
+
this.computeServer = new SupabaseComputeServerStore(clients);
|
|
66
|
+
this.shareLinks = new SupabaseShareLinkStore(clients, events);
|
|
67
|
+
this.userProfile = new SupabaseUserProfileProvider(clients);
|
|
68
|
+
this.permissions = new SupabasePlatformPermissionStore(clients);
|
|
69
|
+
this.platformProjectGrants = notImplementedGrantStore;
|
|
70
|
+
this.auditQuery = new SupabaseAuditQuery(clients);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static fromEnv(
|
|
74
|
+
env: Record<string, string | undefined>,
|
|
75
|
+
events?: IEventSink
|
|
76
|
+
): SupabaseDataProvider {
|
|
77
|
+
const supabaseUrl = env.SUPABASE_URL;
|
|
78
|
+
const anonKey = env.SUPABASE_ANON_KEY;
|
|
79
|
+
const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
|
|
80
|
+
if (!supabaseUrl) throw new Error('Missing required env var: SUPABASE_URL');
|
|
81
|
+
if (!anonKey) throw new Error('Missing required env var: SUPABASE_ANON_KEY');
|
|
82
|
+
if (!serviceRoleKey) throw new Error('Missing required env var: SUPABASE_SERVICE_ROLE_KEY');
|
|
83
|
+
const bundle = buildClientBundle({ supabaseUrl, anonKey, serviceRoleKey });
|
|
84
|
+
return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static create(opts: BuildClientOptions, events?: IEventSink): SupabaseDataProvider {
|
|
88
|
+
const bundle = buildClientBundle(opts);
|
|
89
|
+
return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build from a pre-existing `ClientBundle`. Useful for tests or advanced
|
|
94
|
+
* cases that need to inject a custom event sink or share a bundle externally.
|
|
95
|
+
*/
|
|
96
|
+
static fromBundle(bundle: ClientBundle, events?: IEventSink): SupabaseDataProvider {
|
|
97
|
+
return new SupabaseDataProvider(bundle, events ?? new SupabaseEventSink(bundle));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Expose the underlying bundle so other providers in the same package
|
|
102
|
+
* (storage, user profile, auth) can share one set of clients per process.
|
|
103
|
+
*/
|
|
104
|
+
getClientBundle(): ClientBundle {
|
|
105
|
+
return this.clients;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* No-op: `public.user_profiles` is auto-seeded by the `handle_new_auth_user`
|
|
110
|
+
* trigger on every `auth.users` insert (see `0001_initial.sql`). The data
|
|
111
|
+
* layer always has a row for any authenticated user without per-request work.
|
|
112
|
+
*/
|
|
113
|
+
async ensureUser(): Promise<void> {
|
|
114
|
+
// Trigger handles it.
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* No-op: `public.user_profiles.user_id` references `auth.users(id)` with
|
|
119
|
+
* `on delete cascade`. Deleting the auth user removes the profile row
|
|
120
|
+
* automatically.
|
|
121
|
+
*/
|
|
122
|
+
async onUserDeleted(): Promise<void> {
|
|
123
|
+
// FK cascade handles it.
|
|
124
|
+
}
|
|
125
|
+
}
|