@cosmicdrift/kumiko-framework 0.196.1 → 0.197.1
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/package.json +3 -3
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +32 -0
- package/src/db/event-store-executor-read.ts +10 -2
- package/src/derivatives/__tests__/derivatives-context.test.ts +43 -0
- package/src/derivatives/__tests__/variant-route.integration.test.ts +61 -1
- package/src/derivatives/derivatives-context.ts +23 -2
- package/src/engine/__tests__/qualified-name.test.ts +42 -0
- package/src/engine/__tests__/registry.test.ts +22 -0
- package/src/engine/qualified-name.ts +34 -1
- package/src/files/file-routes.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.197.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.197.1",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.197.1",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -10,6 +10,14 @@ describe("buildFilterWhere", () => {
|
|
|
10
10
|
expect(buildFilterWhere("status", "ne", "active")).toEqual({ status: { ne: "active" } });
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
test("eq with null: returns a direct null WhereObject (IS NULL downstream)", () => {
|
|
14
|
+
expect(buildFilterWhere("status", "eq", null)).toEqual({ status: null });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("ne with null: wraps null in a { ne } clause (IS NOT NULL downstream, #2015)", () => {
|
|
18
|
+
expect(buildFilterWhere("status", "ne", null)).toEqual({ status: { ne: null } });
|
|
19
|
+
});
|
|
20
|
+
|
|
13
21
|
test("lt: wraps the value in a { lt } clause", () => {
|
|
14
22
|
expect(buildFilterWhere("createdAt", "lt", 100)).toEqual({ createdAt: { lt: 100 } });
|
|
15
23
|
});
|
|
@@ -224,6 +224,38 @@ describe("event-store-executor.list — filter (Tier 2.7c)", () => {
|
|
|
224
224
|
expect(res.rows).toHaveLength(0);
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
+
test("filter ne mit value null: matcht rows mit gesetztem Feld (#2015)", async () => {
|
|
228
|
+
// #2015: rank is optional — pre-fix, `ne` compiled to `rank <> NULL`,
|
|
229
|
+
// which is never true in SQL and returned 0 rows.
|
|
230
|
+
await seed(3);
|
|
231
|
+
await exec.create({ title: "no-rank" }, admin, tdb);
|
|
232
|
+
const res = await exec.list(
|
|
233
|
+
{
|
|
234
|
+
limit: 50,
|
|
235
|
+
sort: "rank",
|
|
236
|
+
sortDirection: "asc",
|
|
237
|
+
filter: { field: "rank", op: "ne", value: null },
|
|
238
|
+
},
|
|
239
|
+
admin,
|
|
240
|
+
tdb,
|
|
241
|
+
);
|
|
242
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([0, 1, 2]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("filter eq mit value null: matcht rows mit ungesetztem Feld (#2015)", async () => {
|
|
246
|
+
await seed(3);
|
|
247
|
+
await exec.create({ title: "no-rank" }, admin, tdb);
|
|
248
|
+
const res = await exec.list(
|
|
249
|
+
{
|
|
250
|
+
limit: 50,
|
|
251
|
+
filter: { field: "rank", op: "eq", value: null },
|
|
252
|
+
},
|
|
253
|
+
admin,
|
|
254
|
+
tdb,
|
|
255
|
+
);
|
|
256
|
+
expect(res.rows.map((r) => r["title"])).toEqual(["no-rank"]);
|
|
257
|
+
});
|
|
258
|
+
|
|
227
259
|
test("filter unknown-field: silent skip — kein Crash, alle rows zurück", async () => {
|
|
228
260
|
// Boot-Validator pinst das normalerweise; Runtime-Defense für den
|
|
229
261
|
// Fall dass ein Test/Caller direkt am executor vorbei ein bogus-
|
|
@@ -116,13 +116,21 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
116
116
|
return;
|
|
117
117
|
}
|
|
118
118
|
for (const [field, value] of Object.entries(screen)) {
|
|
119
|
-
|
|
119
|
+
// #2015: `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in bun-db/query.ts.
|
|
120
|
+
if (value === null) {
|
|
121
|
+
whereSql.push(`${colSql(field)} IS NULL`);
|
|
122
|
+
} else if (Array.isArray(value)) {
|
|
120
123
|
const placeholders = value.map((v) => {
|
|
121
124
|
params.push(v);
|
|
122
125
|
return `$${params.length}`;
|
|
123
126
|
});
|
|
124
127
|
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
125
|
-
} else if (typeof value === "object"
|
|
128
|
+
} else if (typeof value === "object") {
|
|
129
|
+
const valueObj = value as Record<string, unknown>;
|
|
130
|
+
if (valueObj["ne"] === null && Object.keys(valueObj).length === 1) {
|
|
131
|
+
whereSql.push(`${colSql(field)} IS NOT NULL`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
126
134
|
const opMap: Record<string, string> = {
|
|
127
135
|
gt: ">",
|
|
128
136
|
gte: ">=",
|
|
@@ -171,4 +171,47 @@ describe("createDerivativesContext — variant()", () => {
|
|
|
171
171
|
expect(provider.mimeTypeOf(result.storageKey)).toBe(result.mimeType);
|
|
172
172
|
expect(result.mimeType).toBe("image/webp");
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
// #2021 — a variant with no spec.format used to fall straight through to
|
|
176
|
+
// the source's mimeType, which is client-controlled (`file.type` off the
|
|
177
|
+
// upload). A field without `accept` plus a broad `image/*` renderer
|
|
178
|
+
// wildcard (like `setup()`'s here) let a client ride an active-content
|
|
179
|
+
// type like `image/svg+xml` all the way to the response Content-Type.
|
|
180
|
+
test("no spec.format + a non-allowlisted source mimeType does not take over that mimeType", async () => {
|
|
181
|
+
const { ctx, provider } = await setup("image/svg+xml");
|
|
182
|
+
const spec = {} as const;
|
|
183
|
+
|
|
184
|
+
const result = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
185
|
+
|
|
186
|
+
expect(result.mimeType).not.toBe("image/svg+xml");
|
|
187
|
+
expect(result.mimeType).toBe("application/octet-stream");
|
|
188
|
+
expect(provider.mimeTypeOf(result.storageKey)).toBe("application/octet-stream");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("no spec.format + a non-allowlisted source mimeType — a cache hit returns the same normalized value", async () => {
|
|
192
|
+
const { ctx } = await setup("image/svg+xml");
|
|
193
|
+
const spec = {} as const;
|
|
194
|
+
|
|
195
|
+
const first = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
196
|
+
const second = await ctx.variant(FILE_REF_ID, spec, "thumb");
|
|
197
|
+
|
|
198
|
+
expect(first.mimeType).toBe("application/octet-stream");
|
|
199
|
+
expect(second.mimeType).toBe("application/octet-stream");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("no spec.format keeps a known-safe source mimeType — resize-without-reformat stays intact", async () => {
|
|
203
|
+
const { ctx } = await setup("image/png");
|
|
204
|
+
|
|
205
|
+
const result = await ctx.variant(FILE_REF_ID, { maxEdge: 320 }, "thumb");
|
|
206
|
+
|
|
207
|
+
expect(result.mimeType).toBe("image/png");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("no spec.format normalizes a `; charset=` suffix off the source mimeType instead of leaking it raw", async () => {
|
|
211
|
+
const { ctx } = await setup("image/jpeg; charset=binary");
|
|
212
|
+
|
|
213
|
+
const result = await ctx.variant(FILE_REF_ID, {}, "thumb");
|
|
214
|
+
|
|
215
|
+
expect(result.mimeType).toBe("image/jpeg");
|
|
216
|
+
});
|
|
174
217
|
});
|
|
@@ -27,7 +27,14 @@ const fakeRender: DerivativeRendererPlugin["render"] = async () => {
|
|
|
27
27
|
const photoEntity = createEntity({
|
|
28
28
|
table: "variant_route_photos",
|
|
29
29
|
fields: {
|
|
30
|
-
avatar: createImageField({
|
|
30
|
+
avatar: createImageField({
|
|
31
|
+
variants: {
|
|
32
|
+
thumb: { maxEdge: 100, format: "webp" },
|
|
33
|
+
// No format — resize-without-reformat, the legitimate reason
|
|
34
|
+
// spec.format can be omitted (see #2021).
|
|
35
|
+
raw: { maxEdge: 100 },
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
31
38
|
},
|
|
32
39
|
});
|
|
33
40
|
|
|
@@ -98,6 +105,19 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
98
105
|
expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
|
|
99
106
|
});
|
|
100
107
|
|
|
108
|
+
// setupTestStack doesn't wrap with the app-wide security-headers default, so a pass proves the route sets its own.
|
|
109
|
+
test("sets X-Content-Type-Options: nosniff on the response itself", async () => {
|
|
110
|
+
const fileId = await uploadFile();
|
|
111
|
+
const token = await stack.jwt.sign(user);
|
|
112
|
+
|
|
113
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
|
|
114
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
119
|
+
});
|
|
120
|
+
|
|
101
121
|
test("a second call hits the cache — the renderer runs only once", async () => {
|
|
102
122
|
renderCalls = 0;
|
|
103
123
|
const fileId = await uploadFile();
|
|
@@ -194,4 +214,44 @@ describe("GET /api/files/:id/variant/:name", () => {
|
|
|
194
214
|
expect(res.status).toBe(415);
|
|
195
215
|
expect(await res.json()).toEqual({ error: "unsupported_media_type" });
|
|
196
216
|
});
|
|
217
|
+
|
|
218
|
+
// #2021 — the "raw" variant has no `format`, so its mimeType comes from
|
|
219
|
+
// outputMimeType's fallback branch. `avatar` has no `accept`, so the
|
|
220
|
+
// source mimeType is exactly the client-controlled `file.type` from the
|
|
221
|
+
// upload — this proves the route no longer echoes it verbatim.
|
|
222
|
+
test("a variant with no spec.format never echoes a non-safe, client-controlled source mimeType", async () => {
|
|
223
|
+
const token = await stack.jwt.sign(user);
|
|
224
|
+
const fd = new FormData();
|
|
225
|
+
fd.append("file", new File([Buffer.from([1, 2, 3])], "avatar.svg", { type: "image/svg+xml" }));
|
|
226
|
+
fd.append("entityType", "photo");
|
|
227
|
+
fd.append("fieldName", "avatar");
|
|
228
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
229
|
+
const uploadRes = await stack.app.request("/api/files", {
|
|
230
|
+
method: "POST",
|
|
231
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
232
|
+
body,
|
|
233
|
+
});
|
|
234
|
+
expect(uploadRes.status).toBe(201);
|
|
235
|
+
const { id: fileId } = (await uploadRes.json()) as { id: string };
|
|
236
|
+
|
|
237
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/raw`, {
|
|
238
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(res.status).toBe(200);
|
|
242
|
+
expect(res.headers.get("Content-Type")).not.toBe("image/svg+xml");
|
|
243
|
+
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a variant with no spec.format keeps a known-safe source mimeType through the full route (resize without reformat)", async () => {
|
|
247
|
+
const fileId = await uploadFile();
|
|
248
|
+
const token = await stack.jwt.sign(user);
|
|
249
|
+
|
|
250
|
+
const res = await stack.app.request(`/api/files/${fileId}/variant/raw`, {
|
|
251
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(res.status).toBe(200);
|
|
255
|
+
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
|
|
256
|
+
});
|
|
197
257
|
});
|
|
@@ -69,6 +69,23 @@ function isFileRefRow(row: Record<string, unknown>): row is FileRefRow {
|
|
|
69
69
|
return typeof row["storageKey"] === "string" && typeof row["mimeType"] === "string";
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// sourceMimeType is client-controlled (it's `file.type` off the upload — see
|
|
73
|
+
// file-routes.ts's "a field without `accept` lets a client upload anything")
|
|
74
|
+
// and, when spec.format is unset, a renderer is documented to preserve the
|
|
75
|
+
// source's own format (DerivativeRendererPlugin's doc comment) — so the
|
|
76
|
+
// bytes really are in this format, but the STRING itself must still be
|
|
77
|
+
// allowlisted before it becomes a response Content-Type or storage-provider
|
|
78
|
+
// metadata. Otherwise an app whose renderer wildcard is broad enough (e.g.
|
|
79
|
+
// `image/*`) would let a client-declared `image/svg+xml` ride straight
|
|
80
|
+
// through as an honestly-declared (non-sniffed) active-content type (#2021).
|
|
81
|
+
const KNOWN_SAFE_VARIANT_MIME_TYPES: ReadonlySet<string> = new Set([
|
|
82
|
+
"image/jpeg",
|
|
83
|
+
"image/png",
|
|
84
|
+
"image/webp",
|
|
85
|
+
"image/avif",
|
|
86
|
+
"image/gif",
|
|
87
|
+
]);
|
|
88
|
+
|
|
72
89
|
// The spec — not the renderer — determines the output mimeType, so it's the
|
|
73
90
|
// single source of truth for both what gets written to storage and what the
|
|
74
91
|
// caller receives; a cache hit never runs the renderer, so there's nothing
|
|
@@ -81,8 +98,12 @@ function outputMimeType(spec: VariantSpec, sourceMimeType: string): string {
|
|
|
81
98
|
return "image/avif";
|
|
82
99
|
case "jpeg":
|
|
83
100
|
return "image/jpeg";
|
|
84
|
-
default:
|
|
85
|
-
|
|
101
|
+
default: {
|
|
102
|
+
const normalized = normalizeMimeType(sourceMimeType);
|
|
103
|
+
return KNOWN_SAFE_VARIANT_MIME_TYPES.has(normalized)
|
|
104
|
+
? normalized
|
|
105
|
+
: "application/octet-stream";
|
|
106
|
+
}
|
|
86
107
|
}
|
|
87
108
|
}
|
|
88
109
|
|
|
@@ -45,6 +45,48 @@ describe("qn()", () => {
|
|
|
45
45
|
expect(qn("feature2", QnTypes.job, "sync-v2")).toBe("feature2:job:sync-v2");
|
|
46
46
|
expect(qn("my-app", QnTypes.event, "user:created")).toBe("my-app:event:user:created");
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
test("rejects a name that starts with '<scope>:<reserved-type>:' (#1991)", () => {
|
|
50
|
+
// The exact shape from the issue: an already-qualified string
|
|
51
|
+
// ("ai-orchestration:query:duplicate-candidates") passed in as the short
|
|
52
|
+
// name for a handler on the "ai-orchestration" feature.
|
|
53
|
+
expect(() =>
|
|
54
|
+
qn("ai-orchestration", "query", "ai-orchestration:query:duplicate-candidates"),
|
|
55
|
+
).toThrow(/double-qualification/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("error suggests the corrected short name (#1991)", () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
qn("ai-orchestration", "query", "ai-orchestration:query:duplicate-candidates"),
|
|
61
|
+
).toThrow(/Did you mean "duplicate-candidates"\?/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("omits the suggestion when stripping the prefix leaves nothing", () => {
|
|
65
|
+
expect(() => qn("billing", "write", "billing:write")).toThrow(/double-qualification/);
|
|
66
|
+
expect(() => qn("billing", "write", "billing:write")).not.toThrow(/Did you mean/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Both signals — scope repeating in segment 0 AND a reserved type in
|
|
70
|
+
// segment 1 — must hold together. Either alone is common, legitimate
|
|
71
|
+
// sub-structure across the real codebase; these are the exact shapes that
|
|
72
|
+
// regressed when the check first fired on either signal independently.
|
|
73
|
+
test("allows the scope repeating as an entity prefix without a reserved type next (#1991)", () => {
|
|
74
|
+
expect(qn("user", "write", "user:create")).toBe("user:write:user:create");
|
|
75
|
+
expect(qn("accounts", "write", "accounts:create")).toBe("accounts:write:accounts:create");
|
|
76
|
+
expect(qn("profile", "query", "profile:me")).toBe("profile:query:profile:me");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("allows a reserved type appearing mid-name without the scope repeating first (#1991)", () => {
|
|
80
|
+
expect(qn("events", "write", "event:create")).toBe("events:write:event:create");
|
|
81
|
+
expect(qn("compliance-profiles", "query", "compliance:query:for-tenant")).toBe(
|
|
82
|
+
"compliance-profiles:query:compliance:query:for-tenant",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("allows a sub-structured name whose first segment merely resembles the scope", () => {
|
|
87
|
+
// "invoice" != "invoices" — no false positive from a near-miss prefix.
|
|
88
|
+
expect(qn("invoices", "query", "invoice:mark-paid")).toBe("invoices:query:invoice:mark-paid");
|
|
89
|
+
});
|
|
48
90
|
});
|
|
49
91
|
|
|
50
92
|
describe("parseQn()", () => {
|
|
@@ -177,6 +177,28 @@ describe("getAllStreamHandlers", () => {
|
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
179
|
|
|
180
|
+
describe("double-qualified handler names (#1991)", () => {
|
|
181
|
+
test("createRegistry throws at boot when a query handler's short name is already fully-qualified", () => {
|
|
182
|
+
const feature = defineFeature("registry-test-ai-orch", (r) => {
|
|
183
|
+
r.queryHandler(
|
|
184
|
+
"registry-test-ai-orch:query:duplicate-candidates",
|
|
185
|
+
z.object({}),
|
|
186
|
+
async () => ({}),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(() => createRegistry([feature])).toThrow(/double-qualification/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("createRegistry allows a sub-structured short name whose entity prefix merely resembles the feature name", () => {
|
|
194
|
+
const feature = defineFeature("registry-test-invoices", (r) => {
|
|
195
|
+
r.queryHandler("invoice:mark-paid", z.object({}), async () => ({}));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(() => createRegistry([feature])).not.toThrow();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
180
202
|
describe("extensionSelector boot-validation", () => {
|
|
181
203
|
function foundationFeature() {
|
|
182
204
|
return defineFeature("probe-foundation", (r) => {
|
|
@@ -45,14 +45,47 @@ function validateSegment(value: string, label: string, context?: string): void {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// registry-ingest.ts also qualifies handlers/projections with these two —
|
|
49
|
+
// not in the public QnTypes enum, but reachable as a type segment the same
|
|
50
|
+
// way (#1991).
|
|
51
|
+
const NON_ENUM_QN_TYPES = ["stream", "projection", "workspace"] as const;
|
|
52
|
+
|
|
53
|
+
// Every segment word that a fully-qualified QN can carry as its type.
|
|
54
|
+
const RESERVED_QN_SEGMENTS: ReadonlySet<string> = new Set<string>([
|
|
55
|
+
...Object.values(QnTypes),
|
|
56
|
+
...NON_ENUM_QN_TYPES,
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
// A double-qualified `name` looks like "<scope>:<type>:<realName>" — the
|
|
60
|
+
// caller accidentally passed the full QN as the short name instead of just
|
|
61
|
+
// the trailing part. Both signals must hold together: the scope alone
|
|
62
|
+
// repeating (e.g. entity "user:create" on feature "user") or a reserved word
|
|
63
|
+
// alone appearing (e.g. "compliance:query:for-tenant") are both common,
|
|
64
|
+
// legitimate sub-structure on their own (#1991).
|
|
65
|
+
function isDoubleQualified(scope: string, segments: readonly string[]): boolean {
|
|
66
|
+
const [first, second] = segments;
|
|
67
|
+
return first === scope && second !== undefined && RESERVED_QN_SEGMENTS.has(second);
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
// Build a qualified name from parts. Validates all segments.
|
|
49
71
|
// The name can contain colons for sub-structure (e.g. "task:create").
|
|
50
72
|
export function qn(scope: string, type: QnType, name: string): string {
|
|
51
73
|
validateSegment(scope, "scope");
|
|
52
74
|
validateSegment(type, "type");
|
|
53
|
-
|
|
75
|
+
const nameSegments = name.split(":");
|
|
76
|
+
for (const part of nameSegments) {
|
|
54
77
|
validateSegment(part, "name");
|
|
55
78
|
}
|
|
79
|
+
if (isDoubleQualified(scope, nameSegments)) {
|
|
80
|
+
const suggestion = nameSegments.slice(2).join(":");
|
|
81
|
+
const suggestionText = suggestion.length > 0 ? ` Did you mean "${suggestion}"?` : "";
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Invalid QN name "${name}" for scope "${scope}": starts with "${scope}:${nameSegments[1]}:" — this looks ` +
|
|
84
|
+
`like an already-qualified name passed in as the short name (double-qualification), not legitimate ` +
|
|
85
|
+
`sub-structure.${suggestionText} Pass only the short name; qn()/qualifyEntityName already add the ` +
|
|
86
|
+
`"${scope}:${type}:" prefix.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
56
89
|
return `${scope}:${type}:${name}`;
|
|
57
90
|
}
|
|
58
91
|
|
package/src/files/file-routes.ts
CHANGED
|
@@ -289,6 +289,8 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
289
289
|
headers: {
|
|
290
290
|
"Content-Type": result.mimeType,
|
|
291
291
|
"Cache-Control": "private, max-age=31536000, immutable",
|
|
292
|
+
// Explicit here, not left to the app-wide security-headers default, so the route stays safe standalone.
|
|
293
|
+
"X-Content-Type-Options": "nosniff",
|
|
292
294
|
},
|
|
293
295
|
});
|
|
294
296
|
});
|