@adobe/spacecat-shared-utils 1.113.0 → 1.115.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [@adobe/spacecat-shared-utils-v1.115.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-utils-v1.114.0...@adobe/spacecat-shared-utils-v1.115.0) (2026-05-04)
2
+
3
+ ### Features
4
+
5
+ * **strategy:** add type discriminator and experimentId for Atomic strategies | LLMO-4643 ([#1571](https://github.com/adobe/spacecat-shared/issues/1571)) ([0151ff5](https://github.com/adobe/spacecat-shared/commit/0151ff510f7643021e6c1ccf21298f6d3c07034b))
6
+
7
+ ## [@adobe/spacecat-shared-utils-v1.114.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-utils-v1.113.0...@adobe/spacecat-shared-utils-v1.114.0) (2026-05-04)
8
+
9
+ ### Features
10
+
11
+ * **llmo-config:** fail-closed writeConfig validation (SITES-43238) ([#1574](https://github.com/adobe/spacecat-shared/issues/1574)) ([a177743](https://github.com/adobe/spacecat-shared/commit/a177743a7def37dd3fabb026241d0e0ea9e9bac3))
12
+
1
13
  ## [@adobe/spacecat-shared-utils-v1.113.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-utils-v1.112.5...@adobe/spacecat-shared-utils-v1.113.0) (2026-04-29)
2
14
 
3
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-utils",
3
- "version": "1.113.0",
3
+ "version": "1.115.0",
4
4
  "description": "Shared modules of the Spacecat Services - utils",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.js CHANGED
@@ -109,6 +109,7 @@ export { detectAEMVersion, DELIVERY_TYPES, AUTHORING_TYPES } from './aem.js';
109
109
  export { determineAEMCSPageId, getPageEditUrl } from './aem-content-api-utils.js';
110
110
 
111
111
  export * as llmoConfig from './llmo-config.js';
112
+ export { LlmoConfigValidationError } from './llmo-config.js';
112
113
  export * as llmoStrategy from './llmo-strategy.js';
113
114
  export * as schemas from './schemas.js';
114
115
 
@@ -18,6 +18,31 @@ import { llmoConfig } from './schemas.js';
18
18
  * @import { LLMOConfig } from "./schemas.js"
19
19
  */
20
20
 
21
+ /**
22
+ * Thrown by `writeConfig` when the supplied LLMO configuration does not match
23
+ * the published Zod schema. Exposes the offending site and the Zod issue list
24
+ * so callers and log readers can identify the failing fields without
25
+ * re-parsing the error.
26
+ */
27
+ export class LlmoConfigValidationError extends Error {
28
+ constructor(siteId, zodError) {
29
+ // Use issue `code` rather than `message` in the summary: Zod's default
30
+ // messages can echo received values, which may include user-supplied
31
+ // content (brand names, competitor URLs) on the api-service write path.
32
+ // The full message and value remain on `this.issues` for trusted callers.
33
+ const summary = zodError.issues
34
+ .map((i) => `${i.path.join('.')}: ${i.code}`)
35
+ .join('; ');
36
+ super(
37
+ `LLMO config for site ${siteId} failed schema validation: ${summary}`,
38
+ { cause: zodError },
39
+ );
40
+ this.name = 'LlmoConfigValidationError';
41
+ this.siteId = siteId;
42
+ this.issues = zodError.issues;
43
+ }
44
+ }
45
+
21
46
  /**
22
47
  * @param {string} siteId The ID of the site to get the config directory for.
23
48
  * @returns {string} The configuration directory path for the given site ID.
@@ -62,6 +87,10 @@ export function defaultConfig() {
62
87
  * Reads the LLMO configuration for a given site.
63
88
  * Returns an empty configuration if the configuration does not exist.
64
89
  *
90
+ * If the persisted config exists but fails schema validation, throws
91
+ * `LlmoConfigValidationError` so callers have a uniform error contract
92
+ * across read and write paths.
93
+ *
65
94
  * @param {string} sideId The ID of the site.
66
95
  * @param {S3Client} s3Client The S3 client to use for reading the configuration.
67
96
  * @param {object} [options]
@@ -70,6 +99,7 @@ export function defaultConfig() {
70
99
  * @param {string} [options.s3Bucket] Optional S3 bucket name.
71
100
  * @returns {Promise<{config: LLMOConfig, exists: boolean, version?: string}>} The configuration,
72
101
  * a flag indicating if it existed, and the version ID if it exists.
102
+ * @throws {LlmoConfigValidationError} If the persisted config fails schema validation.
73
103
  * @throws {Error} If reading the configuration fails for reasons other than it not existing.
74
104
  */
75
105
  export async function readConfig(sideId, s3Client, options) {
@@ -97,20 +127,35 @@ export async function readConfig(sideId, s3Client, options) {
97
127
  throw new Error('LLMO config body is empty');
98
128
  }
99
129
  const text = await body.transformToString();
100
- const config = llmoConfig.parse(JSON.parse(text));
101
- return { config, exists: true, version: res.VersionId || undefined };
130
+ const result = llmoConfig.safeParse(JSON.parse(text));
131
+ if (!result.success) {
132
+ throw new LlmoConfigValidationError(sideId, result.error);
133
+ }
134
+ return { config: result.data, exists: true, version: res.VersionId || undefined };
102
135
  }
103
136
 
104
137
  /**
105
138
  * Writes the LLMO configuration for a given site.
139
+ *
140
+ * Validates `config` against the published Zod schema before issuing the S3
141
+ * `PutObject`. If validation fails, throws `LlmoConfigValidationError` and
142
+ * does not call S3, so invalid configs cannot reach the bucket through this
143
+ * function.
144
+ *
106
145
  * @param {string} siteId The ID of the site.
107
146
  * @param {LLMOConfig} config The configuration object to write.
108
147
  * @param {S3Client} s3Client The S3 client to use for reading the configuration.
109
148
  * @param {object} [options]
110
149
  * @param {string} [options.s3Bucket] Optional S3 bucket name.
111
150
  * @returns {Promise<{ version: string }>} The version of the configuration written.
151
+ * @throws {LlmoConfigValidationError} If `config` does not match the LLMO schema.
112
152
  */
113
153
  export async function writeConfig(siteId, config, s3Client, options) {
154
+ const result = llmoConfig.safeParse(config);
155
+ if (!result.success) {
156
+ throw new LlmoConfigValidationError(siteId, result.error);
157
+ }
158
+
114
159
  const s3Bucket = options?.s3Bucket || process.env.S3_BUCKET_NAME;
115
160
 
116
161
  const putObjectCommand = new PutObjectCommand({
@@ -38,6 +38,10 @@ const strategyGoalType = z.union([
38
38
  z.string(), // Catchall for future goal types
39
39
  ]);
40
40
 
41
+ // Discriminator between Atomic (experiment-driven) and Evolving (iterative) strategies.
42
+ // Existing pre-GA strategies have no `type` field and default to 'evolving' for backward compat.
43
+ const strategyType = z.enum(['atomic', 'evolving']);
44
+
41
45
  /**
42
46
  * Library opportunity - user-created reusable opportunity template
43
47
  */
@@ -76,6 +80,7 @@ const strategyPromptSelection = z.object({
76
80
  */
77
81
  const strategy = z.object({
78
82
  id: nonEmptyString,
83
+ type: strategyType.default('evolving'),
79
84
  name: nonEmptyString,
80
85
  status: workflowStatus,
81
86
  url: z.union([z.string(), z.array(z.string())]),
@@ -90,6 +95,7 @@ const strategy = z.object({
90
95
  createdBy: z.string().optional(), // Email of strategy creator/owner
91
96
  completedAt: z.string().optional(), // ISO 8601 date string
92
97
  goalType: strategyGoalType.optional(),
98
+ experimentId: z.uuid().nullable().optional(),
93
99
  });
94
100
 
95
101
  /**
@@ -138,4 +144,37 @@ export const strategyWorkspaceData = z.object({
138
144
  }
139
145
  });
140
146
  });
147
+
148
+ // Validate type-based invariants (Atomic vs Evolving)
149
+ strategies.forEach((strat, strategyIndex) => {
150
+ // Atomic must have a non-null experimentId
151
+ if (strat.type === 'atomic' && (strat.experimentId === undefined || strat.experimentId === null)) {
152
+ ctx.addIssue({
153
+ code: 'custom',
154
+ path: ['strategies', strategyIndex, 'experimentId'],
155
+ message: 'Atomic strategies require a non-null experimentId',
156
+ });
157
+ }
158
+
159
+ // Atomic must not carry selectedPrompts (those come from GeoExperiment)
160
+ if (strat.type === 'atomic' && Array.isArray(strat.selectedPrompts) && strat.selectedPrompts.length > 0) {
161
+ ctx.addIssue({
162
+ code: 'custom',
163
+ path: ['strategies', strategyIndex, 'selectedPrompts'],
164
+ message: 'Atomic strategies must not carry selectedPrompts (use GeoExperiment.promptsLocation)',
165
+ });
166
+ }
167
+
168
+ // Evolving: schema is intentionally permissive on `selectedPrompts`. Today's
169
+ // CreateStrategyDialog initializes selectedPrompts as `[]` for newly-created
170
+ // Evolving strategies (no prompt-selection UI yet). Enforcing "must be
171
+ // non-empty" at the schema would reject those in-flight reads + writes.
172
+ //
173
+ // The "newly-created Evolving strategies must include selectedPrompts" rule
174
+ // is deferred to the milestone where Evolving promotes out of co-innovation
175
+ // mode — at that point the schema tightens, the API layer (saveStrategy)
176
+ // adds the creation-time check, and the prompt-selection screen ships.
177
+ // All three land together. Until then, selectedPrompts on Evolving is
178
+ // structurally validated only.
179
+ });
141
180
  });