@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.
package/dist/server.js CHANGED
@@ -69,6 +69,20 @@ function safeSlugName(slug) {
69
69
  if (basename !== slug) return null;
70
70
  return basename;
71
71
  }
72
+ var SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
73
+ /** Sanitise a user-supplied record id into a safe filename stem. Like
74
+ * `safeSlugName` but tolerates interior dots (so natural keys work),
75
+ * while still rejecting any `..` substring, path separators, and
76
+ * leading/trailing dots. The `path.basename` round-trip is the same
77
+ * `js/path-injection` sanitiser CodeQL recognises on `safeSlugName`. */
78
+ function safeRecordId(recordId) {
79
+ if (typeof recordId !== "string") return null;
80
+ if (!SAFE_RECORD_ID_PATTERN.test(recordId)) return null;
81
+ if (recordId.includes("..")) return null;
82
+ const basename = path.basename(recordId);
83
+ if (basename !== recordId) return null;
84
+ return basename;
85
+ }
72
86
  /** Realpath the closest existing ancestor of `absPath` and return it.
73
87
  * Returns null if no ancestor exists or if the realpath call fails
74
88
  * for a non-ENOENT reason (permissions, etc.). Used by
@@ -302,7 +316,7 @@ async function listItems(dataDir, opts = {}) {
302
316
  * when the record file itself is a symlink (file-disclosure
303
317
  * defense — see `isRegularFile`). */
304
318
  async function readItem(dataDir, itemId, opts = {}) {
305
- const safeId = safeSlugName(itemId);
319
+ const safeId = safeRecordId(itemId);
306
320
  if (safeId === null) return null;
307
321
  if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) return null;
308
322
  const filePath = itemFilePath(dataDir, safeId);
@@ -332,7 +346,7 @@ async function readItem(dataDir, itemId, opts = {}) {
332
346
  * Update path (`refuseOverwrite: false`) uses `writeFileAtomic` so
333
347
  * PUT remains crash-atomic. No race there — the URL pins the id. */
334
348
  async function writeItem(dataDir, itemId, item, opts = {}) {
335
- const safeId = safeSlugName(itemId);
349
+ const safeId = safeRecordId(itemId);
336
350
  if (safeId === null) return {
337
351
  kind: "invalid-id",
338
352
  itemId
@@ -391,7 +405,7 @@ async function writeItem(dataDir, itemId, item, opts = {}) {
391
405
  };
392
406
  }
393
407
  async function deleteItem(dataDir, itemId, opts = {}) {
394
- const safeId = safeSlugName(itemId);
408
+ const safeId = safeRecordId(itemId);
395
409
  if (safeId === null) return {
396
410
  kind: "invalid-id",
397
411
  itemId
@@ -845,8 +859,8 @@ var CollectionSchemaZ = z.object({
845
859
  views: z.array(CustomViewSchema).optional(),
846
860
  notifyWhen: WhenSchema.optional(),
847
861
  ingest: IngestSchemaZ.optional()
848
- }).refine((schema) => schema.singleton === void 0 || safeSlugName(schema.singleton) !== null, {
849
- message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore, no path separators)",
862
+ }).refine((schema) => schema.singleton === void 0 || safeRecordId(schema.singleton) !== null, {
863
+ message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
850
864
  path: ["singleton"]
851
865
  }).refine((schema) => schema.actions === void 0 || new Set(schema.actions.map((action) => action.id)).size === schema.actions.length, {
852
866
  message: "schema `actions` must have unique `id`s",
@@ -937,6 +951,45 @@ function applyFeedSchemaDefaults(parsed, slug) {
937
951
  dataPath: `data/feeds/${slug}`
938
952
  };
939
953
  }
954
+ /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
955
+ * before a schema becomes a live collection:
956
+ *
957
+ * - the `primaryKey` must be a declared field flagged `primary: true` —
958
+ * without the flag CollectionView renders the field editable, and a
959
+ * rename is silently pinned back to the URL itemId on save, so the user's
960
+ * edit is dropped with no error;
961
+ * - a `feed` schema must declare an `ingest` block (else it's a dead,
962
+ * non-refreshable card);
963
+ * - `dataPath` must resolve INSIDE the workspace.
964
+ *
965
+ * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
966
+ * it reports success — a schema that passes `CollectionSchemaZ` but fails one
967
+ * of these would otherwise write cleanly yet be skipped on the next discovery,
968
+ * hiding the collection (the exact failure that tool exists to prevent). */
969
+ function acceptParsedSchema(schema, opts) {
970
+ const primaryField = schema.fields[schema.primaryKey];
971
+ if (!primaryField) return {
972
+ ok: false,
973
+ reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
974
+ };
975
+ if (primaryField.primary !== true) return {
976
+ ok: false,
977
+ reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
978
+ };
979
+ if (opts.source === "feed" && !schema.ingest) return {
980
+ ok: false,
981
+ reason: "a feed schema must declare an `ingest` block"
982
+ };
983
+ const dataDir = resolveDataDir(schema.dataPath, opts.workspaceRoot);
984
+ if (dataDir === null) return {
985
+ ok: false,
986
+ reason: `dataPath '${schema.dataPath}' escapes the workspace`
987
+ };
988
+ return {
989
+ ok: true,
990
+ dataDir
991
+ };
992
+ }
940
993
  async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
941
994
  const safeName = safeSlugName(slug);
942
995
  if (safeName === null) return null;
@@ -973,31 +1026,14 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
973
1026
  return null;
974
1027
  }
975
1028
  const schema = parsed.data;
976
- const primaryField = schema.fields[schema.primaryKey];
977
- if (!primaryField) {
978
- log.warn("collections", "schema.json primaryKey not found in fields, skipping", {
979
- slug: safeName,
980
- primaryKey: schema.primaryKey
981
- });
982
- return null;
983
- }
984
- if (primaryField.primary !== true) {
985
- log.warn("collections", "schema.json primaryKey field is not flagged primary: true, skipping", {
986
- slug: safeName,
987
- primaryKey: schema.primaryKey
988
- });
989
- return null;
990
- }
991
- if (source === "feed" && !schema.ingest) {
992
- log.warn("collections", "feed schema.json has no `ingest` block, skipping", { slug: safeName });
993
- return null;
994
- }
995
- const dataDir = resolveDataDir(schema.dataPath, workspaceRoot);
996
- if (dataDir === null) {
997
- log.warn("collections", "schema.json dataPath escapes workspace, skipping", {
1029
+ const acceptance = acceptParsedSchema(schema, {
1030
+ source,
1031
+ workspaceRoot
1032
+ });
1033
+ if (!acceptance.ok) {
1034
+ log.warn("collections", "schema.json rejected after validation, skipping", {
998
1035
  slug: safeName,
999
- dataPath: schema.dataPath,
1000
- workspaceRoot
1036
+ reason: acceptance.reason
1001
1037
  });
1002
1038
  return null;
1003
1039
  }
@@ -1005,7 +1041,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1005
1041
  slug: safeName,
1006
1042
  source,
1007
1043
  schema,
1008
- dataDir,
1044
+ dataDir: acceptance.dataDir,
1009
1045
  skillDir: path.join(skillsRoot, safeName)
1010
1046
  };
1011
1047
  }
@@ -1571,6 +1607,6 @@ async function deleteCustomView(collection, viewId, opts = {}) {
1571
1607
  };
1572
1608
  }
1573
1609
  //#endregion
1574
- export { COMPUTED_TYPES, CollectionSchemaZ, SCHEMA_FILE, advanceTriggerDate, buildActionSeedPrompt, buildCollectionActionSeedPrompt, computeSuccessor, configureCollectionHost, daysInMonth, deleteCollection, deleteCollectionRefusalMessage, deleteCustomView, deleteItem, discoverCollections, enrichItems, formatCivil, generateItemId, getWorkspaceRoot, isContainedInRoot, isContainedInWorkspace, isSafeActionTemplatePath, isSafeCustomViewPath, isSafeTemplatePath, isTriggerDue, itemFilePath, listItems, loadCollection, log, maybeSpawnSuccessor, parseCivil, readCustomViewHtml, readItem, readSkillTemplate, resolveCreateItemId, resolveDataDir, resolveTemplatePath, safeSlugName, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
1610
+ export { COMPUTED_TYPES, CollectionSchemaZ, SCHEMA_FILE, acceptParsedSchema, advanceTriggerDate, buildActionSeedPrompt, buildCollectionActionSeedPrompt, computeSuccessor, configureCollectionHost, daysInMonth, deleteCollection, deleteCollectionRefusalMessage, deleteCustomView, deleteItem, discoverCollections, enrichItems, formatCivil, generateItemId, getWorkspaceRoot, isContainedInRoot, isContainedInWorkspace, isSafeActionTemplatePath, isSafeCustomViewPath, isSafeTemplatePath, isTriggerDue, itemFilePath, listItems, loadCollection, log, maybeSpawnSuccessor, parseCivil, readCustomViewHtml, readItem, readSkillTemplate, resolveCreateItemId, resolveDataDir, resolveTemplatePath, safeRecordId, safeSlugName, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
1575
1611
 
1576
1612
  //# sourceMappingURL=server.js.map