1import * as agentidentity from "@distilled.cloud/gcp/agentidentity_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import { tagRecord } from "../../Tags.ts";
7import { ALCHEMY_LABEL_PREFIX, stripInternalLabels } from "../Labels.ts";
8
9export const MAX_NAME_LENGTH = 63;
10export const MAX_DESCRIPTION_LENGTH = 256;
11export const COLLECTION = "authProviders";
12
13export class AuthProviderNotResolved extends Data.TaggedError(
14 "GCP.AgentIdentity.AuthProviderNotResolved",
15)<{
16 name: string;
17}> {}
18
19export const lastSegment = (value: string) => {
20 const trimmed = value.replace(/\/+$/, "");
21 const parts = trimmed.split("/");
22 return parts[parts.length - 1] || trimmed;
23};
24
25export const rfc1035 = (name: string, fallback = "auth"): string => {
26 let next = name
27 .toLowerCase()
28 .replace(/[^a-z0-9-]/g, "-")
29 .replace(/-+/g, "-")
30 .replace(/^-+|-+$/g, "");
31 if (!/^[a-z]/.test(next)) next = `${fallback[0] ?? "a"}${next}`;
32 next = next.slice(0, MAX_NAME_LENGTH).replace(/-+$/g, "");
33 if (next.length === 0) return fallback;
34 if (!/[a-z0-9]$/.test(next)) next = `${next.slice(0, MAX_NAME_LENGTH - 1)}0`;
35 return next.slice(0, MAX_NAME_LENGTH);
36};
37
38export const normalizeLocation = (
39 location: string | undefined,
40 fallback: string,
41) => lastSegment(location ?? fallback).toLowerCase();
42
43export const parentOf = (project: string, location: string) =>
44 `projects/${project}/locations/${lastSegment(location).toLowerCase()}`;
45
46export const resourceName = (
47 project: string,
48 location: string,
49 authProviderId: string,
50) => `${parentOf(project, location)}/${COLLECTION}/${authProviderId}`;
51
52export const toPhysicalId = (
53 id: string,
54 explicit: string | undefined,
55 existing: string | undefined,
56) =>
57 Effect.gen(function* () {
58 if (explicit !== undefined) return explicit;
59 if (existing !== undefined) return existing;
60 return rfc1035(
61 yield* createPhysicalName({
62 id,
63 maxLength: MAX_NAME_LENGTH,
64 lowercase: true,
65 }),
66 "auth",
67 );
68 });
69
70export const parseName = (name: string, fallbackLocation: string) => {
71 const parts = name.split("/").filter((part) => part.length > 0);
72 const collectionAt = parts.lastIndexOf(COLLECTION);
73 const locationsAt = parts.lastIndexOf("locations");
74 const projectsAt = parts.lastIndexOf("projects");
75 return {
76 project:
77 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
78 location:
79 locationsAt >= 0 && parts[locationsAt + 1]
80 ? parts[locationsAt + 1]!
81 : fallbackLocation,
82 authProviderId:
83 collectionAt >= 0 && parts[collectionAt + 1]
84 ? parts[collectionAt + 1]!
85 : lastSegment(name),
86 };
87};
88
89export const userLabels = (
90 labels: Record<string, string | undefined> | null | undefined,
91): Record<string, string> => stripInternalLabels(tagRecord(labels));
92
93export const hasAlchemyLabelMap = (
94 labels: Record<string, string | undefined> | null | undefined,
95) =>
96 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
97
98export const sameText = (left: string | undefined, right: string | undefined) =>
99 (left ?? "") === (right ?? "");
100
101const canonical = (value: unknown): unknown => {
102 if (value === undefined || value === null) return undefined;
103 if (typeof value === "boolean" || typeof value === "number") return value;
104 if (typeof value === "string") {
105 return value.length === 0 ? undefined : value;
106 }
107 if (Array.isArray(value)) {
108 const items = value.map(canonical).filter((item) => item !== undefined);
109 return items.length === 0 ? undefined : items;
110 }
111 if (typeof value === "object") {
112 const entries = Object.entries(value as Record<string, unknown>)
113 .map(([key, item]) => [key, canonical(item)] as const)
114 .filter(([, item]) => item !== undefined)
115 .sort(([left], [right]) => left.localeCompare(right));
116 if (entries.length === 0) return undefined;
117 return Object.fromEntries(entries);
118 }
119 return undefined;
120};
121
122export const fingerprint = (value: unknown): string =>
123 JSON.stringify(canonical(value) ?? null);
124
125export const sameStringList = (
126 left: readonly string[] | undefined,
127 right: readonly string[] | undefined,
128) =>
129 fingerprint([...(left ?? [])].slice().sort()) ===
130 fingerprint([...(right ?? [])].slice().sort());
131
132export const fieldMask = (fields: Array<string | false | undefined>) =>
133 fields
134 .filter((field): field is string => typeof field === "string")
135 .join(",");
136
137export const clipDescription = (description: string | undefined) => {
138 if (description === undefined) return undefined;
139 return description.slice(0, MAX_DESCRIPTION_LENGTH);
140};
141
142export type ThreeLeggedOAuthParams = {
143 clientId?: string;
144 clientSecret?: string;
145 authorizationUrl?: string;
146 tokenUrl?: string;
147 defaultContinueUri?: string;
148 enablePkce?: boolean;
149};
150
151export type TwoLeggedOAuthParams = {
152 clientId?: string;
153 clientSecret?: string;
154 tokenUrl?: string;
155};
156
157export type ApiKeyParams = {
158 apiKey?: string;
159};
160
161export type AuthProviderTypeParams = {
162 threeLeggedOauth?: ThreeLeggedOAuthParams;
163 twoLeggedOauth?: TwoLeggedOAuthParams;
164 apiKey?: ApiKeyParams;
165 geAuthProvider?: Record<string, never> | {};
166};
167
168const publicThreeLegged = (value: ThreeLeggedOAuthParams | undefined) =>
169 value === undefined
170 ? undefined
171 : {
172 clientId: value.clientId,
173 authorizationUrl: value.authorizationUrl,
174 tokenUrl: value.tokenUrl,
175 defaultContinueUri: value.defaultContinueUri,
176 enablePkce: value.enablePkce,
177 };
178
179const publicTwoLegged = (value: TwoLeggedOAuthParams | undefined) =>
180 value === undefined
181 ? undefined
182 : {
183 clientId: value.clientId,
184 tokenUrl: value.tokenUrl,
185 };
186
187export const typeKind = (params: AuthProviderTypeParams | undefined) => {
188 if (params?.threeLeggedOauth !== undefined) return "threeLeggedOauth";
189 if (params?.twoLeggedOauth !== undefined) return "twoLeggedOauth";
190 if (params?.apiKey !== undefined) return "apiKey";
191 if (params?.geAuthProvider !== undefined) return "geAuthProvider";
192 return undefined;
193};
194
195export const publicTypeParams = (
196 params: AuthProviderTypeParams | undefined,
197) => {
198 if (params === undefined || typeKind(params) === undefined) return undefined;
199 return {
200 kind: typeKind(params),
201 threeLeggedOauth: publicThreeLegged(params.threeLeggedOauth),
202 twoLeggedOauth: publicTwoLegged(params.twoLeggedOauth),
203 };
204};
205
206export const hasSecretUpdate = (
207 news: AuthProviderTypeParams | undefined,
208 olds: AuthProviderTypeParams | undefined,
209) => {
210 if (news === undefined || olds === undefined) return false;
211 const apiKeyChanged =
212 news.apiKey?.apiKey !== undefined &&
213 news.apiKey.apiKey !== olds.apiKey?.apiKey;
214 const threeSecretChanged =
215 news.threeLeggedOauth?.clientSecret !== undefined &&
216 news.threeLeggedOauth.clientSecret !== olds.threeLeggedOauth?.clientSecret;
217 const twoSecretChanged =
218 news.twoLeggedOauth?.clientSecret !== undefined &&
219 news.twoLeggedOauth.clientSecret !== olds.twoLeggedOauth?.clientSecret;
220 return apiKeyChanged || threeSecretChanged || twoSecretChanged;
221};
222
223export const toTypeParams = (
224 params:
225 | agentidentity.AuthProviderTypeParams
226 | AuthProviderTypeParams
227 | undefined,
228): AuthProviderTypeParams | undefined => {
229 if (params === undefined) return undefined;
230 const three = params.threeLeggedOauth;
231 const two = params.twoLeggedOauth;
232 const apiKey = params.apiKey;
233 const ge = params.geAuthProvider;
234 if (
235 three === undefined &&
236 two === undefined &&
237 apiKey === undefined &&
238 ge === undefined
239 ) {
240 return undefined;
241 }
242 return {
243 threeLeggedOauth:
244 three === undefined
245 ? undefined
246 : {
247 clientId: three.clientId,
248 authorizationUrl: three.authorizationUrl,
249 tokenUrl: three.tokenUrl,
250 defaultContinueUri: three.defaultContinueUri,
251 enablePkce: three.enablePkce,
252 },
253 twoLeggedOauth:
254 two === undefined
255 ? undefined
256 : {
257 clientId: two.clientId,
258 tokenUrl: two.tokenUrl,
259 },
260 apiKey: apiKey === undefined ? undefined : {},
261 geAuthProvider: ge === undefined ? undefined : {},
262 };
263};
264
265export const typeParamsBody = (
266 params: AuthProviderTypeParams | undefined,
267): agentidentity.AuthProviderTypeParams | undefined => {
268 if (params === undefined) return undefined;
269 return {
270 threeLeggedOauth: params.threeLeggedOauth,
271 twoLeggedOauth: params.twoLeggedOauth,
272 apiKey: params.apiKey,
273 geAuthProvider: params.geAuthProvider === undefined ? undefined : {},
274 };
275};
276
277export const getByName = (name: string) =>
278 name.length === 0
279 ? Effect.succeed(undefined)
280 : agentidentity
281 .getProjectsLocationsAuthProviders({ name })
282 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
283
284const listPages = (parent: string) =>
285 agentidentity.listProjectsLocationsAuthProviders
286 .pages({
287 parent,
288 pageSize: 1000,
289 })
290 .pipe(
291 Stream.flatMap((page) => Stream.fromIterable(page.authProviders ?? [])),
292 Stream.filter(
293 (item) => item.deleted !== true && hasAlchemyLabelMap(item.labels),
294 ),
295 Stream.runCollect,
296 Effect.map((chunk) => Array.from(chunk)),
297 Effect.catchTag("NotFound", () =>
298 Effect.succeed([] as agentidentity.AuthProvider[]),
299 ),
300 );
301
302const listLocationIds = (project: string, region: string) =>
303 agentidentity.listProjectsLocations
304 .pages({
305 name: `projects/${project}`,
306 pageSize: 200,
307 })
308 .pipe(
309 Stream.flatMap((page) => Stream.fromIterable(page.locations ?? [])),
310 Stream.map(
311 (location) => location.locationId ?? lastSegment(location.name ?? ""),
312 ),
313 Stream.filter((id) => id.length > 0),
314 Stream.runCollect,
315 Effect.map((chunk) => {
316 const ids = new Set(Array.from(chunk));
317 ids.add(region);
318 return [...ids];
319 }),
320 Effect.catchTag("NotFound", () => Effect.succeed([region])),
321 );
322
323export const listOwned = (project: string, region: string) =>
324 Effect.gen(function* () {
325 const wildcard = yield* listPages(`projects/${project}/locations/-`);
326 if (wildcard.length > 0) return wildcard;
327 const locations = yield* listLocationIds(project, region);
328 const pages = yield* Effect.forEach(
329 locations,
330 (location) => listPages(parentOf(project, location)),
331 { concurrency: "unbounded" },
332 );
333 const byName = new Map<string, agentidentity.AuthProvider>();
334 for (const item of pages.flat()) {
335 if (item.name) byName.set(item.name, item);
336 }
337 return [...byName.values()];
338 });