Skip to content
Source
Sign inSign up
Open History to see the last commit

pullRequestRoutingPermissions.ts

169 lines6.7 KB

1import { EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as Semaphore from "effect/Semaphore";
7import * as Stream from "effect/Stream";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9​
10import type { ConnectionCatalogEntry } from "./catalog.ts";
11import { ConnectionBlockedError, type ConnectionAttemptError } from "./model.ts";
12​
13export const PullRequestRoutingPermission = Schema.Literals(["off", "read", "read-write"]);
14export type PullRequestRoutingPermission = typeof PullRequestRoutingPermission.Type;
15export const StoredPullRequestRoutingPermission = Schema.Struct({
16 environmentId: EnvironmentId,
17 connectionKey: Schema.String,
18 permission: PullRequestRoutingPermission,
19});
20export type StoredPullRequestRoutingPermission = typeof StoredPullRequestRoutingPermission.Type;
21​
22/**
23 * Trust belongs to the saved endpoints, never to an environment id advertised
24 * by a server alone. With several routes the key covers all of them, sorted so
25 * reordering keeps trust but adding or changing an address revokes it.
26 */
27export function pullRequestRoutingConnectionKey(entry: ConnectionCatalogEntry): string | null {
28 const routes = [
29 { target: entry.target, profile: entry.profile },
30 ...(entry.alternateRoutes ?? []),
31 ].filter((route) => {
32 const profile = Option.getOrNull(route.profile);
33 return !(profile?._tag === "BearerConnectionProfile" && profile.learned === true);
34 });
35 if (routes.length === 1) return routeConnectionKey(routes[0]!.target, routes[0]!.profile);
36 const keys = routes.map((route) => routeConnectionKey(route.target, route.profile));
37 return keys.every((key) => key !== null) ? JSON.stringify([...keys].sort()) : null;
39​
40function routeConnectionKey(
41 target: ConnectionCatalogEntry["target"],
42 routeProfile: ConnectionCatalogEntry["profile"],
43): string | null {
44 const entry = { profile: routeProfile };
45 if (target._tag === "RelayConnectionTarget")
46 return JSON.stringify([target._tag, target.environmentId]);
47 const profile = Option.getOrNull(entry.profile);
48 if (target._tag === "SshConnectionTarget") {
49 if (profile?._tag !== "SshConnectionProfile") return null;
50 const { alias, hostname, username, port } = profile.target;
51 return JSON.stringify([target._tag, target.environmentId, alias, hostname, username, port]);
52 }
53 const baseUrls =
54 target._tag === "PrimaryConnectionTarget"
55 ? [target.httpBaseUrl, target.wsBaseUrl]
56 : profile?._tag === "BearerConnectionProfile"
57 ? [profile.httpBaseUrl, profile.wsBaseUrl]
58 : null;
59 if (baseUrls === null) return null;
60 try {
61 const urls = baseUrls.map((baseUrl) => new URL(baseUrl));
62 if (
63 !["http:", "https:"].includes(urls[0]!.protocol) ||
64 !["ws:", "wss:"].includes(urls[1]!.protocol) ||
65 urls.some((url) => url.username || url.password)
66 )
67 return null;
68 return JSON.stringify([
69 target._tag,
70 target.environmentId,
71 ...urls.map((url) => url.href.replace(/\/+$/, "")),
72 ]);
73 } catch {
74 return null;
75 }
77​
78export function pullRequestRoutingPermissionFor(
79 entry: ConnectionCatalogEntry,
80 permissions: ReadonlyArray<StoredPullRequestRoutingPermission>,
81): PullRequestRoutingPermission {
82 const key = pullRequestRoutingConnectionKey(entry);
83 return key === null
84 ? "off"
85 : (permissions.find((permission) => permission.connectionKey === key)?.permission ?? "off");
87​
88export class PullRequestRoutingPermissions extends Context.Reference<{
89 readonly get: (entry: ConnectionCatalogEntry) => Effect.Effect<PullRequestRoutingPermission>;
90 readonly changes: Stream.Stream<ReadonlyArray<StoredPullRequestRoutingPermission>>;
91 readonly set: (
92 entry: ConnectionCatalogEntry,
93 permission: PullRequestRoutingPermission,
94 ) => Effect.Effect<void, ConnectionAttemptError>;
95 readonly forget: (environmentId: EnvironmentId) => Effect.Effect<void, ConnectionAttemptError>;
96}>("@t3tools/client-runtime/connection/PullRequestRoutingPermissions", {
97 defaultValue: () => ({
98 get: () => Effect.succeed("off"),
99 changes: Stream.succeed([]),
100 set: () =>
101 Effect.fail(
102 new ConnectionBlockedError({
103 reason: "unsupported",
104 detail: "Pull request routing preferences are unavailable on this client.",
105 }),
106 ),
107 forget: () => Effect.void,
108 }),
109}) {}
110​
111export const makePullRequestRoutingPermissions = Effect.fn("makePullRequestRoutingPermissions")(
112 function* (storage: {
113 readonly read: Effect.Effect<
114 ReadonlyArray<StoredPullRequestRoutingPermission>,
115 ConnectionAttemptError
116 >;
117 readonly write: (
118 permissions: ReadonlyArray<StoredPullRequestRoutingPermission>,
119 ) => Effect.Effect<void, ConnectionAttemptError>;
120 }) {
121 const state = yield* SubscriptionRef.make(yield* storage.read);
122 const lock = yield* Semaphore.make(1);
123 const update = (
124 transform: (
125 current: ReadonlyArray<StoredPullRequestRoutingPermission>,
126 ) => ReadonlyArray<StoredPullRequestRoutingPermission>,
127 ) =>
128 lock.withPermits(1)(
129 Effect.gen(function* () {
130 const current = yield* SubscriptionRef.get(state);
131 const next = transform(current);
132 if (next === current) return;
133 yield* storage.write(next);
134 yield* SubscriptionRef.set(state, next);
135 }),
136 );
137 return PullRequestRoutingPermissions.of({
138 get: Effect.fn("PullRequestRoutingPermissions.get")(function (entry) {
139 return SubscriptionRef.get(state).pipe(
140 Effect.map((permissions) => pullRequestRoutingPermissionFor(entry, permissions)),
141 );
142 }),
143 changes: SubscriptionRef.changes(state),
144 set: Effect.fn("PullRequestRoutingPermissions.set")(function (entry, permission) {
145 const connectionKey = pullRequestRoutingConnectionKey(entry);
146 if (connectionKey === null)
147 return Effect.fail(
148 new ConnectionBlockedError({
149 reason: "configuration",
150 detail: "This environment does not have a saved connection endpoint.",
151 }),
152 );
153 return update((current) => [
154 ...current.filter((value) => value.environmentId !== entry.target.environmentId),
155 ...(permission === "off"
156 ? []
157 : [{ environmentId: entry.target.environmentId, connectionKey, permission }]),
158 ]);
159 }),
160 forget: Effect.fn("PullRequestRoutingPermissions.forget")(function (environmentId) {
161 return update((current) =>
162 current.some((permission) => permission.environmentId === environmentId)
163 ? current.filter((permission) => permission.environmentId !== environmentId)
164 : current,
165 );
166 }),
167 });
168 },
169);