@mulmoclaude/collection-plugin 0.5.0 → 0.5.2

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.
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import type { LoadedCollection } from "./discoveredCollection";
3
- import type { CollectionDetail, CollectionSummary } from "../core/schema";
3
+ import type { CollectionDetail, CollectionSchema, CollectionSource, CollectionSummary } from "../core/schema";
4
4
  export declare const CollectionSchemaZ: z.ZodObject<{
5
5
  title: z.ZodString;
6
6
  icon: z.ZodString;
@@ -159,6 +159,34 @@ export declare const CollectionSchemaZ: z.ZodObject<{
159
159
  maxItems: z.ZodOptional<z.ZodNumber>;
160
160
  }, z.core.$strip>>;
161
161
  }, z.core.$strip>;
162
+ /** Result of the post-Zod acceptance gates: the resolved record dir on
163
+ * success, or a one-line reason discovery would skip the schema. */
164
+ export type SchemaAcceptance = {
165
+ ok: true;
166
+ dataDir: string;
167
+ } | {
168
+ ok: false;
169
+ reason: string;
170
+ };
171
+ /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
172
+ * before a schema becomes a live collection:
173
+ *
174
+ * - the `primaryKey` must be a declared field flagged `primary: true` —
175
+ * without the flag CollectionView renders the field editable, and a
176
+ * rename is silently pinned back to the URL itemId on save, so the user's
177
+ * edit is dropped with no error;
178
+ * - a `feed` schema must declare an `ingest` block (else it's a dead,
179
+ * non-refreshable card);
180
+ * - `dataPath` must resolve INSIDE the workspace.
181
+ *
182
+ * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
183
+ * it reports success — a schema that passes `CollectionSchemaZ` but fails one
184
+ * of these would otherwise write cleanly yet be skipped on the next discovery,
185
+ * hiding the collection (the exact failure that tool exists to prevent). */
186
+ export declare function acceptParsedSchema(schema: CollectionSchema, opts: {
187
+ source: CollectionSource;
188
+ workspaceRoot: string;
189
+ }): SchemaAcceptance;
162
190
  export interface DiscoveryOptions {
163
191
  /** Override the workspace root for project-scope skill discovery.
164
192
  * Default: the live `workspacePath`. Tests point this at a
@@ -1 +1 @@
1
- {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../src/server/discovery.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,OAAO,KAAK,EAAE,gBAAgB,EAAoB,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAwT5F,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6P1B,CAAC;AAsHL,MAAM,WAAW,gBAAgB;IAC/B;;;;+DAI2D;IAC3D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;4CAGwC;IACxC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;+BAM+B;AAC/B,wBAAsB,mBAAmB,CAAC,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAgBlG;AAED;4DAC4D;AAC5D,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAchH;AAED,wBAAgB,SAAS,CAAC,UAAU,EAAE,gBAAgB,GAAG,iBAAiB,CAEzE;AAED,wBAAgB,QAAQ,CAAC,UAAU,EAAE,gBAAgB,GAAG,gBAAgB,CAEvE"}
1
+ {"version":3,"file":"discovery.d.ts","sourceRoot":"","sources":["../../src/server/discovery.ts"],"names":[],"mappings":"AASA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE/D,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAwT9G,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6P1B,CAAC;AAmBL;qEACqE;AACrE,MAAM,MAAM,gBAAgB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7F;;;;;;;;;;;;;;6EAc6E;AAC7E,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,gBAAgB,EAAE,IAAI,EAAE;IAAE,MAAM,EAAE,gBAAgB,CAAC;IAAC,aAAa,EAAE,MAAM,CAAA;CAAE,GAAG,gBAAgB,CAQxI;AA+ED,MAAM,WAAW,gBAAgB;IAC/B;;;;+DAI2D;IAC3D,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;4CAGwC;IACxC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;+BAM+B;AAC/B,wBAAsB,mBAAmB,CAAC,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAgBlG;AAED;4DAC4D;AAC5D,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAchH;AAED,wBAAgB,SAAS,CAAC,UAAU,EAAE,gBAAgB,GAAG,iBAAiB,CAEzE;AAED,wBAAgB,QAAQ,CAAC,UAAU,EAAE,gBAAgB,GAAG,gBAAgB,CAEvE"}
@@ -5,6 +5,12 @@ export declare const SCHEMA_FILE = "schema.json";
5
5
  * The basename round-trip is the pattern CodeQL recognises as a
6
6
  * `js/path-injection` sanitiser. */
7
7
  export declare function safeSlugName(slug: string): string | null;
8
+ /** Sanitise a user-supplied record id into a safe filename stem. Like
9
+ * `safeSlugName` but tolerates interior dots (so natural keys work),
10
+ * while still rejecting any `..` substring, path separators, and
11
+ * leading/trailing dots. The `path.basename` round-trip is the same
12
+ * `js/path-injection` sanitiser CodeQL recognises on `safeSlugName`. */
13
+ export declare function safeRecordId(recordId: string): string | null;
8
14
  /** True iff the realpath'd closest existing ancestor of `absPath`
9
15
  * resolves under `rootPath`'s realpath. Pure helper, takes both
10
16
  * paths explicitly so tests can drive it against a `mkdtempSync`
@@ -1 +1 @@
1
- {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/server/paths.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,WAAW,gBAAgB,CAAC;AAQzC;;;;qCAIqC;AACrC,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMxD;AAwBD;;;;;;;;uDAQuD;AACvD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAW5E;AAED;4DAC4D;AAC5D,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE/D;AAED;;;;;;;;;;;;;;4CAc4C;AAC5C,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,GAAE,MAA2B,GAAG,MAAM,GAAG,IAAI,CAQrG;AAED;;qDAEqD;AACrD,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED;;;;iDAIiD;AACjD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ5F"}
1
+ {"version":3,"file":"paths.d.ts","sourceRoot":"","sources":["../../src/server/paths.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,WAAW,gBAAgB,CAAC;AAQzC;;;;qCAIqC;AACrC,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAMxD;AAaD;;;;yEAIyE;AACzE,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAO5D;AAwBD;;;;;;;;uDAQuD;AACvD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAW5E;AAED;4DAC4D;AAC5D,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE/D;AAED;;;;;;;;;;;;;;4CAc4C;AAC5C,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,GAAE,MAA2B,GAAG,MAAM,GAAG,IAAI,CAQrG;AAED;;qDAEqD;AACrD,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAEpE;AAED;;;;iDAIiD;AACjD,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ5F"}
package/dist/server.cjs CHANGED
@@ -72,6 +72,20 @@ function safeSlugName(slug) {
72
72
  if (basename !== slug) return null;
73
73
  return basename;
74
74
  }
75
+ var SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
76
+ /** Sanitise a user-supplied record id into a safe filename stem. Like
77
+ * `safeSlugName` but tolerates interior dots (so natural keys work),
78
+ * while still rejecting any `..` substring, path separators, and
79
+ * leading/trailing dots. The `path.basename` round-trip is the same
80
+ * `js/path-injection` sanitiser CodeQL recognises on `safeSlugName`. */
81
+ function safeRecordId(recordId) {
82
+ if (typeof recordId !== "string") return null;
83
+ if (!SAFE_RECORD_ID_PATTERN.test(recordId)) return null;
84
+ if (recordId.includes("..")) return null;
85
+ const basename = node_path.default.basename(recordId);
86
+ if (basename !== recordId) return null;
87
+ return basename;
88
+ }
75
89
  /** Realpath the closest existing ancestor of `absPath` and return it.
76
90
  * Returns null if no ancestor exists or if the realpath call fails
77
91
  * for a non-ENOENT reason (permissions, etc.). Used by
@@ -305,7 +319,7 @@ async function listItems(dataDir, opts = {}) {
305
319
  * when the record file itself is a symlink (file-disclosure
306
320
  * defense — see `isRegularFile`). */
307
321
  async function readItem(dataDir, itemId, opts = {}) {
308
- const safeId = safeSlugName(itemId);
322
+ const safeId = safeRecordId(itemId);
309
323
  if (safeId === null) return null;
310
324
  if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) return null;
311
325
  const filePath = itemFilePath(dataDir, safeId);
@@ -335,7 +349,7 @@ async function readItem(dataDir, itemId, opts = {}) {
335
349
  * Update path (`refuseOverwrite: false`) uses `writeFileAtomic` so
336
350
  * PUT remains crash-atomic. No race there — the URL pins the id. */
337
351
  async function writeItem(dataDir, itemId, item, opts = {}) {
338
- const safeId = safeSlugName(itemId);
352
+ const safeId = safeRecordId(itemId);
339
353
  if (safeId === null) return {
340
354
  kind: "invalid-id",
341
355
  itemId
@@ -394,7 +408,7 @@ async function writeItem(dataDir, itemId, item, opts = {}) {
394
408
  };
395
409
  }
396
410
  async function deleteItem(dataDir, itemId, opts = {}) {
397
- const safeId = safeSlugName(itemId);
411
+ const safeId = safeRecordId(itemId);
398
412
  if (safeId === null) return {
399
413
  kind: "invalid-id",
400
414
  itemId
@@ -848,8 +862,8 @@ var CollectionSchemaZ = zod.z.object({
848
862
  views: zod.z.array(CustomViewSchema).optional(),
849
863
  notifyWhen: WhenSchema.optional(),
850
864
  ingest: IngestSchemaZ.optional()
851
- }).refine((schema) => schema.singleton === void 0 || safeSlugName(schema.singleton) !== null, {
852
- message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore, no path separators)",
865
+ }).refine((schema) => schema.singleton === void 0 || safeRecordId(schema.singleton) !== null, {
866
+ message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
853
867
  path: ["singleton"]
854
868
  }).refine((schema) => schema.actions === void 0 || new Set(schema.actions.map((action) => action.id)).size === schema.actions.length, {
855
869
  message: "schema `actions` must have unique `id`s",
@@ -940,6 +954,45 @@ function applyFeedSchemaDefaults(parsed, slug) {
940
954
  dataPath: `data/feeds/${slug}`
941
955
  };
942
956
  }
957
+ /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
958
+ * before a schema becomes a live collection:
959
+ *
960
+ * - the `primaryKey` must be a declared field flagged `primary: true` —
961
+ * without the flag CollectionView renders the field editable, and a
962
+ * rename is silently pinned back to the URL itemId on save, so the user's
963
+ * edit is dropped with no error;
964
+ * - a `feed` schema must declare an `ingest` block (else it's a dead,
965
+ * non-refreshable card);
966
+ * - `dataPath` must resolve INSIDE the workspace.
967
+ *
968
+ * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
969
+ * it reports success — a schema that passes `CollectionSchemaZ` but fails one
970
+ * of these would otherwise write cleanly yet be skipped on the next discovery,
971
+ * hiding the collection (the exact failure that tool exists to prevent). */
972
+ function acceptParsedSchema(schema, opts) {
973
+ const primaryField = schema.fields[schema.primaryKey];
974
+ if (!primaryField) return {
975
+ ok: false,
976
+ reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
977
+ };
978
+ if (primaryField.primary !== true) return {
979
+ ok: false,
980
+ reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
981
+ };
982
+ if (opts.source === "feed" && !schema.ingest) return {
983
+ ok: false,
984
+ reason: "a feed schema must declare an `ingest` block"
985
+ };
986
+ const dataDir = resolveDataDir(schema.dataPath, opts.workspaceRoot);
987
+ if (dataDir === null) return {
988
+ ok: false,
989
+ reason: `dataPath '${schema.dataPath}' escapes the workspace`
990
+ };
991
+ return {
992
+ ok: true,
993
+ dataDir
994
+ };
995
+ }
943
996
  async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
944
997
  const safeName = safeSlugName(slug);
945
998
  if (safeName === null) return null;
@@ -976,31 +1029,14 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
976
1029
  return null;
977
1030
  }
978
1031
  const schema = parsed.data;
979
- const primaryField = schema.fields[schema.primaryKey];
980
- if (!primaryField) {
981
- log.warn("collections", "schema.json primaryKey not found in fields, skipping", {
982
- slug: safeName,
983
- primaryKey: schema.primaryKey
984
- });
985
- return null;
986
- }
987
- if (primaryField.primary !== true) {
988
- log.warn("collections", "schema.json primaryKey field is not flagged primary: true, skipping", {
989
- slug: safeName,
990
- primaryKey: schema.primaryKey
991
- });
992
- return null;
993
- }
994
- if (source === "feed" && !schema.ingest) {
995
- log.warn("collections", "feed schema.json has no `ingest` block, skipping", { slug: safeName });
996
- return null;
997
- }
998
- const dataDir = resolveDataDir(schema.dataPath, workspaceRoot);
999
- if (dataDir === null) {
1000
- log.warn("collections", "schema.json dataPath escapes workspace, skipping", {
1032
+ const acceptance = acceptParsedSchema(schema, {
1033
+ source,
1034
+ workspaceRoot
1035
+ });
1036
+ if (!acceptance.ok) {
1037
+ log.warn("collections", "schema.json rejected after validation, skipping", {
1001
1038
  slug: safeName,
1002
- dataPath: schema.dataPath,
1003
- workspaceRoot
1039
+ reason: acceptance.reason
1004
1040
  });
1005
1041
  return null;
1006
1042
  }
@@ -1008,7 +1044,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1008
1044
  slug: safeName,
1009
1045
  source,
1010
1046
  schema,
1011
- dataDir,
1047
+ dataDir: acceptance.dataDir,
1012
1048
  skillDir: node_path.default.join(skillsRoot, safeName)
1013
1049
  };
1014
1050
  }
@@ -1577,6 +1613,7 @@ async function deleteCustomView(collection, viewId, opts = {}) {
1577
1613
  exports.COMPUTED_TYPES = COMPUTED_TYPES;
1578
1614
  exports.CollectionSchemaZ = CollectionSchemaZ;
1579
1615
  exports.SCHEMA_FILE = SCHEMA_FILE;
1616
+ exports.acceptParsedSchema = acceptParsedSchema;
1580
1617
  exports.advanceTriggerDate = advanceTriggerDate;
1581
1618
  exports.buildActionSeedPrompt = buildActionSeedPrompt;
1582
1619
  exports.buildCollectionActionSeedPrompt = buildCollectionActionSeedPrompt;
@@ -1610,6 +1647,7 @@ exports.readSkillTemplate = readSkillTemplate;
1610
1647
  exports.resolveCreateItemId = resolveCreateItemId;
1611
1648
  exports.resolveDataDir = resolveDataDir;
1612
1649
  exports.resolveTemplatePath = resolveTemplatePath;
1650
+ exports.safeRecordId = safeRecordId;
1613
1651
  exports.safeSlugName = safeSlugName;
1614
1652
  exports.successorId = successorId;
1615
1653
  exports.toDetail = toDetail;