authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-28 11:12:07+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 11:12:07+02:00
loge00781a6619125e30f3e30ec6e17bc4c61d30502
treebf095f0563b52bce21562dd7909ef13030eb8fe7
parentd25b15737a058614ab6632bbad23d2497e49b62c
signature Signed by PGP key B5690EEEBB952194

Fix relay credential lookup for unlinked environments (#4692)

Co-authored-by: codex <codex@users.noreply.github.com>

14 files changed, 665 insertions(+), 54 deletions(-)

infra/relay/src/db.ts+18
...@@ -7,6 +7,7 @@ import * as RemovalPolicy from "alchemy/RemovalPolicy";...@@ -7,6 +7,7 @@ import * as RemovalPolicy from "alchemy/RemovalPolicy";
7import type { EffectPgDatabase } from "drizzle-orm/effect-postgres";7import type { EffectPgDatabase } from "drizzle-orm/effect-postgres";
8import * as Context from "effect/Context";8import * as Context from "effect/Context";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
1011
11import { relayDatabaseMode } from "./dbConfig.ts";12import { relayDatabaseMode } from "./dbConfig.ts";
1213
...@@ -17,6 +18,23 @@ export class RelayDb extends Context.Service<...@@ -17,6 +18,23 @@ export class RelayDb extends Context.Service<
17 }18 }
18>()("t3code-relay/db/RelayDb") {}19>()("t3code-relay/db/RelayDb") {}
1920
21export class RelayTransactions extends Context.Service<
22 RelayTransactions,
23 {
24 readonly withTransaction: RelayDb["Service"]["$client"]["withTransaction"];
25 }
26>()("t3code-relay/db/RelayTransactions") {
27 static readonly layer = Layer.effect(
28 RelayTransactions,
29 Effect.gen(function* () {
30 const db = yield* RelayDb;
31 return RelayTransactions.of({
32 withTransaction: db.$client.withTransaction,
33 });
34 }),
35 );
36}
37
20export const PlanetscaleDatabase = Effect.gen(function* () {38export const PlanetscaleDatabase = Effect.gen(function* () {
21 const { stage } = yield* Alchemy.Stack;39 const { stage } = yield* Alchemy.Stack;
22 const schema = yield* Drizzle.Schema("RelaySchema", {40 const schema = yield* Drizzle.Schema("RelaySchema", {
infra/relay/src/environments/EnvironmentConnector.test.ts+2
...@@ -198,7 +198,9 @@ function makeAllocations(...@@ -198,7 +198,9 @@ function makeAllocations(
198 recordDns: () => Effect.die("unused"),198 recordDns: () => Effect.die("unused"),
199 markReady: () => Effect.die("unused"),199 markReady: () => Effect.die("unused"),
200 claimRelease: () => Effect.die("unused"),200 claimRelease: () => Effect.die("unused"),
201 claimDeprovision: () => Effect.die("unused"),
201 remove: () => Effect.die("unused"),202 remove: () => Effect.die("unused"),
203 removeClaimed: () => Effect.die("unused"),
202 };204 };
203}205}
204206
infra/relay/src/environments/EnvironmentCredentials.test.ts+14-4
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";1import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
2import { describe, expect, it } from "@effect/vitest";2import { describe, expect, it } from "@effect/vitest";
3import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core";3import { PgDialect } from "drizzle-orm/pg-core";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
66
...@@ -58,13 +58,17 @@ describe("EnvironmentCredentials", () => {...@@ -58,13 +58,17 @@ describe("EnvironmentCredentials", () => {
58 it.effect("does not retain credential tokens when lookup persistence fails", () => {58 it.effect("does not retain credential tokens when lookup persistence fails", () => {
59 const cause = new Error("database unavailable");59 const cause = new Error("database unavailable");
60 const token = "t3env_sensitive-credential-token";60 const token = "t3env_sensitive-credential-token";
61 const whereConditions: Array<unknown> = [];
61 const fakeDb = {62 const fakeDb = {
62 select: () => ({63 select: () => ({
63 from: (table: unknown) => {64 from: (table: unknown) => {
64 expect(table).toBe(relayEnvironmentCredentials);65 expect(table).toBe(relayEnvironmentCredentials);
65 return {66 return {
66 where: () => ({67 where: (condition: unknown) => ({
67 limit: () => Effect.fail(cause),68 limit: () => {
69 whereConditions.push(condition);
70 return Effect.fail(cause);
71 },
68 }),72 }),
69 };73 };
70 },74 },
...@@ -81,6 +85,13 @@ describe("EnvironmentCredentials", () => {...@@ -81,6 +85,13 @@ describe("EnvironmentCredentials", () => {
81 });85 });
82 expect(error.cause).toBe(cause);86 expect(error.cause).toBe(cause);
83 expect(error).not.toHaveProperty("token");87 expect(error).not.toHaveProperty("token");
88 expect(whereConditions).toHaveLength(1);
89
90 const query = new PgDialect().sqlToQuery(whereConditions[0] as never);
91 expect(query.sql).toContain("exists");
92 expect(query.sql).toContain('"relay_environment_links"."environment_id"');
93 expect(query.sql).toContain('"relay_environment_links"."environment_public_key"');
94 expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null');
84 }).pipe(95 }).pipe(
85 Effect.provide(96 Effect.provide(
86 EnvironmentCredentials.layer.pipe(97 EnvironmentCredentials.layer.pipe(
...@@ -180,7 +191,6 @@ describe("EnvironmentCredentials", () => {...@@ -180,7 +191,6 @@ describe("EnvironmentCredentials", () => {
180 const updateValues: Array<Record<string, unknown>> = [];191 const updateValues: Array<Record<string, unknown>> = [];
181 const whereConditions: Array<unknown> = [];192 const whereConditions: Array<unknown> = [];
182 const fakeDb = {193 const fakeDb = {
183 select: (fields: Parameters<QueryBuilder["select"]>[0]) => new QueryBuilder().select(fields),
184 update: (table: unknown) => {194 update: (table: unknown) => {
185 expect(table).toBe(relayEnvironmentCredentials);195 expect(table).toBe(relayEnvironmentCredentials);
186 return {196 return {
infra/relay/src/environments/EnvironmentCredentials.ts+21-2
...@@ -6,7 +6,8 @@ import * as Encoding from "effect/Encoding";...@@ -6,7 +6,8 @@ import * as Encoding from "effect/Encoding";
6import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import { and, eq, isNull, ne, notExists } from "drizzle-orm";9import { and, eq, exists, isNull, ne, notExists } from "drizzle-orm";
10import { QueryBuilder } from "drizzle-orm/pg-core";
1011
11import * as RelayDb from "../db.ts";12import * as RelayDb from "../db.ts";
12import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts";13import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts";
...@@ -196,6 +197,24 @@ const make = Effect.gen(function* () {...@@ -196,6 +197,24 @@ const make = Effect.gen(function* () {
196 and(197 and(
197 eq(relayEnvironmentCredentials.credentialHash, credentialHash),198 eq(relayEnvironmentCredentials.credentialHash, credentialHash),
198 isNull(relayEnvironmentCredentials.revokedAt),199 isNull(relayEnvironmentCredentials.revokedAt),
200 exists(
201 new QueryBuilder()
202 .select({ userId: relayEnvironmentLinks.userId })
203 .from(relayEnvironmentLinks)
204 .where(
205 and(
206 eq(
207 relayEnvironmentLinks.environmentId,
208 relayEnvironmentCredentials.environmentId,
209 ),
210 eq(
211 relayEnvironmentLinks.environmentPublicKey,
212 relayEnvironmentCredentials.environmentPublicKey,
213 ),
214 isNull(relayEnvironmentLinks.revokedAt),
215 ),
216 ),
217 ),
199 ),218 ),
200 )219 )
201 .limit(1)220 .limit(1)
...@@ -238,7 +257,7 @@ const make = Effect.gen(function* () {...@@ -238,7 +257,7 @@ const make = Effect.gen(function* () {
238 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),257 eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey),
239 isNull(relayEnvironmentCredentials.revokedAt),258 isNull(relayEnvironmentCredentials.revokedAt),
240 notExists(259 notExists(
241 db260 new QueryBuilder()
242 .select({ userId: relayEnvironmentLinks.userId })261 .select({ userId: relayEnvironmentLinks.userId })
243 .from(relayEnvironmentLinks)262 .from(relayEnvironmentLinks)
244 .where(263 .where(
infra/relay/src/environments/EnvironmentLinker.test.ts+1
...@@ -136,6 +136,7 @@ function testLayer(input?: {...@@ -136,6 +136,7 @@ function testLayer(input?: {
136 revokeForEnvironmentPublicKey: () => Effect.succeed(false),136 revokeForEnvironmentPublicKey: () => Effect.succeed(false),
137 }),137 }),
138 Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, {138 Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, {
139 prepareDeprovision: () => Effect.succeed(null),
139 deprovision: input?.deprovision ?? (() => Effect.void),140 deprovision: input?.deprovision ?? (() => Effect.void),
140 release: () => Effect.succeed(true),141 release: () => Effect.succeed(true),
141 provision: () =>142 provision: () =>
infra/relay/src/environments/ManagedEndpointAllocations.test.ts+55
...@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>...@@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) =>
10 ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db)));10 ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db)));
1111
12describe("ManagedEndpointAllocations", () => {12describe("ManagedEndpointAllocations", () => {
13 it.effect("returns a claim generation only when deprovision wins the allocation CAS", () => {
14 let claimedAt: string | undefined;
15 const fakeDb = {
16 update: (table: unknown) => {
17 expect(table).toBe(relayManagedEndpointAllocations);
18 return {
19 set: (values: { readonly updatedAt: string }) => {
20 claimedAt = values.updatedAt;
21 return {
22 where: () => ({
23 returning: () => Effect.succeed([{ userId: "user-1" }]),
24 }),
25 };
26 },
27 };
28 },
29 } as unknown as RelayDb.RelayDb["Service"];
30
31 return Effect.gen(function* () {
32 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
33 const generation = yield* allocations.claimDeprovision({
34 userId: "user-1",
35 environmentId: "environment-1",
36 updatedAt: "captured-generation",
37 });
38
39 expect(generation).toBe(claimedAt);
40 expect(generation).not.toBeNull();
41 }).pipe(Effect.provide(layerWithDb(fakeDb)));
42 });
43
44 it.effect("does not remove an allocation superseded after a deprovision claim", () => {
45 const fakeDb = {
46 delete: (table: unknown) => {
47 expect(table).toBe(relayManagedEndpointAllocations);
48 return {
49 where: () => ({
50 returning: () => Effect.succeed([]),
51 }),
52 };
53 },
54 } as unknown as RelayDb.RelayDb["Service"];
55
56 return Effect.gen(function* () {
57 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
58 expect(
59 yield* allocations.removeClaimed({
60 userId: "user-1",
61 environmentId: "environment-1",
62 updatedAt: "outdated-claim-generation",
63 }),
64 ).toBe(false);
65 }).pipe(Effect.provide(layerWithDb(fakeDb)));
66 });
67
13 it.effect("retains database failures with allocation operation and identity", () => {68 it.effect("retains database failures with allocation operation and identity", () => {
14 const cause = new Error("database unavailable");69 const cause = new Error("database unavailable");
15 const fakeDb = {70 const fakeDb = {
infra/relay/src/environments/ManagedEndpointAllocations.ts+78
...@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro...@@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro
51 "record-dns",51 "record-dns",
52 "mark-ready",52 "mark-ready",
53 "claim-release",53 "claim-release",
54 "claim-deprovision",
54 "remove",55 "remove",
56 "remove-claimed",
55 ]),57 ]),
56 stage: Schema.Literals(["database-request", "resolve-reservation"]),58 stage: Schema.Literals(["database-request", "resolve-reservation"]),
57 userId: Schema.String,59 userId: Schema.String,
...@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey...@@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey
91 readonly updatedAt: string;93 readonly updatedAt: string;
92}94}
9395
96interface ClaimManagedEndpointDeprovisionInput extends ManagedEndpointAllocationKey {
97 readonly updatedAt: string;
98}
99
100interface RemoveClaimedManagedEndpointAllocationInput extends ManagedEndpointAllocationKey {
101 readonly updatedAt: string;
102}
103
94export class ManagedEndpointAllocations extends Context.Service<104export class ManagedEndpointAllocations extends Context.Service<
95 ManagedEndpointAllocations,105 ManagedEndpointAllocations,
96 {106 {
...@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<...@@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service<
119 readonly claimRelease: (129 readonly claimRelease: (
120 input: ClaimManagedEndpointReleaseInput,130 input: ClaimManagedEndpointReleaseInput,
121 ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;131 ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;
132 /**
133 * Claims the complete allocation for teardown only if its generation still
134 * matches the snapshot captured by the unlink operation.
135 *
136 * Returns the claim generation used by `removeClaimed`, or null when a
137 * concurrent provision has already superseded the snapshot.
138 */
139 readonly claimDeprovision: (
140 input: ClaimManagedEndpointDeprovisionInput,
141 ) => Effect.Effect<string | null, ManagedEndpointAllocationPersistenceError>;
122 readonly remove: (142 readonly remove: (
123 input: ManagedEndpointAllocationKey,143 input: ManagedEndpointAllocationKey,
124 ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;144 ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;
145 readonly removeClaimed: (
146 input: RemoveClaimedManagedEndpointAllocationInput,
147 ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;
125 }148 }
126>()("t3code-relay/environments/ManagedEndpointAllocations") {}149>()("t3code-relay/environments/ManagedEndpointAllocations") {}
127150
...@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {...@@ -321,6 +344,35 @@ export const make = Effect.gen(function* () {
321 );344 );
322 return claimed;345 return claimed;
323 }),346 }),
347 claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function* (
348 input: ClaimManagedEndpointDeprovisionInput,
349 ) {
350 const claimedAt = DateTime.formatIso(yield* DateTime.now);
351 const claimed = yield* db
352 .update(relayManagedEndpointAllocations)
353 .set({ updatedAt: claimedAt })
354 .where(
355 and(
356 whereAllocation(input),
357 eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt),
358 ),
359 )
360 .returning({ userId: relayManagedEndpointAllocations.userId })
361 .pipe(
362 Effect.map((rows) => rows.length > 0),
363 Effect.mapError(
364 (cause) =>
365 new ManagedEndpointAllocationPersistenceError({
366 operation: "claim-deprovision",
367 stage: "database-request",
368 userId: input.userId,
369 environmentId: input.environmentId,
370 cause,
371 }),
372 ),
373 );
374 return claimed ? claimedAt : null;
375 }),
324 remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* (376 remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* (
325 input: ManagedEndpointAllocationKey,377 input: ManagedEndpointAllocationKey,
326 ) {378 ) {
...@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {...@@ -339,6 +391,32 @@ export const make = Effect.gen(function* () {
339 ),391 ),
340 );392 );
341 }),393 }),
394 removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function* (
395 input: RemoveClaimedManagedEndpointAllocationInput,
396 ) {
397 return yield* db
398 .delete(relayManagedEndpointAllocations)
399 .where(
400 and(
401 whereAllocation(input),
402 eq(relayManagedEndpointAllocations.updatedAt, input.updatedAt),
403 ),
404 )
405 .returning({ userId: relayManagedEndpointAllocations.userId })
406 .pipe(
407 Effect.map((rows) => rows.length > 0),
408 Effect.mapError(
409 (cause) =>
410 new ManagedEndpointAllocationPersistenceError({
411 operation: "remove-claimed",
412 stage: "database-request",
413 userId: input.userId,
414 environmentId: input.environmentId,
415 cause,
416 }),
417 ),
418 );
419 }),
342 });420 });
343});421});
344422
infra/relay/src/environments/ManagedEndpointProvider.test.ts+70-4
...@@ -50,7 +50,9 @@ interface AllocationCall {...@@ -50,7 +50,9 @@ interface AllocationCall {
50 | "recordDns"50 | "recordDns"
51 | "markReady"51 | "markReady"
52 | "claimRelease"52 | "claimRelease"
53 | "remove";53 | "claimDeprovision"
54 | "remove"
55 | "removeClaimed";
54 readonly input: unknown;56 readonly input: unknown;
55}57}
5658
...@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {...@@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) {
226 mutate(allocationKey(input), (current) => current);228 mutate(allocationKey(input), (current) => current);
227 return true;229 return true;
228 }),230 }),
231 claimDeprovision: (input) =>
232 Effect.sync(() => {
233 calls.push({ operation: "claimDeprovision", input });
234 const allocation = allocations.get(allocationKey(input));
235 if (allocation === undefined || allocation.updatedAt !== input.updatedAt) {
236 return null;
237 }
238 mutate(allocationKey(input), (current) => current);
239 return allocations.get(allocationKey(input))?.updatedAt ?? null;
240 }),
229 remove: (input) =>241 remove: (input) =>
230 Effect.sync(() => {242 Effect.sync(() => {
231 calls.push({ operation: "remove", input });243 calls.push({ operation: "remove", input });
232 allocations.delete(allocationKey(input));244 allocations.delete(allocationKey(input));
233 }),245 }),
246 removeClaimed: (input) =>
247 Effect.sync(() => {
248 calls.push({ operation: "removeClaimed", input });
249 const allocation = allocations.get(allocationKey(input));
250 if (allocation === undefined || allocation.updatedAt !== input.updatedAt) {
251 return false;
252 }
253 allocations.delete(allocationKey(input));
254 return true;
255 }),
234 });256 });
235}257}
236258
...@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {...@@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => {
774 "recordDns",796 "recordDns",
775 "markReady",797 "markReady",
776 "get",798 "get",
777 "remove",799 "claimDeprovision",
800 "removeClaimed",
778 ]);801 ]);
779 }).pipe(Effect.provide(layer));802 }).pipe(Effect.provide(layer));
780 },803 },
781 );804 );
782805
806 it.effect("does not deprovision an allocation superseded by a concurrent relink", () => {
807 const tunnelCalls: TunnelCall[] = [];
808 const dnsCalls: DnsCall[] = [];
809 const allocationCalls: AllocationCall[] = [];
810 const layer = providerLayer(
811 makePersistentTunnelClient(tunnelCalls),
812 makeDnsClient(dnsCalls),
813 makeAllocations(allocationCalls),
814 );
815
816 return Effect.gen(function* () {
817 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
818 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
819 const request = {
820 ...key,
821 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
822 } as const;
823 yield* provider.provision(request);
824 const unlinkTarget = yield* provider.prepareDeprovision(key);
825 expect(unlinkTarget).not.toBeNull();
826 if (unlinkTarget === null) {
827 return;
828 }
829
830 // A relink refreshes the allocation generation after unlink captured its
831 // target but before unlink begins external teardown.
832 yield* provider.provision(request);
833 const tunnelCallCount = tunnelCalls.length;
834 const dnsCallCount = dnsCalls.length;
835 const allocationCallCount = allocationCalls.length;
836
837 yield* provider.deprovision({ ...key, target: unlinkTarget });
838
839 expect(tunnelCalls).toHaveLength(tunnelCallCount);
840 expect(dnsCalls).toHaveLength(dnsCallCount);
841 expect(allocationCalls.slice(allocationCallCount).map((call) => call.operation)).toEqual([
842 "claimDeprovision",
843 ]);
844 }).pipe(Effect.provide(layer));
845 });
846
783 it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => {847 it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => {
784 const tunnelCalls: TunnelCall[] = [];848 const tunnelCalls: TunnelCall[] = [];
785 const dnsCalls: DnsCall[] = [];849 const dnsCalls: DnsCall[] = [];
...@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {...@@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => {
1004 "recordDns",1068 "recordDns",
1005 "markReady",1069 "markReady",
1006 "get",1070 "get",
1071 "claimDeprovision",
1007 "get",1072 "get",
1008 "remove",1073 "claimDeprovision",
1074 "removeClaimed",
1009 ]);1075 ]);
1010 }).pipe(Effect.provide(layer));1076 }).pipe(Effect.provide(layer));
1011 });1077 });
...@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {...@@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => {
1046 });1112 });
1047 yield* provider.deprovision(key);1113 yield* provider.deprovision(key);
10481114
1049 expect(allocationCalls.map((call) => call.operation)).toContain("remove");1115 expect(allocationCalls.map((call) => call.operation)).toContain("removeClaimed");
1050 }).pipe(Effect.provide(layer));1116 }).pipe(Effect.provide(layer));
1051 });1117 });
10521118
infra/relay/src/environments/ManagedEndpointProvider.ts+70-19
...@@ -77,6 +77,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M...@@ -77,6 +77,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M
77const ManagedEndpointDeprovisioningStage = Schema.Literals([77const ManagedEndpointDeprovisioningStage = Schema.Literals([
78 "load-allocation",78 "load-allocation",
79 "claim-release",79 "claim-release",
80 "claim-deprovision",
80 "delete-dns-record",81 "delete-dns-record",
81 "delete-tunnel",82 "delete-tunnel",
82 "remove-allocation",83 "remove-allocation",
...@@ -123,6 +124,8 @@ export interface ManagedEndpointProvisioningResult {...@@ -123,6 +124,8 @@ export interface ManagedEndpointProvisioningResult {
123 readonly runtime: RelayManagedEndpointRuntimeConfig;124 readonly runtime: RelayManagedEndpointRuntimeConfig;
124}125}
125126
127export type ManagedEndpointDeprovisionTarget = ManagedEndpointAllocations.ManagedEndpointAllocation;
128
126export class ManagedEndpointProvider extends Context.Service<129export class ManagedEndpointProvider extends Context.Service<
127 ManagedEndpointProvider,130 ManagedEndpointProvider,
128 {131 {
...@@ -131,9 +134,22 @@ export class ManagedEndpointProvider extends Context.Service<...@@ -131,9 +134,22 @@ export class ManagedEndpointProvider extends Context.Service<
131 readonly environmentId: string;134 readonly environmentId: string;
132 readonly origin: RelayManagedEndpointOrigin;135 readonly origin: RelayManagedEndpointOrigin;
133 }) => Effect.Effect<ManagedEndpointProvisioningResult, ManagedEndpointProviderError>;136 }) => Effect.Effect<ManagedEndpointProvisioningResult, ManagedEndpointProviderError>;
137 /**
138 * Captures the allocation generation owned by an unlink before its link
139 * revocation commits. Passing this target to `deprovision` prevents a
140 * concurrent relink from having its newer allocation torn down.
141 */
142 readonly prepareDeprovision: (input: {
143 readonly userId: string;
144 readonly environmentId: string;
145 }) => Effect.Effect<
146 ManagedEndpointDeprovisionTarget | null,
147 ManagedEndpointDeprovisioningFailed
148 >;
134 readonly deprovision: (input: {149 readonly deprovision: (input: {
135 readonly userId: string;150 readonly userId: string;
136 readonly environmentId: string;151 readonly environmentId: string;
152 readonly target?: ManagedEndpointDeprovisionTarget | null;
137 }) => Effect.Effect<void, ManagedEndpointDeprovisioningFailed>;153 }) => Effect.Effect<void, ManagedEndpointDeprovisioningFailed>;
138 /**154 /**
139 * Deletes the provisioned Cloudflare tunnel while keeping the allocation155 * Deletes the provisioned Cloudflare tunnel while keeping the allocation
...@@ -424,13 +440,9 @@ export const make = Effect.gen(function* () {...@@ -424,13 +440,9 @@ export const make = Effect.gen(function* () {
424 );440 );
425 });441 });
426442
427 return ManagedEndpointProvider.of({443 const prepareDeprovision = Effect.fn("relay.managed_endpoint_provider.prepare_deprovision")(
428 deprovision: Effect.fn("relay.managed_endpoint_provider.deprovision")(function* (input) {444 function* (input: { readonly userId: string; readonly environmentId: string }) {
429 yield* Effect.annotateCurrentSpan({445 return yield* allocations.get(input).pipe(
430 "relay.user_id": input.userId,
431 "relay.environment_id": input.environmentId,
432 });
433 const allocation = yield* allocations.get(input).pipe(
434 Effect.mapError(446 Effect.mapError(
435 (cause) =>447 (cause) =>
436 new ManagedEndpointDeprovisioningFailed({448 new ManagedEndpointDeprovisioningFailed({
...@@ -440,9 +452,42 @@ export const make = Effect.gen(function* () {...@@ -440,9 +452,42 @@ export const make = Effect.gen(function* () {
440 }),452 }),
441 ),453 ),
442 );454 );
455 },
456 );
457
458 return ManagedEndpointProvider.of({
459 prepareDeprovision,
460 deprovision: Effect.fn("relay.managed_endpoint_provider.deprovision")(function* (input) {
461 yield* Effect.annotateCurrentSpan({
462 "relay.user_id": input.userId,
463 "relay.environment_id": input.environmentId,
464 });
465 const allocation =
466 input.target === undefined ? yield* prepareDeprovision(input) : input.target;
443 if (allocation === null) {467 if (allocation === null) {
444 return;468 return;
445 }469 }
470 const claimedAt = yield* allocations
471 .claimDeprovision({
472 userId: input.userId,
473 environmentId: input.environmentId,
474 updatedAt: allocation.updatedAt,
475 })
476 .pipe(
477 Effect.mapError(
478 (cause) =>
479 new ManagedEndpointDeprovisioningFailed({
480 ...input,
481 stage: "claim-deprovision",
482 ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }),
483 ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }),
484 cause,
485 }),
486 ),
487 );
488 if (claimedAt === null) {
489 return;
490 }
446 const dnsRecordId = allocation.dnsRecordId;491 const dnsRecordId = allocation.dnsRecordId;
447 if (dnsRecordId !== null) {492 if (dnsRecordId !== null) {
448 yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe(493 yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe(
...@@ -471,18 +516,24 @@ export const make = Effect.gen(function* () {...@@ -471,18 +516,24 @@ export const make = Effect.gen(function* () {
471 ),516 ),
472 );517 );
473 }518 }
474 yield* allocations.remove(input).pipe(519 yield* allocations
475 Effect.mapError(520 .removeClaimed({
476 (cause) =>521 userId: input.userId,
477 new ManagedEndpointDeprovisioningFailed({522 environmentId: input.environmentId,
478 ...input,523 updatedAt: claimedAt,
479 stage: "remove-allocation",524 })
480 ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }),525 .pipe(
481 ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }),526 Effect.mapError(
482 cause,527 (cause) =>
483 }),528 new ManagedEndpointDeprovisioningFailed({
484 ),529 ...input,
485 );530 stage: "remove-allocation",
531 ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }),
532 ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }),
533 cause,
534 }),
535 ),
536 );
486 }),537 }),
487 release: Effect.fn("relay.managed_endpoint_provider.release")(function* (input) {538 release: Effect.fn("relay.managed_endpoint_provider.release")(function* (input) {
488 yield* Effect.annotateCurrentSpan({539 yield* Effect.annotateCurrentSpan({
infra/relay/src/environments/ManagedTunnelLimits.test.ts+11-1
...@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,6 +4,7 @@ import * as Layer from "effect/Layer";
44
5import * as RelayDb from "../db.ts";5import * as RelayDb from "../db.ts";
6import {6import {
7 relayEnvironmentLinks,
7 relayManagedEndpointAllocations,8 relayManagedEndpointAllocations,
8 relayManagedTunnelLimits,9 relayManagedTunnelLimits,
9} from "../persistence/schema.ts";10} from "../persistence/schema.ts";
...@@ -28,7 +29,16 @@ function makeFakeDb(input: {...@@ -28,7 +29,16 @@ function makeFakeDb(input: {
28 }29 }
29 expect(table).toBe(relayManagedEndpointAllocations);30 expect(table).toBe(relayManagedEndpointAllocations);
30 return {31 return {
31 where: () => input.countRows ?? Effect.succeed([{ activeTunnels: 0 }]),32 innerJoin: (table: unknown, condition: unknown) => {
33 expect(table).toBe(relayEnvironmentLinks);
34 expect(condition).toBeDefined();
35 return {
36 where: (where: unknown) => {
37 expect(where).toBeDefined();
38 return input.countRows ?? Effect.succeed([{ activeTunnels: 0 }]);
39 },
40 };
41 },
32 };42 };
33 },43 },
34 }),44 }),
infra/relay/src/environments/ManagedTunnelLimits.ts+11-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { and, count, eq, ne } from "drizzle-orm";1import { and, count, eq, isNull, ne } from "drizzle-orm";
2import * as Context from "effect/Context";2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -6,6 +6,7 @@ import * as Schema from "effect/Schema";...@@ -6,6 +6,7 @@ import * as Schema from "effect/Schema";
66
7import * as RelayDb from "../db.ts";7import * as RelayDb from "../db.ts";
8import {8import {
9 relayEnvironmentLinks,
9 relayManagedEndpointAllocations,10 relayManagedEndpointAllocations,
10 relayManagedTunnelLimits,11 relayManagedTunnelLimits,
11} from "../persistence/schema.ts";12} from "../persistence/schema.ts";
...@@ -81,10 +82,19 @@ export const make = Effect.gen(function* () {...@@ -81,10 +82,19 @@ export const make = Effect.gen(function* () {
81 const counted = yield* db82 const counted = yield* db
82 .select({ activeTunnels: count() })83 .select({ activeTunnels: count() })
83 .from(relayManagedEndpointAllocations)84 .from(relayManagedEndpointAllocations)
85 .innerJoin(
86 relayEnvironmentLinks,
87 and(
88 eq(relayEnvironmentLinks.userId, relayManagedEndpointAllocations.userId),
89 eq(relayEnvironmentLinks.environmentId, relayManagedEndpointAllocations.environmentId),
90 ),
91 )
84 .where(92 .where(
85 and(93 and(
86 eq(relayManagedEndpointAllocations.userId, input.userId),94 eq(relayManagedEndpointAllocations.userId, input.userId),
87 ne(relayManagedEndpointAllocations.environmentId, input.environmentId),95 ne(relayManagedEndpointAllocations.environmentId, input.environmentId),
96 isNull(relayEnvironmentLinks.revokedAt),
97 eq(relayEnvironmentLinks.managedTunnelsEnabled, true),
88 ),98 ),
89 )99 )
90 .pipe(100 .pipe(
infra/relay/src/http/Api.test.ts+240
...@@ -14,6 +14,7 @@ import * as Tracer from "effect/Tracer";...@@ -14,6 +14,7 @@ import * as Tracer from "effect/Tracer";
14import * as HttpRouter from "effect/unstable/http/HttpRouter";14import * as HttpRouter from "effect/unstable/http/HttpRouter";
15import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";15import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
16import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";16import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
17import { EnvironmentId } from "@t3tools/contracts";
17import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";18import { RelayEnvironmentAuth } from "@t3tools/contracts/relay";
1819
19import {20import {
...@@ -22,12 +23,17 @@ import {...@@ -22,12 +23,17 @@ import {
22 relayDocsRedirectRoute,23 relayDocsRedirectRoute,
23 relayEnvironmentAuthLayer,24 relayEnvironmentAuthLayer,
24 relayNotFoundRoute,25 relayNotFoundRoute,
26 revokeEnvironmentLinkRecord,
25 traceRelayHttpRequestWith,27 traceRelayHttpRequestWith,
28 unlinkEnvironmentRecord,
26 verifyRelayClientBearerToken,29 verifyRelayClientBearerToken,
27 withoutCapturedParentSpan,30 withoutCapturedParentSpan,
28} from "./Api.ts";31} from "./Api.ts";
29import * as RelayConfiguration from "../Config.ts";32import * as RelayConfiguration from "../Config.ts";
33import * as RelayDb from "../db.ts";
30import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";34import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts";
35import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
36import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts";
3137
32vi.mock("@clerk/backend", () => ({38vi.mock("@clerk/backend", () => ({
33 createClerkClient: vi.fn(),39 createClerkClient: vi.fn(),
...@@ -155,6 +161,240 @@ describe("relay environment authentication", () => {...@@ -155,6 +161,240 @@ describe("relay environment authentication", () => {
155 });161 });
156});162});
157163
164function relayUnlinkTestLayer(input?: {
165 readonly withTransaction?: RelayDb.RelayTransactions["Service"]["withTransaction"];
166 readonly getForUser?: EnvironmentLinks.EnvironmentLinks["Service"]["getForUser"];
167 readonly revokeForUser?: EnvironmentLinks.EnvironmentLinks["Service"]["revokeForUser"];
168 readonly revokeCredential?: EnvironmentCredentials.EnvironmentCredentials["Service"]["revokeForEnvironmentPublicKey"];
169 readonly prepareDeprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["prepareDeprovision"];
170 readonly deprovision?: ManagedEndpointProvider.ManagedEndpointProvider["Service"]["deprovision"];
171}) {
172 return Layer.mergeAll(
173 Layer.succeed(
174 RelayDb.RelayTransactions,
175 RelayDb.RelayTransactions.of({
176 withTransaction: input?.withTransaction ?? ((effect) => effect),
177 }),
178 ),
179 Layer.succeed(
180 EnvironmentLinks.EnvironmentLinks,
181 EnvironmentLinks.EnvironmentLinks.of({
182 upsert: () => Effect.die("unused upsert"),
183 listUsersForEnvironment: () => Effect.die("unused listUsersForEnvironment"),
184 listDeliveryUsersForEnvironment: () => Effect.die("unused listDeliveryUsersForEnvironment"),
185 listPublicKeysForEnvironment: () => Effect.die("unused listPublicKeysForEnvironment"),
186 listForUser: () => Effect.die("unused listForUser"),
187 getForUser: input?.getForUser ?? (() => Effect.succeed(null)),
188 revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)),
189 }),
190 ),
191 Layer.succeed(
192 EnvironmentCredentials.EnvironmentCredentials,
193 EnvironmentCredentials.EnvironmentCredentials.of({
194 create: () => Effect.die("unused create"),
195 authenticate: () => Effect.die("unused authenticate"),
196 revokeForEnvironmentPublicKey: input?.revokeCredential ?? (() => Effect.succeed(false)),
197 }),
198 ),
199 Layer.succeed(
200 ManagedEndpointProvider.ManagedEndpointProvider,
201 ManagedEndpointProvider.ManagedEndpointProvider.of({
202 provision: () => Effect.die("unused provision"),
203 prepareDeprovision: input?.prepareDeprovision ?? (() => Effect.succeed(null)),
204 deprovision: input?.deprovision ?? (() => Effect.void),
205 release: () => Effect.die("unused release"),
206 }),
207 ),
208 );
209}
210
211const linkedEnvironmentRecord = {
212 environmentId: EnvironmentId.make("environment-1"),
213 label: "Environment 1",
214 endpoint: {
215 httpBaseUrl: "https://environment-1.example.test/",
216 wsBaseUrl: "wss://environment-1.example.test/ws",
217 providerKind: "cloudflare_tunnel",
218 },
219 environmentPublicKey: "public-key",
220 linkedAt: "2026-07-28T00:00:00.000Z",
221} as const;
222
223describe("relay environment unlink", () => {
224 it.effect("revokes the link and its credentials in one database transaction", () => {
225 const calls: Array<string> = [];
226 return Effect.gen(function* () {
227 expect(
228 yield* revokeEnvironmentLinkRecord({
229 userId: "user-1",
230 environmentId: "environment-1",
231 environmentPublicKey: "public-key",
232 }),
233 ).toBe(true);
234 expect(calls).toEqual(["transaction", "link", "credential"]);
235 }).pipe(
236 Effect.provide(
237 relayUnlinkTestLayer({
238 withTransaction: (effect) => {
239 calls.push("transaction");
240 return effect;
241 },
242 revokeForUser: () =>
243 Effect.sync(() => {
244 calls.push("link");
245 return true;
246 }),
247 revokeCredential: () =>
248 Effect.sync(() => {
249 calls.push("credential");
250 return true;
251 }),
252 }),
253 ),
254 );
255 });
256
257 it.effect("commits database revocation before deprovisioning the managed endpoint", () => {
258 const calls: Array<string> = [];
259 const deprovisionTarget = {
260 userId: "user-1",
261 environmentId: "environment-1",
262 hostname: "environment-1.example.test",
263 tunnelId: "tunnel-1",
264 tunnelName: "environment-1-tunnel",
265 dnsRecordId: "dns-1",
266 readyAt: "2026-07-28T00:00:00.000Z",
267 updatedAt: "generation-before-unlink",
268 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
269
270 return Effect.gen(function* () {
271 expect(
272 yield* unlinkEnvironmentRecord({
273 userId: "user-1",
274 environmentId: "environment-1",
275 }),
276 ).toBe(true);
277 expect(calls).toEqual([
278 "prepare",
279 "lookup",
280 "transaction",
281 "link",
282 "credential",
283 "deprovision",
284 ]);
285 }).pipe(
286 Effect.provide(
287 relayUnlinkTestLayer({
288 withTransaction: (effect) => {
289 calls.push("transaction");
290 return effect;
291 },
292 getForUser: () =>
293 Effect.sync(() => {
294 calls.push("lookup");
295 return linkedEnvironmentRecord;
296 }),
297 revokeForUser: () =>
298 Effect.sync(() => {
299 calls.push("link");
300 return true;
301 }),
302 revokeCredential: () =>
303 Effect.sync(() => {
304 calls.push("credential");
305 return true;
306 }),
307 prepareDeprovision: () =>
308 Effect.sync(() => {
309 calls.push("prepare");
310 return deprovisionTarget;
311 }),
312 deprovision: (request) =>
313 Effect.sync(() => {
314 expect(request.target).toBe(deprovisionTarget);
315 calls.push("deprovision");
316 }),
317 }),
318 ),
319 );
320 });
321
322 it.effect("does not deprovision when database revocation fails", () => {
323 const calls: Array<string> = [];
324 const failure = new EnvironmentCredentials.EnvironmentCredentialRevokePersistenceError({
325 environmentId: "environment-1",
326 cause: "database unavailable",
327 });
328
329 return Effect.gen(function* () {
330 expect(
331 yield* Effect.flip(
332 unlinkEnvironmentRecord({
333 userId: "user-1",
334 environmentId: "environment-1",
335 }),
336 ),
337 ).toBe(failure);
338 expect(calls).toEqual(["prepare", "transaction", "link", "credential"]);
339 }).pipe(
340 Effect.provide(
341 relayUnlinkTestLayer({
342 withTransaction: (effect) => {
343 calls.push("transaction");
344 return effect;
345 },
346 getForUser: () => Effect.succeed(linkedEnvironmentRecord),
347 revokeForUser: () =>
348 Effect.sync(() => {
349 calls.push("link");
350 return true;
351 }),
352 revokeCredential: () =>
353 Effect.sync(() => {
354 calls.push("credential");
355 }).pipe(Effect.andThen(Effect.fail(failure))),
356 prepareDeprovision: () =>
357 Effect.sync(() => {
358 calls.push("prepare");
359 return null;
360 }),
361 deprovision: () =>
362 Effect.sync(() => {
363 calls.push("deprovision");
364 }),
365 }),
366 ),
367 );
368 });
369
370 it.effect("retries deprovisioning after the link is already revoked", () => {
371 const calls: Array<string> = [];
372 return Effect.gen(function* () {
373 expect(
374 yield* unlinkEnvironmentRecord({
375 userId: "user-1",
376 environmentId: "environment-1",
377 }),
378 ).toBe(false);
379 expect(calls).toEqual(["prepare", "deprovision"]);
380 }).pipe(
381 Effect.provide(
382 relayUnlinkTestLayer({
383 prepareDeprovision: () =>
384 Effect.sync(() => {
385 calls.push("prepare");
386 return null;
387 }),
388 deprovision: () =>
389 Effect.sync(() => {
390 calls.push("deprovision");
391 }),
392 }),
393 ),
394 );
395 });
396});
397
158describe("relay request tracing", () => {398describe("relay request tracing", () => {
159 it.effect(399 it.effect(
160 "does not parent endpoint spans to an ambient parent captured while building handlers",400 "does not parent endpoint spans to an ambient parent captured while building handlers",
infra/relay/src/http/Api.ts+69-22
...@@ -403,6 +403,67 @@ export const healthApi = HttpApiBuilder.group(...@@ -403,6 +403,67 @@ export const healthApi = HttpApiBuilder.group(
403 }),403 }),
404);404);
405405
406export const revokeEnvironmentLinkRecord = Effect.fn(
407 "relay.api.client.revokeEnvironmentLinkRecord",
408)(function* (input: {
409 readonly userId: string;
410 readonly environmentId: string;
411 readonly environmentPublicKey: string;
412}) {
413 const transactions = yield* RelayDb.RelayTransactions;
414 const links = yield* EnvironmentLinks.EnvironmentLinks;
415 const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
416 return yield* transactions.withTransaction(
417 Effect.gen(function* () {
418 const revoked = yield* links.revokeForUser({
419 userId: input.userId,
420 environmentId: input.environmentId,
421 });
422 if (revoked) {
423 yield* credentials.revokeForEnvironmentPublicKey({
424 environmentId: input.environmentId,
425 environmentPublicKey: input.environmentPublicKey,
426 });
427 }
428 return revoked;
429 }),
430 );
431});
432
433export const unlinkEnvironmentRecord = Effect.fn("relay.api.client.unlinkEnvironmentRecord")(
434 function* (input: { readonly userId: string; readonly environmentId: string }) {
435 const links = yield* EnvironmentLinks.EnvironmentLinks;
436 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
437 const deprovisionTarget = yield* managedEndpointProvider.prepareDeprovision({
438 userId: input.userId,
439 environmentId: input.environmentId,
440 });
441 const link = yield* links.getForUser({
442 userId: input.userId,
443 environmentId: input.environmentId,
444 });
445 const unlinked =
446 link === null
447 ? false
448 : yield* revokeEnvironmentLinkRecord({
449 userId: input.userId,
450 environmentId: link.environmentId,
451 environmentPublicKey: link.environmentPublicKey,
452 });
453
454 // External teardown cannot share the SQL transaction. Run it only after
455 // revocation commits so a database failure leaves a fully usable active
456 // link. Still run teardown when the link is already revoked, allowing a
457 // retry to finish cleanup after an earlier Cloudflare failure.
458 yield* managedEndpointProvider.deprovision({
459 userId: input.userId,
460 environmentId: input.environmentId,
461 target: deprovisionTarget,
462 });
463 return unlinked;
464 },
465);
466
406export const mobileApi = HttpApiBuilder.group(467export const mobileApi = HttpApiBuilder.group(
407 RelayApi,468 RelayApi,
408 "mobile",469 "mobile",
...@@ -470,7 +531,6 @@ export const clientApi = HttpApiBuilder.group(...@@ -470,7 +531,6 @@ export const clientApi = HttpApiBuilder.group(
470 const linker = yield* EnvironmentLinker.EnvironmentLinker;531 const linker = yield* EnvironmentLinker.EnvironmentLinker;
471 const links = yield* EnvironmentLinks.EnvironmentLinks;532 const links = yield* EnvironmentLinks.EnvironmentLinks;
472 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;533 const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
473 const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
474 const devices = yield* Devices.Devices;534 const devices = yield* Devices.Devices;
475 return handlers535 return handlers
476 .handle(536 .handle(
...@@ -592,29 +652,16 @@ export const clientApi = HttpApiBuilder.group(...@@ -592,29 +652,16 @@ export const clientApi = HttpApiBuilder.group(
592 Effect.fn("relay.api.client.unlinkEnvironment")(function* (args) {652 Effect.fn("relay.api.client.unlinkEnvironment")(function* (args) {
593 const { params } = args;653 const { params } = args;
594 const { userId } = yield* RelayClientPrincipal;654 const { userId } = yield* RelayClientPrincipal;
595 yield* managedEndpointProvider655 const unlinked = yield* unlinkEnvironmentRecord({
596 .deprovision({
597 userId,
598 environmentId: params.environmentId,
599 })
600 .pipe(Effect.catch(() => relayInternalErrorResponse("upstream_unavailable")));
601 const link = yield* links.getForUser({
602 userId,
603 environmentId: params.environmentId,
604 });
605 if (link === null) {
606 return { ok: false };
607 }
608 const unlinked = yield* links.revokeForUser({
609 userId,656 userId,
610 environmentId: params.environmentId,657 environmentId: params.environmentId,
611 });658 }).pipe(
612 if (unlinked) {659 Effect.catchTags({
613 yield* credentials.revokeForEnvironmentPublicKey({660 SqlError: () => relayInternalErrorResponse("internal_error"),
614 environmentId: link.environmentId,661 ManagedEndpointDeprovisioningFailed: () =>
615 environmentPublicKey: link.environmentPublicKey,662 relayInternalErrorResponse("upstream_unavailable"),
616 });663 }),
617 }664 );
618 return { ok: unlinked };665 return { ok: unlinked };
619 }, mapRelayCommonApiErrors("not_authorized")),666 }, mapRelayCommonApiErrors("not_authorized")),
620 )667 )
infra/relay/src/worker.ts+5-1
...@@ -217,7 +217,11 @@ export const ApiLive = Api.make(...@@ -217,7 +217,11 @@ export const ApiLive = Api.make(
217 Layer.provideMerge(LiveActivities.layer),217 Layer.provideMerge(LiveActivities.layer),
218 Layer.provideMerge(DeliveryAttempts.layer),218 Layer.provideMerge(DeliveryAttempts.layer),
219 Layer.provideMerge(RelayTokens.layer),219 Layer.provideMerge(RelayTokens.layer),
220 Layer.provideMerge(Layer.succeed(RelayDb.RelayDb, db)),220 Layer.provideMerge(
221 RelayDb.RelayTransactions.layer.pipe(
222 Layer.provideMerge(Layer.succeed(RelayDb.RelayDb, db)),
223 ),
224 ),
221 Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)),225 Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)),
222 Layer.provideMerge(webcryptoLayer),226 Layer.provideMerge(webcryptoLayer),
223 );227 );