1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 DEFAULT_LOCATION,
13 encodeOwnershipLine,
14 fingerprint,
15 hasOwnershipMarker,
16 internalLabels,
17 lastSegment,
18 listLocations,
19 locationOf,
20 locationParent,
21 normalizeLocation,
22 ownedByAlchemy,
23 parseOwnership,
24 projectOf,
25 sameText,
26 toResourceId,
27 updateMaskOf,
28 retryQuota,
29 waitUntilReadable,
30} from "./internal.ts";
31
32export type RedactionStrategy =
33 | "REDACTION_STRATEGY_UNSPECIFIED"
34 | "REDACT_WITH_SERVICE";
35
36export type RedactionScope =
37 | "REDACTION_SCOPE_UNSPECIFIED"
38 | "REDACT_DISK_STORAGE";
39
40export type RetentionStrategy =
41 | "RETENTION_STRATEGY_UNSPECIFIED"
42 | "REMOVE_AFTER_CONVERSATION";
43
44export type PurgeDataType =
45 | "PURGE_DATA_TYPE_UNSPECIFIED"
46 | "DIALOGFLOW_HISTORY";
47
48export type AudioFormat = "AUDIO_FORMAT_UNSPECIFIED" | "MULAW" | "MP3" | "OGG";
49
50export type AudioExportSettings = {
51 /** GCS bucket that stores exported audio. */
52 gcsBucket?: string;
53 /** Filename pattern for exported audio. */
54 audioExportPattern?: string;
55 /** Whether to redact exported audio. */
56 enableAudioRedaction?: boolean;
57 /** Exported audio format. */
58 audioFormat?: AudioFormat | (string & {});
59 /** Whether to also export synthesized TTS audio. */
60 storeTtsAudio?: boolean;
61};
62
63export type InsightsExportSettings = {
64 /** Whether to export conversations to Contact Center Insights. */
65 enableInsightsExport?: boolean;
66};
67
68export type SecuritySettingProps = {
69 /**
70 * Security settings id (the `{securitySettings}` segment of
71 * `projects/{project}/locations/{location}/securitySettings/{id}`).
72 * Server-assigned on create. Immutable — changing it replaces the
73 * settings.
74 */
75 securitySettingsId?: string;
76 /**
77 * Location (`global`, `us-central1`, …). Immutable — changing it
78 * replaces the settings. `US-CENTRAL1` is accepted and normalized to
79 * `us-central1`.
80 * @default "global"
81 */
82 location?: string;
83 /**
84 * Human-readable name. Security settings have no labels field, so
85 * Alchemy stamps ownership into this field for `list` / nuke.
86 */
87 displayName?: string;
88 /** How sensitive data is redacted. */
89 redactionStrategy?: RedactionStrategy | (string & {});
90 /** Which data is subject to redaction. */
91 redactionScope?: RedactionScope | (string & {});
92 /**
93 * DLP inspect template resource name
94 * `projects/{project}/locations/{location}/inspectTemplates/{template}`.
95 */
96 inspectTemplate?: string;
97 /**
98 * DLP de-identify template resource name
99 * `projects/{project}/locations/{location}/deidentifyTemplates/{template}`.
100 */
101 deidentifyTemplate?: string;
102 /** Data types purged when retention triggers. */
103 purgeDataTypes?: Array<PurgeDataType | (string & {})>;
104 /** Audio export configuration. */
105 audioExportSettings?: AudioExportSettings;
106 /** Contact Center Insights export configuration. */
107 insightsExportSettings?: InsightsExportSettings;
108 /**
109 * Retention window in days. Mutually exclusive with
110 * `retentionStrategy`.
111 */
112 retentionWindowDays?: number;
113 /**
114 * Retention strategy. Mutually exclusive with `retentionWindowDays`.
115 */
116 retentionStrategy?: RetentionStrategy | (string & {});
117};
118
119export type SecuritySetting = Resource<
120 "GCP.Dialogflow.SecuritySetting",
121 SecuritySettingProps,
122 {
123 /** Full resource name `projects/{project}/locations/{location}/securitySettings/{id}`. */
124 name: string;
125 /** Security settings id (last path segment). */
126 securitySettingsId: string;
127 /** Location id. */
128 location: string;
129 /** Project id. */
130 project: string;
131 /** User display name with the Alchemy ownership prefix stripped. */
132 displayName: string | undefined;
133 /** Redaction strategy. */
134 redactionStrategy: string | undefined;
135 /** Redaction scope. */
136 redactionScope: string | undefined;
137 /** DLP inspect template. */
138 inspectTemplate: string | undefined;
139 /** DLP de-identify template. */
140 deidentifyTemplate: string | undefined;
141 /** Data types purged on retention. */
142 purgeDataTypes: string[];
143 /** Audio export configuration. */
144 audioExportSettings: AudioExportSettings | undefined;
145 /** Insights export configuration. */
146 insightsExportSettings: InsightsExportSettings | undefined;
147 /** Retention window in days. */
148 retentionWindowDays: number | undefined;
149 /** Retention strategy. */
150 retentionStrategy: string | undefined;
151 },
152 never,
153 Providers
154>;
155
156/**
157 * Dialogflow CX security settings for data redaction and retention.
158 *
159 * Security settings live at a location (not under an agent) and have no
160 * labels field — Alchemy stamps ownership into `displayName` so `list` /
161 * nuke can find them. Location and id are immutable. Display name,
162 * redaction, DLP templates, purge types, export settings, and retention
163 * update in place.
164 *
165 * ### Creating Security Settings
166 * **Example:** Remove data after each conversation
167 * ```typescript
168 * const settings = yield* GCP.Dialogflow.SecuritySetting("Retention", {
169 * location: "us-central1",
170 * displayName: "session-only",
171 * retentionStrategy: "REMOVE_AFTER_CONVERSATION",
172 * });
173 * ```
174 *
175 * ### Updating Security Settings
176 * Change props on the same logical id; the engine keeps the physical id.
177 *
178 * **Example:** Switch to a 30-day window
179 * ```typescript
180 * const settings = yield* GCP.Dialogflow.SecuritySetting("Retention", {
181 * location: "us-central1",
182 * displayName: "thirty-days",
183 * retentionWindowDays: 30,
184 * });
185 * ```
186 *
187 * @resource
188 * @category Dialogflow
189 */
190export const SecuritySetting = Resource<SecuritySetting>(
191 "GCP.Dialogflow.SecuritySetting",
192);
193
194export class SecuritySettingNotResolved extends Data.TaggedError(
195 "GCP.Dialogflow.SecuritySettingNotResolved",
196)<{
197 name: string;
198}> {}
199
200const resourceName = (
201 project: string,
202 location: string,
203 securitySettingsId: string,
204) =>
205 `${locationParent(project, location)}/securitySettings/${securitySettingsId}`;
206
207const audioOf = (
208 settings:
209 | dialogflow.GoogleCloudDialogflowCxV3SecuritySettingsAudioExportSettings
210 | undefined,
211): AudioExportSettings | undefined => {
212 if (settings === undefined) return undefined;
213 return {
214 gcsBucket: settings.gcsBucket,
215 audioExportPattern: settings.audioExportPattern,
216 enableAudioRedaction: settings.enableAudioRedaction,
217 audioFormat: settings.audioFormat,
218 storeTtsAudio: settings.storeTtsAudio,
219 };
220};
221
222const insightsOf = (
223 settings:
224 | dialogflow.GoogleCloudDialogflowCxV3SecuritySettingsInsightsExportSettings
225 | undefined,
226): InsightsExportSettings | undefined => {
227 if (settings === undefined) return undefined;
228 return { enableInsightsExport: settings.enableInsightsExport };
229};
230
231const toAttrs = (
232 settings: dialogflow.GoogleCloudDialogflowCxV3SecuritySettings,
233 project: string,
234) => {
235 const name = settings.name ?? "";
236 return {
237 name,
238 securitySettingsId: lastSegment(name),
239 location: locationOf(name),
240 project: projectOf(name) || project,
241 displayName: parseOwnership(settings.displayName).text,
242 redactionStrategy: settings.redactionStrategy,
243 redactionScope: settings.redactionScope,
244 inspectTemplate: settings.inspectTemplate,
245 deidentifyTemplate: settings.deidentifyTemplate,
246 purgeDataTypes: [...(settings.purgeDataTypes ?? [])],
247 audioExportSettings: audioOf(settings.audioExportSettings),
248 insightsExportSettings: insightsOf(settings.insightsExportSettings),
249 retentionWindowDays: settings.retentionWindowDays,
250 retentionStrategy: settings.retentionStrategy,
251 };
252};
253
254const getByName = (name: string) =>
255 name.length === 0
256 ? Effect.succeed(undefined)
257 : dialogflow
258 .getProjectsLocationsSecuritySettings({ name })
259 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
260
261const listAt = (parent: string, project: string) =>
262 dialogflow.listProjectsLocationsSecuritySettings
263 .pages({ parent, pageSize: 100 })
264 .pipe(
265 Stream.flatMap((page) =>
266 Stream.fromIterable(page.securitySettings ?? []),
267 ),
268 Stream.filter((settings) => hasOwnershipMarker(settings.displayName)),
269 Stream.map((settings) => toAttrs(settings, project)),
270 Stream.runCollect,
271 Effect.map((chunk) => Array.from(chunk)),
272 Effect.catchTag("NotFound", () => Effect.succeed([])),
273 );
274
275const findByDisplayName = (parent: string, displayName: string) =>
276 dialogflow.listProjectsLocationsSecuritySettings
277 .pages({ parent, pageSize: 100 })
278 .pipe(
279 Stream.flatMap((page) =>
280 Stream.fromIterable(page.securitySettings ?? []),
281 ),
282 Stream.filter((settings) => settings.displayName === displayName),
283 Stream.runHead,
284 Effect.map((option) =>
285 option._tag === "Some" ? option.value : undefined,
286 ),
287 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
288 );
289
290export const SecuritySettingProvider = () =>
291 Provider.succeed(SecuritySetting, {
292 stables: ["name", "securitySettingsId", "location", "project"],
293
294 diff: Effect.fn(function* ({ news, olds, output }) {
295 if (!isResolved(news)) return undefined;
296 const previousLocation = olds?.location ?? output?.location;
297 if (
298 previousLocation !== undefined &&
299 news.location !== undefined &&
300 normalizeLocation(previousLocation) !== normalizeLocation(news.location)
301 ) {
302 return { action: "replace" as const, deleteFirst: false };
303 }
304 const previousId = olds?.securitySettingsId ?? output?.securitySettingsId;
305 if (
306 previousId !== undefined &&
307 news.securitySettingsId !== undefined &&
308 news.securitySettingsId !== previousId
309 ) {
310 return { action: "replace" as const, deleteFirst: false };
311 }
312 return undefined;
313 }),
314
315 read: Effect.fn(function* ({ id, olds, output }) {
316 const env = yield* GcpEnvironment.current;
317 const location = normalizeLocation(olds?.location ?? output?.location);
318 const securitySettingsId = yield* toResourceId(
319 id,
320 olds?.securitySettingsId,
321 output?.securitySettingsId,
322 );
323 const name =
324 output?.name ?? resourceName(env.project, location, securitySettingsId);
325 let existing = yield* getByName(name);
326 if (existing === undefined && output?.name === undefined) {
327 const ownership = yield* internalLabels(id);
328 existing = yield* findByDisplayName(
329 locationParent(env.project, location),
330 encodeOwnershipLine(ownership, olds?.displayName),
331 );
332 }
333 if (existing === undefined) return undefined;
334 const attrs = toAttrs(existing, env.project);
335 return (yield* ownedByAlchemy(id, existing.displayName))
336 ? attrs
337 : Unowned(attrs);
338 }),
339
340 list: () =>
341 Effect.gen(function* () {
342 const env = yield* GcpEnvironment.current;
343 const pages = yield* Effect.forEach(
344 listLocations(env.region),
345 (location) =>
346 listAt(locationParent(env.project, location), env.project),
347 { concurrency: 2 },
348 );
349 return pages.flat();
350 }),
351
352 reconcile: Effect.fn(function* ({ id, news, output }) {
353 const env = yield* GcpEnvironment.current;
354 const location = normalizeLocation(
355 news.location ?? output?.location ?? DEFAULT_LOCATION,
356 );
357 const parent = locationParent(env.project, location);
358 const securitySettingsId = yield* toResourceId(
359 id,
360 news.securitySettingsId,
361 output?.securitySettingsId,
362 );
363 const name =
364 output?.name ?? resourceName(env.project, location, securitySettingsId);
365 const ownership = yield* internalLabels(id);
366 const displayName = encodeOwnershipLine(ownership, news.displayName);
367 const body: dialogflow.GoogleCloudDialogflowCxV3SecuritySettings = {
368 displayName,
369 redactionStrategy: news.redactionStrategy,
370 redactionScope: news.redactionScope,
371 inspectTemplate: news.inspectTemplate,
372 deidentifyTemplate: news.deidentifyTemplate,
373 purgeDataTypes: news.purgeDataTypes,
374 audioExportSettings: news.audioExportSettings,
375 insightsExportSettings: news.insightsExportSettings,
376 retentionWindowDays: news.retentionWindowDays,
377 retentionStrategy:
378 news.retentionWindowDays === undefined
379 ? news.retentionStrategy
380 : undefined,
381 };
382
383 let current = yield* getByName(name);
384 if (current === undefined) {
385 current = yield* findByDisplayName(parent, displayName);
386 }
387
388 if (current === undefined) {
389 const created = yield* dialogflow
390 .createProjectsLocationsSecuritySettings({
391 parent,
392 body,
393 })
394 .pipe(
395 Effect.catchTag("Conflict", () =>
396 findByDisplayName(parent, displayName),
397 ),
398 );
399 current =
400 created?.name !== undefined
401 ? yield* waitUntilReadable(created.name, getByName)
402 : created;
403 }
404
405 if (current === undefined) {
406 return yield* new SecuritySettingNotResolved({ name });
407 }
408
409 const currentName = current.name ?? name;
410 const displayChanged = !sameText(current.displayName, displayName);
411 const strategyChanged = !sameText(
412 current.redactionStrategy,
413 news.redactionStrategy,
414 );
415 const scopeChanged = !sameText(
416 current.redactionScope,
417 news.redactionScope,
418 );
419 const inspectChanged = !sameText(
420 current.inspectTemplate,
421 news.inspectTemplate,
422 );
423 const deidentifyChanged = !sameText(
424 current.deidentifyTemplate,
425 news.deidentifyTemplate,
426 );
427 const purgeChanged =
428 fingerprint(current.purgeDataTypes) !==
429 fingerprint(news.purgeDataTypes);
430 const audioChanged =
431 fingerprint(audioOf(current.audioExportSettings)) !==
432 fingerprint(news.audioExportSettings);
433 const insightsChanged =
434 fingerprint(insightsOf(current.insightsExportSettings)) !==
435 fingerprint(news.insightsExportSettings);
436 const windowChanged =
437 (current.retentionWindowDays ?? 0) !== (news.retentionWindowDays ?? 0);
438 const retentionStrategyChanged = !sameText(
439 current.retentionStrategy,
440 body.retentionStrategy,
441 );
442
443 if (
444 displayChanged ||
445 strategyChanged ||
446 scopeChanged ||
447 inspectChanged ||
448 deidentifyChanged ||
449 purgeChanged ||
450 audioChanged ||
451 insightsChanged ||
452 windowChanged ||
453 retentionStrategyChanged
454 ) {
455 current = yield* dialogflow.patchProjectsLocationsSecuritySettings({
456 name: currentName,
457 updateMask: updateMaskOf(
458 displayChanged ? "display_name" : undefined,
459 strategyChanged ? "redaction_strategy" : undefined,
460 scopeChanged ? "redaction_scope" : undefined,
461 inspectChanged ? "inspect_template" : undefined,
462 deidentifyChanged ? "deidentify_template" : undefined,
463 purgeChanged ? "purge_data_types" : undefined,
464 audioChanged ? "audio_export_settings" : undefined,
465 insightsChanged ? "insights_export_settings" : undefined,
466 windowChanged ? "retention_window_days" : undefined,
467 retentionStrategyChanged ? "retention_strategy" : undefined,
468 ),
469 body: { ...body, name: currentName },
470 });
471 }
472
473 return toAttrs(current, env.project);
474 }, retryQuota),
475
476 delete: Effect.fn(function* ({ output }) {
477 yield* dialogflow
478 .deleteProjectsLocationsSecuritySettings({ name: output.name })
479 .pipe(Effect.catchTag("NotFound", () => Effect.void));
480 }, retryQuota),
481 });