@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
|
@@ -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,
|
|
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"}
|
package/dist/server/paths.d.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 ||
|
|
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
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
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;
|