| 1 | import { |
| 2 | AuthArchiveReadScope, |
| 3 | AuthArchiveWriteScope, |
| 4 | AuthEnvironmentConnectScope, |
| 5 | HostedSourceConnection, |
| 6 | HubError, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import * as Context from "effect/Context"; |
| 9 | import * as DateTime from "effect/DateTime"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as Option from "effect/Option"; |
| 13 | import type * as Stream from "effect/Stream"; |
| 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 15 | import * as Schema from "effect/Schema"; |
| 16 | import * as Semaphore from "effect/Semaphore"; |
| 17 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; |
| 18 | |
| 19 | const SourceSession = Schema.toCodecJson( |
| 20 | Schema.Struct({ |
| 21 | authenticated: Schema.Literal(true), |
| 22 | permissions: Schema.Array(Schema.String), |
| 23 | expiresAt: Schema.DateTimeUtc, |
| 24 | sessionMethod: Schema.Literal("bearer-access-token"), |
| 25 | }), |
| 26 | ); |
| 27 | const sourceScopes = [AuthArchiveReadScope, AuthArchiveWriteScope, AuthEnvironmentConnectScope]; |
| 28 | |
| 29 | export class SourceConnection extends Context.Service< |
| 30 | SourceConnection, |
| 31 | { |
| 32 | readonly current: Effect.Effect<Option.Option<HostedSourceConnection>>; |
| 33 | readonly connected: Effect.Effect<boolean>; |
| 34 | readonly setConnected: (connected: boolean) => Effect.Effect<void>; |
| 35 | readonly update: (input: HostedSourceConnection | undefined) => Effect.Effect<void, HubError>; |
| 36 | readonly changes: Stream.Stream<Option.Option<HostedSourceConnection>>; |
| 37 | } |
| 38 | >()("t3/environment/SourceConnection") {} |
| 39 | |
| 40 | const make = Effect.gen(function* () { |
| 41 | const current = yield* SubscriptionRef.make(Option.none<HostedSourceConnection>()); |
| 42 | const http = yield* HttpClient.HttpClient; |
| 43 | const lock = yield* Semaphore.make(1); |
| 44 | let connected = false; |
| 45 | return SourceConnection.of({ |
| 46 | current: SubscriptionRef.get(current), |
| 47 | connected: Effect.sync(() => connected), |
| 48 | setConnected: (value) => |
| 49 | Effect.sync(() => { |
| 50 | connected = value; |
| 51 | }), |
| 52 | changes: SubscriptionRef.changes(current), |
| 53 | update: (input) => |
| 54 | lock.withPermit( |
| 55 | Effect.gen(function* () { |
| 56 | if (input) { |
| 57 | const url = yield* Effect.try({ |
| 58 | try: () => new URL(input.serverUrl), |
| 59 | catch: () => new HubError({ reason: "address" }), |
| 60 | }); |
| 61 | const now = yield* DateTime.now; |
| 62 | if ( |
| 63 | url.protocol !== "https:" || |
| 64 | url.username || |
| 65 | url.password || |
| 66 | url.search || |
| 67 | url.hash || |
| 68 | url.pathname !== "/" || |
| 69 | !/^Bearer [A-Za-z0-9._~-]+$/u.test(input.authorization) || |
| 70 | input.expiresAt.epochMilliseconds <= now.epochMilliseconds || |
| 71 | input.expiresAt.epochMilliseconds > now.epochMilliseconds + 3_600_000 |
| 72 | ) { |
| 73 | return yield* new HubError({ reason: "credential" }); |
| 74 | } |
| 75 | const previous = yield* SubscriptionRef.get(current); |
| 76 | const serverUrl = `${url.origin}/`; |
| 77 | if ( |
| 78 | Option.isSome(previous) && |
| 79 | previous.value.serverUrl === serverUrl && |
| 80 | previous.value.authorization === input.authorization && |
| 81 | previous.value.expiresAt.epochMilliseconds === input.expiresAt.epochMilliseconds |
| 82 | ) |
| 83 | return; |
| 84 | const verified = yield* Effect.gen(function* () { |
| 85 | const response = yield* http |
| 86 | .execute( |
| 87 | HttpClientRequest.get(new URL("api/auth/session", serverUrl).href).pipe( |
| 88 | HttpClientRequest.setHeader("authorization", input.authorization), |
| 89 | ), |
| 90 | ) |
| 91 | .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" })); |
| 92 | if (response.status !== 200) return yield* new HubError({ reason: "credential" }); |
| 93 | return yield* response.json.pipe( |
| 94 | Effect.flatMap(Schema.decodeUnknownEffect(SourceSession)), |
| 95 | ); |
| 96 | }).pipe( |
| 97 | Effect.timeout("10 seconds"), |
| 98 | Effect.scoped, |
| 99 | Effect.mapError(() => new HubError({ reason: "credential" })), |
| 100 | ); |
| 101 | if ( |
| 102 | verified.expiresAt.epochMilliseconds < input.expiresAt.epochMilliseconds || |
| 103 | verified.permissions.length !== sourceScopes.length || |
| 104 | !sourceScopes.every((scope) => verified.permissions.includes(scope)) |
| 105 | ) { |
| 106 | return yield* new HubError({ reason: "credential" }); |
| 107 | } |
| 108 | yield* SubscriptionRef.set(current, Option.some({ ...input, serverUrl })); |
| 109 | } else yield* SubscriptionRef.set(current, Option.none()); |
| 110 | }), |
| 111 | ), |
| 112 | }); |
| 113 | }); |
| 114 | |
| 115 | export const layer = Layer.effect(SourceConnection, make); |