| author | |
| committer | |
| log | e00781a6619125e30f3e30ec6e17bc4c61d30502 |
| tree | bf095f0563b52bce21562dd7909ef13030eb8fe7 |
| parent | d25b15737a058614ab6632bbad23d2497e49b62c |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 7 | import type { EffectPgDatabase } from "drizzle-orm/effect-postgres"; |
| 8 | 8 | import * as Context from "effect/Context"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Layer from "effect/Layer"; | |
| 10 | 11 | |
| 11 | 12 | import { relayDatabaseMode } from "./dbConfig.ts"; |
| 12 | 13 | |
| ... | ... | @@ -17,6 +18,23 @@ export class RelayDb extends Context.Service< |
| 17 | 18 | } |
| 18 | 19 | >()("t3code-relay/db/RelayDb") {} |
| 19 | 20 | |
| 21 | export 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 | ||
| 20 | 38 | export const PlanetscaleDatabase = Effect.gen(function* () { |
| 21 | 39 | const { stage } = yield* Alchemy.Stack; |
| 22 | 40 | const schema = yield* Drizzle.Schema("RelaySchema", { |
infra/relay/src/environments/EnvironmentConnector.test.ts+2| ... | ... | @@ -198,7 +198,9 @@ function makeAllocations( |
| 198 | 198 | recordDns: () => Effect.die("unused"), |
| 199 | 199 | markReady: () => Effect.die("unused"), |
| 200 | 200 | claimRelease: () => Effect.die("unused"), |
| 201 | claimDeprovision: () => Effect.die("unused"), | |
| 201 | 202 | remove: () => Effect.die("unused"), |
| 203 | removeClaimed: () => Effect.die("unused"), | |
| 202 | 204 | }; |
| 203 | 205 | } |
| 204 | 206 |
infra/relay/src/environments/EnvironmentCredentials.test.ts+14-4| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto"; |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | import { PgDialect, QueryBuilder } from "drizzle-orm/pg-core"; | |
| 3 | import { PgDialect } from "drizzle-orm/pg-core"; | |
| 4 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | |
| ... | ... | @@ -58,13 +58,17 @@ describe("EnvironmentCredentials", () => { |
| 58 | 58 | it.effect("does not retain credential tokens when lookup persistence fails", () => { |
| 59 | 59 | const cause = new Error("database unavailable"); |
| 60 | 60 | const token = "t3env_sensitive-credential-token"; |
| 61 | const whereConditions: Array<unknown> = []; | |
| 61 | 62 | const fakeDb = { |
| 62 | 63 | select: () => ({ |
| 63 | 64 | from: (table: unknown) => { |
| 64 | 65 | expect(table).toBe(relayEnvironmentCredentials); |
| 65 | 66 | return { |
| 66 | where: () => ({ | |
| 67 | limit: () => Effect.fail(cause), | |
| 67 | where: (condition: unknown) => ({ | |
| 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 | 85 | }); |
| 82 | 86 | expect(error.cause).toBe(cause); |
| 83 | 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 | 95 | }).pipe( |
| 85 | 96 | Effect.provide( |
| 86 | 97 | EnvironmentCredentials.layer.pipe( |
| ... | ... | @@ -180,7 +191,6 @@ describe("EnvironmentCredentials", () => { |
| 180 | 191 | const updateValues: Array<Record<string, unknown>> = []; |
| 181 | 192 | const whereConditions: Array<unknown> = []; |
| 182 | 193 | const fakeDb = { |
| 183 | select: (fields: Parameters<QueryBuilder["select"]>[0]) => new QueryBuilder().select(fields), | |
| 184 | 194 | update: (table: unknown) => { |
| 185 | 195 | expect(table).toBe(relayEnvironmentCredentials); |
| 186 | 196 | return { |
infra/relay/src/environments/EnvironmentCredentials.ts+21-2| ... | ... | @@ -6,7 +6,8 @@ import * as Encoding from "effect/Encoding"; |
| 6 | 6 | import * as Layer from "effect/Layer"; |
| 7 | 7 | import * as Option from "effect/Option"; |
| 8 | 8 | import * as Schema from "effect/Schema"; |
| 9 | import { and, eq, isNull, ne, notExists } from "drizzle-orm"; | |
| 9 | import { and, eq, exists, isNull, ne, notExists } from "drizzle-orm"; | |
| 10 | import { QueryBuilder } from "drizzle-orm/pg-core"; | |
| 10 | 11 | |
| 11 | 12 | import * as RelayDb from "../db.ts"; |
| 12 | 13 | import { relayEnvironmentCredentials, relayEnvironmentLinks } from "../persistence/schema.ts"; |
| ... | ... | @@ -196,6 +197,24 @@ const make = Effect.gen(function* () { |
| 196 | 197 | and( |
| 197 | 198 | eq(relayEnvironmentCredentials.credentialHash, credentialHash), |
| 198 | 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 | 220 | .limit(1) |
| ... | ... | @@ -238,7 +257,7 @@ const make = Effect.gen(function* () { |
| 238 | 257 | eq(relayEnvironmentCredentials.environmentPublicKey, input.environmentPublicKey), |
| 239 | 258 | isNull(relayEnvironmentCredentials.revokedAt), |
| 240 | 259 | notExists( |
| 241 | db | |
| 260 | new QueryBuilder() | |
| 242 | 261 | .select({ userId: relayEnvironmentLinks.userId }) |
| 243 | 262 | .from(relayEnvironmentLinks) |
| 244 | 263 | .where( |
infra/relay/src/environments/EnvironmentLinker.test.ts+1| ... | ... | @@ -136,6 +136,7 @@ function testLayer(input?: { |
| 136 | 136 | revokeForEnvironmentPublicKey: () => Effect.succeed(false), |
| 137 | 137 | }), |
| 138 | 138 | Layer.succeed(ManagedEndpointProvider.ManagedEndpointProvider, { |
| 139 | prepareDeprovision: () => Effect.succeed(null), | |
| 139 | 140 | deprovision: input?.deprovision ?? (() => Effect.void), |
| 140 | 141 | release: () => Effect.succeed(true), |
| 141 | 142 | provision: () => |
infra/relay/src/environments/ManagedEndpointAllocations.test.ts+55| ... | ... | @@ -10,6 +10,61 @@ const layerWithDb = (db: RelayDb.RelayDb["Service"]) => |
| 10 | 10 | ManagedEndpointAllocations.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, db))); |
| 11 | 11 | |
| 12 | 12 | describe("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 | 68 | it.effect("retains database failures with allocation operation and identity", () => { |
| 14 | 69 | const cause = new Error("database unavailable"); |
| 15 | 70 | const fakeDb = { |
infra/relay/src/environments/ManagedEndpointAllocations.ts+78| ... | ... | @@ -51,7 +51,9 @@ export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedErro |
| 51 | 51 | "record-dns", |
| 52 | 52 | "mark-ready", |
| 53 | 53 | "claim-release", |
| 54 | "claim-deprovision", | |
| 54 | 55 | "remove", |
| 56 | "remove-claimed", | |
| 55 | 57 | ]), |
| 56 | 58 | stage: Schema.Literals(["database-request", "resolve-reservation"]), |
| 57 | 59 | userId: Schema.String, |
| ... | ... | @@ -91,6 +93,14 @@ interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey |
| 91 | 93 | readonly updatedAt: string; |
| 92 | 94 | } |
| 93 | 95 | |
| 96 | interface ClaimManagedEndpointDeprovisionInput extends ManagedEndpointAllocationKey { | |
| 97 | readonly updatedAt: string; | |
| 98 | } | |
| 99 | ||
| 100 | interface RemoveClaimedManagedEndpointAllocationInput extends ManagedEndpointAllocationKey { | |
| 101 | readonly updatedAt: string; | |
| 102 | } | |
| 103 | ||
| 94 | 104 | export class ManagedEndpointAllocations extends Context.Service< |
| 95 | 105 | ManagedEndpointAllocations, |
| 96 | 106 | { |
| ... | ... | @@ -119,9 +129,22 @@ export class ManagedEndpointAllocations extends Context.Service< |
| 119 | 129 | readonly claimRelease: ( |
| 120 | 130 | input: ClaimManagedEndpointReleaseInput, |
| 121 | 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 | 142 | readonly remove: ( |
| 123 | 143 | input: ManagedEndpointAllocationKey, |
| 124 | 144 | ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>; |
| 145 | readonly removeClaimed: ( | |
| 146 | input: RemoveClaimedManagedEndpointAllocationInput, | |
| 147 | ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>; | |
| 125 | 148 | } |
| 126 | 149 | >()("t3code-relay/environments/ManagedEndpointAllocations") {} |
| 127 | 150 | |
| ... | ... | @@ -321,6 +344,35 @@ export const make = Effect.gen(function* () { |
| 321 | 344 | ); |
| 322 | 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 | 376 | remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* ( |
| 325 | 377 | input: ManagedEndpointAllocationKey, |
| 326 | 378 | ) { |
| ... | ... | @@ -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 | }); |
| 344 | 422 |
infra/relay/src/environments/ManagedEndpointProvider.test.ts+70-4| ... | ... | @@ -50,7 +50,9 @@ interface AllocationCall { |
| 50 | 50 | | "recordDns" |
| 51 | 51 | | "markReady" |
| 52 | 52 | | "claimRelease" |
| 53 | | "remove"; | |
| 53 | | "claimDeprovision" | |
| 54 | | "remove" | |
| 55 | | "removeClaimed"; | |
| 54 | 56 | readonly input: unknown; |
| 55 | 57 | } |
| 56 | 58 | |
| ... | ... | @@ -226,11 +228,31 @@ function makeAllocations(calls: AllocationCall[] = []) { |
| 226 | 228 | mutate(allocationKey(input), (current) => current); |
| 227 | 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 | 241 | remove: (input) => |
| 230 | 242 | Effect.sync(() => { |
| 231 | 243 | calls.push({ operation: "remove", input }); |
| 232 | 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 | } |
| 236 | 258 | |
| ... | ... | @@ -774,12 +796,54 @@ describe("ManagedEndpointProvider", () => { |
| 774 | 796 | "recordDns", |
| 775 | 797 | "markReady", |
| 776 | 798 | "get", |
| 777 | "remove", | |
| 799 | "claimDeprovision", | |
| 800 | "removeClaimed", | |
| 778 | 801 | ]); |
| 779 | 802 | }).pipe(Effect.provide(layer)); |
| 780 | 803 | }, |
| 781 | 804 | ); |
| 782 | 805 | |
| 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 | 847 | it.effect("releases the tunnel while keeping the allocation, DNS record, and hostname", () => { |
| 784 | 848 | const tunnelCalls: TunnelCall[] = []; |
| 785 | 849 | const dnsCalls: DnsCall[] = []; |
| ... | ... | @@ -1004,8 +1068,10 @@ describe("ManagedEndpointProvider", () => { |
| 1004 | 1068 | "recordDns", |
| 1005 | 1069 | "markReady", |
| 1006 | 1070 | "get", |
| 1071 | "claimDeprovision", | |
| 1007 | 1072 | "get", |
| 1008 | "remove", | |
| 1073 | "claimDeprovision", | |
| 1074 | "removeClaimed", | |
| 1009 | 1075 | ]); |
| 1010 | 1076 | }).pipe(Effect.provide(layer)); |
| 1011 | 1077 | }); |
| ... | ... | @@ -1046,7 +1112,7 @@ describe("ManagedEndpointProvider", () => { |
| 1046 | 1112 | }); |
| 1047 | 1113 | yield* provider.deprovision(key); |
| 1048 | 1114 | |
| 1049 | expect(allocationCalls.map((call) => call.operation)).toContain("remove"); | |
| 1115 | expect(allocationCalls.map((call) => call.operation)).toContain("removeClaimed"); | |
| 1050 | 1116 | }).pipe(Effect.provide(layer)); |
| 1051 | 1117 | }); |
| 1052 | 1118 |
infra/relay/src/environments/ManagedEndpointProvider.ts+70-19| ... | ... | @@ -77,6 +77,7 @@ export class ManagedEndpointProvisioningFailed extends Schema.TaggedErrorClass<M |
| 77 | 77 | const ManagedEndpointDeprovisioningStage = Schema.Literals([ |
| 78 | 78 | "load-allocation", |
| 79 | 79 | "claim-release", |
| 80 | "claim-deprovision", | |
| 80 | 81 | "delete-dns-record", |
| 81 | 82 | "delete-tunnel", |
| 82 | 83 | "remove-allocation", |
| ... | ... | @@ -123,6 +124,8 @@ export interface ManagedEndpointProvisioningResult { |
| 123 | 124 | readonly runtime: RelayManagedEndpointRuntimeConfig; |
| 124 | 125 | } |
| 125 | 126 | |
| 127 | export type ManagedEndpointDeprovisionTarget = ManagedEndpointAllocations.ManagedEndpointAllocation; | |
| 128 | ||
| 126 | 129 | export class ManagedEndpointProvider extends Context.Service< |
| 127 | 130 | ManagedEndpointProvider, |
| 128 | 131 | { |
| ... | ... | @@ -131,9 +134,22 @@ export class ManagedEndpointProvider extends Context.Service< |
| 131 | 134 | readonly environmentId: string; |
| 132 | 135 | readonly origin: RelayManagedEndpointOrigin; |
| 133 | 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 | 149 | readonly deprovision: (input: { |
| 135 | 150 | readonly userId: string; |
| 136 | 151 | readonly environmentId: string; |
| 152 | readonly target?: ManagedEndpointDeprovisionTarget | null; | |
| 137 | 153 | }) => Effect.Effect<void, ManagedEndpointDeprovisioningFailed>; |
| 138 | 154 | /** |
| 139 | 155 | * Deletes the provisioned Cloudflare tunnel while keeping the allocation |
| ... | ... | @@ -424,13 +440,9 @@ export const make = Effect.gen(function* () { |
| 424 | 440 | ); |
| 425 | 441 | }); |
| 426 | 442 | |
| 427 | return ManagedEndpointProvider.of({ | |
| 428 | deprovision: Effect.fn("relay.managed_endpoint_provider.deprovision")(function* (input) { | |
| 429 | yield* Effect.annotateCurrentSpan({ | |
| 430 | "relay.user_id": input.userId, | |
| 431 | "relay.environment_id": input.environmentId, | |
| 432 | }); | |
| 433 | const allocation = yield* allocations.get(input).pipe( | |
| 443 | const prepareDeprovision = Effect.fn("relay.managed_endpoint_provider.prepare_deprovision")( | |
| 444 | function* (input: { readonly userId: string; readonly environmentId: string }) { | |
| 445 | return yield* allocations.get(input).pipe( | |
| 434 | 446 | Effect.mapError( |
| 435 | 447 | (cause) => |
| 436 | 448 | new ManagedEndpointDeprovisioningFailed({ |
| ... | ... | @@ -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 | 467 | if (allocation === null) { |
| 444 | 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 | 491 | const dnsRecordId = allocation.dnsRecordId; |
| 447 | 492 | if (dnsRecordId !== null) { |
| 448 | 493 | yield* ignoreNotFound(dns.deleteRecord(dnsRecordId)).pipe( |
| ... | ... | @@ -471,18 +516,24 @@ export const make = Effect.gen(function* () { |
| 471 | 516 | ), |
| 472 | 517 | ); |
| 473 | 518 | } |
| 474 | yield* allocations.remove(input).pipe( | |
| 475 | Effect.mapError( | |
| 476 | (cause) => | |
| 477 | new ManagedEndpointDeprovisioningFailed({ | |
| 478 | ...input, | |
| 479 | stage: "remove-allocation", | |
| 480 | ...(allocation.tunnelId === null ? {} : { tunnelId: allocation.tunnelId }), | |
| 481 | ...(allocation.dnsRecordId === null ? {} : { dnsRecordId: allocation.dnsRecordId }), | |
| 482 | cause, | |
| 483 | }), | |
| 484 | ), | |
| 485 | ); | |
| 519 | yield* allocations | |
| 520 | .removeClaimed({ | |
| 521 | userId: input.userId, | |
| 522 | environmentId: input.environmentId, | |
| 523 | updatedAt: claimedAt, | |
| 524 | }) | |
| 525 | .pipe( | |
| 526 | Effect.mapError( | |
| 527 | (cause) => | |
| 528 | new ManagedEndpointDeprovisioningFailed({ | |
| 529 | ...input, | |
| 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 | 538 | release: Effect.fn("relay.managed_endpoint_provider.release")(function* (input) { |
| 488 | 539 | yield* Effect.annotateCurrentSpan({ |
infra/relay/src/environments/ManagedTunnelLimits.test.ts+11-1| ... | ... | @@ -4,6 +4,7 @@ import * as Layer from "effect/Layer"; |
| 4 | 4 | |
| 5 | 5 | import * as RelayDb from "../db.ts"; |
| 6 | 6 | import { |
| 7 | relayEnvironmentLinks, | |
| 7 | 8 | relayManagedEndpointAllocations, |
| 8 | 9 | relayManagedTunnelLimits, |
| 9 | 10 | } from "../persistence/schema.ts"; |
| ... | ... | @@ -28,7 +29,16 @@ function makeFakeDb(input: { |
| 28 | 29 | } |
| 29 | 30 | expect(table).toBe(relayManagedEndpointAllocations); |
| 30 | 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 | import { and, count, eq, ne } from "drizzle-orm"; | |
| 1 | import { and, count, eq, isNull, ne } from "drizzle-orm"; | |
| 2 | 2 | import * as Context from "effect/Context"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| ... | ... | @@ -6,6 +6,7 @@ import * as Schema from "effect/Schema"; |
| 6 | 6 | |
| 7 | 7 | import * as RelayDb from "../db.ts"; |
| 8 | 8 | import { |
| 9 | relayEnvironmentLinks, | |
| 9 | 10 | relayManagedEndpointAllocations, |
| 10 | 11 | relayManagedTunnelLimits, |
| 11 | 12 | } from "../persistence/schema.ts"; |
| ... | ... | @@ -81,10 +82,19 @@ export const make = Effect.gen(function* () { |
| 81 | 82 | const counted = yield* db |
| 82 | 83 | .select({ activeTunnels: count() }) |
| 83 | 84 | .from(relayManagedEndpointAllocations) |
| 85 | .innerJoin( | |
| 86 | relayEnvironmentLinks, | |
| 87 | and( | |
| 88 | eq(relayEnvironmentLinks.userId, relayManagedEndpointAllocations.userId), | |
| 89 | eq(relayEnvironmentLinks.environmentId, relayManagedEndpointAllocations.environmentId), | |
| 90 | ), | |
| 91 | ) | |
| 84 | 92 | .where( |
| 85 | 93 | and( |
| 86 | 94 | eq(relayManagedEndpointAllocations.userId, input.userId), |
| 87 | 95 | ne(relayManagedEndpointAllocations.environmentId, input.environmentId), |
| 96 | isNull(relayEnvironmentLinks.revokedAt), | |
| 97 | eq(relayEnvironmentLinks.managedTunnelsEnabled, true), | |
| 88 | 98 | ), |
| 89 | 99 | ) |
| 90 | 100 | .pipe( |
infra/relay/src/http/Api.test.ts+240| ... | ... | @@ -14,6 +14,7 @@ import * as Tracer from "effect/Tracer"; |
| 14 | 14 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; |
| 15 | 15 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 16 | 16 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; |
| 17 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 17 | 18 | import { RelayEnvironmentAuth } from "@t3tools/contracts/relay"; |
| 18 | 19 | |
| 19 | 20 | import { |
| ... | ... | @@ -22,12 +23,17 @@ import { |
| 22 | 23 | relayDocsRedirectRoute, |
| 23 | 24 | relayEnvironmentAuthLayer, |
| 24 | 25 | relayNotFoundRoute, |
| 26 | revokeEnvironmentLinkRecord, | |
| 25 | 27 | traceRelayHttpRequestWith, |
| 28 | unlinkEnvironmentRecord, | |
| 26 | 29 | verifyRelayClientBearerToken, |
| 27 | 30 | withoutCapturedParentSpan, |
| 28 | 31 | } from "./Api.ts"; |
| 29 | 32 | import * as RelayConfiguration from "../Config.ts"; |
| 33 | import * as RelayDb from "../db.ts"; | |
| 30 | 34 | import * as EnvironmentCredentials from "../environments/EnvironmentCredentials.ts"; |
| 35 | import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts"; | |
| 36 | import * as ManagedEndpointProvider from "../environments/ManagedEndpointProvider.ts"; | |
| 31 | 37 | |
| 32 | 38 | vi.mock("@clerk/backend", () => ({ |
| 33 | 39 | createClerkClient: vi.fn(), |
| ... | ... | @@ -155,6 +161,240 @@ describe("relay environment authentication", () => { |
| 155 | 161 | }); |
| 156 | 162 | }); |
| 157 | 163 | |
| 164 | function 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 | ||
| 211 | const 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 | ||
| 223 | describe("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 | ||
| 158 | 398 | describe("relay request tracing", () => { |
| 159 | 399 | it.effect( |
| 160 | 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 | 403 | }), |
| 404 | 404 | ); |
| 405 | 405 | |
| 406 | export 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 | ||
| 433 | export 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 | ||
| 406 | 467 | export const mobileApi = HttpApiBuilder.group( |
| 407 | 468 | RelayApi, |
| 408 | 469 | "mobile", |
| ... | ... | @@ -470,7 +531,6 @@ export const clientApi = HttpApiBuilder.group( |
| 470 | 531 | const linker = yield* EnvironmentLinker.EnvironmentLinker; |
| 471 | 532 | const links = yield* EnvironmentLinks.EnvironmentLinks; |
| 472 | 533 | const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 473 | const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; | |
| 474 | 534 | const devices = yield* Devices.Devices; |
| 475 | 535 | return handlers |
| 476 | 536 | .handle( |
| ... | ... | @@ -592,29 +652,16 @@ export const clientApi = HttpApiBuilder.group( |
| 592 | 652 | Effect.fn("relay.api.client.unlinkEnvironment")(function* (args) { |
| 593 | 653 | const { params } = args; |
| 594 | 654 | const { userId } = yield* RelayClientPrincipal; |
| 595 | yield* managedEndpointProvider | |
| 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({ | |
| 655 | const unlinked = yield* unlinkEnvironmentRecord({ | |
| 609 | 656 | userId, |
| 610 | 657 | environmentId: params.environmentId, |
| 611 | }); | |
| 612 | if (unlinked) { | |
| 613 | yield* credentials.revokeForEnvironmentPublicKey({ | |
| 614 | environmentId: link.environmentId, | |
| 615 | environmentPublicKey: link.environmentPublicKey, | |
| 616 | }); | |
| 617 | } | |
| 658 | }).pipe( | |
| 659 | Effect.catchTags({ | |
| 660 | SqlError: () => relayInternalErrorResponse("internal_error"), | |
| 661 | ManagedEndpointDeprovisioningFailed: () => | |
| 662 | relayInternalErrorResponse("upstream_unavailable"), | |
| 663 | }), | |
| 664 | ); | |
| 618 | 665 | return { ok: unlinked }; |
| 619 | 666 | }, mapRelayCommonApiErrors("not_authorized")), |
| 620 | 667 | ) |
infra/relay/src/worker.ts+5-1| ... | ... | @@ -217,7 +217,11 @@ export const ApiLive = Api.make( |
| 217 | 217 | Layer.provideMerge(LiveActivities.layer), |
| 218 | 218 | Layer.provideMerge(DeliveryAttempts.layer), |
| 219 | 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 | 225 | Layer.provideMerge(Layer.effect(RelayConfiguration.RelayConfiguration, loadSettings)), |
| 222 | 226 | Layer.provideMerge(webcryptoLayer), |
| 223 | 227 | ); |