@mulmoclaude/collection-plugin 0.5.1 → 0.5.3

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.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("./chunk-CKQMccvm.cjs");
3
- const require_schema = require("./schema-LrmrgW12.cjs");
3
+ const require_schema = require("./schema-5P5ZJPrY.cjs");
4
4
  const require_deriveAll = require("./deriveAll-BJ0Lvm1Q.cjs");
5
5
  let node_path = require("node:path");
6
6
  node_path = require_chunk.__toESM(node_path, 1);
@@ -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
@@ -766,7 +780,7 @@ var CustomViewSchema = zod.z.object({
766
780
  file: zod.z.string().trim().min(1).refine(isSafeCustomViewPath, "must be a safe path under `views/` ending in `.html` (e.g. `views/year.html`; no `..`, no leading `/`, no backslash)"),
767
781
  capabilities: zod.z.array(zod.z.enum(["read", "write"])).optional()
768
782
  });
769
- var EverySchema = zod.z.object({
783
+ var EveryLiteralSchema = zod.z.object({
770
784
  unit: zod.z.enum([
771
785
  "day",
772
786
  "week",
@@ -775,7 +789,12 @@ var EverySchema = zod.z.object({
775
789
  ]),
776
790
  interval: zod.z.number().int().min(1),
777
791
  dayOfMonth: zod.z.union([zod.z.number().int().min(1).max(31), zod.z.literal("last")]).optional()
778
- });
792
+ }).strict();
793
+ var EveryFieldDrivenSchema = zod.z.object({
794
+ fromField: zod.z.string().trim().min(1),
795
+ map: zod.z.record(zod.z.string(), EveryLiteralSchema)
796
+ }).strict();
797
+ var EverySchema = zod.z.union([EveryLiteralSchema, EveryFieldDrivenSchema]);
779
798
  var SpawnSchema = zod.z.object({
780
799
  when: WhenSchema.optional(),
781
800
  every: EverySchema,
@@ -817,6 +836,36 @@ function spawnSuccessorStartsInert(schema) {
817
836
  if (spawn.set && Object.prototype.hasOwnProperty.call(spawn.set, field)) return !values.includes(String(spawn.set[field]));
818
837
  return !(spawn.carry ?? []).includes(field);
819
838
  }
839
+ function fieldDrivenSpawnEvery(schema) {
840
+ const every = schema.spawn?.every;
841
+ if (!every || !require_schema.isFieldDrivenEvery(every)) return null;
842
+ return every;
843
+ }
844
+ function fieldDrivenFromFieldIsEnum(schema) {
845
+ const driven = fieldDrivenSpawnEvery(schema);
846
+ if (!driven) return true;
847
+ return schema.fields[driven.fromField]?.type === "enum";
848
+ }
849
+ function fieldDrivenMapCoversValues(schema) {
850
+ const driven = fieldDrivenSpawnEvery(schema);
851
+ if (!driven) return true;
852
+ const target = schema.fields[driven.fromField];
853
+ if (!target || target.type !== "enum" || target.values === void 0) return true;
854
+ const values = new Set(target.values);
855
+ const keys = Object.keys(driven.map);
856
+ return keys.length === values.size && keys.every((key) => values.has(key));
857
+ }
858
+ function fieldDrivenFromFieldCarried(schema) {
859
+ const driven = fieldDrivenSpawnEvery(schema);
860
+ if (!driven) return true;
861
+ const { carry, set } = schema.spawn ?? {};
862
+ if (set && Object.prototype.hasOwnProperty.call(set, driven.fromField)) {
863
+ const raw = set[driven.fromField];
864
+ if (raw === void 0 || raw === null || raw === "") return false;
865
+ return Object.prototype.hasOwnProperty.call(driven.map, String(raw));
866
+ }
867
+ return (carry ?? []).includes(driven.fromField);
868
+ }
820
869
  var IngestSchemaZ = zod.z.object({
821
870
  kind: zod.z.enum(require_schema.INGEST_KINDS),
822
871
  url: zod.z.string().url(),
@@ -848,8 +897,8 @@ var CollectionSchemaZ = zod.z.object({
848
897
  views: zod.z.array(CustomViewSchema).optional(),
849
898
  notifyWhen: WhenSchema.optional(),
850
899
  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)",
900
+ }).refine((schema) => schema.singleton === void 0 || safeRecordId(schema.singleton) !== null, {
901
+ message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
853
902
  path: ["singleton"]
854
903
  }).refine((schema) => schema.actions === void 0 || new Set(schema.actions.map((action) => action.id)).size === schema.actions.length, {
855
904
  message: "schema `actions` must have unique `id`s",
@@ -893,6 +942,15 @@ var CollectionSchemaZ = zod.z.object({
893
942
  }).refine((schema) => spawnSuccessorStartsInert(schema), {
894
943
  message: "`spawn` must leave the successor in a non-matching state (e.g. `set` the status to a pending value); seeding the predicate field to a matching value via `set`/`carry` would respawn forever",
895
944
  path: ["spawn"]
945
+ }).refine((schema) => fieldDrivenFromFieldIsEnum(schema), {
946
+ message: "`spawn.every.fromField` must name a top-level `enum` field declared in `fields`",
947
+ path: ["spawn"]
948
+ }).refine((schema) => fieldDrivenMapCoversValues(schema), {
949
+ message: "`spawn.every.map` keys must exactly cover the `values` of the `enum` named by `fromField` (no missing or extra keys)",
950
+ path: ["spawn"]
951
+ }).refine((schema) => fieldDrivenFromFieldCarried(schema), {
952
+ message: "`spawn.every.fromField` must appear in `spawn.carry`, or be written by `spawn.set` to a value present in `spawn.every.map`, so the successor keeps a resolvable recurrence interval",
953
+ path: ["spawn"]
896
954
  }).refine((schema) => schema.calendarField === void 0 || isDateLike(schema.fields[schema.calendarField]?.type), {
897
955
  message: "schema `calendarField` must name a top-level `date` or `datetime` field declared in `fields`",
898
956
  path: ["calendarField"]
@@ -940,6 +998,45 @@ function applyFeedSchemaDefaults(parsed, slug) {
940
998
  dataPath: `data/feeds/${slug}`
941
999
  };
942
1000
  }
1001
+ /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
1002
+ * before a schema becomes a live collection:
1003
+ *
1004
+ * - the `primaryKey` must be a declared field flagged `primary: true` —
1005
+ * without the flag CollectionView renders the field editable, and a
1006
+ * rename is silently pinned back to the URL itemId on save, so the user's
1007
+ * edit is dropped with no error;
1008
+ * - a `feed` schema must declare an `ingest` block (else it's a dead,
1009
+ * non-refreshable card);
1010
+ * - `dataPath` must resolve INSIDE the workspace.
1011
+ *
1012
+ * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
1013
+ * it reports success — a schema that passes `CollectionSchemaZ` but fails one
1014
+ * of these would otherwise write cleanly yet be skipped on the next discovery,
1015
+ * hiding the collection (the exact failure that tool exists to prevent). */
1016
+ function acceptParsedSchema(schema, opts) {
1017
+ const primaryField = schema.fields[schema.primaryKey];
1018
+ if (!primaryField) return {
1019
+ ok: false,
1020
+ reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
1021
+ };
1022
+ if (primaryField.primary !== true) return {
1023
+ ok: false,
1024
+ reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
1025
+ };
1026
+ if (opts.source === "feed" && !schema.ingest) return {
1027
+ ok: false,
1028
+ reason: "a feed schema must declare an `ingest` block"
1029
+ };
1030
+ const dataDir = resolveDataDir(schema.dataPath, opts.workspaceRoot);
1031
+ if (dataDir === null) return {
1032
+ ok: false,
1033
+ reason: `dataPath '${schema.dataPath}' escapes the workspace`
1034
+ };
1035
+ return {
1036
+ ok: true,
1037
+ dataDir
1038
+ };
1039
+ }
943
1040
  async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
944
1041
  const safeName = safeSlugName(slug);
945
1042
  if (safeName === null) return null;
@@ -976,31 +1073,14 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
976
1073
  return null;
977
1074
  }
978
1075
  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", {
1076
+ const acceptance = acceptParsedSchema(schema, {
1077
+ source,
1078
+ workspaceRoot
1079
+ });
1080
+ if (!acceptance.ok) {
1081
+ log.warn("collections", "schema.json rejected after validation, skipping", {
1001
1082
  slug: safeName,
1002
- dataPath: schema.dataPath,
1003
- workspaceRoot
1083
+ reason: acceptance.reason
1004
1084
  });
1005
1085
  return null;
1006
1086
  }
@@ -1008,7 +1088,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1008
1088
  slug: safeName,
1009
1089
  source,
1010
1090
  schema,
1011
- dataDir,
1091
+ dataDir: acceptance.dataDir,
1012
1092
  skillDir: node_path.default.join(skillsRoot, safeName)
1013
1093
  };
1014
1094
  }
@@ -1277,6 +1357,18 @@ function matchesWhen(when, schema, item) {
1277
1357
  const raw = item[completionField];
1278
1358
  return raw !== void 0 && raw !== null && completionDoneValues.includes(String(raw));
1279
1359
  }
1360
+ /** Resolve the literal `every` that applies to `sourceItem`. Literal-arm
1361
+ * `spawn.every` passes through unchanged. Field-driven `spawn.every` reads
1362
+ * `sourceItem[fromField]` and looks it up in `map`; an empty field or a
1363
+ * value with no map entry yields null (caller skips + logs — see plan §5).
1364
+ * Discovery rejects a map that doesn't cover the enum's values, so null
1365
+ * here means a record that predates a map/enum edit. */
1366
+ function resolveEvery(every, sourceItem) {
1367
+ if (!require_schema.isFieldDrivenEvery(every)) return every;
1368
+ const raw = sourceItem[every.fromField];
1369
+ if (raw === void 0 || raw === null || raw === "") return null;
1370
+ return every.map[String(raw)] ?? null;
1371
+ }
1280
1372
  /** Build the successor record purely from (schema, source record, source
1281
1373
  * id). Returns null when the schema has no spawn/triggerField or the
1282
1374
  * source's trigger date is unparseable. */
@@ -1285,7 +1377,9 @@ function computeSuccessor(schema, sourceItem, sourceId) {
1285
1377
  if (!spawn || !triggerField) return null;
1286
1378
  const srcDate = parseCivil(sourceItem[triggerField]);
1287
1379
  if (srcDate === null) return null;
1288
- const next = advanceTriggerDate(srcDate, spawn.every);
1380
+ const every = resolveEvery(spawn.every, sourceItem);
1381
+ if (every === null) return null;
1382
+ const next = advanceTriggerDate(srcDate, every);
1289
1383
  const nextId = successorId(sourceId, next);
1290
1384
  const record = {};
1291
1385
  for (const field of spawn.carry ?? []) if (Object.prototype.hasOwnProperty.call(sourceItem, field)) record[field] = sourceItem[field];
@@ -1297,6 +1391,27 @@ function computeSuccessor(schema, sourceItem, sourceId) {
1297
1391
  record
1298
1392
  };
1299
1393
  }
1394
+ /** Warn precisely about which of `computeSuccessor`'s two null causes fired
1395
+ * (plan §5): an unparseable source trigger date (the original cause), or a
1396
+ * field-driven `every` whose record value has no `map` entry (a record that
1397
+ * predates a map/enum edit — discovery rejects this statically otherwise). */
1398
+ function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
1399
+ if (parseCivil(sourceItem[triggerField]) === null) {
1400
+ log.warn("collections", "spawn skipped: source trigger date unparseable", {
1401
+ slug,
1402
+ sourceId,
1403
+ triggerField
1404
+ });
1405
+ return;
1406
+ }
1407
+ const fromField = require_schema.isFieldDrivenEvery(every) ? every.fromField : void 0;
1408
+ log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
1409
+ slug,
1410
+ sourceId,
1411
+ fromField,
1412
+ value: fromField === void 0 ? void 0 : sourceItem[fromField]
1413
+ });
1414
+ }
1300
1415
  /** Idempotently create the successor for `sourceItem` when it matches the
1301
1416
  * spawn predicate. No-op when the schema declares no spawn, the
1302
1417
  * predicate doesn't match, the trigger date is unparseable, or the
@@ -1308,11 +1423,7 @@ async function maybeSpawnSuccessor(slug, schema, dataDir, sourceItem, sourceId,
1308
1423
  if (!matchesWhen(spawn.when, schema, sourceItem)) return;
1309
1424
  const computed = computeSuccessor(schema, sourceItem, sourceId);
1310
1425
  if (computed === null) {
1311
- log.warn("collections", "spawn skipped: source trigger date unparseable", {
1312
- slug,
1313
- sourceId,
1314
- triggerField: schema.triggerField
1315
- });
1426
+ logSpawnSkip(slug, schema.triggerField, spawn.every, sourceItem, sourceId);
1316
1427
  return;
1317
1428
  }
1318
1429
  if (matchesWhen(spawn.when, schema, computed.record)) {
@@ -1577,6 +1688,7 @@ async function deleteCustomView(collection, viewId, opts = {}) {
1577
1688
  exports.COMPUTED_TYPES = COMPUTED_TYPES;
1578
1689
  exports.CollectionSchemaZ = CollectionSchemaZ;
1579
1690
  exports.SCHEMA_FILE = SCHEMA_FILE;
1691
+ exports.acceptParsedSchema = acceptParsedSchema;
1580
1692
  exports.advanceTriggerDate = advanceTriggerDate;
1581
1693
  exports.buildActionSeedPrompt = buildActionSeedPrompt;
1582
1694
  exports.buildCollectionActionSeedPrompt = buildCollectionActionSeedPrompt;
@@ -1609,7 +1721,9 @@ exports.readItem = readItem;
1609
1721
  exports.readSkillTemplate = readSkillTemplate;
1610
1722
  exports.resolveCreateItemId = resolveCreateItemId;
1611
1723
  exports.resolveDataDir = resolveDataDir;
1724
+ exports.resolveEvery = resolveEvery;
1612
1725
  exports.resolveTemplatePath = resolveTemplatePath;
1726
+ exports.safeRecordId = safeRecordId;
1613
1727
  exports.safeSlugName = safeSlugName;
1614
1728
  exports.successorId = successorId;
1615
1729
  exports.toDetail = toDetail;