@mulmoclaude/collection-plugin 0.5.1 → 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/discovery.d.ts +29 -1
- package/dist/server/discovery.d.ts.map +1 -1
- package/dist/server/paths.d.ts +6 -0
- package/dist/server/paths.d.ts.map +1 -1
- package/dist/server.cjs +68 -30
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +67 -31
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
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 =
|
|
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 =
|
|
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 =
|
|
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 ||
|
|
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
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
|
|
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
|