1import * as androidenterprise from "@distilled.cloud/gcp/androidenterprise_v1";
2import * as Config from "effect/Config";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import {
7 alchemyLabelKeys,
8 createInternalLabels,
9 hasAlchemyLabels,
10} from "../Labels.ts";
11
12export const DEFAULT_LOCALE = "en-US";
13export const DEFAULT_DISPLAY_MODE = "standalone";
14export const MAX_NAME_LENGTH = 256;
15export const MAX_TITLE_LENGTH = 100;
16
17/** Whitespace/comma-separated list setting from the ConfigProvider (env by default). */
18const listSetting = (keys: readonly string[]) =>
19 Effect.gen(function* () {
20 const values: string[] = [];
21 for (const key of keys) {
22 const raw = Option.getOrUndefined(
23 yield* Config.option(Config.String(key)),
24 )?.trim();
25 if (!raw) continue;
26 for (const part of raw.split(/[,\s]+/)) {
27 if (part.length > 0) values.push(part);
28 }
29 }
30 return values;
31 });
32
33/** Enterprises swept by `list` (nuke): `GCP_ANDROIDENTERPRISE_ENTERPRISE_IDS`. */
34const configuredEnterpriseIds = () =>
35 listSetting([
36 "GCP_ANDROIDENTERPRISE_ENTERPRISE_ID",
37 "GCP_ANDROIDENTERPRISE_ENTERPRISE_IDS",
38 "GCP_ANDROID_ENTERPRISE_ID",
39 ]);
40
41/** Domain whose enterprises `list` sweeps: `GCP_ANDROIDENTERPRISE_DOMAIN`. */
42const configuredDomain = () =>
43 listSetting(["GCP_ANDROIDENTERPRISE_DOMAIN"]).pipe(
44 Effect.map((values) => values[0] ?? ""),
45 );
46
47export const sameText = (left: string | undefined, right: string | undefined) =>
48 (left ?? "") === (right ?? "");
49
50export const jsonEqual = (left: unknown, right: unknown) =>
51 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
52
53export const sameStringList = (
54 left: readonly string[] | undefined,
55 right: readonly string[] | undefined,
56) =>
57 jsonEqual(
58 [...(left ?? [])].slice().sort(),
59 [...(right ?? [])].slice().sort(),
60 );
61
62const emptyList = <A>() => Effect.succeed([] as A[]);
63
64const isMissing = <E extends { readonly _tag: string }>(
65 error: E,
66): error is Extract<E, { readonly _tag: "NotFound" }> =>
67 error._tag === "NotFound";
68
69export const catchMissing = <A, E extends { readonly _tag: string }, R>(
70 effect: Effect.Effect<A, E, R>,
71) => effect.pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
72
73export const ignoreList =
74 <A>(fallback: A) =>
75 <A1, E extends { readonly _tag: string }, R>(
76 self: Effect.Effect<A1, E, R>,
77 ): Effect.Effect<A1 | A, E, R> =>
78 self.pipe(Effect.catchIf(isMissing, () => Effect.succeed(fallback)));
79
80const markerOf = (
81 _labels: Record<string, string>,
82 stack: string,
83 stage: string,
84 id: string,
85) =>
86 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
87
88const fitMarker = (labels: Record<string, string>, maxLength: number) => {
89 let stack = labels[alchemyLabelKeys.stack] ?? "x";
90 let stage = labels[alchemyLabelKeys.stage] ?? "x";
91 let id = labels[alchemyLabelKeys.id] ?? "x";
92 let marker = markerOf(labels, stack, stage, id);
93 while (
94 marker.length > maxLength &&
95 (stack.length > 1 || stage.length > 1 || id.length > 1)
96 ) {
97 if (stack.length >= stage.length && stack.length >= id.length) {
98 stack = stack.slice(0, -1);
99 } else if (stage.length >= id.length) {
100 stage = stage.slice(0, -1);
101 } else {
102 id = id.slice(0, -1);
103 }
104 marker = markerOf(labels, stack, stage, id);
105 }
106 return marker.slice(0, maxLength);
107};
108
109export const encodeOwnershipLine = (
110 labels: Record<string, string>,
111 text: string | undefined,
112 maxLength = MAX_NAME_LENGTH,
113): string => {
114 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
115 if (!trimmed) return fitMarker(labels, maxLength);
116 const minMarker = 24;
117 const reserved = Math.min(
118 trimmed.length + 1,
119 Math.max(0, maxLength - minMarker),
120 );
121 const marker = fitMarker(labels, maxLength - reserved);
122 return `${marker} ${trimmed}`.slice(0, maxLength);
123};
124
125export const parseOwnership = (
126 text: string | undefined,
127): {
128 labels: Record<string, string>;
129 text: string | undefined;
130} => {
131 if (!text?.startsWith("[alchemy ")) {
132 return { labels: {}, text };
133 }
134 const end = text.indexOf("]");
135 if (end < 0) return { labels: {}, text };
136 const labels: Record<string, string> = {};
137 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
138 const eq = part.indexOf("=");
139 if (eq > 0) {
140 labels[part.slice(0, eq)] = part.slice(eq + 1);
141 }
142 }
143 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
144 return { labels, text: rest.length > 0 ? rest : undefined };
145};
146
147export const hasOwnershipMarker = (text: string | undefined) =>
148 Object.keys(parseOwnership(text).labels).some((key) =>
149 key.startsWith("alchemy-"),
150 );
151
152const prefixMatch = (expected: string, observed: string) =>
153 expected === observed ||
154 expected.startsWith(observed) ||
155 observed.startsWith(expected);
156
157export const ownedByAlchemy = (id: string, text: string | undefined) =>
158 Effect.gen(function* () {
159 const expected = yield* createInternalLabels(id);
160 const { labels } = parseOwnership(text);
161 if (!hasOwnershipMarker(text)) return false;
162 const exact = yield* hasAlchemyLabels(id, labels);
163 if (exact) return true;
164 return (
165 prefixMatch(
166 expected[alchemyLabelKeys.stack] ?? "",
167 labels[alchemyLabelKeys.stack] ?? "",
168 ) &&
169 prefixMatch(
170 expected[alchemyLabelKeys.stage] ?? "",
171 labels[alchemyLabelKeys.stage] ?? "",
172 ) &&
173 prefixMatch(
174 expected[alchemyLabelKeys.id] ?? "",
175 labels[alchemyLabelKeys.id] ?? "",
176 )
177 );
178 });
179
180export const toDisplayName = (
181 id: string,
182 requested: string | undefined,
183 existing: string | undefined,
184 maxLength = MAX_NAME_LENGTH,
185) =>
186 Effect.gen(function* () {
187 if (requested !== undefined && requested.length > 0) {
188 return requested.slice(0, maxLength);
189 }
190 if (existing !== undefined && existing.length > 0) {
191 return existing.slice(0, maxLength);
192 }
193 const generated = yield* createPhysicalName({
194 id,
195 maxLength: Math.min(40, maxLength),
196 lowercase: true,
197 delimiter: "-",
198 });
199 return /^[a-z]/.test(generated)
200 ? generated
201 : `a${generated}`.slice(0, maxLength);
202 });
203
204/**
205 * Localized names as given (or one default-locale entry). Names are shown
206 * to device users, so no ownership marker is written into them.
207 */
208export const defaultNames = (
209 names: readonly androidenterprise.LocalizedText[] | undefined,
210 fallback: string,
211): androidenterprise.LocalizedText[] =>
212 names && names.length > 0
213 ? names.map((entry) => ({
214 ...entry,
215 locale: entry.locale ?? DEFAULT_LOCALE,
216 }))
217 : [{ locale: DEFAULT_LOCALE, text: fallback }];
218
219export const publicNames = (
220 names: readonly androidenterprise.LocalizedText[] | undefined,
221): androidenterprise.LocalizedText[] | undefined => {
222 if (names === undefined) return undefined;
223 return names.map((entry) => ({
224 ...entry,
225 text: parseOwnership(entry.text).text,
226 }));
227};
228
229export const ownershipTextFromNames = (
230 names: readonly androidenterprise.LocalizedText[] | undefined,
231) => names?.[0]?.text;
232
233/** Title as given; shown to device users, so it carries no marker. */
234export const defaultTitle = (title: string | undefined, fallback: string) =>
235 (title ?? fallback).slice(0, MAX_TITLE_LENGTH);
236
237export const getPage = (enterpriseId: string, pageId: string) =>
238 enterpriseId.length === 0 || pageId.length === 0
239 ? Effect.succeed(undefined)
240 : catchMissing(
241 androidenterprise.getStorelayoutpages({ enterpriseId, pageId }),
242 );
243
244export const getCluster = (
245 enterpriseId: string,
246 pageId: string,
247 clusterId: string,
248) =>
249 enterpriseId.length === 0 || pageId.length === 0 || clusterId.length === 0
250 ? Effect.succeed(undefined)
251 : catchMissing(
252 androidenterprise.getStorelayoutclusters({
253 enterpriseId,
254 pageId,
255 clusterId,
256 }),
257 );
258
259export const getWebapp = (enterpriseId: string, webAppId: string) =>
260 enterpriseId.length === 0 || webAppId.length === 0
261 ? Effect.succeed(undefined)
262 : catchMissing(androidenterprise.getWebapps({ enterpriseId, webAppId }));
263
264export const listPagesAt = (enterpriseId: string) =>
265 enterpriseId.length === 0
266 ? emptyList<androidenterprise.StorePage>()
267 : androidenterprise.listStorelayoutpages({ enterpriseId }).pipe(
268 Effect.map((page) => page.page ?? []),
269 ignoreList([] as androidenterprise.StorePage[]),
270 );
271
272export const listClustersAt = (enterpriseId: string, pageId: string) =>
273 enterpriseId.length === 0 || pageId.length === 0
274 ? emptyList<androidenterprise.StoreCluster>()
275 : androidenterprise.listStorelayoutclusters({ enterpriseId, pageId }).pipe(
276 Effect.map((page) => page.cluster ?? []),
277 ignoreList([] as androidenterprise.StoreCluster[]),
278 );
279
280export const listWebappsAt = (enterpriseId: string) =>
281 enterpriseId.length === 0
282 ? emptyList<androidenterprise.WebApp>()
283 : androidenterprise.listWebapps({ enterpriseId }).pipe(
284 Effect.map((page) => page.webApp ?? []),
285 ignoreList([] as androidenterprise.WebApp[]),
286 );
287
288const listEnterprisesAtDomain = (domain: string) =>
289 domain.length === 0
290 ? emptyList<androidenterprise.Enterprise>()
291 : androidenterprise.listEnterprises({ domain }).pipe(
292 Effect.map((page) => page.enterprise ?? []),
293 ignoreList([] as androidenterprise.Enterprise[]),
294 );
295
296export const listEnterpriseIds = () =>
297 Effect.gen(function* () {
298 const fromEnv = yield* configuredEnterpriseIds();
299 const domain = yield* configuredDomain();
300 if (domain.length === 0) return fromEnv;
301 const lookedUp = yield* listEnterprisesAtDomain(domain);
302 const ids = lookedUp
303 .map((enterprise) => enterprise.id)
304 .filter((id): id is string => id !== undefined && id.length > 0);
305 return [...new Set([...fromEnv, ...ids])];
306 });
307
308export const findOwnedPage = (id: string, enterpriseId: string) =>
309 Effect.gen(function* () {
310 const pages = yield* listPagesAt(enterpriseId);
311 for (const page of pages) {
312 if (yield* ownedByAlchemy(id, ownershipTextFromNames(page.name))) {
313 return page;
314 }
315 }
316 return undefined;
317 });
318
319export const findOwnedCluster = (
320 id: string,
321 enterpriseId: string,
322 pageId?: string,
323) =>
324 Effect.gen(function* () {
325 const pageIds = pageId
326 ? [pageId]
327 : (yield* listPagesAt(enterpriseId))
328 .map((page) => page.id)
329 .filter((value): value is string => value !== undefined);
330 for (const currentPageId of pageIds) {
331 const clusters = yield* listClustersAt(enterpriseId, currentPageId);
332 for (const cluster of clusters) {
333 if (yield* ownedByAlchemy(id, ownershipTextFromNames(cluster.name))) {
334 return { cluster, pageId: currentPageId };
335 }
336 }
337 }
338 return undefined;
339 });
340
341export const findOwnedWebapp = (id: string, enterpriseId: string) =>
342 Effect.gen(function* () {
343 const webapps = yield* listWebappsAt(enterpriseId);
344 for (const webapp of webapps) {
345 if (yield* ownedByAlchemy(id, webapp.title)) {
346 return webapp;
347 }
348 }
349 return undefined;
350 });
351
352export const listOwnedPages = () =>
353 Effect.gen(function* () {
354 const enterpriseIds = yield* listEnterpriseIds();
355 const pages = yield* Effect.forEach(
356 enterpriseIds,
357 (enterpriseId) =>
358 listPagesAt(enterpriseId).pipe(
359 Effect.map((items) =>
360 items
361 .filter((page) =>
362 hasOwnershipMarker(ownershipTextFromNames(page.name)),
363 )
364 .map((page) => ({ page, enterpriseId })),
365 ),
366 ),
367 { concurrency: 4 },
368 );
369 return pages.flat();
370 });
371
372export const listOwnedClusters = () =>
373 Effect.gen(function* () {
374 const enterpriseIds = yield* listEnterpriseIds();
375 const pages = yield* Effect.forEach(
376 enterpriseIds,
377 (enterpriseId) =>
378 Effect.gen(function* () {
379 const storePages = yield* listPagesAt(enterpriseId);
380 const clusters = yield* Effect.forEach(
381 storePages,
382 (page) =>
383 page.id
384 ? listClustersAt(enterpriseId, page.id).pipe(
385 Effect.map((items) =>
386 items
387 .filter((cluster) =>
388 hasOwnershipMarker(
389 ownershipTextFromNames(cluster.name),
390 ),
391 )
392 .map((cluster) => ({
393 cluster,
394 enterpriseId,
395 pageId: page.id ?? "",
396 })),
397 ),
398 )
399 : Effect.succeed(
400 [] as Array<{
401 cluster: androidenterprise.StoreCluster;
402 enterpriseId: string;
403 pageId: string;
404 }>,
405 ),
406 { concurrency: 4 },
407 );
408 return clusters.flat();
409 }),
410 { concurrency: 4 },
411 );
412 return pages.flat();
413 });
414
415export const listOwnedWebapps = () =>
416 Effect.gen(function* () {
417 const enterpriseIds = yield* listEnterpriseIds();
418 const pages = yield* Effect.forEach(
419 enterpriseIds,
420 (enterpriseId) =>
421 listWebappsAt(enterpriseId).pipe(
422 Effect.map((items) =>
423 items
424 .filter((webapp) => hasOwnershipMarker(webapp.title))
425 .map((webapp) => ({ webapp, enterpriseId })),
426 ),
427 ),
428 { concurrency: 4 },
429 );
430 return pages.flat();
431 });