1import * as sasportal from "@distilled.cloud/gcp/sasportal_v1alpha1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 decodeSignedDevice,
12 deviceBody,
13 deviceConfigOf,
14 deviceGrantsOf,
15 deviceMetadataOf,
16 encodeOwnershipLine,
17 expandCustomer,
18 findDeviceBySerial,
19 findOwnedCustomerDevice,
20 frequencyRangesOf,
21 getCustomerDevice,
22 hasOwnershipMarker,
23 lastSegment,
24 listCustomerDevices,
25 MAX_DISPLAY_NAME_LENGTH,
26 ownedByAlchemy,
27 ownershipLabels,
28 parentOf,
29 parseOwnership,
30 replaceOnIdentity,
31 retryDelete,
32 sameJson,
33 sameText,
34 scanOwnedCustomerDevice,
35 signedDeviceBody,
36 toDisplayName,
37 updateMaskOf,
38 waitUntilGone,
39 listCustomers,
40 type DeviceConfig,
41 type DeviceGrant,
42 type DeviceMetadata,
43 type FrequencyRange,
44} from "./internal.ts";
45
46export type SignedCustomersDeviceProps = {
47 /**
48 * Parent SAS customer, as `customers/{customer}` or the customer id.
49 * Immutable — changing it replaces the device.
50 */
51 parent: string;
52 /**
53 * Server-assigned device resource name
54 * (`customers/{customer}/devices/{device}`). Immutable — changing it
55 * replaces the device.
56 */
57 name?: string;
58 /**
59 * JSON Web Token signed with a CPI private key. Payload must be the
60 * JSON encoding of the device. Create-only.
61 */
62 encodedDevice: string;
63 /**
64 * Unique installer id (CPI ID) from the Certified Professional
65 * Installers database. Create-only.
66 */
67 installerId: string;
68 /**
69 * Display name. SAS Portal devices have no labels field, so Alchemy
70 * stamps ownership into a `[alchemy …]` prefix after create and
71 * strips it from attributes.
72 */
73 displayName?: string;
74 /**
75 * Manufacturer serial number, if known. Used to locate a device
76 * after a signed create when ownership has not been stamped yet.
77 */
78 serialNumber?: string;
79 /**
80 * FCC identifier of the CBSD, if known.
81 */
82 fccId?: string;
83 /**
84 * Frequency ranges available for new grants.
85 */
86 grantRangeAllowlists?: FrequencyRange[];
87 /**
88 * Device configuration supplied through the SAS Portal API.
89 */
90 preloadedConfig?: DeviceConfig;
91 /**
92 * Overridable device metadata (ICG, CCG, antenna model).
93 */
94 deviceMetadata?: DeviceMetadata;
95};
96
97export type SignedCustomersDevice = Resource<
98 "GCP.SasPortal.SignedCustomersDevice",
99 SignedCustomersDeviceProps,
100 {
101 /** Resource name `customers/{customer}/devices/{device}`. */
102 name: string;
103 /** Device id (last path segment). */
104 deviceId: string;
105 /** Parent resource name. */
106 parent: string;
107 /** Project id used when the device was reconciled. */
108 project: string;
109 /** User-facing display name with the Alchemy prefix stripped. */
110 displayName: string | undefined;
111 /** Manufacturer serial number. */
112 serialNumber: string | undefined;
113 /** FCC identifier. */
114 fccId: string | undefined;
115 /** Grant-range allowlists. */
116 grantRangeAllowlists: FrequencyRange[] | undefined;
117 /** Portal-supplied configuration. */
118 preloadedConfig: DeviceConfig | undefined;
119 /** Current SAS-registered configuration. */
120 activeConfig: DeviceConfig | undefined;
121 /** Overridable device metadata. */
122 deviceMetadata: DeviceMetadata | undefined;
123 /** Device state. */
124 state: string | undefined;
125 /** Grants held by the device. */
126 grants: DeviceGrant[] | undefined;
127 },
128 never,
129 Providers
130>;
131
132/**
133 * A CPI-signed Spectrum Access System (SAS) Portal CBSD under a customer.
134 *
135 * Signed create takes a CPI-signed JWT. After the device exists,
136 * Alchemy stamps ownership into `displayName` for `list` / nuke and
137 * syncs mutable fields with the ordinary device patch API.
138 *
139 * ### Creating a Signed Device
140 * **Example:** CPI-signed CBSD
141 * ```typescript
142 * const device = yield* GCP.SasPortal.SignedCustomersDevice("Cbsd", {
143 * parent: "customers/123",
144 * encodedDevice: jwt,
145 * installerId: "cpi-1",
146 * displayName: "rooftop-1",
147 * });
148 * ```
149 *
150 * ### Updating a Signed Device
151 * Change props on the same logical id; the engine keeps the physical id.
152 *
153 * **Example:** Rename
154 * ```typescript
155 * const device = yield* GCP.SasPortal.SignedCustomersDevice("Cbsd", {
156 * parent: "customers/123",
157 * encodedDevice: jwt,
158 * installerId: "cpi-1",
159 * displayName: "rooftop-2",
160 * });
161 * ```
162 *
163 * @resource
164 * @category SasPortal
165 */
166export const SignedCustomersDevice = Resource<SignedCustomersDevice>(
167 "GCP.SasPortal.SignedCustomersDevice",
168);
169
170export class SignedCustomersDeviceNotResolved extends Data.TaggedError(
171 "GCP.SasPortal.SignedCustomersDeviceNotResolved",
172)<{
173 parent: string;
174 name: string;
175}> {}
176
177const toAttrs = (
178 device: sasportal.SasPortalDevice,
179 parent: string,
180 project: string,
181) => {
182 const name = device.name ?? "";
183 return {
184 name,
185 deviceId: lastSegment(name),
186 parent: parentOf(name) || parent,
187 project,
188 displayName: parseOwnership(device.displayName).text,
189 serialNumber: device.serialNumber,
190 fccId: device.fccId,
191 grantRangeAllowlists: frequencyRangesOf(device.grantRangeAllowlists),
192 preloadedConfig: deviceConfigOf(device.preloadedConfig),
193 activeConfig: deviceConfigOf(device.activeConfig),
194 deviceMetadata: deviceMetadataOf(device.deviceMetadata),
195 state: device.state,
196 grants: deviceGrantsOf(device.grants),
197 };
198};
199
200export const SignedCustomersDeviceProvider = () =>
201 Provider.succeed(SignedCustomersDevice, {
202 stables: ["name", "deviceId", "parent", "project", "serialNumber", "fccId"],
203
204 diff: Effect.fn(function* ({ news, olds, output }) {
205 if (!isResolved(news)) return undefined;
206 return replaceOnIdentity({
207 previousParent: olds?.parent ?? output?.parent,
208 nextParent: expandCustomer(news.parent),
209 previousId: olds?.name ?? output?.name,
210 nextId: news.name,
211 });
212 }),
213
214 read: Effect.fn(function* ({ id, olds, output }) {
215 const env = yield* GcpEnvironment.current;
216 const parent = expandCustomer(olds?.parent ?? output?.parent ?? "");
217 const name = olds?.name ?? output?.name ?? "";
218 let existing = yield* getCustomerDevice(name);
219 let locatedParent = existing
220 ? parentOf(existing.name ?? "") || parent
221 : parent;
222 if (existing === undefined) {
223 const found =
224 (yield* findOwnedCustomerDevice(id, parent)) ??
225 (yield* scanOwnedCustomerDevice(id, parent));
226 existing = found?.row;
227 locatedParent = found?.parent ?? locatedParent;
228 }
229 if (existing === undefined) return undefined;
230 const attrs = toAttrs(existing, locatedParent, env.project);
231 return (yield* ownedByAlchemy(id, existing.displayName))
232 ? attrs
233 : Unowned(attrs);
234 }),
235
236 list: () =>
237 Effect.gen(function* () {
238 const env = yield* GcpEnvironment.current;
239 const parents = yield* listCustomers();
240 const pages = yield* Effect.forEach(
241 parents,
242 (entry) => {
243 const parent = entry.name ?? "";
244 return parent.length === 0
245 ? Effect.succeed([])
246 : listCustomerDevices(parent).pipe(
247 Effect.map((rows) =>
248 rows
249 .filter((row) => hasOwnershipMarker(row.displayName))
250 .map((row) => toAttrs(row, parent, env.project)),
251 ),
252 );
253 },
254 { concurrency: 4 },
255 );
256 return pages.flat();
257 }),
258
259 reconcile: Effect.fn(function* ({ id, news, output }) {
260 const env = yield* GcpEnvironment.current;
261 const parent = expandCustomer(news.parent);
262 const decoded = yield* decodeSignedDevice(news.encodedDevice);
263 const ownership = yield* ownershipLabels(id);
264 const displayName = encodeOwnershipLine(
265 ownership,
266 yield* toDisplayName(
267 id,
268 news.displayName ?? decoded?.displayName,
269 output?.displayName,
270 ),
271 MAX_DISPLAY_NAME_LENGTH,
272 );
273 const serialNumber =
274 news.serialNumber ?? output?.serialNumber ?? decoded?.serialNumber;
275 const desired = deviceBody({
276 displayName,
277 serialNumber,
278 fccId: news.fccId ?? output?.fccId ?? decoded?.fccId,
279 grantRangeAllowlists: news.grantRangeAllowlists,
280 preloadedConfig: news.preloadedConfig,
281 deviceMetadata: news.deviceMetadata,
282 });
283
284 let current = yield* getCustomerDevice(news.name ?? output?.name ?? "");
285 if (current === undefined) {
286 const found =
287 (yield* findOwnedCustomerDevice(id, parent)) ??
288 (yield* scanOwnedCustomerDevice(id, parent));
289 current = found?.row;
290 }
291 if (current === undefined) {
292 const rows = yield* listCustomerDevices(parent);
293 current = findDeviceBySerial(rows, serialNumber);
294 }
295
296 if (current === undefined) {
297 const created = yield* sasportal
298 .createSignedCustomersDevices({
299 parent,
300 body: signedDeviceBody({
301 encodedDevice: news.encodedDevice,
302 installerId: news.installerId,
303 }),
304 })
305 .pipe(
306 Effect.catchTag("Conflict", () =>
307 Effect.gen(function* () {
308 const found = yield* findOwnedCustomerDevice(id, parent);
309 if (found?.row !== undefined) return found.row;
310 const rows = yield* listCustomerDevices(parent);
311 return findDeviceBySerial(rows, serialNumber);
312 }),
313 ),
314 );
315 current = created ?? undefined;
316 }
317
318 if (current === undefined) {
319 return yield* new SignedCustomersDeviceNotResolved({
320 parent,
321 name: news.name ?? output?.name ?? displayName,
322 });
323 }
324
325 const name = current.name ?? news.name ?? output?.name ?? "";
326 const nameChanged = !sameText(current.displayName, displayName);
327 const allowlistsChanged =
328 news.grantRangeAllowlists !== undefined &&
329 !sameJson(
330 frequencyRangesOf(current.grantRangeAllowlists),
331 news.grantRangeAllowlists,
332 );
333 const configChanged =
334 news.preloadedConfig !== undefined &&
335 !sameJson(
336 deviceConfigOf(current.preloadedConfig),
337 news.preloadedConfig,
338 );
339 const metadataChanged =
340 news.deviceMetadata !== undefined &&
341 !sameJson(
342 deviceMetadataOf(current.deviceMetadata),
343 news.deviceMetadata,
344 );
345 if (
346 nameChanged ||
347 allowlistsChanged ||
348 configChanged ||
349 metadataChanged
350 ) {
351 current = yield* sasportal.patchCustomersDevices({
352 name,
353 updateMask: updateMaskOf(
354 "displayName",
355 news.grantRangeAllowlists !== undefined
356 ? "grantRangeAllowlists"
357 : undefined,
358 news.preloadedConfig !== undefined ? "preloadedConfig" : undefined,
359 news.deviceMetadata !== undefined ? "deviceMetadata" : undefined,
360 ),
361 body: desired,
362 });
363 }
364
365 return toAttrs(current, parent, env.project);
366 }),
367
368 delete: Effect.fn(function* ({ output }) {
369 if (!output.name) return;
370 yield* retryDelete(
371 sasportal.deleteCustomersDevices({ name: output.name }),
372 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
373 yield* waitUntilGone(getCustomerDevice(output.name));
374 }),
375 });