@alfe.ai/integration-manifest 0.3.3 → 0.3.5

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
@@ -15,7 +16,7 @@ import { z } from "zod";
15
16
  * caught at runtime when the integrations service tries to resolve the
16
17
  * provider and gets nothing back.
17
18
  */
18
- declare const KNOWN_CONNECT_PROVIDER_IDS: readonly ["atlassian", "bluesky", "ctrader", "custom", "discord", "github", "google", "microsoft", "mobile", "myob", "notion", "salesforce", "slack", "xero"];
19
+ declare const KNOWN_CONNECT_PROVIDER_IDS: readonly ["atlassian", "bluesky", "ctrader", "custom", "discord", "github", "google", "microsoft", "mobile", "myob", "notion", "salesforce", "shopify", "slack", "x", "xero"];
19
20
  type KnownConnectProviderId = (typeof KNOWN_CONNECT_PROVIDER_IDS)[number];
20
21
  /**
21
22
  * `custom` is a special provider id: it doesn't correspond to a fixed
@@ -79,7 +80,7 @@ declare const ConfigSchemaFieldSchema: z.ZodObject<{
79
80
  label: z.ZodString;
80
81
  description: z.ZodOptional<z.ZodString>;
81
82
  required: z.ZodDefault<z.ZodBoolean>;
82
- default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
83
+ default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
83
84
  editable: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
84
85
  admin: "admin";
85
86
  agent: "agent";
@@ -91,6 +92,8 @@ declare const ConfigSchemaFieldSchema: z.ZodObject<{
91
92
  label: z.ZodString;
92
93
  }, z.core.$strip>>>;
93
94
  oauth_provider: z.ZodOptional<z.ZodString>;
95
+ oauth_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
96
+ oauth_integration_id: z.ZodOptional<z.ZodString>;
94
97
  depends_on_field: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
95
98
  key: z.ZodString;
96
99
  value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
@@ -240,7 +243,7 @@ declare const IntegrationManifestSchema: z.ZodObject<{
240
243
  label: z.ZodString;
241
244
  description: z.ZodOptional<z.ZodString>;
242
245
  required: z.ZodDefault<z.ZodBoolean>;
243
- default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>>;
246
+ default: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodArray<z.ZodString>]>>;
244
247
  editable: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
245
248
  admin: "admin";
246
249
  agent: "agent";
@@ -252,6 +255,8 @@ declare const IntegrationManifestSchema: z.ZodObject<{
252
255
  label: z.ZodString;
253
256
  }, z.core.$strip>>>;
254
257
  oauth_provider: z.ZodOptional<z.ZodString>;
258
+ oauth_scopes: z.ZodOptional<z.ZodArray<z.ZodString>>;
259
+ oauth_integration_id: z.ZodOptional<z.ZodString>;
255
260
  depends_on_field: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
256
261
  key: z.ZodString;
257
262
  value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean]>;
@@ -355,7 +360,7 @@ interface ConfigSchemaField {
355
360
  label: string;
356
361
  description?: string;
357
362
  required: boolean;
358
- default?: string | number | boolean;
363
+ default?: string | number | boolean | string[];
359
364
  /** Who can mutate this field at runtime. Default: 'admin' */
360
365
  editable: 'admin' | 'agent';
361
366
  /** Only used when type === 'enum' */
@@ -516,7 +521,7 @@ interface IntegrationManifest {
516
521
  * Default: ['agent'] (per-agent only).
517
522
  * 'org' means it can be installed at the org level and cascades to all agents.
518
523
  */
519
- supported_scopes?: ('agent' | 'org')[];
524
+ supported_scopes?: ('agent' | 'org' | 'team' | 'project')[];
520
525
  /**
521
526
  * Connect provider ids whose credentials this integration resolves at
522
527
  * 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
@@ -35,7 +79,9 @@ const KNOWN_CONNECT_PROVIDER_IDS = [
35
79
  "myob",
36
80
  "notion",
37
81
  "salesforce",
82
+ "shopify",
38
83
  "slack",
84
+ "x",
39
85
  "xero"
40
86
  ];
41
87
  /**
@@ -85,7 +131,7 @@ const SelectOptionSchema = z.object({
85
131
  label: z.string().min(1)
86
132
  });
87
133
  const ConfigSchemaFieldSchema = z.object({
88
- key: z.string().min(1, "Config key must not be empty"),
134
+ 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"),
89
135
  type: ConfigFieldTypeSchema,
90
136
  label: z.string().min(1, "Config label must not be empty"),
91
137
  description: z.string().optional(),
@@ -93,15 +139,18 @@ const ConfigSchemaFieldSchema = z.object({
93
139
  default: z.union([
94
140
  z.string(),
95
141
  z.number(),
96
- z.boolean()
142
+ z.boolean(),
143
+ z.array(z.string())
97
144
  ]).optional(),
98
145
  editable: z.enum(["admin", "agent"]).optional().default("admin"),
99
146
  hidden: z.boolean().optional(),
100
- options: z.array(z.string()).optional(),
147
+ options: z.array(z.string().min(1, "Enum option must not be empty")).optional(),
101
148
  select_options: z.array(SelectOptionSchema).optional(),
102
149
  oauth_provider: z.string().optional(),
103
- depends_on_field: z.union([z.string(), z.object({
104
- key: z.string().min(1),
150
+ oauth_scopes: z.array(z.string().min(1, "OAuth scope must not be empty")).optional(),
151
+ 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(),
152
+ 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({
153
+ 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"),
105
154
  value: z.union([
106
155
  z.string(),
107
156
  z.number(),
@@ -118,7 +167,47 @@ const ConfigSchemaFieldSchema = z.object({
118
167
  }, { message: "Select/multi_select fields must have at least one option in select_options" }).refine((field) => {
119
168
  if (field.type === "oauth_connect") return field.oauth_provider !== void 0 && field.oauth_provider.length > 0;
120
169
  return true;
121
- }, { message: "oauth_connect fields must specify an oauth_provider" });
170
+ }, { message: "oauth_connect fields must specify an oauth_provider" }).superRefine((field, ctx) => {
171
+ if (field.options) addDuplicateIssues(field.options, ctx, ["options"], "enum option");
172
+ if (field.select_options) addDuplicateIssues(field.select_options.map((option) => option.value), ctx, ["select_options"], "select option value");
173
+ if (field.oauth_scopes) addDuplicateIssues(field.oauth_scopes, ctx, ["oauth_scopes"], "OAuth scope");
174
+ if (field.type !== "oauth_connect" && (field.oauth_scopes || field.oauth_integration_id)) ctx.addIssue({
175
+ code: "custom",
176
+ path: ["type"],
177
+ message: "OAuth scope and integration metadata is only valid on oauth_connect fields"
178
+ });
179
+ if (field.default === void 0) return;
180
+ let valid = false;
181
+ switch (field.type) {
182
+ case "string":
183
+ valid = typeof field.default === "string";
184
+ break;
185
+ case "secret":
186
+ case "oauth_connect":
187
+ valid = false;
188
+ break;
189
+ case "number":
190
+ valid = typeof field.default === "number";
191
+ break;
192
+ case "boolean":
193
+ valid = typeof field.default === "boolean";
194
+ break;
195
+ case "enum":
196
+ valid = typeof field.default === "string" && (field.options?.includes(field.default) ?? false);
197
+ break;
198
+ case "select":
199
+ valid = typeof field.default === "string" && (field.select_options?.some((option) => option.value === field.default) ?? false);
200
+ break;
201
+ case "multi_select":
202
+ valid = Array.isArray(field.default) && field.default.every((value) => field.select_options?.some((option) => option.value === value));
203
+ break;
204
+ }
205
+ if (!valid) ctx.addIssue({
206
+ code: "custom",
207
+ path: ["default"],
208
+ message: `Default value is invalid for config field type ${field.type}`
209
+ });
210
+ });
122
211
  /**
123
212
  * A field a manifest's owner expects the user to fill in when creating
124
213
  * a Custom Connection. Used ONLY by manifests authored against the
@@ -139,7 +228,7 @@ const CredentialFieldTypeSchema = z.enum([
139
228
  "boolean"
140
229
  ]);
141
230
  const CredentialFieldSpecSchema = z.object({
142
- 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)"),
231
+ 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"),
143
232
  type: CredentialFieldTypeSchema,
144
233
  label: z.string().min(1, "Credential field label must not be empty"),
145
234
  description: z.string().optional(),
@@ -148,25 +237,25 @@ const CredentialFieldSpecSchema = z.object({
148
237
  pattern: z.string().max(256).optional()
149
238
  });
150
239
  const McpServerDeclarationSchema = z.object({
151
- 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"),
240
+ 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"),
152
241
  command: z.string().min(1, "MCP server command must not be empty"),
153
242
  args: z.array(z.string()).optional().default([]),
154
- env: z.record(z.string(), z.string()).optional(),
243
+ 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(),
155
244
  cwd: z.string().optional(),
156
245
  requires_credentials: z.string().min(1).optional(),
157
246
  hook_managed: z.boolean().optional().default(false)
158
247
  });
159
248
  const CommandDeclarationSchema = z.object({
160
249
  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\")"),
161
- handler: z.string().min(1, "Handler path must not be empty"),
162
- method: z.string().optional().default("handle"),
163
- timeout_ms: z.number().int().positive().optional().default(3e4),
250
+ handler: RepositoryRelativePathSchema,
251
+ 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"),
252
+ timeout_ms: z.number().int().positive().max(1800 * 1e3).optional().default(3e4),
164
253
  description: z.string().optional()
165
254
  });
166
255
  const SkillInstallSchema = z.object({
167
- path: z.string().min(1, "Skill path must not be empty").optional(),
256
+ path: RepositoryRelativePathSchema.optional(),
168
257
  clawhub: z.string().min(1, "ClawHub skill slug must not be empty").optional()
169
- }).refine((data) => data.path ?? data.clawhub, { message: "Skill must have either a path or clawhub slug" });
258
+ }).refine((data) => Number(data.path !== void 0) + Number(data.clawhub !== void 0) === 1, { message: "Skill must have exactly one of path or clawhub slug" });
170
259
  const PluginInstallSchema = z.object({ package: z.string().min(1, "Plugin package name must not be empty") });
171
260
  /** Per-runtime install overrides */
172
261
  const RuntimeInstallSchema = z.object({
@@ -175,19 +264,19 @@ const RuntimeInstallSchema = z.object({
175
264
  config: z.record(z.string(), z.unknown()).optional()
176
265
  });
177
266
  /** Runtime key: lowercase alphanumeric + hyphens */
178
- const RuntimeKeySchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "Runtime key must be lowercase alphanumeric with hyphens");
267
+ 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");
179
268
  const InstallTargetsSchema = z.object({
180
269
  skills: z.array(SkillInstallSchema).optional().default([]),
181
270
  plugins: z.array(PluginInstallSchema).optional().default([]),
182
271
  runtimes: z.record(RuntimeKeySchema, RuntimeInstallSchema).optional()
183
272
  });
184
273
  const IntegrationHooksSchema = z.object({
185
- pre_install: z.string().optional(),
186
- post_install: z.string().optional(),
187
- post_activate: z.string().optional(),
188
- pre_uninstall: z.string().optional(),
189
- post_uninstall: z.string().optional(),
190
- health_check: z.string().optional()
274
+ pre_install: RepositoryRelativePathSchema.optional(),
275
+ post_install: RepositoryRelativePathSchema.optional(),
276
+ post_activate: RepositoryRelativePathSchema.optional(),
277
+ pre_uninstall: RepositoryRelativePathSchema.optional(),
278
+ post_uninstall: RepositoryRelativePathSchema.optional(),
279
+ health_check: RepositoryRelativePathSchema.optional()
191
280
  });
192
281
  const IntegrationPricingPlanSchema = z.object({
193
282
  name: z.string().min(1),
@@ -220,13 +309,13 @@ const IntegrationAuthorSchema = z.union([z.string().min(1, "Author must not be e
220
309
  })]);
221
310
  const SemverSchema = z.string().regex(/^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/, "Must be a valid semver version (e.g. 1.0.0)");
222
311
  const IntegrationManifestSchema = z.object({
223
- 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"),
312
+ 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"),
224
313
  name: z.string().min(1, "Integration name must not be empty"),
225
314
  version: SemverSchema,
226
315
  description: z.string().min(1, "Description must not be empty"),
227
316
  author: IntegrationAuthorSchema,
228
317
  license: z.string().default("MIT"),
229
- depends_on: z.array(z.string()).optional().default([]),
318
+ 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([]),
230
319
  min_gateway_version: SemverSchema.optional().default("0.1.0"),
231
320
  installs: InstallTargetsSchema.optional().default({
232
321
  skills: [],
@@ -240,7 +329,7 @@ const IntegrationManifestSchema = z.object({
240
329
  requires_connection: z.array(z.string().min(1)).optional(),
241
330
  channel_type: z.string().min(1).optional(),
242
331
  expected_credentials: z.array(CredentialFieldSpecSchema).optional(),
243
- repository: z.url().optional(),
332
+ repository: HttpsUrlSchema.optional(),
244
333
  supported_agents: z.array(RuntimeKeySchema).optional(),
245
334
  supported_scopes: z.array(z.enum([
246
335
  "agent",
@@ -253,14 +342,85 @@ const IntegrationManifestSchema = z.object({
253
342
  pricing: IntegrationPricingSchema.optional(),
254
343
  preview_images: z.array(z.string()).optional(),
255
344
  features: z.array(z.string()).optional()
345
+ }).superRefine((manifest, ctx) => {
346
+ addDuplicateIssues(manifest.depends_on, ctx, ["depends_on"], "dependency");
347
+ addDuplicateIssues(manifest.config_schema.map((field) => field.key), ctx, ["config_schema"], "config key");
348
+ addDuplicateIssues(manifest.commands.map((command) => command.name), ctx, ["commands"], "command name");
349
+ addDuplicateIssues(manifest.mcp_servers.map((server) => server.id), ctx, ["mcp_servers"], "MCP server id");
350
+ addDuplicateIssues(manifest.capabilities, ctx, ["capabilities"], "capability");
351
+ addDuplicateIssues(manifest.requires_connection ?? [], ctx, ["requires_connection"], "connection requirement");
352
+ addDuplicateIssues(manifest.supported_agents ?? [], ctx, ["supported_agents"], "supported agent");
353
+ addDuplicateIssues(manifest.supported_scopes, ctx, ["supported_scopes"], "supported scope");
354
+ if (manifest.depends_on.includes(manifest.id)) ctx.addIssue({
355
+ code: "custom",
356
+ path: ["depends_on"],
357
+ message: "An integration cannot depend on itself"
358
+ });
359
+ const configKeys = new Set(manifest.config_schema.map((field) => field.key));
360
+ for (const [index, field] of manifest.config_schema.entries()) {
361
+ if (!field.depends_on_field) continue;
362
+ const dependencyKey = typeof field.depends_on_field === "string" ? field.depends_on_field : field.depends_on_field.key;
363
+ if (!configKeys.has(dependencyKey)) ctx.addIssue({
364
+ code: "custom",
365
+ path: [
366
+ "config_schema",
367
+ index,
368
+ "depends_on_field"
369
+ ],
370
+ message: `Config dependency does not exist: ${dependencyKey}`
371
+ });
372
+ else if (dependencyKey === field.key) ctx.addIssue({
373
+ code: "custom",
374
+ path: [
375
+ "config_schema",
376
+ index,
377
+ "depends_on_field"
378
+ ],
379
+ message: "A config field cannot depend on itself"
380
+ });
381
+ }
382
+ if (manifest.expected_credentials) {
383
+ addDuplicateIssues(manifest.expected_credentials.map((field) => field.key), ctx, ["expected_credentials"], "credential key");
384
+ if (!manifest.requires_connection?.includes("custom")) ctx.addIssue({
385
+ code: "custom",
386
+ path: ["expected_credentials"],
387
+ message: "expected_credentials requires requires_connection to include custom"
388
+ });
389
+ }
390
+ const validateInstallDuplicates = (install, path) => {
391
+ addDuplicateIssues(install.plugins.map((plugin) => plugin.package), ctx, [...path, "plugins"], "plugin package");
392
+ addDuplicateIssues(install.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`), ctx, [...path, "skills"], "skill source");
393
+ };
394
+ validateInstallDuplicates(manifest.installs, ["installs"]);
395
+ for (const [runtime, install] of Object.entries(manifest.installs.runtimes ?? {})) {
396
+ validateInstallDuplicates(install, [
397
+ "installs",
398
+ "runtimes",
399
+ runtime
400
+ ]);
401
+ addDuplicateIssues([...manifest.installs.plugins.map((plugin) => plugin.package), ...install.plugins.map((plugin) => plugin.package)], ctx, [
402
+ "installs",
403
+ "runtimes",
404
+ runtime,
405
+ "plugins"
406
+ ], "effective plugin package");
407
+ addDuplicateIssues([...manifest.installs.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`), ...install.skills.map((skill) => skill.path ?? `clawhub:${skill.clawhub ?? ""}`)], ctx, [
408
+ "installs",
409
+ "runtimes",
410
+ runtime,
411
+ "skills"
412
+ ], "effective skill source");
413
+ }
256
414
  });
257
415
  /**
258
416
  * Validate user-provided config values against a manifest's config_schema.
259
417
  * Returns a Zod schema dynamically built from the manifest's config_schema.
260
418
  */
261
419
  function buildConfigValidationSchema(configSchema) {
262
- const shape = {};
420
+ const shape = Object.create(null);
263
421
  for (const field of configSchema) {
422
+ if (!IDENTIFIER_RE.test(field.key) || UNSAFE_RECORD_KEYS.has(field.key)) throw new Error(`Invalid config schema key: ${field.key}`);
423
+ if (Object.hasOwn(shape, field.key)) throw new Error(`Duplicate config schema key: ${field.key}`);
264
424
  let fieldSchema;
265
425
  switch (field.type) {
266
426
  case "secret":
@@ -298,7 +458,7 @@ function buildConfigValidationSchema(configSchema) {
298
458
  if (!field.required) fieldSchema = fieldSchema.optional();
299
459
  shape[field.key] = fieldSchema;
300
460
  }
301
- return z.object(shape);
461
+ return z.object(shape).strict();
302
462
  }
303
463
  //#endregion
304
464
  //#region src/parser.ts
@@ -406,11 +566,23 @@ function isRelativePath(path) {
406
566
  */
407
567
  function resolveAssetUrl(assetPath, repoUrl, commitHash, subdir) {
408
568
  if (!isRelativePath(assetPath)) return assetPath;
409
- const repoMatch = /^https?:\/\/github\.com\/([^/]+)\/([^/]+)/.exec(repoUrl);
410
- if (!repoMatch) return assetPath;
411
- const [, owner, repo] = repoMatch;
412
- const normalizedPath = assetPath.replace(/^\.\//, "");
413
- return `https://raw.githubusercontent.com/${owner}/${repo}/${commitHash}/${subdir ? `${subdir.replace(/\/$/, "")}/${normalizedPath}` : normalizedPath}`;
569
+ let repository;
570
+ try {
571
+ repository = new URL(repoUrl);
572
+ } catch {
573
+ return assetPath;
574
+ }
575
+ const repoParts = repository.pathname.split("/").filter(Boolean);
576
+ const owner = repoParts[0];
577
+ const repo = repoParts[1]?.replace(/\.git$/, "");
578
+ 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;
579
+ const portableSubdir = subdir.replaceAll("\\", "/");
580
+ const portableAssetPath = assetPath.replaceAll("\\", "/");
581
+ if (portableSubdir.startsWith("/") || portableAssetPath.startsWith("/") || /^[A-Za-z]:\//.test(portableSubdir) || /^[A-Za-z]:\//.test(portableAssetPath)) return assetPath;
582
+ const fullPath = posix.normalize(posix.join(portableSubdir, portableAssetPath));
583
+ if (fullPath === "." || fullPath === ".." || fullPath.startsWith("../")) return assetPath;
584
+ const encodedPath = fullPath.split("/").map(encodeURIComponent).join("/");
585
+ return `https://raw.githubusercontent.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${encodeURIComponent(commitHash)}/${encodedPath}`;
414
586
  }
415
587
  //#endregion
416
588
  //#region src/interpolation.ts
@@ -430,14 +602,33 @@ const TEMPLATE_RE = /\{\{integration\.([a-z0-9-]+)\.([a-z0-9_]+)\}\}/g;
430
602
  */
431
603
  function interpolateConfig(config, ctx) {
432
604
  const result = {};
433
- for (const [key, value] of Object.entries(config)) if (typeof value === "string") result[key] = value.replace(TEMPLATE_RE, (_match, integrationId, field) => {
434
- const integrationConfig = ctx.integrations[integrationId];
435
- if (!integrationConfig) return _match;
436
- const resolved = integrationConfig[field];
437
- if (resolved == null) return _match;
438
- return typeof resolved === "string" ? resolved : JSON.stringify(resolved);
605
+ for (const [key, value] of Object.entries(config)) if (typeof value === "string") {
606
+ const interpolated = value.replace(TEMPLATE_RE, (_match, integrationId, field) => {
607
+ if (!Object.hasOwn(ctx.integrations, integrationId)) return _match;
608
+ const integrationConfig = ctx.integrations[integrationId];
609
+ if (!integrationConfig || !Object.hasOwn(integrationConfig, field)) return _match;
610
+ const resolved = integrationConfig[field];
611
+ if (resolved == null) return _match;
612
+ if (typeof resolved === "string") return resolved;
613
+ try {
614
+ const serialized = JSON.stringify(resolved);
615
+ return typeof serialized === "string" ? serialized : _match;
616
+ } catch {
617
+ return _match;
618
+ }
619
+ });
620
+ Object.defineProperty(result, key, {
621
+ configurable: true,
622
+ enumerable: true,
623
+ value: interpolated,
624
+ writable: true
625
+ });
626
+ } else Object.defineProperty(result, key, {
627
+ configurable: true,
628
+ enumerable: true,
629
+ value,
630
+ writable: true
439
631
  });
440
- else result[key] = value;
441
632
  return result;
442
633
  }
443
634
  /**
@@ -459,12 +650,38 @@ function extractTemplateReferences(config) {
459
650
  }
460
651
  //#endregion
461
652
  //#region src/migration.ts
653
+ function schemaByKey(fields, label) {
654
+ const result = /* @__PURE__ */ new Map();
655
+ for (const field of fields) {
656
+ if (result.has(field.key)) throw new Error(`Duplicate ${label} config schema key: ${field.key}`);
657
+ result.set(field.key, field);
658
+ }
659
+ return result;
660
+ }
661
+ function setOwn(target, key, value) {
662
+ Object.defineProperty(target, key, {
663
+ configurable: true,
664
+ enumerable: true,
665
+ value,
666
+ writable: true
667
+ });
668
+ }
669
+ function isValidFieldValue(field, value) {
670
+ try {
671
+ return buildConfigValidationSchema([{
672
+ ...field,
673
+ required: true
674
+ }]).safeParse({ [field.key]: value }).success;
675
+ } catch {
676
+ return false;
677
+ }
678
+ }
462
679
  /**
463
680
  * Diff two config schemas to determine what changed between versions.
464
681
  */
465
682
  function diffConfigSchemas(oldSchema, newSchema) {
466
- const oldMap = new Map(oldSchema.map((f) => [f.key, f]));
467
- const newMap = new Map(newSchema.map((f) => [f.key, f]));
683
+ const oldMap = schemaByKey(oldSchema, "old");
684
+ const newMap = schemaByKey(newSchema, "new");
468
685
  const added = [];
469
686
  const removed = [];
470
687
  const changed = [];
@@ -499,12 +716,20 @@ function diffConfigSchemas(oldSchema, newSchema) {
499
716
  function migrateConfig(currentConfig, diff) {
500
717
  const config = {};
501
718
  const warnings = [];
502
- for (const field of diff.unchanged) if (field.key in currentConfig) config[field.key] = currentConfig[field.key];
719
+ for (const field of diff.unchanged) if (Object.hasOwn(currentConfig, field.key)) setOwn(config, field.key, currentConfig[field.key]);
503
720
  for (const change of diff.changed) if (change.breaking) {
504
- if (change.newSchema.default !== void 0) config[change.field] = change.newSchema.default;
721
+ if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
505
722
  warnings.push(`Field "${change.field}" type changed from "${change.oldSchema.type}" to "${change.newSchema.type}" — value reset`);
506
- } else if (change.field in currentConfig) config[change.field] = currentConfig[change.field];
507
- for (const field of diff.added) if (field.default !== void 0) config[field.key] = field.default;
723
+ } else if (Object.hasOwn(currentConfig, change.field)) {
724
+ const currentValue = currentConfig[change.field];
725
+ if (isValidFieldValue(change.newSchema, currentValue)) setOwn(config, change.field, currentValue);
726
+ else {
727
+ if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
728
+ warnings.push(`Field "${change.field}" no longer accepts its existing value — value reset`);
729
+ }
730
+ } else if (change.newSchema.required) if (change.newSchema.default !== void 0) setOwn(config, change.field, change.newSchema.default);
731
+ else warnings.push(`Field "${change.field}" is now required and has no default — user input needed`);
732
+ for (const field of diff.added) if (field.default !== void 0) setOwn(config, field.key, field.default);
508
733
  else if (field.required) warnings.push(`New required field "${field.key}" has no default — user input needed`);
509
734
  return {
510
735
  config,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integration-manifest",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Integration manifest schema, types, and parser for Alfe integration platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",