emdash 0.2.0 → 0.3.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.
Files changed (126) hide show
  1. package/dist/{adapters-N6BF7RCD.d.mts → adapters-BLMa4JGD.d.mts} +1 -1
  2. package/dist/{adapters-N6BF7RCD.d.mts.map → adapters-BLMa4JGD.d.mts.map} +1 -1
  3. package/dist/{apply-wmVEOSbR.mjs → apply-Bqoekfbe.mjs} +6 -6
  4. package/dist/{apply-wmVEOSbR.mjs.map → apply-Bqoekfbe.mjs.map} +1 -1
  5. package/dist/astro/index.d.mts +25 -11
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +31 -19
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.mjs +1 -1
  11. package/dist/astro/middleware/redirect.mjs +2 -2
  12. package/dist/astro/middleware/setup.mjs +1 -1
  13. package/dist/astro/middleware.mjs +20 -16
  14. package/dist/astro/middleware.mjs.map +1 -1
  15. package/dist/astro/types.d.mts +9 -9
  16. package/dist/astro/types.d.mts.map +1 -1
  17. package/dist/{byline-1WQPlISL.mjs → byline-BGj9p9Ht.mjs} +3 -3
  18. package/dist/{byline-1WQPlISL.mjs.map → byline-BGj9p9Ht.mjs.map} +1 -1
  19. package/dist/{bylines-BYdTYmia.mjs → bylines-BihaoIDY.mjs} +5 -5
  20. package/dist/{bylines-BYdTYmia.mjs.map → bylines-BihaoIDY.mjs.map} +1 -1
  21. package/dist/cli/index.mjs +8 -8
  22. package/dist/client/cf-access.d.mts +1 -1
  23. package/dist/client/index.d.mts +1 -1
  24. package/dist/client/index.mjs +1 -1
  25. package/dist/{content-BmXndhdi.mjs → content-BsBoyj8G.mjs} +20 -3
  26. package/dist/content-BsBoyj8G.mjs.map +1 -0
  27. package/dist/db/index.d.mts +3 -3
  28. package/dist/db/index.mjs +2 -2
  29. package/dist/db/libsql.d.mts +1 -1
  30. package/dist/db/postgres.d.mts +1 -1
  31. package/dist/db/sqlite.d.mts +1 -1
  32. package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
  33. package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
  34. package/dist/{index-UHEVQMus.d.mts → index-Cff7AimE.d.mts} +40 -16
  35. package/dist/index-Cff7AimE.d.mts.map +1 -0
  36. package/dist/index.d.mts +11 -11
  37. package/dist/index.mjs +12 -12
  38. package/dist/{loader-CHb2v0jm.mjs → loader-BmYdf3Dr.mjs} +4 -2
  39. package/dist/loader-BmYdf3Dr.mjs.map +1 -0
  40. package/dist/media/index.d.mts +1 -1
  41. package/dist/media/local-runtime.d.mts +7 -7
  42. package/dist/{mode-CYeM2rPt.mjs → mode-C2EzN1uE.mjs} +1 -1
  43. package/dist/{mode-CYeM2rPt.mjs.map → mode-C2EzN1uE.mjs.map} +1 -1
  44. package/dist/page/index.d.mts +1 -1
  45. package/dist/{placeholder-bOx1xCTY.d.mts → placeholder-SvFCKbz_.d.mts} +1 -1
  46. package/dist/{placeholder-bOx1xCTY.d.mts.map → placeholder-SvFCKbz_.d.mts.map} +1 -1
  47. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  48. package/dist/{query-5Hcv_5ER.mjs → query-sesiOndV.mjs} +6 -6
  49. package/dist/{query-5Hcv_5ER.mjs.map → query-sesiOndV.mjs.map} +1 -1
  50. package/dist/{redirect-DIfIni3r.mjs → redirect-DUAk-Yl_.mjs} +9 -2
  51. package/dist/redirect-DUAk-Yl_.mjs.map +1 -0
  52. package/dist/{registry-1EvbAfsC.mjs → registry-DU18yVo0.mjs} +9 -3
  53. package/dist/registry-DU18yVo0.mjs.map +1 -0
  54. package/dist/{runner-BoN0-FPi.mjs → runner-Biufrii2.mjs} +3 -3
  55. package/dist/{runner-BoN0-FPi.mjs.map → runner-Biufrii2.mjs.map} +1 -1
  56. package/dist/{runner-DTqkzOzc.d.mts → runner-EAtf0ZIe.d.mts} +2 -2
  57. package/dist/{runner-DTqkzOzc.d.mts.map → runner-EAtf0ZIe.d.mts.map} +1 -1
  58. package/dist/runtime.d.mts +6 -6
  59. package/dist/runtime.mjs +2 -2
  60. package/dist/{search-BsYMed12.mjs → search-BXB-jfu2.mjs} +13 -11
  61. package/dist/search-BXB-jfu2.mjs.map +1 -0
  62. package/dist/seed/index.d.mts +2 -2
  63. package/dist/seed/index.mjs +7 -7
  64. package/dist/seo/index.d.mts +1 -1
  65. package/dist/storage/local.d.mts +1 -1
  66. package/dist/storage/s3.d.mts +11 -3
  67. package/dist/storage/s3.d.mts.map +1 -1
  68. package/dist/storage/s3.mjs +75 -14
  69. package/dist/storage/s3.mjs.map +1 -1
  70. package/dist/{transport-COOs9GSE.d.mts → transport-BFGblqwG.d.mts} +1 -1
  71. package/dist/{transport-COOs9GSE.d.mts.map → transport-BFGblqwG.d.mts.map} +1 -1
  72. package/dist/{transport-Bl8cTdYt.mjs → transport-yxiQsi8I.mjs} +1 -1
  73. package/dist/{transport-Bl8cTdYt.mjs.map → transport-yxiQsi8I.mjs.map} +1 -1
  74. package/dist/{types-CIsTnQvJ.d.mts → types-BbsYgi_R.d.mts} +1 -1
  75. package/dist/{types-CIsTnQvJ.d.mts.map → types-BbsYgi_R.d.mts.map} +1 -1
  76. package/dist/types-Bec-r_3_.mjs.map +1 -1
  77. package/dist/{types-BljtYPSd.d.mts → types-C1-PVaS_.d.mts} +14 -6
  78. package/dist/types-C1-PVaS_.d.mts.map +1 -0
  79. package/dist/{types-6dqxBqsH.d.mts → types-CaKte3hR.d.mts} +102 -4
  80. package/dist/types-CaKte3hR.d.mts.map +1 -0
  81. package/dist/{types-CcreFIIH.d.mts → types-DPfzHnjW.d.mts} +1 -1
  82. package/dist/{types-CcreFIIH.d.mts.map → types-DPfzHnjW.d.mts.map} +1 -1
  83. package/dist/{types-7-UjSEyB.d.mts → types-DRjfYOEv.d.mts} +1 -1
  84. package/dist/{types-7-UjSEyB.d.mts.map → types-DRjfYOEv.d.mts.map} +1 -1
  85. package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
  86. package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
  87. package/dist/{validate-B7KP7VLM.d.mts → validate-bfg9OR6N.d.mts} +4 -4
  88. package/dist/{validate-B7KP7VLM.d.mts.map → validate-bfg9OR6N.d.mts.map} +1 -1
  89. package/dist/version-REAapfsU.mjs +7 -0
  90. package/dist/version-REAapfsU.mjs.map +1 -0
  91. package/package.json +5 -5
  92. package/src/api/handlers/redirects.ts +95 -3
  93. package/src/api/schemas/redirects.ts +1 -0
  94. package/src/astro/integration/vite-config.ts +7 -4
  95. package/src/astro/routes/admin.astro +2 -2
  96. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
  97. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
  98. package/src/astro/routes/api/manifest.ts +3 -1
  99. package/src/astro/storage/adapters.ts +19 -5
  100. package/src/astro/storage/types.ts +12 -4
  101. package/src/astro/types.ts +1 -0
  102. package/src/bylines/index.ts +2 -2
  103. package/src/database/dialect-helpers.ts +3 -0
  104. package/src/database/repositories/content.ts +5 -0
  105. package/src/database/repositories/redirect.ts +13 -0
  106. package/src/database/validate.ts +10 -10
  107. package/src/emdash-runtime.ts +5 -1
  108. package/src/index.ts +1 -0
  109. package/src/loader.ts +2 -0
  110. package/src/menus/index.ts +4 -0
  111. package/src/redirects/loops.ts +318 -0
  112. package/src/schema/registry.ts +3 -0
  113. package/src/search/fts-manager.ts +4 -0
  114. package/src/storage/s3.ts +94 -25
  115. package/src/storage/types.ts +13 -5
  116. package/src/utils/slugify.ts +11 -0
  117. package/src/version.ts +12 -0
  118. package/dist/content-BmXndhdi.mjs.map +0 -1
  119. package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
  120. package/dist/index-UHEVQMus.d.mts.map +0 -1
  121. package/dist/loader-CHb2v0jm.mjs.map +0 -1
  122. package/dist/redirect-DIfIni3r.mjs.map +0 -1
  123. package/dist/registry-1EvbAfsC.mjs.map +0 -1
  124. package/dist/search-BsYMed12.mjs.map +0 -1
  125. package/dist/types-6dqxBqsH.d.mts.map +0 -1
  126. package/dist/types-BljtYPSd.d.mts.map +0 -1
@@ -3,6 +3,7 @@ import { ulid } from "ulidx";
3
3
 
4
4
  import { slugify } from "../../utils/slugify.js";
5
5
  import type { Database } from "../types.js";
6
+ import { validateIdentifier } from "../validate.js";
6
7
  import { RevisionRepository } from "./revision.js";
7
8
  import type {
8
9
  CreateContentInput,
@@ -41,6 +42,7 @@ const SYSTEM_COLUMNS = new Set([
41
42
  * Get the table name for a collection type
42
43
  */
43
44
  function getTableName(type: string): string {
45
+ validateIdentifier(type, "collection type");
44
46
  return `ec_${type}`;
45
47
  }
46
48
 
@@ -168,6 +170,7 @@ export class ContentRepository {
168
170
  if (data && typeof data === "object") {
169
171
  for (const [key, value] of Object.entries(data)) {
170
172
  if (!SYSTEM_COLUMNS.has(key)) {
173
+ validateIdentifier(key, "content field name");
171
174
  columns.push(key);
172
175
  values.push(serializeValue(value));
173
176
  }
@@ -578,6 +581,7 @@ export class ContentRepository {
578
581
  if (input.data !== undefined && typeof input.data === "object") {
579
582
  for (const [key, value] of Object.entries(input.data)) {
580
583
  if (!SYSTEM_COLUMNS.has(key)) {
584
+ validateIdentifier(key, "content field name");
581
585
  updates[key] = serializeValue(value);
582
586
  }
583
587
  }
@@ -1079,6 +1083,7 @@ export class ContentRepository {
1079
1083
  for (const [key, value] of Object.entries(data)) {
1080
1084
  if (SYSTEM_COLUMNS.has(key)) continue;
1081
1085
  if (key.startsWith("_")) continue; // revision metadata
1086
+ validateIdentifier(key, "content field name");
1082
1087
  updates[key] = serializeValue(value);
1083
1088
  }
1084
1089
 
@@ -237,6 +237,19 @@ export class RedirectRepository {
237
237
  return BigInt(result.numDeletedRows) > 0n;
238
238
  }
239
239
 
240
+ /**
241
+ * Fetch all enabled redirects (for loop detection graph building).
242
+ * Not paginated — returns the full set.
243
+ */
244
+ async findAllEnabled(): Promise<Redirect[]> {
245
+ const rows = await this.db
246
+ .selectFrom("_emdash_redirects")
247
+ .selectAll()
248
+ .where("enabled", "=", 1)
249
+ .execute();
250
+ return rows.map(rowToRedirect);
251
+ }
252
+
240
253
  // --- Matching -----------------------------------------------------------
241
254
 
242
255
  async findExactMatch(path: string): Promise<Redirect | null> {
@@ -79,16 +79,6 @@ export function validateIdentifier(value: string, label = "identifier"): void {
79
79
  }
80
80
  }
81
81
 
82
- /**
83
- * Validate that a string is a safe SQL identifier, allowing hyphens.
84
- *
85
- * Like `validateIdentifier` but also permits hyphens, which appear in
86
- * plugin IDs (e.g., "my-plugin"). Matches `/^[a-z][a-z0-9_-]*$/`.
87
- *
88
- * @param value - The string to validate
89
- * @param label - Human-readable label for error messages
90
- * @throws {IdentifierError} If the value is not valid
91
- */
92
82
  /**
93
83
  * Validate that a string is a safe JSON field name for use in json_extract paths.
94
84
  *
@@ -120,6 +110,16 @@ export function validateJsonFieldName(value: string, label = "JSON field name"):
120
110
  }
121
111
  }
122
112
 
113
+ /**
114
+ * Validate that a string is a safe SQL identifier, allowing hyphens.
115
+ *
116
+ * Like `validateIdentifier` but also permits hyphens, which appear in
117
+ * plugin IDs (e.g., "my-plugin"). Matches `/^[a-z][a-z0-9_-]*$/`.
118
+ *
119
+ * @param value - The string to validate
120
+ * @param label - Human-readable label for error messages
121
+ * @throws {IdentifierError} If the value is not valid
122
+ */
123
123
  export function validatePluginIdentifier(value: string, label = "plugin identifier"): void {
124
124
  if (!value || typeof value !== "string") {
125
125
  throw new IdentifierError(`${label} must be a non-empty string`, String(value));
@@ -23,6 +23,7 @@ import { isSqlite } from "./database/dialect-helpers.js";
23
23
  import { runMigrations } from "./database/migrations/runner.js";
24
24
  import { RevisionRepository } from "./database/repositories/revision.js";
25
25
  import type { ContentItem as ContentItemInternal } from "./database/repositories/types.js";
26
+ import { validateIdentifier } from "./database/validate.js";
26
27
  import { normalizeMediaValue } from "./media/normalize.js";
27
28
  import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js";
28
29
  import type { SandboxedPlugin, SandboxRunner } from "./plugins/sandbox/types.js";
@@ -38,6 +39,7 @@ import type {
38
39
  } from "./plugins/types.js";
39
40
  import type { FieldType } from "./schema/types.js";
40
41
  import { hashString } from "./utils/hash.js";
42
+ import { COMMIT, VERSION } from "./version.js";
41
43
 
42
44
  const LEADING_SLASH_PATTERN = /^\//;
43
45
 
@@ -1352,7 +1354,8 @@ export class EmDashRuntime {
1352
1354
  : undefined;
1353
1355
 
1354
1356
  return {
1355
- version: "0.1.0",
1357
+ version: VERSION,
1358
+ commit: COMMIT,
1356
1359
  hash: manifestHash,
1357
1360
  collections: manifestCollections,
1358
1361
  plugins: manifestPlugins,
@@ -1540,6 +1543,7 @@ export class EmDashRuntime {
1540
1543
  });
1541
1544
 
1542
1545
  // Update entry to point to new draft (metadata only, not data columns)
1546
+ validateIdentifier(collection, "collection");
1543
1547
  const tableName = `ec_${collection}`;
1544
1548
  await sql`
1545
1549
  UPDATE ${sql.ref(tableName)}
package/src/index.ts CHANGED
@@ -102,6 +102,7 @@ export type {
102
102
  export { ulid } from "ulidx";
103
103
  export { computeContentHash, hashString } from "./utils/hash.js";
104
104
  export { sanitizeHref, isSafeHref } from "./utils/url.js";
105
+ export { decodeSlug } from "./utils/slugify.js";
105
106
 
106
107
  // Live Collections query functions (loader is in emdash/runtime)
107
108
  export {
package/src/loader.ts CHANGED
@@ -16,6 +16,7 @@ import { Kysely, sql, type Dialect } from "kysely";
16
16
 
17
17
  import { currentTimestampValue, isPostgres } from "./database/dialect-helpers.js";
18
18
  import { decodeCursor, encodeCursor } from "./database/repositories/types.js";
19
+ import { validateIdentifier } from "./database/validate.js";
19
20
  import type { Database } from "./index.js";
20
21
  import { getRequestContext } from "./request-context.js";
21
22
 
@@ -50,6 +51,7 @@ const SYSTEM_COLUMNS = new Set([
50
51
  * Get the table name for a collection type
51
52
  */
52
53
  function getTableName(type: string): string {
54
+ validateIdentifier(type, "collection type");
53
55
  return `ec_${type}`;
54
56
  }
55
57
 
@@ -8,6 +8,7 @@ import type { Kysely } from "kysely";
8
8
  import { sql } from "kysely";
9
9
 
10
10
  import type { Database } from "../database/types.js";
11
+ import { validateIdentifier } from "../database/validate.js";
11
12
  import { getDb } from "../loader.js";
12
13
  import { sanitizeHref } from "../utils/url.js";
13
14
  import type { Menu, MenuItem, MenuItemRow } from "./types.js";
@@ -273,6 +274,9 @@ async function resolveContentUrl(
273
274
  }
274
275
 
275
276
  try {
277
+ // Validate collection name before interpolating into table reference
278
+ validateIdentifier(collection, "menu item collection");
279
+
276
280
  // Dynamic content tables (ec_*) aren't in the Database type, so use sql
277
281
  const result = await sql<{ slug: string }>`
278
282
  SELECT slug FROM ${sql.ref(`ec_${collection}`)} WHERE id = ${entryId} LIMIT 1
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Redirect loop and chain detection utilities.
3
+ *
4
+ * Builds a directed graph from redirect rules and detects:
5
+ * - Cycles (loops): /a → /b → /c → /a
6
+ * - Long chains: /a → /b → /c → /d → /e (exceeding a warning threshold)
7
+ *
8
+ * Handles both exact and pattern redirects. When the walker encounters
9
+ * a path with no exact source match, it tests against compiled pattern
10
+ * sources and resolves the destination using captured parameters.
11
+ */
12
+
13
+ import {
14
+ compilePattern,
15
+ matchPattern,
16
+ interpolateDestination,
17
+ type CompiledPattern,
18
+ } from "./patterns.js";
19
+
20
+ export interface RedirectEdge {
21
+ id: string;
22
+ source: string;
23
+ destination: string;
24
+ enabled: boolean;
25
+ isPattern: boolean;
26
+ }
27
+
28
+ interface CompiledPatternRedirect {
29
+ id: string;
30
+ compiled: CompiledPattern;
31
+ destination: string;
32
+ }
33
+
34
+ /**
35
+ * Compile all enabled pattern redirects for matching during graph walks.
36
+ */
37
+ function compilePatterns(edges: RedirectEdge[]): CompiledPatternRedirect[] {
38
+ const result: CompiledPatternRedirect[] = [];
39
+ for (const edge of edges) {
40
+ if (edge.enabled && edge.isPattern) {
41
+ result.push({
42
+ id: edge.id,
43
+ compiled: compilePattern(edge.source),
44
+ destination: edge.destination,
45
+ });
46
+ }
47
+ }
48
+ return result;
49
+ }
50
+
51
+ /** Single-segment dummy value for representative path generation */
52
+ const DUMMY_SEGMENT = "__p__";
53
+
54
+ /** Splat pattern: [...paramName] */
55
+ const SPLAT_RE = /\[\.\.\.(\w+)\]/g;
56
+
57
+ /** Param pattern: [paramName] */
58
+ const PARAM_RE = /\[(\w+)\]/g;
59
+
60
+ /**
61
+ * Extract the literal prefix from a pattern source (everything before the
62
+ * first placeholder), stripped of leading segments shared with a base path.
63
+ * e.g., "/new/docs/[slug]" → "docs/__p__" (the part after "/new/")
64
+ */
65
+ function extractPatternSuffix(patternSource: string): string {
66
+ // Replace placeholders with dummy values
67
+ let result = patternSource.replace(SPLAT_RE, DUMMY_SEGMENT);
68
+ SPLAT_RE.lastIndex = 0;
69
+ result = result.replace(PARAM_RE, DUMMY_SEGMENT);
70
+ // Strip leading slash and first segment (e.g., "/new/docs/__p__" → "docs/__p__")
71
+ const parts = result.split("/").filter(Boolean);
72
+ return parts.slice(1).join("/");
73
+ }
74
+
75
+ /**
76
+ * Generate representative concrete paths from a template string.
77
+ * Replaces [param] with a dummy segment and [...rest] with multiple
78
+ * depth variants. For catch-alls, also generates representatives using
79
+ * literal prefixes from existing pattern sources to catch cross-pattern loops.
80
+ */
81
+ function generateRepresentatives(template: string, existingEdges?: RedirectEdge[]): string[] {
82
+ const hasSplat = SPLAT_RE.test(template);
83
+ SPLAT_RE.lastIndex = 0;
84
+
85
+ if (hasSplat) {
86
+ // Extract the static prefix before the catch-all (e.g., "/old/" from "/old/[...path]")
87
+ const splatIndex = template.indexOf("[...");
88
+ const prefix = template.slice(0, splatIndex);
89
+
90
+ const reps = [
91
+ template.replace(SPLAT_RE, DUMMY_SEGMENT).replace(PARAM_RE, DUMMY_SEGMENT),
92
+ template
93
+ .replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)
94
+ .replace(PARAM_RE, DUMMY_SEGMENT),
95
+ template
96
+ .replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)
97
+ .replace(PARAM_RE, DUMMY_SEGMENT),
98
+ ];
99
+
100
+ // Add representatives derived from existing pattern sources' literal prefixes
101
+ if (existingEdges) {
102
+ for (const edge of existingEdges) {
103
+ if (edge.enabled && edge.isPattern && edge.source !== template) {
104
+ const suffix = extractPatternSuffix(edge.source);
105
+ if (suffix) {
106
+ reps.push(`${prefix}${suffix}`);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ return reps;
113
+ }
114
+
115
+ return [template.replace(PARAM_RE, DUMMY_SEGMENT)];
116
+ }
117
+
118
+ /**
119
+ * Resolve the next hop for a given path. Tries exact match first,
120
+ * then pattern matching with parameter interpolation for concrete paths,
121
+ * then representative-based matching for template strings.
122
+ */
123
+ function resolveNext(
124
+ path: string,
125
+ graph: Map<string, { destination: string; id: string }>,
126
+ patterns: CompiledPatternRedirect[],
127
+ edges?: RedirectEdge[],
128
+ ): { destination: string; id: string } | null {
129
+ // Exact match (fast) — works for both real paths and template strings
130
+ const exact = graph.get(path);
131
+ if (exact) return exact;
132
+
133
+ if (!path.includes("[")) {
134
+ // Concrete path — try pattern matching directly
135
+ for (const pr of patterns) {
136
+ const params = matchPattern(pr.compiled, path);
137
+ if (params) {
138
+ const resolved = interpolateDestination(pr.destination, params);
139
+ return { destination: resolved, id: pr.id };
140
+ }
141
+ }
142
+ } else {
143
+ // Template string — generate representative paths and test against patterns
144
+ const representatives = generateRepresentatives(path, edges);
145
+ for (const pr of patterns) {
146
+ for (const rep of representatives) {
147
+ const params = matchPattern(pr.compiled, rep);
148
+ if (params) {
149
+ const resolved = interpolateDestination(pr.destination, params);
150
+ return { destination: resolved, id: pr.id };
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ return null;
157
+ }
158
+
159
+ /**
160
+ * Build an adjacency map from redirect edges.
161
+ * Includes both exact and pattern redirects — pattern redirects use their
162
+ * template strings as literal graph edges, which works because EmDash
163
+ * patterns pass parameters through without transformation.
164
+ */
165
+ function buildGraph(edges: RedirectEdge[]): Map<string, { destination: string; id: string }> {
166
+ const graph = new Map<string, { destination: string; id: string }>();
167
+ for (const edge of edges) {
168
+ if (edge.enabled) {
169
+ graph.set(edge.source, { destination: edge.destination, id: edge.id });
170
+ }
171
+ }
172
+ return graph;
173
+ }
174
+
175
+ /**
176
+ * Detect all redirect IDs that participate in cycles.
177
+ * Walks every node in the graph once, collecting IDs from any cycles found.
178
+ *
179
+ * @returns Array of redirect IDs that are part of a loop
180
+ */
181
+ export function detectLoops(edges: RedirectEdge[]): string[] {
182
+ const graph = buildGraph(edges);
183
+ const patterns = compilePatterns(edges);
184
+ const visited = new Set<string>();
185
+ const loopRedirectIds = new Set<string>();
186
+
187
+ for (const [startSource] of graph) {
188
+ if (visited.has(startSource)) continue;
189
+
190
+ const path: string[] = [];
191
+ const pathSet = new Set<string>();
192
+ const pathIds: string[] = [];
193
+ let current: string | undefined = startSource;
194
+
195
+ while (current) {
196
+ if (pathSet.has(current)) {
197
+ // Found a cycle — collect IDs of redirects in the loop
198
+ const loopStart = path.indexOf(current);
199
+ for (const id of pathIds.slice(loopStart)) loopRedirectIds.add(id);
200
+ break;
201
+ }
202
+
203
+ if (visited.has(current)) {
204
+ break;
205
+ }
206
+
207
+ const next = resolveNext(current, graph, patterns, edges);
208
+ if (!next) break;
209
+
210
+ path.push(current);
211
+ pathSet.add(current);
212
+ pathIds.push(next.id);
213
+ current = next.destination;
214
+ }
215
+
216
+ for (const node of path) visited.add(node);
217
+ }
218
+
219
+ return [...loopRedirectIds];
220
+ }
221
+
222
+ /**
223
+ * Find a compiled pattern redirect whose source matches the given resolved path,
224
+ * returning the source template string for display purposes.
225
+ */
226
+ function findMatchingTemplate(
227
+ resolvedPath: string,
228
+ patterns: CompiledPatternRedirect[],
229
+ ): string | null {
230
+ for (const pr of patterns) {
231
+ if (matchPattern(pr.compiled, resolvedPath) !== null) {
232
+ return pr.compiled.source;
233
+ }
234
+ }
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Check if adding or updating a redirect would create a loop.
240
+ *
241
+ * Walks the chain from `destination` through existing redirects.
242
+ * If it reaches `source`, a cycle would form.
243
+ *
244
+ * @returns The loop path if a cycle would be created, or null if safe
245
+ */
246
+ export function wouldCreateLoop(
247
+ source: string,
248
+ destination: string,
249
+ existingEdges: RedirectEdge[],
250
+ excludeId?: string,
251
+ ): string[] | null {
252
+ const filtered = excludeId ? existingEdges.filter((e) => e.id !== excludeId) : existingEdges;
253
+ const graph = buildGraph(filtered);
254
+ const patterns = compilePatterns(filtered);
255
+
256
+ // If the proposed source is a pattern, compile it so we can check
257
+ // whether resolved paths would match it (not just string equality)
258
+ const sourceIsPattern = source.includes("[");
259
+ const compiledSource = sourceIsPattern ? compilePattern(source) : null;
260
+
261
+ // Determine starting points for the walk. If the destination is a
262
+ // template, generate representative concrete paths AND find existing
263
+ // exact sources in the graph that match the template.
264
+ let startingPoints: string[];
265
+ if (destination.includes("[")) {
266
+ const reps = generateRepresentatives(destination, filtered);
267
+ // Also find existing exact graph keys that match this template
268
+ const compiled = compilePattern(destination);
269
+ for (const [key] of graph) {
270
+ if (!key.includes("[") && matchPattern(compiled, key) !== null) {
271
+ reps.push(key);
272
+ }
273
+ }
274
+ // Always include the destination itself — it may be an exact graph key
275
+ // (e.g., /a/sub/[...path] exists as a literal source in the graph)
276
+ reps.push(destination);
277
+ startingPoints = reps;
278
+ } else {
279
+ startingPoints = [destination];
280
+ }
281
+
282
+ for (const start of startingPoints) {
283
+ const path = [source, destination];
284
+ let current = start;
285
+ const seen = new Set<string>([source, destination, start]);
286
+
287
+ // Walk the chain until it ends or we revisit a node
288
+ // eslint-disable-next-line no-constant-condition -- terminates via return/break when chain ends or cycle found
289
+ while (true) {
290
+ const next = resolveNext(current, graph, patterns, filtered);
291
+ if (!next) break; // chain ends, try next starting point
292
+
293
+ // Check if we've looped back — either exact match or pattern match
294
+ const loopsBack =
295
+ seen.has(next.destination) ||
296
+ (compiledSource !== null && matchPattern(compiledSource, next.destination) !== null);
297
+
298
+ if (loopsBack) {
299
+ // Show the source template instead of dummy resolved path
300
+ const displayPath =
301
+ !seen.has(next.destination) && compiledSource !== null ? source : next.destination;
302
+ path.push(displayPath);
303
+ return path; // cycle found
304
+ }
305
+
306
+ // If the resolved path contains dummy segments, try to find the
307
+ // original pattern template that produced it for cleaner display
308
+ const cleanDest = next.destination.includes(DUMMY_SEGMENT)
309
+ ? (findMatchingTemplate(next.destination, patterns) ?? next.destination)
310
+ : next.destination;
311
+ path.push(cleanDest);
312
+ seen.add(next.destination);
313
+ current = next.destination;
314
+ }
315
+ }
316
+
317
+ return null;
318
+ }
@@ -6,6 +6,7 @@ import { ulid } from "ulidx";
6
6
  import { currentTimestamp, listTablesLike, tableExists } from "../database/dialect-helpers.js";
7
7
  import { withTransaction } from "../database/transaction.js";
8
8
  import type { CollectionTable, Database, FieldTable } from "../database/types.js";
9
+ import { validateIdentifier } from "../database/validate.js";
9
10
  import { FTSManager } from "../search/fts-manager.js";
10
11
  import {
11
12
  type Collection,
@@ -684,6 +685,7 @@ export class SchemaRegistry {
684
685
  * Get table name for a collection
685
686
  */
686
687
  private getTableName(slug: string): string {
688
+ validateIdentifier(slug, "collection slug");
687
689
  return `ec_${slug}`;
688
690
  }
689
691
 
@@ -691,6 +693,7 @@ export class SchemaRegistry {
691
693
  * Get column name for a field
692
694
  */
693
695
  private getColumnName(slug: string): string {
696
+ validateIdentifier(slug, "field slug");
694
697
  return slug;
695
698
  }
696
699
 
@@ -39,6 +39,7 @@ export class FTSManager {
39
39
  * Uses _emdash_ prefix to clearly mark as internal/system table
40
40
  */
41
41
  getFtsTableName(collectionSlug: string): string {
42
+ validateIdentifier(collectionSlug, "collection slug");
42
43
  return `_emdash_fts_${collectionSlug}`;
43
44
  }
44
45
 
@@ -46,6 +47,7 @@ export class FTSManager {
46
47
  * Get the content table name for a collection
47
48
  */
48
49
  getContentTableName(collectionSlug: string): string {
50
+ validateIdentifier(collectionSlug, "collection slug");
49
51
  return `ec_${collectionSlug}`;
50
52
  }
51
53
 
@@ -101,6 +103,7 @@ export class FTSManager {
101
103
  * Create triggers to keep FTS table in sync with content table
102
104
  */
103
105
  private async createTriggers(collectionSlug: string, searchableFields: string[]): Promise<void> {
106
+ this.validateInputs(collectionSlug, searchableFields);
104
107
  const ftsTable = this.getFtsTableName(collectionSlug);
105
108
  const contentTable = this.getContentTableName(collectionSlug);
106
109
  const fieldList = searchableFields.join(", ");
@@ -147,6 +150,7 @@ export class FTSManager {
147
150
  * Drop triggers for a collection
148
151
  */
149
152
  private async dropTriggers(collectionSlug: string): Promise<void> {
153
+ this.validateInputs(collectionSlug);
150
154
  const ftsTable = this.getFtsTableName(collectionSlug);
151
155
 
152
156
  await sql.raw(`DROP TRIGGER IF EXISTS "${ftsTable}_insert"`).execute(this.db);
package/src/storage/s3.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type ListObjectsV2Response,
16
16
  } from "@aws-sdk/client-s3";
17
17
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
18
+ import { z } from "zod";
18
19
 
19
20
  import type {
20
21
  Storage,
@@ -28,6 +29,87 @@ import type {
28
29
  } from "./types.js";
29
30
  import { EmDashStorageError } from "./types.js";
30
31
 
32
+ const ENV_KEYS = {
33
+ endpoint: "S3_ENDPOINT",
34
+ bucket: "S3_BUCKET",
35
+ accessKeyId: "S3_ACCESS_KEY_ID",
36
+ secretAccessKey: "S3_SECRET_ACCESS_KEY",
37
+ region: "S3_REGION",
38
+ publicUrl: "S3_PUBLIC_URL",
39
+ } as const satisfies Record<keyof S3StorageConfig, string>;
40
+
41
+ function fail(msg: string): never {
42
+ throw new EmDashStorageError(msg, "MISSING_S3_CONFIG");
43
+ }
44
+
45
+ const s3ConfigSchema = z.object({
46
+ endpoint: z.url({ protocol: /^https?$/, error: "is not a valid http/https URL" }).optional(),
47
+ bucket: z.string().optional(),
48
+ accessKeyId: z.string().optional(),
49
+ secretAccessKey: z.string().optional(),
50
+ region: z.string().optional(),
51
+ publicUrl: z.string().optional(),
52
+ });
53
+
54
+ function isConfigKey(key: unknown): key is keyof S3StorageConfig {
55
+ return typeof key === "string" && key in ENV_KEYS;
56
+ }
57
+
58
+ /**
59
+ * Build the merged config: for each field, use the explicit value if present,
60
+ * otherwise fall back to the corresponding S3_* env var. Validate once on the
61
+ * final merged result so a malformed env var never breaks the build when the
62
+ * caller provides that field explicitly.
63
+ */
64
+ export function resolveS3Config(partial: Record<string, unknown>): S3StorageConfig {
65
+ const raw: Record<string, unknown> = {};
66
+ for (const [field, envKey] of Object.entries(ENV_KEYS)) {
67
+ const explicit = partial[field];
68
+ if (explicit !== undefined && explicit !== "") {
69
+ raw[field] = explicit;
70
+ continue;
71
+ }
72
+ const envVal = typeof process !== "undefined" && process.env ? process.env[envKey] : undefined;
73
+ if (envVal !== undefined && envVal !== "") {
74
+ raw[field] = envVal;
75
+ }
76
+ }
77
+
78
+ const result = s3ConfigSchema.safeParse(raw);
79
+ if (!result.success) {
80
+ const issue = result.error.issues[0];
81
+ const pathKey = issue?.path[0];
82
+ if (!issue || !isConfigKey(pathKey)) fail("S3 config validation failed");
83
+ const fromExplicit = partial[pathKey] !== undefined && partial[pathKey] !== "";
84
+ const label = fromExplicit ? `s3({ ${pathKey} })` : ENV_KEYS[pathKey];
85
+ fail(`${label} ${issue.message}`);
86
+ }
87
+ const merged = result.data;
88
+
89
+ const endpoint = merged.endpoint;
90
+ const bucket = merged.bucket;
91
+ if (!endpoint || !bucket) {
92
+ const missing: string[] = [];
93
+ if (!endpoint) missing.push(`endpoint: set ${ENV_KEYS.endpoint} or pass endpoint to s3({...})`);
94
+ if (!bucket) missing.push(`bucket: set ${ENV_KEYS.bucket} or pass bucket to s3({...})`);
95
+ fail(`missing required S3 config: ${missing.join("; ")}`);
96
+ }
97
+ const accessKeyId = merged.accessKeyId;
98
+ const secretAccessKey = merged.secretAccessKey;
99
+ if (accessKeyId && !secretAccessKey) {
100
+ fail(
101
+ `S3 credentials incomplete: accessKeyId is set but secretAccessKey is missing (set ${ENV_KEYS.secretAccessKey} or pass secretAccessKey to s3({...}))`,
102
+ );
103
+ }
104
+ if (secretAccessKey && !accessKeyId) {
105
+ fail(
106
+ `S3 credentials incomplete: secretAccessKey is set but accessKeyId is missing (set ${ENV_KEYS.accessKeyId} or pass accessKeyId to s3({...}))`,
107
+ );
108
+ }
109
+
110
+ return { ...merged, endpoint, bucket };
111
+ }
112
+
31
113
  const TRAILING_SLASH_PATTERN = /\/$/;
32
114
 
33
115
  /** Type guard for AWS SDK errors (have a `name` property) */
@@ -52,13 +134,17 @@ export class S3Storage implements Storage {
52
134
  this.client = new S3Client({
53
135
  endpoint: config.endpoint,
54
136
  region: config.region || "auto",
55
- credentials: {
56
- accessKeyId: config.accessKeyId,
57
- secretAccessKey: config.secretAccessKey,
58
- },
137
+ ...(config.accessKeyId && config.secretAccessKey
138
+ ? {
139
+ credentials: {
140
+ accessKeyId: config.accessKeyId,
141
+ secretAccessKey: config.secretAccessKey,
142
+ },
143
+ }
144
+ : {}),
59
145
  // Required for R2 and some S3-compatible services
60
146
  forcePathStyle: true,
61
- });
147
+ } as ConstructorParameters<typeof S3Client>[0]);
62
148
  }
63
149
 
64
150
  async upload(options: {
@@ -238,26 +324,9 @@ export class S3Storage implements Storage {
238
324
 
239
325
  /**
240
326
  * Create S3 storage adapter
241
- * This is the factory function called at runtime
327
+ * This is the factory function called at runtime.
328
+ * Config fields are merged with S3_* env vars; env vars fill in any missing fields.
242
329
  */
243
330
  export function createStorage(config: Record<string, unknown>): Storage {
244
- const { endpoint, bucket, accessKeyId, secretAccessKey, region, publicUrl } = config;
245
- if (
246
- typeof endpoint !== "string" ||
247
- typeof bucket !== "string" ||
248
- typeof accessKeyId !== "string" ||
249
- typeof secretAccessKey !== "string"
250
- ) {
251
- throw new Error(
252
- "S3Storage requires 'endpoint', 'bucket', 'accessKeyId', and 'secretAccessKey' string config values",
253
- );
254
- }
255
- return new S3Storage({
256
- endpoint,
257
- bucket,
258
- accessKeyId,
259
- secretAccessKey,
260
- region: typeof region === "string" ? region : undefined,
261
- publicUrl: typeof publicUrl === "string" ? publicUrl : undefined,
262
- });
331
+ return new S3Storage(resolveS3Config(config));
263
332
  }