@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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as INGEST_KINDS, t as FEED_SCHEDULES } from "./schema-DyfSfVzh.js";
1
+ import { n as INGEST_KINDS, r as isFieldDrivenEvery, t as FEED_SCHEDULES } from "./schema-BcnitlL8.js";
2
2
  import { t as deriveAll } from "./deriveAll-BHcs1erT.js";
3
3
  import path from "node:path";
4
4
  import { promises, realpathSync } from "node:fs";
@@ -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
@@ -763,7 +777,7 @@ var CustomViewSchema = z.object({
763
777
  file: 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)"),
764
778
  capabilities: z.array(z.enum(["read", "write"])).optional()
765
779
  });
766
- var EverySchema = z.object({
780
+ var EveryLiteralSchema = z.object({
767
781
  unit: z.enum([
768
782
  "day",
769
783
  "week",
@@ -772,7 +786,12 @@ var EverySchema = z.object({
772
786
  ]),
773
787
  interval: z.number().int().min(1),
774
788
  dayOfMonth: z.union([z.number().int().min(1).max(31), z.literal("last")]).optional()
775
- });
789
+ }).strict();
790
+ var EveryFieldDrivenSchema = z.object({
791
+ fromField: z.string().trim().min(1),
792
+ map: z.record(z.string(), EveryLiteralSchema)
793
+ }).strict();
794
+ var EverySchema = z.union([EveryLiteralSchema, EveryFieldDrivenSchema]);
776
795
  var SpawnSchema = z.object({
777
796
  when: WhenSchema.optional(),
778
797
  every: EverySchema,
@@ -814,6 +833,36 @@ function spawnSuccessorStartsInert(schema) {
814
833
  if (spawn.set && Object.prototype.hasOwnProperty.call(spawn.set, field)) return !values.includes(String(spawn.set[field]));
815
834
  return !(spawn.carry ?? []).includes(field);
816
835
  }
836
+ function fieldDrivenSpawnEvery(schema) {
837
+ const every = schema.spawn?.every;
838
+ if (!every || !isFieldDrivenEvery(every)) return null;
839
+ return every;
840
+ }
841
+ function fieldDrivenFromFieldIsEnum(schema) {
842
+ const driven = fieldDrivenSpawnEvery(schema);
843
+ if (!driven) return true;
844
+ return schema.fields[driven.fromField]?.type === "enum";
845
+ }
846
+ function fieldDrivenMapCoversValues(schema) {
847
+ const driven = fieldDrivenSpawnEvery(schema);
848
+ if (!driven) return true;
849
+ const target = schema.fields[driven.fromField];
850
+ if (!target || target.type !== "enum" || target.values === void 0) return true;
851
+ const values = new Set(target.values);
852
+ const keys = Object.keys(driven.map);
853
+ return keys.length === values.size && keys.every((key) => values.has(key));
854
+ }
855
+ function fieldDrivenFromFieldCarried(schema) {
856
+ const driven = fieldDrivenSpawnEvery(schema);
857
+ if (!driven) return true;
858
+ const { carry, set } = schema.spawn ?? {};
859
+ if (set && Object.prototype.hasOwnProperty.call(set, driven.fromField)) {
860
+ const raw = set[driven.fromField];
861
+ if (raw === void 0 || raw === null || raw === "") return false;
862
+ return Object.prototype.hasOwnProperty.call(driven.map, String(raw));
863
+ }
864
+ return (carry ?? []).includes(driven.fromField);
865
+ }
817
866
  var IngestSchemaZ = z.object({
818
867
  kind: z.enum(INGEST_KINDS),
819
868
  url: z.string().url(),
@@ -845,8 +894,8 @@ var CollectionSchemaZ = z.object({
845
894
  views: z.array(CustomViewSchema).optional(),
846
895
  notifyWhen: WhenSchema.optional(),
847
896
  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)",
897
+ }).refine((schema) => schema.singleton === void 0 || safeRecordId(schema.singleton) !== null, {
898
+ message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
850
899
  path: ["singleton"]
851
900
  }).refine((schema) => schema.actions === void 0 || new Set(schema.actions.map((action) => action.id)).size === schema.actions.length, {
852
901
  message: "schema `actions` must have unique `id`s",
@@ -890,6 +939,15 @@ var CollectionSchemaZ = z.object({
890
939
  }).refine((schema) => spawnSuccessorStartsInert(schema), {
891
940
  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",
892
941
  path: ["spawn"]
942
+ }).refine((schema) => fieldDrivenFromFieldIsEnum(schema), {
943
+ message: "`spawn.every.fromField` must name a top-level `enum` field declared in `fields`",
944
+ path: ["spawn"]
945
+ }).refine((schema) => fieldDrivenMapCoversValues(schema), {
946
+ message: "`spawn.every.map` keys must exactly cover the `values` of the `enum` named by `fromField` (no missing or extra keys)",
947
+ path: ["spawn"]
948
+ }).refine((schema) => fieldDrivenFromFieldCarried(schema), {
949
+ 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",
950
+ path: ["spawn"]
893
951
  }).refine((schema) => schema.calendarField === void 0 || isDateLike(schema.fields[schema.calendarField]?.type), {
894
952
  message: "schema `calendarField` must name a top-level `date` or `datetime` field declared in `fields`",
895
953
  path: ["calendarField"]
@@ -937,6 +995,45 @@ function applyFeedSchemaDefaults(parsed, slug) {
937
995
  dataPath: `data/feeds/${slug}`
938
996
  };
939
997
  }
998
+ /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
999
+ * before a schema becomes a live collection:
1000
+ *
1001
+ * - the `primaryKey` must be a declared field flagged `primary: true` —
1002
+ * without the flag CollectionView renders the field editable, and a
1003
+ * rename is silently pinned back to the URL itemId on save, so the user's
1004
+ * edit is dropped with no error;
1005
+ * - a `feed` schema must declare an `ingest` block (else it's a dead,
1006
+ * non-refreshable card);
1007
+ * - `dataPath` must resolve INSIDE the workspace.
1008
+ *
1009
+ * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
1010
+ * it reports success — a schema that passes `CollectionSchemaZ` but fails one
1011
+ * of these would otherwise write cleanly yet be skipped on the next discovery,
1012
+ * hiding the collection (the exact failure that tool exists to prevent). */
1013
+ function acceptParsedSchema(schema, opts) {
1014
+ const primaryField = schema.fields[schema.primaryKey];
1015
+ if (!primaryField) return {
1016
+ ok: false,
1017
+ reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
1018
+ };
1019
+ if (primaryField.primary !== true) return {
1020
+ ok: false,
1021
+ reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
1022
+ };
1023
+ if (opts.source === "feed" && !schema.ingest) return {
1024
+ ok: false,
1025
+ reason: "a feed schema must declare an `ingest` block"
1026
+ };
1027
+ const dataDir = resolveDataDir(schema.dataPath, opts.workspaceRoot);
1028
+ if (dataDir === null) return {
1029
+ ok: false,
1030
+ reason: `dataPath '${schema.dataPath}' escapes the workspace`
1031
+ };
1032
+ return {
1033
+ ok: true,
1034
+ dataDir
1035
+ };
1036
+ }
940
1037
  async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
941
1038
  const safeName = safeSlugName(slug);
942
1039
  if (safeName === null) return null;
@@ -973,31 +1070,14 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
973
1070
  return null;
974
1071
  }
975
1072
  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", {
1073
+ const acceptance = acceptParsedSchema(schema, {
1074
+ source,
1075
+ workspaceRoot
1076
+ });
1077
+ if (!acceptance.ok) {
1078
+ log.warn("collections", "schema.json rejected after validation, skipping", {
998
1079
  slug: safeName,
999
- dataPath: schema.dataPath,
1000
- workspaceRoot
1080
+ reason: acceptance.reason
1001
1081
  });
1002
1082
  return null;
1003
1083
  }
@@ -1005,7 +1085,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1005
1085
  slug: safeName,
1006
1086
  source,
1007
1087
  schema,
1008
- dataDir,
1088
+ dataDir: acceptance.dataDir,
1009
1089
  skillDir: path.join(skillsRoot, safeName)
1010
1090
  };
1011
1091
  }
@@ -1274,6 +1354,18 @@ function matchesWhen(when, schema, item) {
1274
1354
  const raw = item[completionField];
1275
1355
  return raw !== void 0 && raw !== null && completionDoneValues.includes(String(raw));
1276
1356
  }
1357
+ /** Resolve the literal `every` that applies to `sourceItem`. Literal-arm
1358
+ * `spawn.every` passes through unchanged. Field-driven `spawn.every` reads
1359
+ * `sourceItem[fromField]` and looks it up in `map`; an empty field or a
1360
+ * value with no map entry yields null (caller skips + logs — see plan §5).
1361
+ * Discovery rejects a map that doesn't cover the enum's values, so null
1362
+ * here means a record that predates a map/enum edit. */
1363
+ function resolveEvery(every, sourceItem) {
1364
+ if (!isFieldDrivenEvery(every)) return every;
1365
+ const raw = sourceItem[every.fromField];
1366
+ if (raw === void 0 || raw === null || raw === "") return null;
1367
+ return every.map[String(raw)] ?? null;
1368
+ }
1277
1369
  /** Build the successor record purely from (schema, source record, source
1278
1370
  * id). Returns null when the schema has no spawn/triggerField or the
1279
1371
  * source's trigger date is unparseable. */
@@ -1282,7 +1374,9 @@ function computeSuccessor(schema, sourceItem, sourceId) {
1282
1374
  if (!spawn || !triggerField) return null;
1283
1375
  const srcDate = parseCivil(sourceItem[triggerField]);
1284
1376
  if (srcDate === null) return null;
1285
- const next = advanceTriggerDate(srcDate, spawn.every);
1377
+ const every = resolveEvery(spawn.every, sourceItem);
1378
+ if (every === null) return null;
1379
+ const next = advanceTriggerDate(srcDate, every);
1286
1380
  const nextId = successorId(sourceId, next);
1287
1381
  const record = {};
1288
1382
  for (const field of spawn.carry ?? []) if (Object.prototype.hasOwnProperty.call(sourceItem, field)) record[field] = sourceItem[field];
@@ -1294,6 +1388,27 @@ function computeSuccessor(schema, sourceItem, sourceId) {
1294
1388
  record
1295
1389
  };
1296
1390
  }
1391
+ /** Warn precisely about which of `computeSuccessor`'s two null causes fired
1392
+ * (plan §5): an unparseable source trigger date (the original cause), or a
1393
+ * field-driven `every` whose record value has no `map` entry (a record that
1394
+ * predates a map/enum edit — discovery rejects this statically otherwise). */
1395
+ function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
1396
+ if (parseCivil(sourceItem[triggerField]) === null) {
1397
+ log.warn("collections", "spawn skipped: source trigger date unparseable", {
1398
+ slug,
1399
+ sourceId,
1400
+ triggerField
1401
+ });
1402
+ return;
1403
+ }
1404
+ const fromField = isFieldDrivenEvery(every) ? every.fromField : void 0;
1405
+ log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
1406
+ slug,
1407
+ sourceId,
1408
+ fromField,
1409
+ value: fromField === void 0 ? void 0 : sourceItem[fromField]
1410
+ });
1411
+ }
1297
1412
  /** Idempotently create the successor for `sourceItem` when it matches the
1298
1413
  * spawn predicate. No-op when the schema declares no spawn, the
1299
1414
  * predicate doesn't match, the trigger date is unparseable, or the
@@ -1305,11 +1420,7 @@ async function maybeSpawnSuccessor(slug, schema, dataDir, sourceItem, sourceId,
1305
1420
  if (!matchesWhen(spawn.when, schema, sourceItem)) return;
1306
1421
  const computed = computeSuccessor(schema, sourceItem, sourceId);
1307
1422
  if (computed === null) {
1308
- log.warn("collections", "spawn skipped: source trigger date unparseable", {
1309
- slug,
1310
- sourceId,
1311
- triggerField: schema.triggerField
1312
- });
1423
+ logSpawnSkip(slug, schema.triggerField, spawn.every, sourceItem, sourceId);
1313
1424
  return;
1314
1425
  }
1315
1426
  if (matchesWhen(spawn.when, schema, computed.record)) {
@@ -1571,6 +1682,6 @@ async function deleteCustomView(collection, viewId, opts = {}) {
1571
1682
  };
1572
1683
  }
1573
1684
  //#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 };
1685
+ 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, resolveEvery, resolveTemplatePath, safeRecordId, safeSlugName, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
1575
1686
 
1576
1687
  //# sourceMappingURL=server.js.map