@selvajs/supabase-provider 0.14.4-beta.0 → 0.15.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/dist/auth/SupabaseAuthProvider.d.ts.map +1 -1
  4. package/dist/auth/SupabaseAuthProvider.js +7 -14
  5. package/dist/auth/SupabaseAuthProvider.js.map +1 -1
  6. package/dist/data/SupabaseComputeServerStore.d.ts +14 -3
  7. package/dist/data/SupabaseComputeServerStore.d.ts.map +1 -1
  8. package/dist/data/SupabaseComputeServerStore.js +67 -16
  9. package/dist/data/SupabaseComputeServerStore.js.map +1 -1
  10. package/dist/data/SupabaseDataProvider.d.ts +36 -8
  11. package/dist/data/SupabaseDataProvider.d.ts.map +1 -1
  12. package/dist/data/SupabaseDataProvider.js +119 -15
  13. package/dist/data/SupabaseDataProvider.js.map +1 -1
  14. package/dist/data/SupabaseDefinitionStore.d.ts +1 -0
  15. package/dist/data/SupabaseDefinitionStore.d.ts.map +1 -1
  16. package/dist/data/SupabaseDefinitionStore.js +41 -4
  17. package/dist/data/SupabaseDefinitionStore.js.map +1 -1
  18. package/dist/data/SupabaseEventSink.d.ts +5 -2
  19. package/dist/data/SupabaseEventSink.d.ts.map +1 -1
  20. package/dist/data/SupabaseEventSink.js +20 -3
  21. package/dist/data/SupabaseEventSink.js.map +1 -1
  22. package/dist/data/SupabaseSolveMetricSink.d.ts +49 -4
  23. package/dist/data/SupabaseSolveMetricSink.d.ts.map +1 -1
  24. package/dist/data/SupabaseSolveMetricSink.js +109 -19
  25. package/dist/data/SupabaseSolveMetricSink.js.map +1 -1
  26. package/dist/data/index.d.ts +1 -0
  27. package/dist/data/index.d.ts.map +1 -1
  28. package/dist/data/index.js +1 -0
  29. package/dist/data/index.js.map +1 -1
  30. package/dist/data/migrationHead.d.ts +14 -0
  31. package/dist/data/migrationHead.d.ts.map +1 -0
  32. package/dist/data/migrationHead.js +14 -0
  33. package/dist/data/migrationHead.js.map +1 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/permissions/SupabasePlatformPermissionStore.d.ts +5 -3
  39. package/dist/permissions/SupabasePlatformPermissionStore.d.ts.map +1 -1
  40. package/dist/permissions/SupabasePlatformPermissionStore.js +17 -30
  41. package/dist/permissions/SupabasePlatformPermissionStore.js.map +1 -1
  42. package/dist/storage/SupabaseStorageProvider.d.ts.map +1 -1
  43. package/dist/storage/SupabaseStorageProvider.js +3 -0
  44. package/dist/storage/SupabaseStorageProvider.js.map +1 -1
  45. package/package.json +23 -14
  46. package/supabase/migrations/20260712120000_user_profiles_disabled_and_gin.sql +95 -0
  47. package/supabase/migrations/20260712123000_selva_migration_head_fn.sql +38 -0
  48. package/supabase/migrations/20260713120000_selva_next_version_number.sql +59 -0
  49. package/supabase/migrations/20260713140000_selva_solve_cache_limit.sql +15 -0
  50. package/supabase/migrations/20260716120000_definition_status_and_audit_version.sql +36 -0
  51. package/supabase/migrations/20260716130000_erase_user_audit_email.sql +36 -0
  52. package/supabase/migrations/20260717120000_compute_server_has_api_key.sql +20 -0
  53. package/dist/auth/__tests__/conformance.test.d.ts +0 -2
  54. package/dist/auth/__tests__/conformance.test.d.ts.map +0 -1
  55. package/dist/auth/__tests__/conformance.test.js +0 -76
  56. package/dist/auth/__tests__/conformance.test.js.map +0 -1
  57. package/dist/auth/__tests__/token-verification.test.d.ts +0 -13
  58. package/dist/auth/__tests__/token-verification.test.d.ts.map +0 -1
  59. package/dist/auth/__tests__/token-verification.test.js +0 -135
  60. package/dist/auth/__tests__/token-verification.test.js.map +0 -1
  61. package/dist/data/__tests__/compute-server-conformance.test.d.ts +0 -2
  62. package/dist/data/__tests__/compute-server-conformance.test.d.ts.map +0 -1
  63. package/dist/data/__tests__/compute-server-conformance.test.js +0 -45
  64. package/dist/data/__tests__/compute-server-conformance.test.js.map +0 -1
  65. package/dist/data/__tests__/compute-server-encryption.test.d.ts +0 -12
  66. package/dist/data/__tests__/compute-server-encryption.test.d.ts.map +0 -1
  67. package/dist/data/__tests__/compute-server-encryption.test.js +0 -143
  68. package/dist/data/__tests__/compute-server-encryption.test.js.map +0 -1
  69. package/dist/data/__tests__/definition-conformance.test.d.ts +0 -2
  70. package/dist/data/__tests__/definition-conformance.test.d.ts.map +0 -1
  71. package/dist/data/__tests__/definition-conformance.test.js +0 -85
  72. package/dist/data/__tests__/definition-conformance.test.js.map +0 -1
  73. package/dist/data/__tests__/event-sink-conformance.test.d.ts +0 -2
  74. package/dist/data/__tests__/event-sink-conformance.test.d.ts.map +0 -1
  75. package/dist/data/__tests__/event-sink-conformance.test.js +0 -27
  76. package/dist/data/__tests__/event-sink-conformance.test.js.map +0 -1
  77. package/dist/data/__tests__/invite-conformance.test.d.ts +0 -2
  78. package/dist/data/__tests__/invite-conformance.test.d.ts.map +0 -1
  79. package/dist/data/__tests__/invite-conformance.test.js +0 -45
  80. package/dist/data/__tests__/invite-conformance.test.js.map +0 -1
  81. package/dist/data/__tests__/org-conformance.test.d.ts +0 -2
  82. package/dist/data/__tests__/org-conformance.test.d.ts.map +0 -1
  83. package/dist/data/__tests__/org-conformance.test.js +0 -29
  84. package/dist/data/__tests__/org-conformance.test.js.map +0 -1
  85. package/dist/data/__tests__/project-conformance.test.d.ts +0 -2
  86. package/dist/data/__tests__/project-conformance.test.d.ts.map +0 -1
  87. package/dist/data/__tests__/project-conformance.test.js +0 -57
  88. package/dist/data/__tests__/project-conformance.test.js.map +0 -1
  89. package/dist/data/__tests__/share-link-concurrency.test.d.ts +0 -22
  90. package/dist/data/__tests__/share-link-concurrency.test.d.ts.map +0 -1
  91. package/dist/data/__tests__/share-link-concurrency.test.js +0 -115
  92. package/dist/data/__tests__/share-link-concurrency.test.js.map +0 -1
  93. package/dist/data/__tests__/share-link-conformance.test.d.ts +0 -2
  94. package/dist/data/__tests__/share-link-conformance.test.d.ts.map +0 -1
  95. package/dist/data/__tests__/share-link-conformance.test.js +0 -88
  96. package/dist/data/__tests__/share-link-conformance.test.js.map +0 -1
  97. package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts +0 -2
  98. package/dist/data/__tests__/solve-metric-sink-conformance.test.d.ts.map +0 -1
  99. package/dist/data/__tests__/solve-metric-sink-conformance.test.js +0 -52
  100. package/dist/data/__tests__/solve-metric-sink-conformance.test.js.map +0 -1
  101. package/dist/data/__tests__/test-helpers.d.ts +0 -52
  102. package/dist/data/__tests__/test-helpers.d.ts.map +0 -1
  103. package/dist/data/__tests__/test-helpers.js +0 -177
  104. package/dist/data/__tests__/test-helpers.js.map +0 -1
  105. package/dist/permissions/__tests__/conformance.test.d.ts +0 -2
  106. package/dist/permissions/__tests__/conformance.test.d.ts.map +0 -1
  107. package/dist/permissions/__tests__/conformance.test.js +0 -27
  108. package/dist/permissions/__tests__/conformance.test.js.map +0 -1
  109. package/dist/storage/__tests__/conformance.test.d.ts +0 -2
  110. package/dist/storage/__tests__/conformance.test.d.ts.map +0 -1
  111. package/dist/storage/__tests__/conformance.test.js +0 -149
  112. package/dist/storage/__tests__/conformance.test.js.map +0 -1
  113. package/dist/userProfile/__tests__/conformance.test.d.ts +0 -2
  114. package/dist/userProfile/__tests__/conformance.test.d.ts.map +0 -1
  115. package/dist/userProfile/__tests__/conformance.test.js +0 -38
  116. package/dist/userProfile/__tests__/conformance.test.js.map +0 -1
  117. package/src/auth/SupabaseAuthProvider.ts +0 -600
  118. package/src/auth/__tests__/conformance.test.ts +0 -78
  119. package/src/auth/__tests__/token-verification.test.ts +0 -158
  120. package/src/auth/index.ts +0 -2
  121. package/src/cli/sync-migrations.ts +0 -133
  122. package/src/data/SupabaseAuditQuery.ts +0 -103
  123. package/src/data/SupabaseComputeServerStore.ts +0 -382
  124. package/src/data/SupabaseDataProvider.ts +0 -153
  125. package/src/data/SupabaseDefinitionStore.ts +0 -527
  126. package/src/data/SupabaseEventSink.ts +0 -30
  127. package/src/data/SupabaseInviteStore.ts +0 -171
  128. package/src/data/SupabaseOrgStore.ts +0 -508
  129. package/src/data/SupabaseProjectStore.ts +0 -431
  130. package/src/data/SupabaseShareLinkStore.ts +0 -176
  131. package/src/data/SupabaseSolveMetricSink.ts +0 -38
  132. package/src/data/__tests__/compute-server-conformance.test.ts +0 -46
  133. package/src/data/__tests__/compute-server-encryption.test.ts +0 -173
  134. package/src/data/__tests__/definition-conformance.test.ts +0 -89
  135. package/src/data/__tests__/event-sink-conformance.test.ts +0 -32
  136. package/src/data/__tests__/invite-conformance.test.ts +0 -46
  137. package/src/data/__tests__/org-conformance.test.ts +0 -30
  138. package/src/data/__tests__/project-conformance.test.ts +0 -57
  139. package/src/data/__tests__/share-link-concurrency.test.ts +0 -124
  140. package/src/data/__tests__/share-link-conformance.test.ts +0 -87
  141. package/src/data/__tests__/solve-metric-sink-conformance.test.ts +0 -59
  142. package/src/data/__tests__/test-helpers.ts +0 -188
  143. package/src/data/client.ts +0 -173
  144. package/src/data/errors.ts +0 -45
  145. package/src/data/index.ts +0 -29
  146. package/src/data/pagination.ts +0 -68
  147. package/src/data/query.ts +0 -22
  148. package/src/data/rowStamp.ts +0 -48
  149. package/src/index.ts +0 -42
  150. package/src/permissions/SupabasePlatformPermissionStore.ts +0 -161
  151. package/src/permissions/__tests__/conformance.test.ts +0 -28
  152. package/src/permissions/index.ts +0 -1
  153. package/src/storage/SupabaseStorageProvider.ts +0 -182
  154. package/src/storage/__tests__/conformance.test.ts +0 -152
  155. package/src/storage/index.ts +0 -2
  156. package/src/userProfile/SupabaseUserProfileProvider.ts +0 -177
  157. package/src/userProfile/__tests__/conformance.test.ts +0 -39
  158. package/src/userProfile/index.ts +0 -1
@@ -1,171 +0,0 @@
1
- import type {
2
- IInviteStore,
3
- IEventSink,
4
- Invite,
5
- RequestContext,
6
- ListOptions,
7
- Page
8
- } from '@selvajs/platform';
9
- import { NoopEventSink, actorFrom } from '@selvajs/platform';
10
- import type { ClientBundle } from './client.js';
11
- import { mapPostgrestError } from './errors.js';
12
- import { nextCursorFromRange, toRange } from './pagination.js';
13
-
14
- /** Explicit column list for `invites` — every field `rowToInvite` consumes. */
15
- const INVITE_COLUMNS =
16
- 'id, token_hash, email, org_id, org_role, org_permissions, invited_by, created_at, expires_at, accepted_at, accepted_by_user_id';
17
-
18
- /**
19
- * Invite store. `getByTokenHash` routes through a SECURITY DEFINER RPC so
20
- * unauthenticated / just-signed-up callers can validate and consume the
21
- * invite without RLS blocking them. The raw token is hashed at the route
22
- * layer (the selva app's `invites/token.server.ts`); the store sees only the
23
- * HMAC digest. Other operations (create, list, revoke) go through the normal
24
- * REST API with RLS enforcing `manage_org_members`.
25
- */
26
- export class SupabaseInviteStore implements IInviteStore {
27
- constructor(
28
- private readonly clients: ClientBundle,
29
- private readonly events: IEventSink = new NoopEventSink()
30
- ) {}
31
-
32
- async create(ctx: RequestContext, invite: Invite): Promise<void> {
33
- const { error } = await this.clients
34
- .forRequest(ctx)
35
- .from('invites')
36
- .insert(inviteToRow(invite));
37
- if (error) throw mapPostgrestError(error);
38
- await this.events.emit({
39
- type: 'invite.created',
40
- inviteId: invite.id,
41
- orgId: invite.orgId,
42
- email: invite.email,
43
- actorId: actorFrom(ctx)
44
- });
45
- }
46
-
47
- async getByTokenHash(ctx: RequestContext, tokenHash: string): Promise<Invite | null> {
48
- // Use the SECURITY DEFINER RPC so the token itself is the capability —
49
- // unauthenticated callers visiting /accept-invite can resolve it. The
50
- // caller has already hashed the raw URL token; the RPC just looks up
51
- // the row by digest.
52
- const { data, error } = await this.clients
53
- .forRequest(ctx)
54
- .rpc('get_invite_by_token_hash', { h: tokenHash });
55
- if (error) throw mapPostgrestError(error);
56
- // rpc returns the matching row (or null). PostgREST may wrap it in
57
- // an array depending on the signature — normalize both shapes.
58
- const row = Array.isArray(data) ? data[0] : data;
59
- return row ? rowToInvite(row as InviteRow) : null;
60
- }
61
-
62
- async listByOrg(ctx: RequestContext, orgId: string, opts?: ListOptions): Promise<Page<Invite>> {
63
- const range = toRange(opts);
64
- const { data, error, count } = await this.clients
65
- .forRequest(ctx)
66
- .from('invites')
67
- .select(INVITE_COLUMNS, { count: 'exact' })
68
- .eq('org_id', orgId)
69
- .order('created_at', { ascending: (opts?.orderDir ?? 'desc') === 'asc' })
70
- .range(range.from, range.to);
71
- if (error) throw mapPostgrestError(error);
72
- const items = (data ?? []).map(rowToInvite);
73
- return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
74
- }
75
-
76
- async markAccepted(ctx: RequestContext, id: string, userId: string): Promise<void> {
77
- // Direct UPDATE — the caller is authenticated (they just signed up).
78
- // `markAccepted` is idempotent so we don't error if no row matches.
79
- const { data, error } = await this.clients
80
- .forRequest(ctx)
81
- .from('invites')
82
- .update({ accepted_at: new Date().toISOString(), accepted_by_user_id: userId })
83
- .eq('id', id)
84
- .is('accepted_at', null)
85
- .select('org_id');
86
- if (error) throw mapPostgrestError(error);
87
- const row = data?.[0];
88
- if (!row) return;
89
- await this.events.emit({
90
- type: 'invite.accepted',
91
- inviteId: id,
92
- orgId: (row as { org_id: string }).org_id,
93
- userId,
94
- actorId: actorFrom(ctx)
95
- });
96
- }
97
-
98
- async revoke(ctx: RequestContext, id: string): Promise<void> {
99
- const { data, error } = await this.clients
100
- .forRequest(ctx)
101
- .from('invites')
102
- .delete()
103
- .eq('id', id)
104
- .is('accepted_at', null)
105
- .select('org_id');
106
- if (error) throw mapPostgrestError(error);
107
- const row = data?.[0];
108
- if (!row) return;
109
- await this.events.emit({
110
- type: 'invite.revoked',
111
- inviteId: id,
112
- orgId: (row as { org_id: string }).org_id,
113
- actorId: actorFrom(ctx)
114
- });
115
- }
116
-
117
- async deleteByOrg(ctx: RequestContext, orgId: string): Promise<void> {
118
- const { error } = await this.clients
119
- .forRequest(ctx)
120
- .from('invites')
121
- .delete()
122
- .eq('org_id', orgId);
123
- if (error) throw mapPostgrestError(error);
124
- }
125
- }
126
-
127
- interface InviteRow {
128
- id: string;
129
- token_hash: string;
130
- email: string;
131
- org_id: string;
132
- org_role: Invite['orgRole'];
133
- org_permissions: string[];
134
- invited_by: string;
135
- created_at: string;
136
- expires_at: string;
137
- accepted_at: string | null;
138
- accepted_by_user_id: string | null;
139
- }
140
-
141
- function rowToInvite(row: InviteRow): Invite {
142
- return {
143
- id: row.id,
144
- tokenHash: row.token_hash,
145
- email: row.email,
146
- orgId: row.org_id,
147
- orgRole: row.org_role,
148
- orgPermissions: row.org_permissions as Invite['orgPermissions'],
149
- invitedBy: row.invited_by,
150
- createdAt: row.created_at,
151
- expiresAt: row.expires_at,
152
- acceptedAt: row.accepted_at ?? undefined,
153
- acceptedByUserId: row.accepted_by_user_id ?? undefined
154
- };
155
- }
156
-
157
- function inviteToRow(i: Invite): InviteRow {
158
- return {
159
- id: i.id,
160
- token_hash: i.tokenHash,
161
- email: i.email,
162
- org_id: i.orgId,
163
- org_role: i.orgRole,
164
- org_permissions: i.orgPermissions,
165
- invited_by: i.invitedBy,
166
- created_at: i.createdAt,
167
- expires_at: i.expiresAt,
168
- accepted_at: i.acceptedAt ?? null,
169
- accepted_by_user_id: i.acceptedByUserId ?? null
170
- };
171
- }
@@ -1,508 +0,0 @@
1
- import type {
2
- IOrgStore,
3
- IEventSink,
4
- Organization,
5
- OrgMember,
6
- OrgRole,
7
- OrgPermission,
8
- RequestContext,
9
- ListOptions,
10
- Page
11
- } from '@selvajs/platform';
12
- import {
13
- DEFAULT_ORG_PERMISSIONS,
14
- ProviderError,
15
- actorFrom,
16
- NoopEventSink
17
- } from '@selvajs/platform';
18
- import type { ClientBundle } from './client.js';
19
- import { mapPostgrestError } from './errors.js';
20
- import { nextCursorFromRange, orderColumn, toRange } from './pagination.js';
21
- import { stampSoftDelete, stampUpdate } from './rowStamp.js';
22
-
23
- /** Explicit column list for `orgs` — every field `rowToOrg` consumes. */
24
- const ORG_COLUMNS =
25
- 'id, name, slug, owner_id, assets, created_by, updated_by, created_at, updated_at, deleted_at';
26
- /** Explicit column list for `org_members` — every field `rowToOrgMember` consumes. */
27
- const ORG_MEMBER_COLUMNS =
28
- 'org_id, user_id, role, permissions, joined_at, updated_at, updated_by, deleted_at';
29
-
30
- /**
31
- * Org + org-membership store backed by Postgres. The queries rely on RLS
32
- * for per-user visibility; the service-role client bypasses those policies
33
- * for admin paths (setup, janitor, tests).
34
- *
35
- * `createOrg` atomically seeds the creator as `owner` with every
36
- * `OrgPermission` — otherwise under user-scoped RLS the creator would
37
- * immediately lose visibility of their own org.
38
- */
39
- export class SupabaseOrgStore implements IOrgStore {
40
- private readonly events: IEventSink;
41
-
42
- constructor(
43
- private readonly clients: ClientBundle,
44
- events: IEventSink = new NoopEventSink()
45
- ) {
46
- this.events = events;
47
- }
48
-
49
- async listOrgs(ctx: RequestContext, opts?: ListOptions): Promise<Page<Organization>> {
50
- const range = toRange(opts);
51
- const direction = opts?.orderDir ?? 'desc';
52
- const { data, error, count } = await this.clients
53
- .forRequest(ctx)
54
- .from('orgs')
55
- .select(ORG_COLUMNS, { count: 'exact' })
56
- .is('deleted_at', null)
57
- .order(orderColumn(opts?.orderBy), { ascending: direction === 'asc' })
58
- .range(range.from, range.to);
59
- if (error) throw mapPostgrestError(error);
60
- const items = (data ?? []).map(rowToOrg);
61
- return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
62
- }
63
-
64
- async getOrg(ctx: RequestContext, id: string): Promise<Organization | null> {
65
- const { data, error } = await this.clients
66
- .forRequest(ctx)
67
- .from('orgs')
68
- .select(ORG_COLUMNS)
69
- .eq('id', id)
70
- .is('deleted_at', null)
71
- .maybeSingle();
72
- if (error) throw mapPostgrestError(error);
73
- return data ? rowToOrg(data) : null;
74
- }
75
-
76
- async getOrgBySlug(ctx: RequestContext, slug: string): Promise<Organization | null> {
77
- const { data, error } = await this.clients
78
- .forRequest(ctx)
79
- .from('orgs')
80
- .select(ORG_COLUMNS)
81
- .eq('slug', slug)
82
- .is('deleted_at', null)
83
- .maybeSingle();
84
- if (error) throw mapPostgrestError(error);
85
- return data ? rowToOrg(data) : null;
86
- }
87
-
88
- async createOrg(ctx: RequestContext, org: Organization): Promise<void> {
89
- const client = this.clients.forRequest(ctx);
90
- const { error } = await client.from('orgs').insert(orgToRow(org));
91
- if (error) throw mapPostgrestError(error);
92
-
93
- // Seed owner membership so user-scoped queries can see the org post-create.
94
- // Service-role bypasses RLS, but the same code path runs for user-scoped
95
- // callers — the membership row is what makes them visible to themselves.
96
- // Upsert so a stale soft-deleted row from a prior org with the same id
97
- // is reactivated rather than blocking creation.
98
- const { error: memberError } = await client.from('org_members').upsert(
99
- {
100
- org_id: org.id,
101
- user_id: org.ownerId,
102
- role: 'owner',
103
- permissions: [...DEFAULT_ORG_PERMISSIONS.owner],
104
- joined_at: new Date().toISOString(),
105
- deleted_at: null
106
- },
107
- { onConflict: 'org_id,user_id' }
108
- );
109
- if (memberError) throw mapPostgrestError(memberError);
110
- await this.events.emit({ type: 'org.created', orgId: org.id, actorId: actorFrom(ctx) });
111
- }
112
-
113
- async updateOrg(
114
- ctx: RequestContext,
115
- id: string,
116
- patch: Partial<Pick<Organization, 'name' | 'slug' | 'assets'>>
117
- ): Promise<void> {
118
- const row: Record<string, unknown> = {};
119
- if (patch.name !== undefined) row.name = patch.name;
120
- if (patch.slug !== undefined) row.slug = patch.slug;
121
- // Callers pass the full merged map; store it wholesale (JSONB column).
122
- if (patch.assets !== undefined) row.assets = patch.assets;
123
- if (Object.keys(row).length === 0) return;
124
- // `updated_at` is set by the trg_orgs_updated_at trigger; `stampUpdate`
125
- // only adds `updated_by`, and only when ctx.userId is set (empty on
126
- // system contexts would violate the `on delete set null` FK).
127
- Object.assign(row, stampUpdate(ctx));
128
-
129
- const { error, data } = await this.clients
130
- .forRequest(ctx)
131
- .from('orgs')
132
- .update(row)
133
- .eq('id', id)
134
- .is('deleted_at', null)
135
- .select('id');
136
- if (error) throw mapPostgrestError(error);
137
- if (!data || data.length === 0) throw new ProviderError(`Org '${id}' not found`, 404);
138
- }
139
-
140
- async deleteOrg(ctx: RequestContext, id: string): Promise<void> {
141
- // §9 soft-delete with cascade. Mirrors LocalOrgStore: org → org_members,
142
- // projects → project_members, definitions. Hard delete is reserved for
143
- // the background janitor; user-facing deletes preserve the audit trail.
144
- const client = this.clients.forRequest(ctx);
145
- const stampRow = stampSoftDelete(ctx);
146
-
147
- // Org itself.
148
- const { error: orgErr, data: orgData } = await client
149
- .from('orgs')
150
- .update(stampRow)
151
- .eq('id', id)
152
- .is('deleted_at', null)
153
- .select('id');
154
- if (orgErr) throw mapPostgrestError(orgErr);
155
- if (!orgData || orgData.length === 0) throw new ProviderError(`Org '${id}' not found`, 404);
156
-
157
- // Cascade: org_members.
158
- const { error: omErr } = await client
159
- .from('org_members')
160
- .update(stampRow)
161
- .eq('org_id', id)
162
- .is('deleted_at', null);
163
- if (omErr) throw mapPostgrestError(omErr);
164
-
165
- // Cascade: projects in this org. Fetch IDs first so we can cascade to
166
- // project_members and definitions in app code (Postgres FK CASCADE only
167
- // fires on hard DELETE, which we're not doing here).
168
- const { data: orgProjects, error: projFetchErr } = await client
169
- .from('projects')
170
- .select('id')
171
- .eq('org_id', id)
172
- .is('deleted_at', null);
173
- if (projFetchErr) throw mapPostgrestError(projFetchErr);
174
- const projectIds = (orgProjects ?? []).map((p) => p.id as string);
175
-
176
- const { error: projErr } = await client
177
- .from('projects')
178
- .update(stampRow)
179
- .eq('org_id', id)
180
- .is('deleted_at', null);
181
- if (projErr) throw mapPostgrestError(projErr);
182
-
183
- if (projectIds.length > 0) {
184
- const { error: pmErr } = await client
185
- .from('project_members')
186
- .update(stampRow)
187
- .in('project_id', projectIds)
188
- .is('deleted_at', null);
189
- if (pmErr) throw mapPostgrestError(pmErr);
190
-
191
- const { error: defErr } = await client
192
- .from('definitions')
193
- .update(stampRow)
194
- .in('project_id', projectIds)
195
- .is('deleted_at', null);
196
- if (defErr) throw mapPostgrestError(defErr);
197
- }
198
-
199
- // Hard-delete tables that have no `deleted_at` column. SQL CASCADE on
200
- // the org FK only fires for hard org deletes; we soft-delete, so we
201
- // clean these up here. Pending invites to a dead org are unredeemable;
202
- // stale compute config is operational state with no audit need.
203
- const { error: invErr } = await client.from('invites').delete().eq('org_id', id);
204
- if (invErr) throw mapPostgrestError(invErr);
205
-
206
- const { error: cdErr } = await client
207
- .from('compute_server_org_defaults')
208
- .delete()
209
- .eq('org_id', id);
210
- if (cdErr) throw mapPostgrestError(cdErr);
211
-
212
- const { error: shErr } = await client.from('compute_server_shares').delete().eq('org_id', id);
213
- if (shErr) throw mapPostgrestError(shErr);
214
-
215
- const { error: csErr } = await client
216
- .from('compute_servers')
217
- .delete()
218
- .eq('scope', 'org')
219
- .eq('owner_org_id', id);
220
- if (csErr) throw mapPostgrestError(csErr);
221
-
222
- await this.events.emit({ type: 'org.deleted', orgId: id, actorId: actorFrom(ctx) });
223
- }
224
-
225
- // ============================================================================
226
- // Org members
227
- // ============================================================================
228
- async listOrgMembers(
229
- ctx: RequestContext,
230
- orgId: string,
231
- opts?: ListOptions
232
- ): Promise<Page<OrgMember>> {
233
- const range = toRange(opts);
234
- const { data, error, count } = await this.clients
235
- .forRequest(ctx)
236
- .from('org_members')
237
- .select(ORG_MEMBER_COLUMNS, { count: 'exact' })
238
- .eq('org_id', orgId)
239
- .is('deleted_at', null)
240
- .order('joined_at', { ascending: (opts?.orderDir ?? 'desc') === 'asc' })
241
- .range(range.from, range.to);
242
- if (error) throw mapPostgrestError(error);
243
- const items = (data ?? []).map(rowToOrgMember);
244
- return { items, nextCursor: nextCursorFromRange(range, items.length, count) };
245
- }
246
-
247
- async getOrgMember(
248
- ctx: RequestContext,
249
- orgId: string,
250
- userId: string
251
- ): Promise<OrgMember | null> {
252
- const { data, error } = await this.clients
253
- .forRequest(ctx)
254
- .from('org_members')
255
- .select(ORG_MEMBER_COLUMNS)
256
- .eq('org_id', orgId)
257
- .eq('user_id', userId)
258
- .is('deleted_at', null)
259
- .maybeSingle();
260
- if (error) throw mapPostgrestError(error);
261
- return data ? rowToOrgMember(data) : null;
262
- }
263
-
264
- async findUserMembership(
265
- ctx: RequestContext,
266
- userId: string
267
- ): Promise<{ org: Organization; member: OrgMember } | null> {
268
- // One round-trip: join org_members → orgs and filter live rows on
269
- // both sides. PostgREST `!inner` on the join keeps it a single SELECT,
270
- // avoiding the listOrgs + getOrgMember-per-org N+1 the bootstrap path
271
- // used to do. RLS still scopes by ctx; service-role sees all.
272
- const { data, error } = await this.clients
273
- .forRequest(ctx)
274
- .from('org_members')
275
- .select(`${ORG_MEMBER_COLUMNS}, org:orgs!inner(${ORG_COLUMNS})`)
276
- .eq('user_id', userId)
277
- .is('deleted_at', null)
278
- .is('org.deleted_at', null)
279
- .order('joined_at', { ascending: true })
280
- .limit(1)
281
- .maybeSingle();
282
- if (error) throw mapPostgrestError(error);
283
- if (!data) return null;
284
-
285
- // PostgREST embeds the related row as `org` per the alias above. Strip
286
- // it off the member row before mapping so OrgMember stays clean.
287
- const row = data as OrgMemberRow & { org: OrgRow | OrgRow[] };
288
- const orgRow = Array.isArray(row.org) ? row.org[0] : row.org;
289
- if (!orgRow) return null;
290
- const { org: _omit, ...memberRow } = row;
291
- return {
292
- org: rowToOrg(orgRow),
293
- member: rowToOrgMember(memberRow as OrgMemberRow)
294
- };
295
- }
296
-
297
- async addOrgMember(ctx: RequestContext, member: OrgMember): Promise<void> {
298
- // Upsert so a prior soft-deleted row is reactivated rather than throwing
299
- // a duplicate-key error. Mirrors LocalOrgStore.addOrgMember.
300
- const row: Record<string, unknown> = {
301
- ...memberToRow(member),
302
- deleted_at: null,
303
- ...stampUpdate(ctx)
304
- };
305
- const { error } = await this.clients
306
- .forRequest(ctx)
307
- .from('org_members')
308
- .upsert(row, { onConflict: 'org_id,user_id' });
309
- if (error) throw mapPostgrestError(error);
310
- await this.events.emit({
311
- type: 'org_member.added',
312
- orgId: member.orgId,
313
- userId: member.userId,
314
- actorId: actorFrom(ctx)
315
- });
316
- }
317
-
318
- async updateOrgMemberRole(
319
- ctx: RequestContext,
320
- orgId: string,
321
- userId: string,
322
- role: OrgRole
323
- ): Promise<void> {
324
- // Role change re-seeds default permissions. Matches LocalOrgStore.
325
- const row: Record<string, unknown> = {
326
- role,
327
- permissions: [...DEFAULT_ORG_PERMISSIONS[role]],
328
- ...stampUpdate(ctx)
329
- };
330
- const { data, error } = await this.clients
331
- .forRequest(ctx)
332
- .from('org_members')
333
- .update(row)
334
- .eq('org_id', orgId)
335
- .eq('user_id', userId)
336
- .is('deleted_at', null)
337
- .select('user_id');
338
- if (error) throw mapPostgrestError(error);
339
- if (!data || data.length === 0)
340
- throw new ProviderError(`Org member '${userId}' not found`, 404);
341
- await this.events.emit({
342
- type: 'org_member.role_changed',
343
- orgId,
344
- userId,
345
- role,
346
- actorId: actorFrom(ctx)
347
- });
348
- }
349
-
350
- async updateOrgMemberPermissions(
351
- ctx: RequestContext,
352
- orgId: string,
353
- userId: string,
354
- permissions: readonly OrgPermission[]
355
- ): Promise<void> {
356
- // Replace permissions only. Matches LocalOrgStore.updateOrgMemberPermissions —
357
- // distinct from role change so callers can grant a finer-grained set without
358
- // re-seeding defaults from the role.
359
- const row: Record<string, unknown> = { permissions: [...permissions], ...stampUpdate(ctx) };
360
- const { data, error } = await this.clients
361
- .forRequest(ctx)
362
- .from('org_members')
363
- .update(row)
364
- .eq('org_id', orgId)
365
- .eq('user_id', userId)
366
- .is('deleted_at', null)
367
- .select('user_id');
368
- if (error) throw mapPostgrestError(error);
369
- if (!data || data.length === 0)
370
- throw new ProviderError(`Org member '${userId}' not found`, 404);
371
- await this.events.emit({
372
- type: 'org_member.permissions_changed',
373
- orgId,
374
- userId,
375
- permissions: [...permissions],
376
- actorId: actorFrom(ctx)
377
- });
378
- }
379
-
380
- async removeOrgMember(ctx: RequestContext, orgId: string, userId: string): Promise<void> {
381
- // §9 cascade — losing org membership ends every project membership scoped
382
- // to that tenant. Soft-delete the org_member row, then cascade-soft-delete
383
- // the user's project_members for any project in this org. Run the project
384
- // cascade BEFORE the org_member update so RLS policies that gate
385
- // project_members on org_member existence still pass for the duration.
386
- const client = this.clients.forRequest(ctx);
387
- const stampRow = stampSoftDelete(ctx);
388
-
389
- const { data: orgProjects, error: projError } = await client
390
- .from('projects')
391
- .select('id')
392
- .eq('org_id', orgId)
393
- .is('deleted_at', null);
394
- if (projError) throw mapPostgrestError(projError);
395
-
396
- const projectIds = (orgProjects ?? []).map((p) => p.id as string);
397
- if (projectIds.length > 0) {
398
- const { error: pmError } = await client
399
- .from('project_members')
400
- .update(stampRow)
401
- .in('project_id', projectIds)
402
- .eq('user_id', userId)
403
- .is('deleted_at', null);
404
- if (pmError) throw mapPostgrestError(pmError);
405
- }
406
-
407
- const { error } = await client
408
- .from('org_members')
409
- .update(stampRow)
410
- .eq('org_id', orgId)
411
- .eq('user_id', userId)
412
- .is('deleted_at', null);
413
- if (error) throw mapPostgrestError(error);
414
- await this.events.emit({
415
- type: 'org_member.removed',
416
- orgId,
417
- userId,
418
- actorId: actorFrom(ctx)
419
- });
420
- }
421
- }
422
-
423
- // ============================================================================
424
- // Row ↔ domain mappers
425
- // ============================================================================
426
- //
427
- // Audit columns (`created_by` / `updated_by`) FK to `auth.users(id)` with
428
- // `ON DELETE SET NULL`, so they can legitimately become NULL when the
429
- // referenced user is deleted (spec §8). Mappers fall back to `owner_id` /
430
- // `user_id` so the domain type stays non-nullable; UIs that care about
431
- // creator-vs-owner attribution should render "Deleted user" at that layer.
432
-
433
- interface OrgRow {
434
- id: string;
435
- name: string;
436
- slug: string;
437
- owner_id: string;
438
- assets?: Record<string, string> | null;
439
- created_by?: string | null;
440
- updated_by?: string | null;
441
- created_at: string;
442
- updated_at: string;
443
- deleted_at?: string | null;
444
- }
445
-
446
- interface OrgMemberRow {
447
- org_id: string;
448
- user_id: string;
449
- role: OrgRole;
450
- permissions: string[];
451
- joined_at: string;
452
- updated_at?: string | null;
453
- updated_by?: string | null;
454
- deleted_at?: string | null;
455
- }
456
-
457
- function rowToOrg(row: OrgRow): Organization {
458
- return {
459
- id: row.id,
460
- name: row.name,
461
- slug: row.slug,
462
- ownerId: row.owner_id,
463
- assets: row.assets ?? undefined,
464
- createdBy: row.created_by ?? row.owner_id,
465
- updatedBy: row.updated_by ?? row.owner_id,
466
- createdAt: row.created_at,
467
- updatedAt: row.updated_at,
468
- deletedAt: row.deleted_at ?? null
469
- };
470
- }
471
-
472
- function orgToRow(org: Organization): OrgRow {
473
- return {
474
- id: org.id,
475
- name: org.name,
476
- slug: org.slug,
477
- owner_id: org.ownerId,
478
- assets: org.assets ?? null,
479
- created_by: org.createdBy,
480
- updated_by: org.updatedBy,
481
- created_at: org.createdAt,
482
- updated_at: org.updatedAt,
483
- deleted_at: org.deletedAt ?? null
484
- };
485
- }
486
-
487
- function rowToOrgMember(row: OrgMemberRow): OrgMember {
488
- return {
489
- orgId: row.org_id,
490
- userId: row.user_id,
491
- role: row.role,
492
- permissions: (row.permissions ?? []) as OrgMember['permissions'],
493
- joinedAt: row.joined_at,
494
- updatedAt: row.updated_at ?? row.joined_at,
495
- updatedBy: row.updated_by ?? row.user_id,
496
- deletedAt: row.deleted_at ?? null
497
- };
498
- }
499
-
500
- function memberToRow(m: OrgMember): OrgMemberRow {
501
- return {
502
- org_id: m.orgId,
503
- user_id: m.userId,
504
- role: m.role,
505
- permissions: m.permissions ?? [],
506
- joined_at: m.joinedAt
507
- };
508
- }