@alfe.ai/integration-manifest 0.3.4 → 0.3.6

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/README.md CHANGED
@@ -61,6 +61,19 @@ hooks:
61
61
 
62
62
  Only `id`, `name`, `version`, `description`, and `author` are required — everything else has sensible defaults.
63
63
 
64
+ Manifest input is executable control-plane data. Identity fields must be
65
+ unique, record keys must be identifier-safe, and repository-owned hook, skill,
66
+ and command paths must remain relative to the integration checkout. Filesystem
67
+ consumers must additionally resolve real paths before I/O so repository
68
+ symlinks cannot escape that checkout.
69
+
70
+ `config_schema` validation is strict: undeclared keys are rejected, default
71
+ values must match the declared type/options, and `secret` or `oauth_connect`
72
+ fields cannot declare shared defaults. Consumers must persist the parsed
73
+ validation result, never the original caller object.
74
+
75
+ `supported_scopes` accepts `agent`, `org`, `team`, and `project`.
76
+
64
77
  ## Custom Connections — `expected_credentials`
65
78
 
66
79
  For manifests consumed by [Custom Connections](../../services/connect/DEVELOPING.md#custom-connections-pr-8a-of-channels-and-credential-driven-integrations) (`requires_connection: ["custom"]`), the manifest declares the credential fields it needs the tenant operator to fill in via an `expected_credentials` block:
@@ -91,7 +104,7 @@ Supported field `type`s — same vocabulary as the dashboard form renderer:
91
104
  |-----------|------------------------------------|-----------------------------------------|
92
105
  | `secret` | KMS-encrypted (`encryptedAccessToken`) | Joined into a JSON bundle by services/connect at write time |
93
106
  | `string` | `providerMetadata.{key}` | Plain string |
94
- | `url` | `providerMetadata.{key}` | Plain string; URL validation client-side |
107
+ | `url` | `providerMetadata.{key}` | Plain string; URL validation on client and backend |
95
108
  | `number` | `providerMetadata.{key}` | Stored as JS number |
96
109
  | `boolean` | `providerMetadata.{key}` | Stored as JS boolean |
97
110
 
package/dist/index.d.ts CHANGED
@@ -5,8 +5,9 @@ import { z } from "zod";
5
5
  /**
6
6
  * Connect provider ids that a manifest's `requires_connection` field may
7
7
  * reference. The source of truth lives in `services/connect/src/providers/`
8
- * (one `ProviderDefinition` per id) — this list mirrors that registry so
9
- * the manifest schema can validate references statically.
8
+ * (one `ProviderDefinition` per id) — this list mirrors that registry for
9
+ * authoring, lint, and dashboard tooling. The runtime parser deliberately
10
+ * accepts unknown provider ids for forward compatibility (see below).
10
11
  *
11
12
  * **Keep in sync** with `services/connect/src/providers/index.ts`. Adding a
12
13
  * new connect provider requires updating both: add the provider definition
@@ -38,10 +39,11 @@ type KnownConnectProviderId = (typeof KNOWN_CONNECT_PROVIDER_IDS)[number];
38
39
  * The enum is locked — channel adapters and the dashboard's compatibility
39
40
  * matrix both depend on this exact list.
40
41
  */
41
- declare const LOCKED_CHANNEL_TYPES: readonly ["whatsapp", "sms", "voice", "google_chat", "slack", "discord", "teams"];
42
+ declare const LOCKED_CHANNEL_TYPES: readonly ["whatsapp", "mobile", "sms", "voice", "google_chat", "slack", "discord", "teams"];
42
43
  type ChannelType = (typeof LOCKED_CHANNEL_TYPES)[number];
43
44
  declare const ChannelTypeSchema: z.ZodEnum<{
44
45
  discord: "discord";
46
+ mobile: "mobile";
45
47
  slack: "slack";
46
48
  whatsapp: "whatsapp";
47
49
  sms: "sms";
@@ -79,7 +81,7 @@ declare const ConfigSchemaFieldSchema: z.ZodObject<{
79
81
  label: z.ZodString;
80
82
  description: z.ZodOptional<z.ZodString>;
81
83
  required: z.ZodDefault<z.ZodBoolean>;
82
- default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
84
+ default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
83
85
  editable: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
84
86
  admin: "admin";
85
87
  agent: "agent";
@@ -91,6 +93,8 @@ declare const ConfigSchemaFieldSchema: z.ZodObject<{
91
93
  label: z.ZodString;
92
94
  }, z.core.$strip>>>;
93
95
  oauth_provider: z.ZodOptional<z.ZodString>;
96
+ oauth_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
97
+ oauth_integration_id: z.ZodOptional<z.ZodString>;
94
98
  depends_on_field: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
95
99
  key: z.ZodString;
96
100
  value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
@@ -240,7 +244,7 @@ declare const IntegrationManifestSchema: z.ZodObject<{
240
244
  label: z.ZodString;
241
245
  description: z.ZodOptional<z.ZodString>;
242
246
  required: z.ZodDefault<z.ZodBoolean>;
243
- default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
247
+ default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
244
248
  editable: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
245
249
  admin: "admin";
246
250
  agent: "agent";
@@ -252,6 +256,8 @@ declare const IntegrationManifestSchema: z.ZodObject<{
252
256
  label: z.ZodString;
253
257
  }, z.core.$strip>>>;
254
258
  oauth_provider: z.ZodOptional<z.ZodString>;
259
+ oauth_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
260
+ oauth_integration_id: z.ZodOptional<z.ZodString>;
255
261
  depends_on_field: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
256
262
  key: z.ZodString;
257
263
  value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
@@ -355,7 +361,7 @@ interface ConfigSchemaField {
355
361
  label: string;
356
362
  description?: string;
357
363
  required: boolean;
358
- default?: string | number | boolean;
364
+ default?: string | number | boolean | string[];
359
365
  /** Who can mutate this field at runtime. Default: 'admin' */
360
366
  editable: 'admin' | 'agent';
361
367
  /** Only used when type === 'enum' */
@@ -516,7 +522,7 @@ interface IntegrationManifest {
516
522
  * Default: ['agent'] (per-agent only).
517
523
  * 'org' means it can be installed at the org level and cascades to all agents.
518
524
  */
519
- supported_scopes?: ('agent' | 'org')[];
525
+ supported_scopes?: ('agent' | 'org' | 'team' | 'project')[];
520
526
  /**
521
527
  * Connect provider ids whose credentials this integration resolves at
522
528
  * runtime. OR-semantics: any one in the list satisfies activation. See
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { parse } from "yaml";
4
+ import { posix } from "node:path";
4
5
  //#region src/schema.ts
5
6
  /**
6
7
  * Zod validation schema for the Alfe integration manifest.
@@ -9,11 +10,54 @@ import { parse } from "yaml";
9
10
  * Used by the parser and by the lifecycle manager to ensure manifest
10
11
  * correctness before installation.
11
12
  */
13
+ const IDENTIFIER_RE = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
14
+ const INTEGRATION_ID_RE = /^[a-z0-9][a-z0-9-]*$/;
15
+ const UNSAFE_RECORD_KEYS = new Set([
16
+ "__proto__",
17
+ "constructor",
18
+ "prototype"
19
+ ]);
20
+ const HttpsUrlSchema = z.url().refine((value) => {
21
+ const url = new URL(value);
22
+ return url.protocol === "https:" && url.username === "" && url.password === "";
23
+ }, "URL must use HTTPS and must not contain embedded credentials");
24
+ /**
25
+ * Repository-owned file references are portable relative paths. They may use
26
+ * `./`, but may not be absolute or walk above the integration checkout.
27
+ * Consumers still need realpath checks before filesystem I/O because a cloned
28
+ * repository can contain symlinks.
29
+ */
30
+ const RepositoryRelativePathSchema = z.string().min(1, "Path must not be empty").refine((value) => {
31
+ if (value.includes("\0")) return false;
32
+ const portable = value.replaceAll("\\", "/");
33
+ if (portable.startsWith("/") || /^[a-zA-Z]:\//.test(portable)) return false;
34
+ let depth = 0;
35
+ for (const segment of portable.split("/")) {
36
+ if (segment === "" || segment === ".") continue;
37
+ if (segment === "..") {
38
+ depth -= 1;
39
+ if (depth < 0) return false;
40
+ } else depth += 1;
41
+ }
42
+ return depth > 0;
43
+ }, "Path must stay within the integration directory");
44
+ function addDuplicateIssues(values, ctx, path, label) {
45
+ const seen = /* @__PURE__ */ new Set();
46
+ for (const [index, value] of values.entries()) {
47
+ if (seen.has(value)) ctx.addIssue({
48
+ code: "custom",
49
+ path: [...path, index],
50
+ message: `Duplicate ${label}: ${value}`
51
+ });
52
+ seen.add(value);
53
+ }
54
+ }
12
55
  /**
13
56
  * Connect provider ids that a manifest's `requires_connection` field may
14
57
  * reference. The source of truth lives in `services/connect/src/providers/`
15
- * (one `ProviderDefinition` per id) — this list mirrors that registry so
16
- * the manifest schema can validate references statically.
58
+ * (one `ProviderDefinition` per id) — this list mirrors that registry for
59
+ * authoring, lint, and dashboard tooling. The runtime parser deliberately
60
+ * accepts unknown provider ids for forward compatibility (see below).
17
61
  *
18
62
  * **Keep in sync** with `services/connect/src/providers/index.ts`. Adding a
19
63
  * new connect provider requires updating both: add the provider definition
@@ -63,6 +107,7 @@ const KNOWN_CONNECT_PROVIDER_IDS = [
63
107
  */
64
108
  const LOCKED_CHANNEL_TYPES = [
65
109
  "whatsapp",
110
+ "mobile",
66
111
  "sms",
67
112
  "voice",
68
113
  "google_chat",
@@ -87,7 +132,7 @@ const SelectOptionSchema = z.object({
87
132
  label: z.string().min(1)
88
133
  });
89
134
  const ConfigSchemaFieldSchema = z.object({
90
- key: z.string().min(1, "Config key must not be empty"),
135
+ key: z.string().min(1, "Config key must not be empty").regex(IDENTIFIER_RE, "Config key must be a valid identifier").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Config key must be a valid identifier"),
91
136
  type: ConfigFieldTypeSchema,
92
137
  label: z.string().min(1, "Config label must not be empty"),
93
138
  description: z.string().optional(),
@@ -95,15 +140,18 @@ const ConfigSchemaFieldSchema = z.object({
95
140
  default: z.union([
96
141
  z.string(),
97
142
  z.number(),
98
- z.boolean()
143
+ z.boolean(),
144
+ z.array(z.string())
99
145
  ]).optional(),
100
146
  editable: z.enum(["admin", "agent"]).optional().default("admin"),
101
147
  hidden: z.boolean().optional(),
102
- options: z.array(z.string()).optional(),
148
+ options: z.array(z.string().min(1, "Enum option must not be empty")).optional(),
103
149
  select_options: z.array(SelectOptionSchema).optional(),
104
150
  oauth_provider: z.string().optional(),
105
- depends_on_field: z.union([z.string(), z.object({
106
- key: z.string().min(1),
151
+ oauth_scopes: z.array(z.string().min(1, "OAuth scope must not be empty")).optional(),
152
+ oauth_integration_id: z.string().regex(INTEGRATION_ID_RE, "OAuth integration id must be a valid integration id").refine((id) => !UNSAFE_RECORD_KEYS.has(id), "OAuth integration id is reserved").optional(),
153
+ depends_on_field: z.union([z.string().regex(IDENTIFIER_RE, "Dependency key must be a valid identifier").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Dependency key must be a valid identifier"), z.object({
154
+ key: z.string().regex(IDENTIFIER_RE, "Dependency key must be a valid identifier").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Dependency key must be a valid identifier"),
107
155
  value: z.union([
108
156
  z.string(),
109
157
  z.number(),
@@ -120,7 +168,47 @@ const ConfigSchemaFieldSchema = z.object({
120
168
  }, { message: "Select/multi_select fields must have at least one option in select_options" }).refine((field) => {
121
169
  if (field.type === "oauth_connect") return field.oauth_provider !== void 0 && field.oauth_provider.length > 0;
122
170
  return true;
123
- }, { message: "oauth_connect fields must specify an oauth_provider" });
171
+ }, { message: "oauth_connect fields must specify an oauth_provider" }).superRefine((field, ctx) => {
172
+ if (field.options) addDuplicateIssues(field.options, ctx, ["options"], "enum option");
173
+ if (field.select_options) addDuplicateIssues(field.select_options.map((option) => option.value), ctx, ["select_options"], "select option value");
174
+ if (field.oauth_scopes) addDuplicateIssues(field.oauth_scopes, ctx, ["oauth_scopes"], "OAuth scope");
175
+ if (field.type !== "oauth_connect" && (field.oauth_scopes || field.oauth_integration_id)) ctx.addIssue({
176
+ code: "custom",
177
+ path: ["type"],
178
+ message: "OAuth scope and integration metadata is only valid on oauth_connect fields"
179
+ });
180
+ if (field.default === void 0) return;
181
+ let valid = false;
182
+ switch (field.type) {
183
+ case "string":
184
+ valid = typeof field.default === "string";
185
+ break;
186
+ case "secret":
187
+ case "oauth_connect":
188
+ valid = false;
189
+ break;
190
+ case "number":
191
+ valid = typeof field.default === "number";
192
+ break;
193
+ case "boolean":
194
+ valid = typeof field.default === "boolean";
195
+ break;
196
+ case "enum":
197
+ valid = typeof field.default === "string" && (field.options?.includes(field.default) ?? false);
198
+ break;
199
+ case "select":
200
+ valid = typeof field.default === "string" && (field.select_options?.some((option) => option.value === field.default) ?? false);
201
+ break;
202
+ case "multi_select":
203
+ valid = Array.isArray(field.default) && field.default.every((value) => field.select_options?.some((option) => option.value === value));
204
+ break;
205
+ }
206
+ if (!valid) ctx.addIssue({
207
+ code: "custom",
208
+ path: ["default"],
209
+ message: `Default value is invalid for config field type ${field.type}`
210
+ });
211
+ });
124
212
  /**
125
213
  * A field a manifest's owner expects the user to fill in when creating
126
214
  * a Custom Connection. Used ONLY by manifests authored against the
@@ -141,7 +229,7 @@ const CredentialFieldTypeSchema = z.enum([
141
229
  "boolean"
142
230
  ]);
143
231
  const CredentialFieldSpecSchema = z.object({
144
- key: z.string().min(1, "Credential field key must not be empty").regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Credential field key must be a valid identifier (letters, digits, underscores; not starting with a digit)"),
232
+ key: z.string().min(1, "Credential field key must not be empty").regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/, "Credential field key must be a valid identifier (letters, digits, underscores; not starting with a digit)").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Credential field key must be a valid identifier"),
145
233
  type: CredentialFieldTypeSchema,
146
234
  label: z.string().min(1, "Credential field label must not be empty"),
147
235
  description: z.string().optional(),
@@ -150,25 +238,25 @@ const CredentialFieldSpecSchema = z.object({
150
238
  pattern: z.string().max(256).optional()
151
239
  });
152
240
  const McpServerDeclarationSchema = z.object({
153
- id: z.string().min(1, "MCP server id must not be empty").regex(/^[a-z0-9][a-z0-9-]*$/, "MCP server id must be lowercase alphanumeric with hyphens"),
241
+ id: z.string().min(1, "MCP server id must not be empty").regex(INTEGRATION_ID_RE, "MCP server id must be lowercase alphanumeric with hyphens").refine((id) => !UNSAFE_RECORD_KEYS.has(id), "MCP server id is reserved"),
154
242
  command: z.string().min(1, "MCP server command must not be empty"),
155
243
  args: z.array(z.string()).optional().default([]),
156
- env: z.record(z.string(), z.string()).optional(),
244
+ env: z.record(z.string().regex(IDENTIFIER_RE, "MCP environment keys must be valid identifiers").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "MCP environment keys must be valid identifiers"), z.string()).optional(),
157
245
  cwd: z.string().optional(),
158
246
  requires_credentials: z.string().min(1).optional(),
159
247
  hook_managed: z.boolean().optional().default(false)
160
248
  });
161
249
  const CommandDeclarationSchema = z.object({
162
250
  name: z.string().min(1, "Command name must not be empty").regex(/^[a-z][a-z0-9]*\.[a-z][a-z0-9_]*$/, "Command name must be dot-namespaced (e.g. \"support.diagnostic\")"),
163
- handler: z.string().min(1, "Handler path must not be empty"),
164
- method: z.string().optional().default("handle"),
165
- timeout_ms: z.number().int().positive().optional().default(3e4),
251
+ handler: RepositoryRelativePathSchema,
252
+ method: z.string().regex(IDENTIFIER_RE, "Handler method must be a valid identifier").refine((method) => !UNSAFE_RECORD_KEYS.has(method), "Handler method must be a valid identifier").optional().default("handle"),
253
+ timeout_ms: z.number().int().positive().max(1800 * 1e3).optional().default(3e4),
166
254
  description: z.string().optional()
167
255
  });
168
256
  const SkillInstallSchema = z.object({
169
- path: z.string().min(1, "Skill path must not be empty").optional(),
257
+ path: RepositoryRelativePathSchema.optional(),
170
258
  clawhub: z.string().min(1, "ClawHub skill slug must not be empty").optional()
171
- }).refine((data) => data.path ?? data.clawhub, { message: "Skill must have either a path or clawhub slug" });
259
+ }).refine((data) => Number(data.path !== void 0) + Number(data.clawhub !== void 0) === 1, { message: "Skill must have exactly one of path or clawhub slug" });
172
260
  const PluginInstallSchema = z.object({ package: z.string().min(1, "Plugin package name must not be empty") });
173
261
  /** Per-runtime install overrides */
174
262
  const RuntimeInstallSchema = z.object({
@@ -177,19 +265,19 @@ const RuntimeInstallSchema = z.object({
177
265
  config: z.record(z.string(), z.unknown()).optional()
178
266
  });
179
267
  /** Runtime key: lowercase alphanumeric + hyphens */
180
- const RuntimeKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Runtime key must be lowercase alphanumeric with hyphens");
268
+ const RuntimeKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Runtime key must be lowercase alphanumeric with hyphens").refine((key) => !UNSAFE_RECORD_KEYS.has(key), "Runtime key is reserved");
181
269
  const InstallTargetsSchema = z.object({
182
270
  skills: z.array(SkillInstallSchema).optional().default([]),
183
271
  plugins: z.array(PluginInstallSchema).optional().default([]),
184
272
  runtimes: z.record(RuntimeKeySchema, RuntimeInstallSchema).optional()
185
273
  });
186
274
  const IntegrationHooksSchema = z.object({
187
- pre_install: z.string().optional(),
188
- post_install: z.string().optional(),
189
- post_activate: z.string().optional(),
190
- pre_uninstall: z.string().optional(),
191
- post_uninstall: z.string().optional(),
192
- health_check: z.string().optional()
275
+ pre_install: RepositoryRelativePathSchema.optional(),
276
+ post_install: RepositoryRelativePathSchema.optional(),
277
+ post_activate: RepositoryRelativePathSchema.optional(),
278
+ pre_uninstall: RepositoryRelativePathSchema.optional(),
279
+ post_uninstall: RepositoryRelativePathSchema.optional(),
280
+ health_check: RepositoryRelativePathSchema.optional()
193
281
  });
194
282
  const IntegrationPricingPlanSchema = z.object({
195
283
  name: z.string().min(1),
@@ -222,13 +310,13 @@ const IntegrationAuthorSchema = z.union([z.string().min(1, "Author must not be e
222
310
  })]);
223
311
  const SemverSchema = z.string().regex(/^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/, "Must be a valid semver version (e.g. 1.0.0)");
224
312
  const IntegrationManifestSchema = z.object({
225
- id: z.string().min(1, "Integration id must not be empty").regex(/^[a-z0-9][a-z0-9-]*$/, "id must be lowercase alphanumeric with hyphens"),
313
+ id: z.string().min(1, "Integration id must not be empty").regex(INTEGRATION_ID_RE, "id must be lowercase alphanumeric with hyphens").refine((id) => !UNSAFE_RECORD_KEYS.has(id), "Integration id is reserved"),
226
314
  name: z.string().min(1, "Integration name must not be empty"),
227
315
  version: SemverSchema,
228
316
  description: z.string().min(1, "Description must not be empty"),
229
317
  author: IntegrationAuthorSchema,
230
318
  license: z.string().default("MIT"),
231
- depends_on: z.array(z.string()).optional().default([]),
319
+ depends_on: z.array(z.string().regex(INTEGRATION_ID_RE, "Dependency must be a valid integration id").refine((id) => !UNSAFE_RECORD_KEYS.has(id), "Dependency id is reserved")).optional().default([]),
232
320
  min_gateway_version: SemverSchema.optional().default("0.1.0"),
233
321
  installs: InstallTargetsSchema.optional().default({
234
322
  skills: [],
@@ -242,7 +330,7 @@ const IntegrationManifestSchema = z.object({
242
330
  requires_connection: z.array(z.string().min(1)).optional(),
243
331
  channel_type: z.string().min(1).optional(),
244
332
  expected_credentials: z.array(CredentialFieldSpecSchema).optional(),
245
- repository: z.url().optional(),
333
+ repository: HttpsUrlSchema.optional(),
246
334
  supported_agents: z.array(RuntimeKeySchema).optional(),
247
335
  supported_scopes: z.array(z.enum([
248
336
  "agent",
@@ -255,14 +343,85 @@ const IntegrationManifestSchema = z.object({
255
343
  pricing: IntegrationPricingSchema.optional(),
256
344
  preview_images: z.array(z.string()).optional(),
257
345
  features: z.array(z.string()).optional()
346
+ }).superRefine((manifest, ctx) => {
347
+ addDuplicateIssues(manifest.depends_on, ctx, ["depends_on"], "dependency");
348
+ addDuplicateIssues(manifest.config_schema.map((field) => field.key), ctx, ["config_schema"], "config key");
349
+ addDuplicateIssues(manifest.commands.map((command) => command.name), ctx, ["commands"], "command name");
350
+ addDuplicateIssues(manifest.mcp_servers.map((server) => server.id), ctx, ["mcp_servers"], "MCP server id");
351
+ addDuplicateIssues(manifest.capabilities, ctx, ["capabilities"], "capability");
352
+ addDuplicateIssues(manifest.requires_connection ?? [], ctx, ["requires_connection"], "connection requirement");
353
+ addDuplicateIssues(manifest.supported_agents ?? [], ctx, ["supported_agents"], "supported agent");
354
+ addDuplicateIssues(manifest.supported_scopes, ctx, ["supported_scopes"], "supported scope");
355
+ if (manifest.depends_on.includes(manifest.id)) ctx.addIssue({
356
+ code: "custom",
357
+ path: ["depends_on"],
358
+ message: "An integration cannot depend on itself"
359
+ });
360
+ const configKeys = new Set(manifest.config_schema.map((field) => field.key));
361
+ for (const [index, field] of manifest.config_schema.entries()) {
362
+ if (!field.depends_on_field) continue;
363
+ const dependencyKey = typeof field.depends_on_field === "string" ? field.depends_on_field : field.depends_on_field.key;
364
+ if (!configKeys.has(dependencyKey)) ctx.addIssue({
365
+ code: "custom",
366
+ path: [
367
+ "config_schema",
368
+ index,
369
+ "depends_on_field"
370
+ ],
371
+ message: `Config dependency does not exist: ${dependencyKey}`
372
+ });
373
+ else if (dependencyKey === field.key) ctx.addIssue({
374
+ code: "custom",
375
+ path: [
376
+ "config_schema",
377
+ index,
378
+ "depends_on_field"
379
+ ],
380
+ message: "A config field cannot depend on itself"
381
+ });
382
+ }
383
+ if (manifest.expected_credentials) {
384
+ addDuplicateIssues(manifest.expected_credentials.map((field) => field.key), ctx, ["expected_credentials"], "credential key");
385
+ if (!manifest.requires_connection?.includes("custom")) ctx.addIssue({
386
+ code: "custom",
387
+ path: ["expected_credentials"],
388
+ message: "expected_credentials requires requires_connection to include custom"
389
+ });
390
+ }
391
+ const validateInstallDuplicates = (install, path) => {
392
+ addDuplicateIssues(install.plugins.map((plugin) => plugin.package), ctx, [...path, "plugins"], "plugin package");
393
+ addDuplicateIssues(install.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`), ctx, [...path, "skills"], "skill source");
394
+ };
395
+ validateInstallDuplicates(manifest.installs, ["installs"]);
396
+ for (const [runtime, install] of Object.entries(manifest.installs.runtimes ?? {})) {
397
+ validateInstallDuplicates(install, [
398
+ "installs",
399
+ "runtimes",
400
+ runtime
401
+ ]);
402
+ addDuplicateIssues([...manifest.installs.plugins.map((plugin) => plugin.package), ...install.plugins.map((plugin) => plugin.package)], ctx, [
403
+ "installs",
404
+ "runtimes",
405
+ runtime,
406
+ "plugins"
407
+ ], "effective plugin package");
408
+ addDuplicateIssues([...manifest.installs.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`), ...install.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`)], ctx, [
409
+ "installs",
410
+ "runtimes",
411
+ runtime,
412
+ "skills"
413
+ ], "effective skill source");
414
+ }
258
415
  });
259
416
  /**
260
417
  * Validate user-provided config values against a manifest's config_schema.
261
418
  * Returns a Zod schema dynamically built from the manifest's config_schema.
262
419
  */
263
420
  function buildConfigValidationSchema(configSchema) {
264
- const shape = {};
421
+ const shape = Object.create(null);
265
422
  for (const field of configSchema) {
423
+ if (!IDENTIFIER_RE.test(field.key) || UNSAFE_RECORD_KEYS.has(field.key)) throw new Error(`Invalid config schema key: ${field.key}`);
424
+ if (Object.hasOwn(shape, field.key)) throw new Error(`Duplicate config schema key: ${field.key}`);
266
425
  let fieldSchema;
267
426
  switch (field.type) {
268
427
  case "secret":
@@ -300,7 +459,7 @@ function buildConfigValidationSchema(configSchema) {
300
459
  if (!field.required) fieldSchema = fieldSchema.optional();
301
460
  shape[field.key] = fieldSchema;
302
461
  }
303
- return z.object(shape);
462
+ return z.object(shape).strict();
304
463
  }
305
464
  //#endregion
306
465
  //#region src/parser.ts
@@ -408,11 +567,23 @@ function isRelativePath(path) {
408
567
  */
409
568
  function resolveAssetUrl(assetPath, repoUrl, commitHash, subdir) {
410
569
  if (!isRelativePath(assetPath)) return assetPath;
411
- const repoMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)/.exec(repoUrl);
412
- if (!repoMatch) return assetPath;
413
- const [, owner, repo] = repoMatch;
414
- const normalizedPath = assetPath.replace(/^\.\//, "");
415
- return `https://raw.githubusercontent.com/${owner}/${repo}/${commitHash}/${subdir ? `${subdir.replace(/\/$/, "")}/${normalizedPath}` : normalizedPath}`;
570
+ let repository;
571
+ try {
572
+ repository = new URL(repoUrl);
573
+ } catch {
574
+ return assetPath;
575
+ }
576
+ const repoParts = repository.pathname.split("/").filter(Boolean);
577
+ const owner = repoParts[0];
578
+ const repo = repoParts[1]?.replace(/\.git$/, "");
579
+ if (!["http:", "https:"].includes(repository.protocol) || repository.hostname.toLowerCase() !== "github.com" || repository.username !== "" || repository.password !== "" || repository.port !== "" || repository.search !== "" || repository.hash !== "" || repoParts.length !== 2 || !owner || !repo || !/^[A-Za-z0-9_.-]+$/.test(owner) || !/^[A-Za-z0-9_.-]+$/.test(repo)) return assetPath;
580
+ const portableSubdir = subdir.replaceAll("\\", "/");
581
+ const portableAssetPath = assetPath.replaceAll("\\", "/");
582
+ if (portableSubdir.startsWith("/") || portableAssetPath.startsWith("/") || /^[A-Za-z]:\//.test(portableSubdir) || /^[A-Za-z]:\//.test(portableAssetPath)) return assetPath;
583
+ const fullPath = posix.normalize(posix.join(portableSubdir, portableAssetPath));
584
+ if (fullPath === "." || fullPath === ".." || fullPath.startsWith("../")) return assetPath;
585
+ const encodedPath = fullPath.split("/").map(encodeURIComponent).join("/");
586
+ return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(commitHash)}/${encodedPath}`;
416
587
  }
417
588
  //#endregion
418
589
  //#region src/interpolation.ts
@@ -432,14 +603,33 @@ const TEMPLATE_RE = /\{\{integration\.([a-z0-9-]+)\.([a-z0-9_]+)\}\}/g;
432
603
  */
433
604
  function interpolateConfig(config, ctx) {
434
605
  const result = {};
435
- for (const [key, value] of Object.entries(config)) if (typeof value === "string") result[key] = value.replace(TEMPLATE_RE, (_match, integrationId, field) => {
436
- const integrationConfig = ctx.integrations[integrationId];
437
- if (!integrationConfig) return _match;
438
- const resolved = integrationConfig[field];
439
- if (resolved == null) return _match;
440
- return typeof resolved === "string" ? resolved : JSON.stringify(resolved);
606
+ for (const [key, value] of Object.entries(config)) if (typeof value === "string") {
607
+ const interpolated = value.replace(TEMPLATE_RE, (_match, integrationId, field) => {
608
+ if (!Object.hasOwn(ctx.integrations, integrationId)) return _match;
609
+ const integrationConfig = ctx.integrations[integrationId];
610
+ if (!integrationConfig || !Object.hasOwn(integrationConfig, field)) return _match;
611
+ const resolved = integrationConfig[field];
612
+ if (resolved == null) return _match;
613
+ if (typeof resolved === "string") return resolved;
614
+ try {
615
+ const serialized = JSON.stringify(resolved);
616
+ return typeof serialized === "string" ? serialized : _match;
617
+ } catch {
618
+ return _match;
619
+ }
620
+ });
621
+ Object.defineProperty(result, key, {
622
+ configurable: true,
623
+ enumerable: true,
624
+ value: interpolated,
625
+ writable: true
626
+ });
627
+ } else Object.defineProperty(result, key, {
628
+ configurable: true,
629
+ enumerable: true,
630
+ value,
631
+ writable: true
441
632
  });
442
- else result[key] = value;
443
633
  return result;
444
634
  }
445
635
  /**
@@ -461,12 +651,38 @@ function extractTemplateReferences(config) {
461
651
  }
462
652
  //#endregion
463
653
  //#region src/migration.ts
654
+ function schemaByKey(fields, label) {
655
+ const result = /* @__PURE__ */ new Map();
656
+ for (const field of fields) {
657
+ if (result.has(field.key)) throw new Error(`Duplicate ${label} config schema key: ${field.key}`);
658
+ result.set(field.key, field);
659
+ }
660
+ return result;
661
+ }
662
+ function setOwn(target, key, value) {
663
+ Object.defineProperty(target, key, {
664
+ configurable: true,
665
+ enumerable: true,
666
+ value,
667
+ writable: true
668
+ });
669
+ }
670
+ function isValidFieldValue(field, value) {
671
+ try {
672
+ return buildConfigValidationSchema([{
673
+ ...field,
674
+ required: true
675
+ }]).safeParse({ [field.key]: value }).success;
676
+ } catch {
677
+ return false;
678
+ }
679
+ }
464
680
  /**
465
681
  * Diff two config schemas to determine what changed between versions.
466
682
  */
467
683
  function diffConfigSchemas(oldSchema, newSchema) {
468
- const oldMap = new Map(oldSchema.map((f) => [f.key, f]));
469
- const newMap = new Map(newSchema.map((f) => [f.key, f]));
684
+ const oldMap = schemaByKey(oldSchema, "old");
685
+ const newMap = schemaByKey(newSchema, "new");
470
686
  const added = [];
471
687
  const removed = [];
472
688
  const changed = [];
@@ -501,12 +717,20 @@ function diffConfigSchemas(oldSchema, newSchema) {
501
717
  function migrateConfig(currentConfig, diff) {
502
718
  const config = {};
503
719
  const warnings = [];
504
- for (const field of diff.unchanged) if (field.key in currentConfig) config[field.key] = currentConfig[field.key];
720
+ for (const field of diff.unchanged) if (Object.hasOwn(currentConfig, field.key)) setOwn(config, field.key, currentConfig[field.key]);
505
721
  for (const change of diff.changed) if (change.breaking) {
506
- if (change.newSchema.default !== void 0) config[change.field] = change.newSchema.default;
722
+ if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
507
723
  warnings.push(`Field "${change.field}" type changed from "${change.oldSchema.type}" to "${change.newSchema.type}" — value reset`);
508
- } else if (change.field in currentConfig) config[change.field] = currentConfig[change.field];
509
- for (const field of diff.added) if (field.default !== void 0) config[field.key] = field.default;
724
+ } else if (Object.hasOwn(currentConfig, change.field)) {
725
+ const currentValue = currentConfig[change.field];
726
+ if (isValidFieldValue(change.newSchema, currentValue)) setOwn(config, change.field, currentValue);
727
+ else {
728
+ if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
729
+ warnings.push(`Field "${change.field}" no longer accepts its existing value — value reset`);
730
+ }
731
+ } else if (change.newSchema.required) if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
732
+ else warnings.push(`Field "${change.field}" is now required and has no default — user input needed`);
733
+ for (const field of diff.added) if (field.default !== void 0) setOwn(config, field.key, field.default);
510
734
  else if (field.required) warnings.push(`New required field "${field.key}" has no default — user input needed`);
511
735
  return {
512
736
  config,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integration-manifest",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Integration manifest schema, types, and parser for Alfe integration platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",