1import {
2 AuthArchiveReadScope,
3 AuthArchiveWriteScope,
4 AuthEnvironmentConnectScope,
5 HostedSourceConnection,
6 HubError,
7} from "@t3tools/contracts";
8import * as Context from "effect/Context";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import type * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";
15import * as Schema from "effect/Schema";
16import * as Semaphore from "effect/Semaphore";
17import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
18
19const 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);
27const sourceScopes = [AuthArchiveReadScope, AuthArchiveWriteScope, AuthEnvironmentConnectScope];
28
29export 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
40const 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
115export const layer = Layer.effect(SourceConnection, make);