1import * as Neon from "@distilled.cloud/neon";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../AdoptPolicy.ts";
5import { isResolved } from "../Diff.ts";
6import * as Provider from "../Provider.ts";
7import { Resource } from "../Resource.ts";
8import type { Providers } from "./Providers.ts";
9
10/** Organization roles supported by Neon; availability depends on the organization. */
11export type OrganizationRole =
12 | "admin"
13 | "member"
14 | "editor"
15 | "viewer"
16 | "collaborator";
17
18export interface OrganizationMemberRoleProps {
19 /** Existing Neon organization ID. Restore and remove this control before changing its identity. */
20 orgId: string;
21 /** Existing membership ID, not a user ID or email. Remove this control before changing the member. */
22 memberId: string;
23 /** Desired organization role. The legacy member role is equivalent to editor. No membership is created or invited. */
24 role: OrganizationRole;
25}
26
27/** Durable ownership and restore evidence, persisted by the engine before reconciliation. */
28export interface GovernanceRoleBaseline<Role> {
29 /** Resource generation that captured the original role. */
30 instanceId: string;
31 /** Fully qualified logical resource identity. */
32 fqn: string;
33 /** User identity captured with the existing membership. */
34 userId: string;
35 /** Exact role before this resource first managed it. */
36 originalRole: Role;
37 /** Last successfully persisted managed role, never inferred from input properties. */
38 managedRole: Role;
39}
40
41export interface OrganizationMemberRoleAttributes {
42 /** Organization containing the membership. */
43 orgId: string;
44 /** Existing membership controlled by this resource. */
45 memberId: string;
46 /** Observed organization role. */
47 role: OrganizationRole;
48 /** Original role and ownership evidence required for safe restoration. */
49 baseline: GovernanceRoleBaseline<OrganizationRole>;
50}
51
52export interface OrganizationMemberRole extends Resource<
53 "Neon.OrganizationMemberRole",
54 OrganizationMemberRoleProps,
55 OrganizationMemberRoleAttributes,
56 never,
57 Providers
58> {}
59
60/**
61 * Manage the role of an existing organization member, never their membership.
62 * Existing roles require explicit adoption. Destruction restores the captured
63 * original role, but refuses to overwrite a role changed outside this resource.
64 * A complete bounded organization-member listing can prove that a removed
65 * membership needs no restoration; listing failures never count as absence.
66 * Self changes are refused, including restoration, to avoid self-demotion.
67 * Neon enforces organization-admin authorization and organization role availability.
68 *
69 * The engine persists the adoption snapshot before reconciliation. Supply resolved
70 * IDs and role values on first deployment; an unresolved first deployment fails
71 * before mutation because this engine has no provider checkpoint API. Keep the
72 * state: a lost baseline cannot be reconstructed from desired props. An ambiguous
73 * interrupted write fails closed and requires operator reconciliation of state.
74 * Role mutations have no compare-and-swap API; do not edit the same role concurrently.
75 *
76 * ### Manage an existing member
77 * **Example:** Explicitly adopt a consenting member's role
78 * ```typescript
79 * const role = yield* Neon.OrganizationMemberRole("DeveloperRole", {
80 * orgId: "org-example",
81 * memberId: authorizedMemberId,
82 * role: "editor",
83 * }).pipe(Alchemy.adopt(true));
84 * ```
85 *
86 * @resource
87 * @product Organization
88 */
89export const OrganizationMemberRole = Resource<OrganizationMemberRole>(
90 "Neon.OrganizationMemberRole",
91);
92
93export class GovernanceRoleSafetyError extends Data.TaggedError(
94 "NeonGovernanceRoleSafetyError",
95)<{
96 message: string;
97}> {}
98
99/** Validate scope without performing cloud I/O. @internal */
100export const validateGovernanceScope = (
101 scope: { orgId: string; memberId: string } | undefined,
102) =>
103 typeof scope?.orgId === "string" &&
104 scope.orgId.trim().length > 0 &&
105 typeof scope.memberId === "string" &&
106 scope.memberId.trim().length > 0
107 ? Effect.void
108 : Effect.fail(
109 new GovernanceRoleSafetyError({
110 message:
111 "An explicit organization ID and existing membership ID are required",
112 }),
113 );
114
115/** Runtime validation also guards persisted state and unrecognized SDK enum values. @internal */
116export const validateOrganizationRole = (role: OrganizationRole) =>
117 role === "admin" ||
118 role === "member" ||
119 role === "editor" ||
120 role === "viewer" ||
121 role === "collaborator"
122 ? Effect.void
123 : Effect.fail(
124 new GovernanceRoleSafetyError({
125 message: "Unsupported organization role",
126 }),
127 );
128
129/** Check persisted ownership independently of desired properties. @internal */
130export const requireGovernanceBaseline = <Role>(
131 baseline: GovernanceRoleBaseline<Role> | undefined,
132 owner: { fqn: string; instanceId: string },
133) =>
134 baseline &&
135 baseline.fqn === owner.fqn &&
136 baseline.instanceId === owner.instanceId &&
137 baseline.userId
138 ? Effect.succeed(baseline)
139 : Effect.fail(
140 new GovernanceRoleSafetyError({
141 message:
142 "Missing or foreign durable role baseline; use a resolved initial declaration and explicit adoption, never reconstruct a lost baseline from props",
143 }),
144 );
145
146/** Observe-before-write guard; a completed restoration is idempotent. @internal */
147export const governanceRoleTransition = <Role>(
148 baseline: GovernanceRoleBaseline<Role>,
149 observed: Role,
150 desired: Role,
151 restoring = false,
152) => {
153 if (restoring && observed === baseline.originalRole)
154 return Effect.succeed(false);
155 if (observed !== baseline.managedRole) {
156 return Effect.fail(
157 new GovernanceRoleSafetyError({
158 message:
159 "Observed role differs from the last persisted managed role; refusing an out-of-band edit or ambiguous interrupted write",
160 }),
161 );
162 }
163 return Effect.succeed(observed !== desired);
164};
165
166const normalizeOrganizationRole = (role: OrganizationRole) =>
167 role === "member" ? "editor" : role;
168
169/** Neon treats member as a legacy spelling of editor. @internal */
170export const sameOrganizationRole = (
171 a: OrganizationRole,
172 b: OrganizationRole,
173) => normalizeOrganizationRole(a) === normalizeOrganizationRole(b);
174
175/** Preserve original spellings while comparing equivalent organization roles. @internal */
176export const organizationRoleTransition = (
177 baseline: GovernanceRoleBaseline<OrganizationRole>,
178 observed: OrganizationRole,
179 desired: OrganizationRole,
180 restoring = false,
181) =>
182 governanceRoleTransition(
183 {
184 ...baseline,
185 originalRole: normalizeOrganizationRole(baseline.originalRole),
186 managedRole: normalizeOrganizationRole(baseline.managedRole),
187 },
188 normalizeOrganizationRole(observed),
189 normalizeOrganizationRole(desired),
190 restoring,
191 );
192
193/** Never mutate the authenticated actor, including during restoration. @internal */
194export const validateGovernanceActor = (userId: string, actorId: string) =>
195 userId && actorId && userId !== actorId
196 ? Effect.void
197 : Effect.fail(
198 new GovernanceRoleSafetyError({
199 message:
200 "Cannot manage the current actor's role or a membership without a verified user identity",
201 }),
202 );
203
204const request = (scope: { orgId: string; memberId: string }) => ({
205 org_id: scope.orgId,
206 member_id: scope.memberId,
207});
208
209/** Complete membership evidence for cleanup; failed or truncated listings never prove absence. @internal */
210export const listGovernanceOrganizationMembers = Effect.fn(function* (
211 orgId: string,
212) {
213 const members: Neon.Member[] = [];
214 const seen = new Set<string>();
215 let cursor: string | undefined;
216 for (let page = 0; page < 10; page++) {
217 const response = yield* Neon.getOrganizationMembers({
218 org_id: orgId,
219 cursor,
220 limit: 100,
221 });
222 for (const { member } of response.members) {
223 if (member.org_id !== orgId) {
224 return yield* new GovernanceRoleSafetyError({
225 message:
226 "Organization member listing returned a different organization",
227 });
228 }
229 members.push(member);
230 }
231 const next = response.pagination?.next;
232 if (next === undefined) return members;
233 if (!next || seen.has(next)) {
234 return yield* new GovernanceRoleSafetyError({
235 message:
236 "Organization membership listing returned an invalid or repeated cursor",
237 });
238 }
239 seen.add(next);
240 cursor = next;
241 }
242 return yield* new GovernanceRoleSafetyError({
243 message:
244 "Organization membership listing exceeded its bounded pagination limit; absence is unproven",
245 });
246});
247
248const validateObservedMember = Effect.fn(function* (
249 scope: { orgId: string; memberId: string },
250 member: Neon.Member,
251) {
252 if (
253 member.id !== scope.memberId ||
254 member.org_id !== scope.orgId ||
255 !member.user_id
256 ) {
257 return yield* new GovernanceRoleSafetyError({
258 message:
259 "Organization membership identity does not match the requested scope",
260 });
261 }
262 yield* validateOrganizationRole(member.role);
263 return member;
264});
265
266const observe = Effect.fn(function* (scope: {
267 orgId: string;
268 memberId: string;
269}) {
270 yield* validateGovernanceScope(scope);
271 return yield* validateObservedMember(
272 scope,
273 yield* Neon.getOrganizationMember(request(scope)),
274 );
275});
276
277const validateScope = (
278 scope: { orgId: string; memberId: string },
279 output: OrganizationMemberRoleAttributes,
280) =>
281 scope.orgId === output.orgId && scope.memberId === output.memberId
282 ? Effect.void
283 : Effect.fail(
284 new GovernanceRoleSafetyError({
285 message: "Membership identity changed without replacement",
286 }),
287 );
288
289const verifyBaseline = Effect.fn(function* (
290 output: OrganizationMemberRoleAttributes | undefined,
291 owner: { fqn: string; instanceId: string },
292) {
293 const baseline = yield* requireGovernanceBaseline(output?.baseline, owner);
294 yield* validateOrganizationRole(baseline.originalRole);
295 yield* validateOrganizationRole(baseline.managedRole);
296 return baseline;
297});
298
299export const OrganizationMemberRoleProvider = () =>
300 Provider.succeed(OrganizationMemberRole, {
301 nuke: { skip: true },
302 stables: ["orgId", "memberId"],
303 diff: Effect.fn(function* ({ news, olds, output }) {
304 if (!isResolved(news)) return;
305 yield* validateGovernanceScope(news);
306 yield* validateOrganizationRole(news.role);
307 const previous = output ?? olds;
308 if (
309 news.orgId !== previous.orgId ||
310 news.memberId !== previous.memberId
311 ) {
312 return yield* new GovernanceRoleSafetyError({
313 message:
314 "Restore and remove the existing role control before changing its membership identity; the new membership requires a separate durable adoption snapshot",
315 });
316 }
317 }),
318 read: Effect.fn(function* ({ olds, output, fqn, instanceId }) {
319 const scope = output ?? olds;
320 yield* validateGovernanceScope(scope);
321 const baseline = output
322 ? yield* verifyBaseline(output, { fqn, instanceId })
323 : undefined;
324 const member = yield* observe(scope);
325 if (output && baseline) {
326 if (member.user_id !== baseline.userId) {
327 return yield* new GovernanceRoleSafetyError({
328 message:
329 "Membership user changed; refusing to reuse the captured baseline",
330 });
331 }
332 yield* organizationRoleTransition(baseline, member.role, member.role);
333 return { ...output, role: member.role };
334 }
335 return Unowned({
336 orgId: scope.orgId,
337 memberId: scope.memberId,
338 role: member.role,
339 baseline: {
340 fqn,
341 instanceId,
342 userId: member.user_id,
343 originalRole: member.role,
344 managedRole: member.role,
345 },
346 });
347 }),
348 reconcile: Effect.fn(function* ({ news, output, fqn, instanceId }) {
349 yield* validateGovernanceScope(news);
350 yield* validateOrganizationRole(news.role);
351 const baseline = yield* verifyBaseline(output, { fqn, instanceId });
352 if (!output)
353 return yield* new GovernanceRoleSafetyError({
354 message:
355 "A persisted adoption snapshot is required before changing an organization role",
356 });
357 yield* validateScope(news, output);
358 const member = yield* observe(news);
359 if (member.user_id !== baseline.userId)
360 return yield* new GovernanceRoleSafetyError({
361 message: "Membership user changed",
362 });
363 if (yield* organizationRoleTransition(baseline, member.role, news.role)) {
364 yield* validateGovernanceActor(
365 member.user_id,
366 (yield* Neon.getCurrentUserInfo({})).id,
367 );
368 yield* Neon.updateOrganizationMember({
369 ...request(news),
370 role: news.role,
371 });
372 }
373 const current = yield* observe(news);
374 if (
375 current.user_id !== baseline.userId ||
376 !sameOrganizationRole(current.role, news.role)
377 ) {
378 return yield* new GovernanceRoleSafetyError({
379 message:
380 "Organization role did not converge; retaining the original baseline",
381 });
382 }
383 return {
384 orgId: news.orgId,
385 memberId: news.memberId,
386 role: current.role,
387 baseline: { ...baseline, managedRole: current.role },
388 };
389 }),
390 delete: Effect.fn(function* ({ output, fqn, instanceId }) {
391 const baseline = yield* verifyBaseline(output, { fqn, instanceId });
392 yield* validateGovernanceScope(output);
393 // getOrganizationMember has no typed missing-member error in the SDK.
394 const listed = (yield* listGovernanceOrganizationMembers(
395 output.orgId,
396 )).find((member) => member.id === output.memberId);
397 if (!listed) return;
398 const member = yield* validateObservedMember(output, listed);
399 if (member.user_id !== baseline.userId)
400 return yield* new GovernanceRoleSafetyError({
401 message: "Membership user changed",
402 });
403 if (
404 yield* organizationRoleTransition(
405 baseline,
406 member.role,
407 baseline.originalRole,
408 true,
409 )
410 ) {
411 yield* validateGovernanceActor(
412 member.user_id,
413 (yield* Neon.getCurrentUserInfo({})).id,
414 );
415 yield* Neon.updateOrganizationMember({
416 ...request(output),
417 role: baseline.originalRole,
418 });
419 const restored = yield* observe(output);
420 if (
421 restored.user_id !== baseline.userId ||
422 !sameOrganizationRole(restored.role, baseline.originalRole)
423 ) {
424 return yield* new GovernanceRoleSafetyError({
425 message: "Original organization role was not restored",
426 });
427 }
428 }
429 }),
430 });