emdash 0.10.0 → 0.11.0
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/{apply-UsrFuO7l.mjs → apply-Ded_1vng.mjs} +36 -25
- package/dist/{apply-UsrFuO7l.mjs.map → apply-Ded_1vng.mjs.map} +1 -1
- package/dist/astro/index.d.mts +5 -5
- package/dist/astro/index.mjs +1 -1
- package/dist/astro/middleware/auth.d.mts +5 -5
- package/dist/astro/middleware/redirect.mjs +2 -2
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +83 -33
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +9 -7
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-C3vnhIpU.mjs → byline-gFn1r0vA.mjs} +2 -2
- package/dist/{byline-C3vnhIpU.mjs.map → byline-gFn1r0vA.mjs.map} +1 -1
- package/dist/{bylines-esI7ioa9.mjs → bylines-DTFI8nDM.mjs} +4 -4
- package/dist/{bylines-esI7ioa9.mjs.map → bylines-DTFI8nDM.mjs.map} +1 -1
- package/dist/{cache-fTzxgMFJ.mjs → cache-BAJbeoZ8.mjs} +2 -2
- package/dist/{cache-fTzxgMFJ.mjs.map → cache-BAJbeoZ8.mjs.map} +1 -1
- package/dist/{chunks-Da2-b-oA.mjs → chunks-BK1oZS-l.mjs} +2 -2
- package/dist/{chunks-Da2-b-oA.mjs.map → chunks-BK1oZS-l.mjs.map} +1 -1
- package/dist/cli/index.mjs +102 -27
- package/dist/cli/index.mjs.map +1 -1
- package/dist/{content-C7G4QXkK.mjs → content-CERxPUN0.mjs} +2 -2
- package/dist/{content-C7G4QXkK.mjs.map → content-CERxPUN0.mjs.map} +1 -1
- package/dist/database/instrumentation.d.mts +6 -4
- package/dist/database/instrumentation.d.mts.map +1 -1
- package/dist/database/instrumentation.mjs +19 -7
- package/dist/database/instrumentation.mjs.map +1 -1
- package/dist/db/index.d.mts +2 -2
- package/dist/db/index.mjs +1 -1
- package/dist/{index-DjPMOfO0.d.mts → index-Cg-rC4Gj.d.mts} +32 -24
- package/dist/index-Cg-rC4Gj.d.mts.map +1 -0
- package/dist/index.d.mts +7 -7
- package/dist/index.mjs +19 -19
- package/dist/{load-sXRuM7Us.mjs → load-DR1VwFXR.mjs} +2 -2
- package/dist/{load-sXRuM7Us.mjs.map → load-DR1VwFXR.mjs.map} +1 -1
- package/dist/{loader-Bx2_9-5e.mjs → loader-ou_PXAjg.mjs} +2 -2
- package/dist/{loader-Bx2_9-5e.mjs.map → loader-ou_PXAjg.mjs.map} +1 -1
- package/dist/media/local-runtime.d.mts +5 -5
- package/dist/media/local-runtime.mjs +1 -1
- package/dist/{media-D8FbNsl0.mjs → media-1fFhub9c.mjs} +21 -9
- package/dist/media-1fFhub9c.mjs.map +1 -0
- package/dist/page/index.d.mts +2 -2
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
- package/dist/{query-Bo-msrmu.mjs → query-8c_meo_K.mjs} +10 -10
- package/dist/{query-Bo-msrmu.mjs.map → query-8c_meo_K.mjs.map} +1 -1
- package/dist/{registry-Beb7wxFc.mjs → registry-Do34mz_P.mjs} +6 -5
- package/dist/registry-Do34mz_P.mjs.map +1 -0
- package/dist/{request-cache-C-tIpYIw.mjs → request-cache-D4I69LeL.mjs} +6 -2
- package/dist/request-cache-D4I69LeL.mjs.map +1 -0
- package/dist/request-context.d.mts +27 -1
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs +16 -3
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-DMnlIkh4.mjs → runner-DIcU2UCC.mjs} +174 -152
- package/dist/runner-DIcU2UCC.mjs.map +1 -0
- package/dist/{runner-Clwe4Mme.d.mts → runner-Iu3IZSDM.d.mts} +2 -2
- package/dist/{runner-Clwe4Mme.d.mts.map → runner-Iu3IZSDM.d.mts.map} +1 -1
- package/dist/runtime.d.mts +5 -5
- package/dist/runtime.mjs +1 -1
- package/dist/{search-DkN-BqsS.mjs → search-DuWhx4NG.mjs} +172 -30
- package/dist/search-DuWhx4NG.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +10 -10
- package/dist/{taxonomies-CTtewrSQ.mjs → taxonomies-Bw76xAxo.mjs} +6 -6
- package/dist/{taxonomies-CTtewrSQ.mjs.map → taxonomies-Bw76xAxo.mjs.map} +1 -1
- package/dist/{taxonomy-DSxx2K2L.mjs → taxonomy-D6NvlKo8.mjs} +3 -3
- package/dist/{taxonomy-DSxx2K2L.mjs.map → taxonomy-D6NvlKo8.mjs.map} +1 -1
- package/dist/{types-Eg829jj9.mjs → types-56BKbld_.mjs} +1 -1
- package/dist/types-56BKbld_.mjs.map +1 -0
- package/dist/{types-Dtx1mSMX.d.mts → types-BQx6ZXpR.d.mts} +2 -1
- package/dist/types-BQx6ZXpR.d.mts.map +1 -0
- package/dist/types-DiI8NOG_.mjs +16 -0
- package/dist/types-DiI8NOG_.mjs.map +1 -0
- package/dist/{types-D19uBYWn.d.mts → types-IN5z_S3P.d.mts} +19 -98
- package/dist/types-IN5z_S3P.d.mts.map +1 -0
- package/dist/{types-Dl1fgFjn.d.mts → types-IZSZfEwv.d.mts} +4 -3
- package/dist/types-IZSZfEwv.d.mts.map +1 -0
- package/dist/{validate-DHGwADqO.d.mts → validate-CO3JjFV5.d.mts} +7 -3
- package/dist/validate-CO3JjFV5.d.mts.map +1 -0
- package/dist/{validate-CBIbxM3L.mjs → validate-UK4Ja1uo.mjs} +3 -3
- package/dist/{validate-CBIbxM3L.mjs.map → validate-UK4Ja1uo.mjs.map} +1 -1
- package/dist/{validation-B1NYiEos.mjs → validation-Vc5DQkJa.mjs} +4 -4
- package/dist/{validation-B1NYiEos.mjs.map → validation-Vc5DQkJa.mjs.map} +1 -1
- package/dist/version-Bg31I_Ff.mjs +7 -0
- package/dist/{version-CMD42IRC.mjs.map → version-Bg31I_Ff.mjs.map} +1 -1
- package/dist/{zod-generator-BNJDQBSZ.mjs → zod-generator-CHnJUP2l.mjs} +1 -1
- package/dist/{zod-generator-BNJDQBSZ.mjs.map → zod-generator-CHnJUP2l.mjs.map} +1 -1
- package/package.json +9 -8
- package/src/api/errors.ts +5 -0
- package/src/api/handlers/content.ts +9 -0
- package/src/api/handlers/media-allowlist.ts +40 -0
- package/src/api/handlers/media.ts +1 -1
- package/src/api/handlers/menus.ts +158 -28
- package/src/api/handlers/validate-media-fields.ts +125 -0
- package/src/api/schemas/media.ts +23 -3
- package/src/api/schemas/schema.ts +11 -2
- package/src/astro/middleware.ts +46 -11
- package/src/astro/routes/api/media/upload-url.ts +10 -4
- package/src/astro/routes/api/media.ts +12 -4
- package/src/astro/types.ts +5 -1
- package/src/auth/rate-limit.ts +3 -3
- package/src/cli/commands/bundle-utils.ts +81 -6
- package/src/cli/commands/bundle.ts +18 -15
- package/src/cli/commands/export-seed.ts +57 -3
- package/src/database/instrumentation.ts +22 -8
- package/src/database/migrations/016_api_tokens.ts +18 -3
- package/src/database/migrations/037_credential_algorithm.ts +18 -0
- package/src/database/migrations/runner.ts +2 -0
- package/src/database/repositories/media.ts +40 -10
- package/src/database/types.ts +2 -1
- package/src/emdash-runtime.ts +16 -3
- package/src/fields/file.ts +7 -6
- package/src/fields/image.ts +12 -11
- package/src/fields/types.ts +3 -0
- package/src/index.ts +1 -1
- package/src/mcp/server.ts +37 -8
- package/src/media/mime.ts +75 -0
- package/src/plugins/types.ts +81 -191
- package/src/request-cache.ts +6 -2
- package/src/request-context.ts +42 -2
- package/src/schema/registry.ts +5 -5
- package/src/schema/types.ts +3 -2
- package/src/seed/apply.ts +25 -8
- package/src/seed/types.ts +4 -0
- package/dist/index-DjPMOfO0.d.mts.map +0 -1
- package/dist/media-D8FbNsl0.mjs.map +0 -1
- package/dist/registry-Beb7wxFc.mjs.map +0 -1
- package/dist/request-cache-C-tIpYIw.mjs.map +0 -1
- package/dist/runner-DMnlIkh4.mjs.map +0 -1
- package/dist/search-DkN-BqsS.mjs.map +0 -1
- package/dist/types-CoO6mpV3.mjs +0 -68
- package/dist/types-CoO6mpV3.mjs.map +0 -1
- package/dist/types-D19uBYWn.d.mts.map +0 -1
- package/dist/types-Dl1fgFjn.d.mts.map +0 -1
- package/dist/types-Dtx1mSMX.d.mts.map +0 -1
- package/dist/types-Eg829jj9.mjs.map +0 -1
- package/dist/validate-DHGwADqO.d.mts.map +0 -1
- package/dist/version-CMD42IRC.mjs +0 -7
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
SeedWidget,
|
|
33
33
|
SeedContentEntry,
|
|
34
34
|
} from "../../seed/types.js";
|
|
35
|
+
import { slugify } from "../../utils/slugify.js";
|
|
35
36
|
|
|
36
37
|
const SETTINGS_PREFIX = "site:";
|
|
37
38
|
|
|
@@ -101,7 +102,7 @@ export const exportSeedCommand = defineCommand({
|
|
|
101
102
|
/**
|
|
102
103
|
* Export database to seed file format
|
|
103
104
|
*/
|
|
104
|
-
async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
|
|
105
|
+
export async function exportSeed(db: Kysely<Database>, withContent?: string): Promise<SeedFile> {
|
|
105
106
|
const seed: SeedFile = {
|
|
106
107
|
$schema: "https://emdashcms.com/seed.schema.json",
|
|
107
108
|
version: "1",
|
|
@@ -317,6 +318,9 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
|
|
|
317
318
|
const result: SeedMenu[] = [];
|
|
318
319
|
// translation_group -> seed-local id of the anchor menu in that group.
|
|
319
320
|
const groupToSeedId = new Map<string, string>();
|
|
321
|
+
// Shared across menus: translated items reference anchor items in sibling menus.
|
|
322
|
+
const itemGroupToSeedId = new Map<string, string>();
|
|
323
|
+
const usedItemSeedIds = new Set<string>();
|
|
320
324
|
|
|
321
325
|
for (const menu of menus) {
|
|
322
326
|
const seedId =
|
|
@@ -329,7 +333,13 @@ async function exportMenus(db: Kysely<Database>): Promise<SeedMenu[]> {
|
|
|
329
333
|
.orderBy("sort_order", "asc")
|
|
330
334
|
.execute();
|
|
331
335
|
|
|
332
|
-
const seedItems = buildMenuItemTree(items
|
|
336
|
+
const seedItems = buildMenuItemTree(items, {
|
|
337
|
+
i18nEnabled,
|
|
338
|
+
menuName: menu.name,
|
|
339
|
+
menuLocale: menu.locale ?? null,
|
|
340
|
+
itemGroupToSeedId,
|
|
341
|
+
usedItemSeedIds,
|
|
342
|
+
});
|
|
333
343
|
|
|
334
344
|
const seedMenu: SeedMenu = {
|
|
335
345
|
id: seedId,
|
|
@@ -376,7 +386,17 @@ function buildMenuItemTree(
|
|
|
376
386
|
target: string | null;
|
|
377
387
|
title_attr: string | null;
|
|
378
388
|
css_classes: string | null;
|
|
389
|
+
locale?: string | null;
|
|
390
|
+
translation_group?: string | null;
|
|
379
391
|
}>,
|
|
392
|
+
i18nCtx: {
|
|
393
|
+
i18nEnabled: boolean;
|
|
394
|
+
menuName: string;
|
|
395
|
+
menuLocale: string | null;
|
|
396
|
+
// translation_group -> seed-local id of the anchor item in that group.
|
|
397
|
+
itemGroupToSeedId: Map<string, string>;
|
|
398
|
+
usedItemSeedIds: Set<string>;
|
|
399
|
+
},
|
|
380
400
|
): SeedMenuItem[] {
|
|
381
401
|
// Build parent -> children map
|
|
382
402
|
const childMap = new Map<string | null, typeof items>();
|
|
@@ -389,10 +409,28 @@ function buildMenuItemTree(
|
|
|
389
409
|
childMap.get(parentId)!.push(item);
|
|
390
410
|
}
|
|
391
411
|
|
|
412
|
+
function makeSeedId(item: (typeof items)[number]): string {
|
|
413
|
+
const base = slugify(item.label || "") || item.id;
|
|
414
|
+
const locale = i18nCtx.i18nEnabled ? (item.locale ?? i18nCtx.menuLocale) : null;
|
|
415
|
+
const candidate = locale
|
|
416
|
+
? `item:${i18nCtx.menuName}:${base}:${locale}`
|
|
417
|
+
: `item:${i18nCtx.menuName}:${base}`;
|
|
418
|
+
if (!i18nCtx.usedItemSeedIds.has(candidate)) {
|
|
419
|
+
i18nCtx.usedItemSeedIds.add(candidate);
|
|
420
|
+
return candidate;
|
|
421
|
+
}
|
|
422
|
+
// Collision fallback: append DB id to disambiguate duplicate labels.
|
|
423
|
+
const fallback = locale
|
|
424
|
+
? `item:${i18nCtx.menuName}:${base}:${item.id}:${locale}`
|
|
425
|
+
: `item:${i18nCtx.menuName}:${base}:${item.id}`;
|
|
426
|
+
i18nCtx.usedItemSeedIds.add(fallback);
|
|
427
|
+
return fallback;
|
|
428
|
+
}
|
|
429
|
+
|
|
392
430
|
// Recursively build tree
|
|
393
431
|
function buildLevel(parentId: string | null): SeedMenuItem[] {
|
|
394
432
|
const children = childMap.get(parentId) || [];
|
|
395
|
-
|
|
433
|
+
const result = children.map((item) => {
|
|
396
434
|
const seedItem: SeedMenuItem = {
|
|
397
435
|
type: item.type,
|
|
398
436
|
label: item.label || undefined,
|
|
@@ -415,6 +453,18 @@ function buildMenuItemTree(
|
|
|
415
453
|
seedItem.cssClasses = item.css_classes;
|
|
416
454
|
}
|
|
417
455
|
|
|
456
|
+
if (i18nCtx.i18nEnabled) {
|
|
457
|
+
const itemLocale = item.locale ?? i18nCtx.menuLocale;
|
|
458
|
+
const seedId = makeSeedId(item);
|
|
459
|
+
seedItem.id = seedId;
|
|
460
|
+
if (itemLocale) seedItem.locale = itemLocale;
|
|
461
|
+
if (item.translation_group) {
|
|
462
|
+
const anchor = i18nCtx.itemGroupToSeedId.get(item.translation_group);
|
|
463
|
+
if (anchor && anchor !== seedId) seedItem.translationOf = anchor;
|
|
464
|
+
else if (!anchor) i18nCtx.itemGroupToSeedId.set(item.translation_group, seedId);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
418
468
|
// Add children
|
|
419
469
|
const itemChildren = buildLevel(item.id);
|
|
420
470
|
if (itemChildren.length > 0) {
|
|
@@ -423,6 +473,10 @@ function buildMenuItemTree(
|
|
|
423
473
|
|
|
424
474
|
return seedItem;
|
|
425
475
|
});
|
|
476
|
+
|
|
477
|
+
// Sibling order is preserved (maps to sort_order on import). Cross-menu
|
|
478
|
+
// `translationOf` already resolves because exportMenus sorts anchors first.
|
|
479
|
+
return result;
|
|
426
480
|
}
|
|
427
481
|
|
|
428
482
|
return buildLevel(null);
|
|
@@ -83,16 +83,30 @@ export function isInstrumentationEnabled(): boolean {
|
|
|
83
83
|
|
|
84
84
|
function kyselyLog(event: LogEvent): void {
|
|
85
85
|
if (event.level !== "query") return;
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
88
|
-
|
|
86
|
+
const ctx = getRequestContext();
|
|
87
|
+
if (!ctx) return;
|
|
88
|
+
const dur = event.queryDurationMillis;
|
|
89
|
+
if (ctx.metrics) {
|
|
90
|
+
const m = ctx.metrics;
|
|
91
|
+
m.dbCount += 1;
|
|
92
|
+
m.dbTotalMs += dur;
|
|
93
|
+
const finishedAt = performance.now() - m.start;
|
|
94
|
+
const startedAt = finishedAt - dur;
|
|
95
|
+
if (m.dbFirstOffset === null) m.dbFirstOffset = startedAt;
|
|
96
|
+
m.dbLastOffset = finishedAt;
|
|
97
|
+
}
|
|
98
|
+
if (ctx.queryRecorder) {
|
|
99
|
+
recordEvent(ctx.queryRecorder, event.query.sql, event.query.parameters, dur);
|
|
100
|
+
}
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
/**
|
|
92
|
-
* Returns a Kysely `log`
|
|
93
|
-
*
|
|
94
|
-
*
|
|
104
|
+
* Returns a Kysely `log` callback. Always returns a function so per-request
|
|
105
|
+
* counters (db.count, db.total, db.first, db.last) and the optional NDJSON
|
|
106
|
+
* recorder both get fed. The cost over the previous "undefined when off"
|
|
107
|
+
* behaviour is one `performance.now()` pair per query inside Kysely, which
|
|
108
|
+
* is in the noise compared to any real query.
|
|
95
109
|
*/
|
|
96
|
-
export function kyselyLogOption(): Logger
|
|
97
|
-
return
|
|
110
|
+
export function kyselyLogOption(): Logger {
|
|
111
|
+
return kyselyLog;
|
|
98
112
|
}
|
|
@@ -9,11 +9,20 @@ import { currentTimestamp } from "../dialect-helpers.js";
|
|
|
9
9
|
* 1. _emdash_api_tokens — Personal Access Tokens (ec_pat_...)
|
|
10
10
|
* 2. _emdash_oauth_tokens — OAuth access/refresh tokens (ec_oat_/ec_ort_...)
|
|
11
11
|
* 3. _emdash_device_codes — OAuth Device Flow state (RFC 8628)
|
|
12
|
+
*
|
|
13
|
+
* Every CREATE is guarded with `.ifNotExists()` so the migration is safe to
|
|
14
|
+
* re-run against a partially-applied schema. See #954 for the failure mode:
|
|
15
|
+
* if `up()` crashes mid-way (D1 subrequest limit, isolate cancellation,
|
|
16
|
+
* transient connection error), the migration record never gets inserted
|
|
17
|
+
* into `_emdash_migrations`, and the next request retries `up()` from the
|
|
18
|
+
* top. Without these guards, the retry crashed with `table ... already
|
|
19
|
+
* exists` and blocked every subsequent boot of the Worker.
|
|
12
20
|
*/
|
|
13
21
|
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
14
22
|
// ── Personal Access Tokens ───────────────────────────────────────
|
|
15
23
|
await db.schema
|
|
16
24
|
.createTable("_emdash_api_tokens")
|
|
25
|
+
.ifNotExists()
|
|
17
26
|
.addColumn("id", "text", (col) => col.primaryKey())
|
|
18
27
|
.addColumn("name", "text", (col) => col.notNull())
|
|
19
28
|
.addColumn("token_hash", "text", (col) => col.notNull().unique())
|
|
@@ -30,12 +39,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
30
39
|
|
|
31
40
|
await db.schema
|
|
32
41
|
.createIndex("idx_api_tokens_token_hash")
|
|
42
|
+
.ifNotExists()
|
|
33
43
|
.on("_emdash_api_tokens")
|
|
34
44
|
.column("token_hash")
|
|
35
45
|
.execute();
|
|
36
46
|
|
|
37
47
|
await db.schema
|
|
38
48
|
.createIndex("idx_api_tokens_user_id")
|
|
49
|
+
.ifNotExists()
|
|
39
50
|
.on("_emdash_api_tokens")
|
|
40
51
|
.column("user_id")
|
|
41
52
|
.execute();
|
|
@@ -43,6 +54,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
43
54
|
// ── OAuth Tokens ─────────────────────────────────────────────────
|
|
44
55
|
await db.schema
|
|
45
56
|
.createTable("_emdash_oauth_tokens")
|
|
57
|
+
.ifNotExists()
|
|
46
58
|
.addColumn("token_hash", "text", (col) => col.primaryKey())
|
|
47
59
|
.addColumn("token_type", "text", (col) => col.notNull()) // 'access' | 'refresh'
|
|
48
60
|
.addColumn("user_id", "text", (col) => col.notNull())
|
|
@@ -58,12 +70,14 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
58
70
|
|
|
59
71
|
await db.schema
|
|
60
72
|
.createIndex("idx_oauth_tokens_user_id")
|
|
73
|
+
.ifNotExists()
|
|
61
74
|
.on("_emdash_oauth_tokens")
|
|
62
75
|
.column("user_id")
|
|
63
76
|
.execute();
|
|
64
77
|
|
|
65
78
|
await db.schema
|
|
66
79
|
.createIndex("idx_oauth_tokens_expires")
|
|
80
|
+
.ifNotExists()
|
|
67
81
|
.on("_emdash_oauth_tokens")
|
|
68
82
|
.column("expires_at")
|
|
69
83
|
.execute();
|
|
@@ -71,6 +85,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
71
85
|
// ── Device Codes (OAuth Device Flow, RFC 8628) ───────────────────
|
|
72
86
|
await db.schema
|
|
73
87
|
.createTable("_emdash_device_codes")
|
|
88
|
+
.ifNotExists()
|
|
74
89
|
.addColumn("device_code", "text", (col) => col.primaryKey())
|
|
75
90
|
.addColumn("user_code", "text", (col) => col.notNull().unique())
|
|
76
91
|
.addColumn("scopes", "text", (col) => col.notNull()) // JSON array
|
|
@@ -83,7 +98,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
86
|
-
await db.schema.dropTable("_emdash_device_codes").execute();
|
|
87
|
-
await db.schema.dropTable("_emdash_oauth_tokens").execute();
|
|
88
|
-
await db.schema.dropTable("_emdash_api_tokens").execute();
|
|
101
|
+
await db.schema.dropTable("_emdash_device_codes").ifExists().execute();
|
|
102
|
+
await db.schema.dropTable("_emdash_oauth_tokens").ifExists().execute();
|
|
103
|
+
await db.schema.dropTable("_emdash_api_tokens").ifExists().execute();
|
|
89
104
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Kysely } from "kysely";
|
|
2
|
+
|
|
3
|
+
import { columnExists } from "../dialect-helpers.js";
|
|
4
|
+
|
|
5
|
+
export async function up(db: Kysely<unknown>): Promise<void> {
|
|
6
|
+
if (!(await columnExists(db, "credentials", "algorithm"))) {
|
|
7
|
+
await db.schema
|
|
8
|
+
.alterTable("credentials")
|
|
9
|
+
.addColumn("algorithm", "integer", (col) => col.notNull().defaultTo(-7))
|
|
10
|
+
.execute();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function down(db: Kysely<unknown>): Promise<void> {
|
|
15
|
+
if (await columnExists(db, "credentials", "algorithm")) {
|
|
16
|
+
await db.schema.alterTable("credentials").dropColumn("algorithm").execute();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -37,6 +37,7 @@ import * as m033 from "./033_optimize_content_indexes.js";
|
|
|
37
37
|
import * as m034 from "./034_published_at_index.js";
|
|
38
38
|
import * as m035 from "./035_bounded_404_log.js";
|
|
39
39
|
import * as m036 from "./036_i18n_menus_and_taxonomies.js";
|
|
40
|
+
import * as m037 from "./037_credential_algorithm.js";
|
|
40
41
|
|
|
41
42
|
const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
|
|
42
43
|
"001_initial": m001,
|
|
@@ -74,6 +75,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
|
|
|
74
75
|
"034_published_at_index": m034,
|
|
75
76
|
"035_bounded_404_log": m035,
|
|
76
77
|
"036_i18n_menus_and_taxonomies": m036,
|
|
78
|
+
"037_credential_algorithm": m037,
|
|
77
79
|
});
|
|
78
80
|
|
|
79
81
|
/** Total number of registered migrations. Exported for use in tests. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql, type Kysely, type SqlBool } from "kysely";
|
|
1
|
+
import { sql, type ExpressionBuilder, type Kysely, type SqlBool } from "kysely";
|
|
2
2
|
import { ulid } from "ulidx";
|
|
3
3
|
|
|
4
4
|
import type { Database, MediaRow } from "../types.js";
|
|
@@ -10,6 +10,35 @@ function escapeLike(value: string): string {
|
|
|
10
10
|
return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Normalize a mimeType filter (string or array) into a clean string[].
|
|
15
|
+
* Entries that are empty strings are dropped.
|
|
16
|
+
*/
|
|
17
|
+
function normalizeMimeFilter(input?: string | readonly string[]): string[] {
|
|
18
|
+
if (!input) return [];
|
|
19
|
+
const arr = Array.isArray(input) ? input : [input];
|
|
20
|
+
return arr
|
|
21
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
|
22
|
+
.map((entry) =>
|
|
23
|
+
entry.endsWith("/") ? entry.toLowerCase() : entry.split(";")[0]!.trim().toLowerCase(),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a WHERE clause that matches `mime_type` against any of the given
|
|
29
|
+
* filter entries — exact equality for full MIMEs, LIKE prefix for entries
|
|
30
|
+
* ending in "/".
|
|
31
|
+
*/
|
|
32
|
+
function mimeMatchExpr(eb: ExpressionBuilder<Database, "media">, filters: string[]) {
|
|
33
|
+
return eb.or(
|
|
34
|
+
filters.map((entry) =>
|
|
35
|
+
entry.endsWith("/")
|
|
36
|
+
? sql<SqlBool>`mime_type LIKE ${`${escapeLike(entry)}%`} ESCAPE '\\'`
|
|
37
|
+
: eb("mime_type", "=", entry),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
export type MediaStatus = "pending" | "ready" | "failed";
|
|
14
43
|
|
|
15
44
|
export interface MediaItem {
|
|
@@ -49,7 +78,8 @@ export interface CreateMediaInput {
|
|
|
49
78
|
export interface FindManyMediaOptions {
|
|
50
79
|
limit?: number;
|
|
51
80
|
cursor?: string;
|
|
52
|
-
|
|
81
|
+
/** Filter by MIME type. Pass a string for a single prefix/exact, or an array to match any. Strings ending with "/" are treated as LIKE prefix matches; others are exact equality. */
|
|
82
|
+
mimeType?: string | readonly string[];
|
|
53
83
|
status?: MediaStatus | "all"; // Filter by status, defaults to "ready"
|
|
54
84
|
}
|
|
55
85
|
|
|
@@ -215,9 +245,9 @@ export class MediaRepository {
|
|
|
215
245
|
);
|
|
216
246
|
}
|
|
217
247
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
query = query.where(
|
|
248
|
+
const mimeFilters = normalizeMimeFilter(options.mimeType);
|
|
249
|
+
if (mimeFilters.length > 0) {
|
|
250
|
+
query = query.where((eb) => mimeMatchExpr(eb, mimeFilters));
|
|
221
251
|
}
|
|
222
252
|
|
|
223
253
|
// Default to only showing ready items
|
|
@@ -276,12 +306,12 @@ export class MediaRepository {
|
|
|
276
306
|
/**
|
|
277
307
|
* Count media items
|
|
278
308
|
*/
|
|
279
|
-
async count(mimeType?: string): Promise<number> {
|
|
280
|
-
|
|
309
|
+
async count(mimeType?: string | readonly string[]): Promise<number> {
|
|
310
|
+
const filters = normalizeMimeFilter(mimeType);
|
|
311
|
+
let query = this.db.selectFrom("media").select((eb) => eb.fn.count<number>("id").as("count"));
|
|
281
312
|
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
query = query.where(sql<SqlBool>`mime_type LIKE ${pattern} ESCAPE '\\'`);
|
|
313
|
+
if (filters.length > 0) {
|
|
314
|
+
query = query.where((eb) => mimeMatchExpr(eb, filters));
|
|
285
315
|
}
|
|
286
316
|
|
|
287
317
|
const result = await query.executeTakeFirst();
|
package/src/database/types.ts
CHANGED
|
@@ -76,7 +76,8 @@ export interface UserTable {
|
|
|
76
76
|
export interface CredentialTable {
|
|
77
77
|
id: string; // Base64url credential ID
|
|
78
78
|
user_id: string;
|
|
79
|
-
public_key: Uint8Array; //
|
|
79
|
+
public_key: Uint8Array; // SEC1 or PKIX encoded public key
|
|
80
|
+
algorithm: number;
|
|
80
81
|
counter: number;
|
|
81
82
|
device_type: string; // 'singleDevice' | 'multiDevice'
|
|
82
83
|
backed_up: number; // 0 or 1
|
package/src/emdash-runtime.ts
CHANGED
|
@@ -1287,6 +1287,8 @@ export class EmDashRuntime {
|
|
|
1287
1287
|
// or arbitrary `Record<string, unknown>` for plugin field widgets that
|
|
1288
1288
|
// need per-field config (e.g. a checkbox grid receiving its column defs).
|
|
1289
1289
|
options?: Array<{ value: string; label: string }> | Record<string, unknown>;
|
|
1290
|
+
id?: string;
|
|
1291
|
+
validation?: Record<string, unknown>;
|
|
1290
1292
|
}
|
|
1291
1293
|
> = {};
|
|
1292
1294
|
|
|
@@ -1296,6 +1298,9 @@ export class EmDashRuntime {
|
|
|
1296
1298
|
label: field.label,
|
|
1297
1299
|
required: field.required,
|
|
1298
1300
|
};
|
|
1301
|
+
// Always include the field's database ID so the admin can forward it
|
|
1302
|
+
// to upload/media-list API calls for MIME allowlist widening.
|
|
1303
|
+
entry.id = field.id;
|
|
1299
1304
|
if (field.widget) entry.widget = field.widget;
|
|
1300
1305
|
// Plugin field widgets read their per-field config from `field.options`,
|
|
1301
1306
|
// which the seed schema types as `Record<string, unknown>`. Pass it
|
|
@@ -1312,8 +1317,12 @@ export class EmDashRuntime {
|
|
|
1312
1317
|
}));
|
|
1313
1318
|
}
|
|
1314
1319
|
// Include full validation for repeater fields (subFields, minItems, maxItems)
|
|
1315
|
-
|
|
1316
|
-
|
|
1320
|
+
// and for file/image fields (allowedMimeTypes).
|
|
1321
|
+
if (
|
|
1322
|
+
(field.type === "repeater" || field.type === "file" || field.type === "image") &&
|
|
1323
|
+
field.validation
|
|
1324
|
+
) {
|
|
1325
|
+
entry.validation = { ...field.validation };
|
|
1317
1326
|
}
|
|
1318
1327
|
fields[field.slug] = entry;
|
|
1319
1328
|
}
|
|
@@ -1980,7 +1989,11 @@ export class EmDashRuntime {
|
|
|
1980
1989
|
// Media Handlers
|
|
1981
1990
|
// =========================================================================
|
|
1982
1991
|
|
|
1983
|
-
async handleMediaList(params: {
|
|
1992
|
+
async handleMediaList(params: {
|
|
1993
|
+
cursor?: string;
|
|
1994
|
+
limit?: number;
|
|
1995
|
+
mimeType?: string | readonly string[];
|
|
1996
|
+
}) {
|
|
1984
1997
|
return handleMediaList(this.db, params);
|
|
1985
1998
|
}
|
|
1986
1999
|
|
package/src/fields/file.ts
CHANGED
|
@@ -5,13 +5,10 @@ import type { FieldDefinition, FieldUIHints, FileValue } from "./types.js";
|
|
|
5
5
|
export interface FileOptions {
|
|
6
6
|
required?: boolean;
|
|
7
7
|
maxSize?: number; // In bytes
|
|
8
|
-
allowedTypes?: string[]; // MIME types
|
|
8
|
+
allowedTypes?: string[]; // MIME types — exact (image/png) or prefix (image/)
|
|
9
9
|
helpText?: string;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
* File field - file upload
|
|
14
|
-
*/
|
|
15
12
|
export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
|
|
16
13
|
const fileObjSchema = z.object({
|
|
17
14
|
id: z.string(),
|
|
@@ -21,21 +18,25 @@ export function file(options: FileOptions = {}): FieldDefinition<FileValue> {
|
|
|
21
18
|
size: z.number(),
|
|
22
19
|
});
|
|
23
20
|
|
|
24
|
-
// Optional vs required
|
|
25
21
|
const schema: z.ZodTypeAny = options.required ? fileObjSchema : fileObjSchema.optional();
|
|
26
22
|
|
|
27
23
|
const ui: FieldUIHints = {
|
|
28
24
|
widget: "file",
|
|
29
25
|
helpText: options.helpText,
|
|
30
26
|
maxSize: options.maxSize,
|
|
31
|
-
allowedTypes: options.allowedTypes,
|
|
32
27
|
};
|
|
33
28
|
|
|
29
|
+
const validation =
|
|
30
|
+
options.allowedTypes && options.allowedTypes.length > 0
|
|
31
|
+
? { allowedMimeTypes: [...options.allowedTypes] }
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
34
|
return {
|
|
35
35
|
type: "file",
|
|
36
36
|
columnType: "TEXT",
|
|
37
37
|
schema,
|
|
38
38
|
options,
|
|
39
39
|
ui,
|
|
40
|
+
validation,
|
|
40
41
|
};
|
|
41
42
|
}
|
package/src/fields/image.ts
CHANGED
|
@@ -2,9 +2,6 @@ import { z } from "astro/zod";
|
|
|
2
2
|
|
|
3
3
|
import type { FieldDefinition, ImageValue } from "./types.js";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Image field schema
|
|
7
|
-
*/
|
|
8
5
|
const imageSchema = z.object({
|
|
9
6
|
id: z.string(),
|
|
10
7
|
src: z.string(),
|
|
@@ -13,22 +10,26 @@ const imageSchema = z.object({
|
|
|
13
10
|
height: z.number().optional(),
|
|
14
11
|
});
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
* Image field
|
|
18
|
-
* References media items from the media library
|
|
19
|
-
*/
|
|
20
|
-
export function image(options?: {
|
|
13
|
+
export interface ImageOptions {
|
|
21
14
|
required?: boolean;
|
|
22
15
|
maxSize?: number; // in bytes
|
|
23
|
-
allowedTypes?: string[]; // MIME types
|
|
24
|
-
}
|
|
16
|
+
allowedTypes?: string[]; // MIME types — exact or prefix
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function image(options: ImageOptions = {}): FieldDefinition<ImageValue | undefined> {
|
|
20
|
+
const validation =
|
|
21
|
+
options.allowedTypes && options.allowedTypes.length > 0
|
|
22
|
+
? { allowedMimeTypes: [...options.allowedTypes] }
|
|
23
|
+
: undefined;
|
|
24
|
+
|
|
25
25
|
return {
|
|
26
26
|
type: "image",
|
|
27
27
|
columnType: "TEXT",
|
|
28
|
-
schema: options
|
|
28
|
+
schema: options.required === false ? imageSchema.optional() : imageSchema,
|
|
29
29
|
options,
|
|
30
30
|
ui: {
|
|
31
31
|
widget: "image",
|
|
32
32
|
},
|
|
33
|
+
validation,
|
|
33
34
|
};
|
|
34
35
|
}
|
package/src/fields/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { z } from "astro/zod";
|
|
2
2
|
|
|
3
|
+
import type { FieldValidation } from "../schema/types.js";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* SQLite column types that map from field types
|
|
5
7
|
*/
|
|
@@ -19,6 +21,7 @@ export interface FieldDefinition<_T = unknown> {
|
|
|
19
21
|
schema: z.ZodTypeAny;
|
|
20
22
|
options?: unknown;
|
|
21
23
|
ui?: FieldUIHints;
|
|
24
|
+
validation?: FieldValidation;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/**
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type {
|
|
|
27
27
|
export type { MediaItem, CreateMediaInput } from "./database/repositories/media.js";
|
|
28
28
|
|
|
29
29
|
// Fields
|
|
30
|
-
export { portableText, image, reference } from "./fields/index.js";
|
|
30
|
+
export { portableText, image, file, reference } from "./fields/index.js";
|
|
31
31
|
export { normalizeMediaValue } from "./media/normalize.js";
|
|
32
32
|
export { generatePlaceholder } from "./media/placeholder.js";
|
|
33
33
|
export type { PlaceholderData } from "./media/placeholder.js";
|
package/src/mcp/server.ts
CHANGED
|
@@ -1993,13 +1993,23 @@ export function createMcpServer(): McpServer {
|
|
|
1993
1993
|
description:
|
|
1994
1994
|
"Create a new navigation menu. The `name` is the stable identifier used " +
|
|
1995
1995
|
"by site templates (e.g. 'main', 'footer'); `label` is the human-readable " +
|
|
1996
|
-
"name shown in the admin.
|
|
1996
|
+
"name shown in the admin. Menus are per-locale, so pass `locale` when " +
|
|
1997
|
+
"the same menu name exists in multiple translations. Add items afterwards " +
|
|
1998
|
+
"with menu_set_items. If `translationOf` is set, `locale` must also be set.",
|
|
1999
|
+
// `locale`-when-`translationOf` is enforced inside handleMenuCreate
|
|
2000
|
+
// so REST/SDK callers get the same guard. The description above
|
|
2001
|
+
// documents the rule; the handler returns VALIDATION_ERROR.
|
|
1997
2002
|
inputSchema: z.object({
|
|
1998
2003
|
name: z
|
|
1999
2004
|
.string()
|
|
2000
2005
|
.regex(COLLECTION_SLUG_PATTERN)
|
|
2001
2006
|
.describe("Stable identifier (lowercase letters, numbers, underscores)"),
|
|
2002
2007
|
label: z.string().describe("Display name for the admin"),
|
|
2008
|
+
locale: z.string().optional().describe("Locale for this menu (e.g. 'fr-fr')"),
|
|
2009
|
+
translationOf: z
|
|
2010
|
+
.string()
|
|
2011
|
+
.optional()
|
|
2012
|
+
.describe("Existing menu id to create this locale variant from"),
|
|
2003
2013
|
}),
|
|
2004
2014
|
},
|
|
2005
2015
|
async (args, extra) => {
|
|
@@ -2008,7 +2018,14 @@ export function createMcpServer(): McpServer {
|
|
|
2008
2018
|
const ec = getEmDash(extra);
|
|
2009
2019
|
try {
|
|
2010
2020
|
const { handleMenuCreate } = await import("../api/handlers/menus.js");
|
|
2011
|
-
return unwrap(
|
|
2021
|
+
return unwrap(
|
|
2022
|
+
await handleMenuCreate(ec.db, {
|
|
2023
|
+
name: args.name,
|
|
2024
|
+
label: args.label,
|
|
2025
|
+
locale: args.locale,
|
|
2026
|
+
translationOf: args.translationOf,
|
|
2027
|
+
}),
|
|
2028
|
+
);
|
|
2012
2029
|
} catch (error) {
|
|
2013
2030
|
return respondHandlerError(error, "MENU_CREATE_ERROR");
|
|
2014
2031
|
}
|
|
@@ -2019,10 +2036,13 @@ export function createMcpServer(): McpServer {
|
|
|
2019
2036
|
"menu_update",
|
|
2020
2037
|
{
|
|
2021
2038
|
title: "Update Menu",
|
|
2022
|
-
description:
|
|
2039
|
+
description:
|
|
2040
|
+
"Update a menu's label. The `name` (stable identifier) cannot be changed. " +
|
|
2041
|
+
"On multi-locale installs, pass `locale` so the correct translation is updated.",
|
|
2023
2042
|
inputSchema: z.object({
|
|
2024
2043
|
name: z.string().describe("Menu name to update"),
|
|
2025
2044
|
label: z.string().describe("New display label"),
|
|
2045
|
+
locale: z.string().optional().describe("Locale of the menu to update"),
|
|
2026
2046
|
}),
|
|
2027
2047
|
},
|
|
2028
2048
|
async (args, extra) => {
|
|
@@ -2031,7 +2051,9 @@ export function createMcpServer(): McpServer {
|
|
|
2031
2051
|
const ec = getEmDash(extra);
|
|
2032
2052
|
try {
|
|
2033
2053
|
const { handleMenuUpdate } = await import("../api/handlers/menus.js");
|
|
2034
|
-
return unwrap(
|
|
2054
|
+
return unwrap(
|
|
2055
|
+
await handleMenuUpdate(ec.db, args.name, { label: args.label, locale: args.locale }),
|
|
2056
|
+
);
|
|
2035
2057
|
} catch (error) {
|
|
2036
2058
|
return respondHandlerError(error, "MENU_UPDATE_ERROR");
|
|
2037
2059
|
}
|
|
@@ -2042,9 +2064,12 @@ export function createMcpServer(): McpServer {
|
|
|
2042
2064
|
"menu_delete",
|
|
2043
2065
|
{
|
|
2044
2066
|
title: "Delete Menu",
|
|
2045
|
-
description:
|
|
2067
|
+
description:
|
|
2068
|
+
"Delete a menu. Items are also removed. Cannot be undone. On multi-locale " +
|
|
2069
|
+
"installs, pass `locale` so only the intended translation is removed.",
|
|
2046
2070
|
inputSchema: z.object({
|
|
2047
2071
|
name: z.string().describe("Menu name to delete"),
|
|
2072
|
+
locale: z.string().optional().describe("Locale of the menu to delete"),
|
|
2048
2073
|
}),
|
|
2049
2074
|
annotations: { destructiveHint: true },
|
|
2050
2075
|
},
|
|
@@ -2054,7 +2079,7 @@ export function createMcpServer(): McpServer {
|
|
|
2054
2079
|
const ec = getEmDash(extra);
|
|
2055
2080
|
try {
|
|
2056
2081
|
const { handleMenuDelete } = await import("../api/handlers/menus.js");
|
|
2057
|
-
return unwrap(await handleMenuDelete(ec.db, args.name));
|
|
2082
|
+
return unwrap(await handleMenuDelete(ec.db, args.name, { locale: args.locale }));
|
|
2058
2083
|
} catch (error) {
|
|
2059
2084
|
return respondHandlerError(error, "MENU_DELETE_ERROR");
|
|
2060
2085
|
}
|
|
@@ -2069,9 +2094,11 @@ export function createMcpServer(): McpServer {
|
|
|
2069
2094
|
"Replace the entire item list of a menu in one call. This is atomic: the " +
|
|
2070
2095
|
"existing items are deleted and the new list is inserted in the order " +
|
|
2071
2096
|
"provided. Use this rather than per-item add/remove tools so the resulting " +
|
|
2072
|
-
"order and parent links are unambiguous."
|
|
2097
|
+
"order and parent links are unambiguous. On multi-locale installs, pass " +
|
|
2098
|
+
"`locale` so only the intended translation is rewritten.",
|
|
2073
2099
|
inputSchema: z.object({
|
|
2074
2100
|
name: z.string().describe("Menu name to update"),
|
|
2101
|
+
locale: z.string().optional().describe("Locale of the menu to rewrite"),
|
|
2075
2102
|
items: z
|
|
2076
2103
|
.array(
|
|
2077
2104
|
z.object({
|
|
@@ -2115,7 +2142,9 @@ export function createMcpServer(): McpServer {
|
|
|
2115
2142
|
const ec = getEmDash(extra);
|
|
2116
2143
|
try {
|
|
2117
2144
|
const { handleMenuSetItems } = await import("../api/handlers/menus.js");
|
|
2118
|
-
return unwrap(
|
|
2145
|
+
return unwrap(
|
|
2146
|
+
await handleMenuSetItems(ec.db, args.name, args.items, { locale: args.locale }),
|
|
2147
|
+
);
|
|
2119
2148
|
} catch (error) {
|
|
2120
2149
|
return respondHandlerError(error, "MENU_SET_ITEMS_ERROR");
|
|
2121
2150
|
}
|