@jini-ai/cms 0.5.5 → 0.5.7

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 (138) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/consent/express/index.d.ts +53 -0
  3. package/dist/consent/express/index.js +159 -0
  4. package/dist/consent/express/response.d.ts +21 -0
  5. package/dist/consent/express/response.js +89 -0
  6. package/dist/consent/html/client.d.ts +6 -0
  7. package/dist/consent/html/client.js +135 -0
  8. package/dist/consent/html/gate.d.ts +29 -0
  9. package/dist/consent/html/gate.js +104 -0
  10. package/dist/consent/html/index.d.ts +4 -0
  11. package/dist/consent/html/index.js +3 -0
  12. package/dist/consent/html/render.d.ts +27 -0
  13. package/dist/consent/html/render.js +62 -0
  14. package/dist/consent/html/static.d.ts +7 -0
  15. package/dist/consent/html/static.js +25 -0
  16. package/dist/consent/html/styles.d.ts +3 -0
  17. package/dist/consent/html/styles.js +22 -0
  18. package/dist/consent/index.d.ts +3 -0
  19. package/dist/consent/index.js +3 -0
  20. package/dist/consent/node/index.d.ts +47 -0
  21. package/dist/consent/node/index.js +169 -0
  22. package/dist/consent/policy.d.ts +47 -0
  23. package/dist/consent/policy.js +62 -0
  24. package/dist/consent/types.d.ts +40 -0
  25. package/dist/consent/types.js +5 -0
  26. package/dist/content-types/agent-tools.js +1 -1
  27. package/dist/content-types/tool-registrations.d.ts +2 -0
  28. package/dist/content-types/tool-registrations.js +1 -0
  29. package/dist/content-types/write-service.d.ts +2 -0
  30. package/dist/content-types/write-service.js +3 -0
  31. package/dist/forms/descriptor-rules.d.ts +9 -0
  32. package/dist/forms/descriptor-rules.js +112 -0
  33. package/dist/forms/email-format.d.ts +7 -0
  34. package/dist/forms/email-format.js +11 -0
  35. package/dist/forms/express/handler.js +4 -8
  36. package/dist/forms/field-registry.d.ts +193 -0
  37. package/dist/forms/field-registry.js +231 -0
  38. package/dist/forms/forms.d.ts +8 -5
  39. package/dist/forms/forms.js +29 -46
  40. package/dist/forms/html/html-authoring.js +63 -13
  41. package/dist/forms/html/html-submission-adapter.d.ts +5 -5
  42. package/dist/forms/html/html-submission-adapter.js +6 -13
  43. package/dist/forms/html-render.d.ts +10 -3
  44. package/dist/forms/html-render.js +116 -14
  45. package/dist/forms/index.d.ts +1 -0
  46. package/dist/forms/index.js +1 -0
  47. package/dist/forms/repo.memory.js +4 -4
  48. package/dist/forms/submission-attempts.d.ts +1 -1
  49. package/dist/forms/submission-attempts.js +13 -2
  50. package/dist/forms/submission-email-validation.js +2 -4
  51. package/dist/forms/submit-service.d.ts +3 -0
  52. package/dist/forms/submit-service.js +38 -5
  53. package/dist/forms/types.d.ts +50 -2
  54. package/dist/listing/batch.d.ts +53 -0
  55. package/dist/listing/batch.js +57 -0
  56. package/dist/listing/html.d.ts +72 -0
  57. package/dist/listing/html.js +109 -0
  58. package/dist/listing/index.d.ts +5 -0
  59. package/dist/listing/index.js +5 -0
  60. package/dist/listing/paths.d.ts +32 -0
  61. package/dist/listing/paths.js +37 -0
  62. package/dist/listing/see-more-script.d.ts +16 -0
  63. package/dist/listing/see-more-script.js +62 -0
  64. package/dist/media/content-type-sniffer.d.ts +1 -1
  65. package/dist/media/content-type-sniffer.js +15 -0
  66. package/dist/media/image-transformer.sharp.js +4 -1
  67. package/dist/media/media-service.d.ts +5 -3
  68. package/dist/media/media-service.js +10 -26
  69. package/dist/media/node.d.ts +1 -1
  70. package/dist/media/node.js +1 -1
  71. package/dist/media/slug.d.ts +8 -0
  72. package/dist/media/slug.js +32 -0
  73. package/dist/media/transform-registry.js +1 -1
  74. package/dist/media/transform-types.d.ts +2 -0
  75. package/dist/media/transform-types.js +3 -0
  76. package/dist/media/video-frames.ffmpeg.d.ts +15 -2
  77. package/dist/media/video-frames.ffmpeg.js +28 -13
  78. package/dist/navigation/dropdown.d.ts +41 -0
  79. package/dist/navigation/dropdown.js +85 -0
  80. package/dist/navigation/index.d.ts +2 -0
  81. package/dist/navigation/index.js +2 -0
  82. package/dist/redirects/capture.d.ts +2 -0
  83. package/dist/redirects/capture.js +4 -0
  84. package/dist/redirects/redirects.d.ts +8 -0
  85. package/dist/redirects/redirects.js +14 -0
  86. package/dist/routing/index.d.ts +5 -0
  87. package/dist/routing/index.js +2 -0
  88. package/dist/routing/ports.d.ts +51 -0
  89. package/dist/routing/ports.js +2 -0
  90. package/dist/routing/routing.d.ts +138 -0
  91. package/dist/routing/routing.js +443 -0
  92. package/dist/routing/types.d.ts +164 -0
  93. package/dist/routing/types.js +2 -0
  94. package/dist/seo/media.d.ts +23 -0
  95. package/dist/seo/media.js +30 -5
  96. package/dist/seo/page-head.js +8 -1
  97. package/dist/seo/seo.d.ts +2 -0
  98. package/dist/seo/seo.js +11 -6
  99. package/dist/seo/types.d.ts +8 -0
  100. package/dist/seo/xml-escape.d.ts +1 -3
  101. package/dist/seo/xml-escape.js +1 -6
  102. package/dist/widgets/concurrency.d.ts +27 -1
  103. package/dist/widgets/concurrency.js +12 -23
  104. package/dist/widgets/config-validation.js +6 -0
  105. package/dist/widgets/gallery-resolver.d.ts +23 -0
  106. package/dist/widgets/gallery-resolver.js +80 -0
  107. package/dist/widgets/html/gallery-render.d.ts +24 -0
  108. package/dist/widgets/html/gallery-render.js +65 -0
  109. package/dist/widgets/html/html-embeds.d.ts +4 -0
  110. package/dist/widgets/html/html-embeds.js +7 -1
  111. package/dist/widgets/html/index.d.ts +3 -0
  112. package/dist/widgets/html/index.js +3 -0
  113. package/dist/widgets/html/server-fragment.d.ts +9 -0
  114. package/dist/widgets/html/server-fragment.js +38 -0
  115. package/dist/widgets/markers/gallery.d.ts +50 -0
  116. package/dist/widgets/markers/gallery.js +164 -0
  117. package/dist/widgets/markers/html-attributes.d.ts +6 -1
  118. package/dist/widgets/markers/html-attributes.js +24 -9
  119. package/dist/widgets/markers/index.d.ts +2 -0
  120. package/dist/widgets/markers/index.js +2 -0
  121. package/dist/widgets/markers/marker.d.ts +4 -0
  122. package/dist/widgets/markers/marker.js +6 -0
  123. package/dist/widgets/markers/media-reference.d.ts +11 -0
  124. package/dist/widgets/markers/media-reference.js +11 -0
  125. package/dist/widgets/page-embed-types.d.ts +1 -1
  126. package/dist/widgets/page-embed-types.js +1 -1
  127. package/dist/widgets/registry.d.ts +144 -10
  128. package/dist/widgets/registry.js +40 -0
  129. package/dist/widgets/resolver-service.d.ts +10 -0
  130. package/dist/widgets/resolver-service.js +73 -29
  131. package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
  132. package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
  133. package/dist/widgets/resolvers/gallery.d.ts +12 -0
  134. package/dist/widgets/resolvers/gallery.js +33 -0
  135. package/dist/widgets/resolvers/index.d.ts +1 -0
  136. package/dist/widgets/resolvers/index.js +1 -0
  137. package/dist/widgets/types.d.ts +3 -1
  138. package/package.json +100 -7
@@ -151,7 +151,10 @@ export class SharpImageTransformer {
151
151
  fit: input.params.fit ?? "cover",
152
152
  });
153
153
  }
154
- pipeline = pipeline.toFormat(input.params.format);
154
+ // JPEG cannot carry alpha; explicit white avoids libvips' black background for logos.
155
+ if (input.params.format === "jpeg")
156
+ pipeline = pipeline.flatten({ background: "#ffffff" });
157
+ pipeline = pipeline.toFormat(input.params.format, input.params.quality === undefined ? undefined : { quality: input.params.quality });
155
158
  const outBuffer = await pipeline.toBuffer();
156
159
  return {
157
160
  bytes: new Uint8Array(outBuffer),
@@ -1,3 +1,4 @@
1
+ export { isValidMediaSlugFormat } from "./slug.js";
1
2
  import type { Clock, IdGenerator, UUID } from "@jini-ai/core/primitives";
2
3
  import { MediaConflictError, MediaNotFoundError, MediaSourceImmutableError, MediaStillReferencedError, MediaValidationError, type MediaRecord, type MediaSource } from "./types.js";
3
4
  import type { AssetBlobRepoPort, AssetRenditionRepoPort, BlobStorePort, MediaRepoPort } from "./ports.js";
@@ -27,6 +28,10 @@ export declare const DEFAULT_MAX_UPLOAD_BYTES: number;
27
28
  * `routes/site/media-rendition.ts`). This widens the SAME advisory,
28
29
  * client-declared-string check item 4 above already discloses as untrusted —
29
30
  * no new ingress hardening was added for video.
31
+ *
32
+ * Audio (owner-approved mediawidget, 2026-10-10/11) follows that same original-byte
33
+ * lifecycle. Only formats recognized by the shared sniffer are allowed; no image
34
+ * transform is requested for them.
30
35
  */
31
36
  export declare const DEFAULT_ALLOWED_MIME_TYPES: ReadonlySet<string>;
32
37
  /**
@@ -49,9 +54,6 @@ export declare function resolveWriteOnceSource(required: {
49
54
  existing: MediaSource | undefined;
50
55
  requestedSha256: string;
51
56
  }, _optional?: Record<string, never>): MediaSource;
52
- export declare function isValidMediaSlugFormat(requiredArgs: {
53
- slug: string;
54
- }, optionalArgs?: Record<string, never>): boolean;
55
57
  export interface UploadMediaInput {
56
58
  workspaceId: UUID;
57
59
  bytes: Uint8Array;
@@ -1,3 +1,5 @@
1
+ import { isValidMediaSlugFormat } from "./slug.js";
2
+ export { isValidMediaSlugFormat } from "./slug.js";
1
3
  import { createSystemClock } from "@jini-ai/core/primitives";
2
4
  import { nowIso as kernelNowIso } from "@jini-ai/core/primitives";
3
5
  /**
@@ -100,6 +102,10 @@ function formatMaxUploadBytesAsMb(bytes) {
100
102
  * `routes/site/media-rendition.ts`). This widens the SAME advisory,
101
103
  * client-declared-string check item 4 above already discloses as untrusted —
102
104
  * no new ingress hardening was added for video.
105
+ *
106
+ * Audio (owner-approved mediawidget, 2026-10-10/11) follows that same original-byte
107
+ * lifecycle. Only formats recognized by the shared sniffer are allowed; no image
108
+ * transform is requested for them.
103
109
  */
104
110
  export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
105
111
  "image/jpeg",
@@ -109,6 +115,10 @@ export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
109
115
  "image/avif",
110
116
  "video/mp4",
111
117
  "video/webm",
118
+ "audio/wav",
119
+ "audio/mpeg",
120
+ "audio/flac",
121
+ "audio/mp4",
112
122
  ]);
113
123
  /**
114
124
  * Enforces the "settable-once-from-absent" write-once rule for
@@ -137,28 +147,6 @@ function deriveTitleFromFilename(filename) {
137
147
  const base = filename.trim().replace(/\.[^./\\]+$/, "");
138
148
  return base.trim() || "Untitled";
139
149
  }
140
- /** Shared slug-format rule (2026-09-07) — same shape as `post`'s `SLUG_FORMAT_PATTERN`: lowercase
141
- * letters, digits, and dashes only. Media slugs have no reserved-word list (`post`'s `admin`/`api`
142
- * exclusions exist because a post slug can become a literal URL path segment a route dispatches
143
- * on; a media slug is only ever a lookup key, never a route itself, so that hazard doesn't apply). */
144
- const MEDIA_SLUG_FORMAT_PATTERN = /^[a-z0-9-]+$/;
145
- /**
146
- * Slugs that spell a UUID are refused outright (2026-09-07 fix).
147
- *
148
- * A lowercase UUID is `[a-z0-9-]+` character for character, so {@link MEDIA_SLUG_FORMAT_PATTERN}
149
- * happily accepted one — and {@link findMediaByIdOrSlug} used to resolve SLUG FIRST, so pointing one
150
- * asset's slug at another asset's `id` silently took over every reference already authored against
151
- * that id (every `/m/{id}/…` URL a host's renderer has ever emitted for the victim). That resolution
152
- * order is now id-first, which closes the hijack for rows that already carry such a slug; this rule
153
- * is the write-path half — a slug that can only ever shadow an id is not a slug anyone wants, so it
154
- * is refused where a human can still see why rather than silently shadowed at read time.
155
- *
156
- * Shape-based, not a lookup: it rejects EVERY UUID-shaped value, not only ones that currently
157
- * collide, so a slug cannot become a hijack later when some future upload happens to mint that id.
158
- * Ordinary hex-and-dash slugs (`abc-123-def`, `2026-09-07-launch-clip`) are unaffected — nothing but
159
- * the exact 8-4-4-4-12 hex grouping matches.
160
- */
161
- const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
162
150
  /** Shared slug max-length rule (2026-09-07) — same bound as `post.ts`'s `MAX_SLUG_LENGTH`, reused
163
151
  * rather than inventing a second number. `post.ts` only enforces this on its EXPLICIT/caller-
164
152
  * supplied slug path (`resolveExplicitSlug`, reject-with-error); its own title-derived path is
@@ -169,10 +157,6 @@ const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9
169
157
  * human for a shorter value — silently truncating an internally-derived value is the same
170
158
  * "coerce, don't fail" convention `deriveTitleFromFilename`/`slugifyMediaTitle` already use. */
171
159
  const MEDIA_MAX_SLUG_LENGTH = 120;
172
- export function isValidMediaSlugFormat(requiredArgs, optionalArgs = {}) {
173
- const { slug } = requiredArgs;
174
- return MEDIA_SLUG_FORMAT_PATTERN.test(slug) && !MEDIA_SLUG_UUID_SHAPE_PATTERN.test(slug);
175
- }
176
160
  /** Truncates a slug candidate to `maxLength`, stripping a trailing dash the cut can introduce (so a
177
161
  * truncation never leaves a slug ending mid-word with a dangling "-"), falling back to `"untitled"`
178
162
  * if nothing legible survives (only reachable when `maxLength` itself is tiny, e.g. while reserving
@@ -1,5 +1,5 @@
1
1
  /** Node-bound media adapters; hosts supply the filesystem and optional codecs. */
2
2
  export { LocalFsBlobStore, type LocalFsBlobStoreDeps } from "./blob-store.fs.js";
3
3
  export { SharpImageTransformer, ImageTransformUnavailableError, ImageSourceCorruptError } from "./image-transformer.sharp.js";
4
- export { createFfmpegVideoFrameExtractor, findVideoBinaries, runLowPriorityVideoProcess, type VideoBinaries, type VideoBinaryFinder, type VideoProcessRunner, } from './video-frames.ffmpeg.js';
4
+ export { createFfmpegVideoFrameExtractor, createVideoExtractionLock, findVideoBinaries, runLowPriorityVideoProcess, type VideoExtractionLock, type VideoBinaries, type VideoBinaryFinder, type VideoProcessRunner, } from './video-frames.ffmpeg.js';
5
5
  //# sourceMappingURL=node.d.ts.map
@@ -2,5 +2,5 @@
2
2
  export { LocalFsBlobStore } from "./blob-store.fs.js";
3
3
  export { SharpImageTransformer, ImageTransformUnavailableError, ImageSourceCorruptError } from "./image-transformer.sharp.js";
4
4
  // Video previews: policy/port plus a bounded optional-host codec adapter.
5
- export { createFfmpegVideoFrameExtractor, findVideoBinaries, runLowPriorityVideoProcess, } from './video-frames.ffmpeg.js';
5
+ export { createFfmpegVideoFrameExtractor, createVideoExtractionLock, findVideoBinaries, runLowPriorityVideoProcess, } from './video-frames.ffmpeg.js';
6
6
  //# sourceMappingURL=node.js.map
@@ -0,0 +1,8 @@
1
+ /** Return whether a media lookup slug has the accepted shape, without I/O.
2
+ * @complexity O(n) time in the slug length, O(1) space.
3
+ * @example isValidMediaSlugFormat({ slug: "summer-photo" }); // true
4
+ */
5
+ export declare function isValidMediaSlugFormat(requiredArgs: {
6
+ slug: string;
7
+ }, optionalArgs?: Record<string, never>): boolean;
8
+ //# sourceMappingURL=slug.d.ts.map
@@ -0,0 +1,32 @@
1
+ /** @file Pure media slug policy; writes and public URL rendering share the same format and UUID-shadowing rule. */
2
+ /** Shared slug-format rule (2026-09-07) — same shape as `post`'s `SLUG_FORMAT_PATTERN`: lowercase
3
+ * letters, digits, and dashes only. Media slugs have no reserved-word list (`post`'s `admin`/`api`
4
+ * exclusions exist because a post slug can become a literal URL path segment a route dispatches
5
+ * on; a media slug is only ever a lookup key, never a route itself, so that hazard doesn't apply). */
6
+ const MEDIA_SLUG_FORMAT_PATTERN = /^[a-z0-9-]+$/;
7
+ /**
8
+ * Slugs that spell a UUID are refused outright (2026-09-07 fix).
9
+ *
10
+ * A lowercase UUID is `[a-z0-9-]+` character for character, so {@link MEDIA_SLUG_FORMAT_PATTERN}
11
+ * happily accepted one — and {@link findMediaByIdOrSlug} used to resolve SLUG FIRST, so pointing one
12
+ * asset's slug at another asset's `id` silently took over every reference already authored against
13
+ * that id (every `/m/{id}/…` URL a host's renderer has ever emitted for the victim). That resolution
14
+ * order is now id-first, which closes the hijack for rows that already carry such a slug; this rule
15
+ * is the write-path half — a slug that can only ever shadow an id is not a slug anyone wants, so it
16
+ * is refused where a human can still see why rather than silently shadowed at read time.
17
+ *
18
+ * Shape-based, not a lookup: it rejects EVERY UUID-shaped value, not only ones that currently
19
+ * collide, so a slug cannot become a hijack later when some future upload happens to mint that id.
20
+ * Ordinary hex-and-dash slugs (`abc-123-def`, `2026-09-07-launch-clip`) are unaffected — nothing but
21
+ * the exact 8-4-4-4-12 hex grouping matches.
22
+ */
23
+ const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
24
+ /** Return whether a media lookup slug has the accepted shape, without I/O.
25
+ * @complexity O(n) time in the slug length, O(1) space.
26
+ * @example isValidMediaSlugFormat({ slug: "summer-photo" }); // true
27
+ */
28
+ export function isValidMediaSlugFormat(requiredArgs, optionalArgs = {}) {
29
+ const { slug } = requiredArgs;
30
+ return MEDIA_SLUG_FORMAT_PATTERN.test(slug) && !MEDIA_SLUG_UUID_SHAPE_PATTERN.test(slug);
31
+ }
32
+ //# sourceMappingURL=slug.js.map
@@ -26,7 +26,7 @@ export async function registerTransform(required, _optional = {}) {
26
26
  const owner = input.owner.trim();
27
27
  if (!owner)
28
28
  throw new TransformValidationError({ message: "owner is required" });
29
- assertValidTransformParams({ format: input.params.format }, { width: input.params.width, height: input.params.height, fit: input.params.fit });
29
+ assertValidTransformParams({ format: input.params.format }, { width: input.params.width, height: input.params.height, fit: input.params.fit, quality: input.params.quality });
30
30
  return withTransformRegistryLock({ key: `${input.workspaceId}:${name}`, criticalSection: async () => {
31
31
  const existingVersions = await deps.transformRepo.listByName({ workspaceId: input.workspaceId, name });
32
32
  const currentMaxVersion = existingVersions.reduce((max, row) => Math.max(max, row.version), 0);
@@ -41,6 +41,8 @@ export interface TransformParams {
41
41
  height?: number | undefined;
42
42
  fit?: TransformFit | undefined;
43
43
  format: TransformFormat;
44
+ /** Encoder quality, 1–100; omitted preserves the codec default. */
45
+ quality?: number | undefined;
44
46
  }
45
47
  /**
46
48
  * `transform_registry` sidecar row. Identity is
@@ -45,6 +45,9 @@ export function assertValidTransformParams(required, optional = {}) {
45
45
  throw new TransformValidationError({ message: `transform params.${key} must be an integer between 1 and ${MAX_TRANSFORM_DIMENSION_PX}` });
46
46
  }
47
47
  }
48
+ if (params.quality !== undefined && (!Number.isInteger(params.quality) || params.quality < 1 || params.quality > 100)) {
49
+ throw new TransformValidationError({ message: "transform params.quality must be an integer between 1 and 100" });
50
+ }
48
51
  if (params.fit !== undefined && !(params.width && params.height)) {
49
52
  throw new TransformValidationError({ message: "transform params.fit requires both width and height" });
50
53
  }
@@ -5,7 +5,10 @@ export interface VideoBinaries {
5
5
  readonly ffmpeg: string;
6
6
  readonly ffprobe: string;
7
7
  }
8
- export type VideoBinaryFinder = (required: Record<string, never>, optional: Record<string, never>) => Promise<VideoBinaries | undefined>;
8
+ export type VideoBinaryFinder = (required: Record<string, never>, optional: {
9
+ env?: NodeJS.ProcessEnv;
10
+ platform?: NodeJS.Platform;
11
+ }) => Promise<VideoBinaries | undefined>;
9
12
  export type VideoProcessRunner = (required: {
10
13
  command: string;
11
14
  args: readonly string[];
@@ -31,7 +34,17 @@ export declare const runLowPriorityVideoProcess: ({ command, args, spawnProcess,
31
34
  maxOutputBytes: number;
32
35
  signal?: AbortSignal;
33
36
  }) => Promise<Uint8Array>;
34
- export declare function createFfmpegVideoFrameExtractor({ findBinaries, runProcess, nowMs }: {
37
+ export interface VideoExtractionLock {
38
+ /** Synchronous acquisition prevents races before the first awaited probe. */
39
+ tryAcquire(required: Record<string, never>, optional: Record<string, never>): (() => void) | undefined;
40
+ }
41
+ /** A host may share this lock across its adapters to enforce a decoder budget.
42
+ * Returns a synchronous try-acquire port; release follows decoder cleanup. @complexity O(1). */
43
+ export declare function createVideoExtractionLock(_required: Record<string, never>, _optional?: Record<string, never>): VideoExtractionLock;
44
+ export declare function createFfmpegVideoFrameExtractor({ findBinaries, runProcess, nowMs, lock, env, platform }: {
45
+ lock?: VideoExtractionLock;
46
+ env?: NodeJS.ProcessEnv;
47
+ platform?: NodeJS.Platform;
35
48
  findBinaries?: VideoBinaryFinder;
36
49
  runProcess?: VideoProcessRunner;
37
50
  nowMs?: (required: Record<string, never>, optional: Record<string, never>) => number;
@@ -13,9 +13,9 @@ class VideoProcessError extends Error {
13
13
  this.reason = reason;
14
14
  }
15
15
  }
16
- async function executable(required, { configured } = {}) {
16
+ async function executable(required, { configured, env = process.env } = {}) {
17
17
  const candidates = configured ? [configured] : [
18
- ...(process.env['PATH'] ?? '').split(delimiter).filter(isAbsolute).map(directory => join(directory, required.name)),
18
+ ...(env['PATH'] ?? '').split(delimiter).filter(isAbsolute).map(directory => join(directory, required.name)),
19
19
  join('/opt/homebrew/bin', required.name), join('/usr/local/bin', required.name), join('/usr/bin', required.name),
20
20
  ];
21
21
  for (const candidate of candidates) {
@@ -32,12 +32,12 @@ async function executable(required, { configured } = {}) {
32
32
  }
33
33
  /** Discovery never executes a version/probe command. Configured paths are host configuration,
34
34
  * never tool arguments, and all subprocesses (including ffprobe) still go through nice. */
35
- export const findVideoBinaries = async (_required, _optional) => {
36
- if (process.platform === 'win32')
35
+ export const findVideoBinaries = async (_required, { env = process.env, platform = process.platform } = {}) => {
36
+ if (platform === 'win32')
37
37
  return undefined; // Never silently omit the low-priority guarantee.
38
- const nice = await executable({ name: 'nice' }, {});
39
- const ffmpeg = await executable({ name: 'ffmpeg' }, { configured: process.env['FFMPEG_PATH'] });
40
- const ffprobe = await executable({ name: 'ffprobe' }, { configured: process.env['FFPROBE_PATH'] });
38
+ const nice = await executable({ name: 'nice' }, { env });
39
+ const ffmpeg = await executable({ name: 'ffmpeg' }, { configured: env['FFMPEG_PATH'], env });
40
+ const ffprobe = await executable({ name: 'ffprobe' }, { configured: env['FFPROBE_PATH'], env });
41
41
  return nice && ffmpeg && ffprobe ? { nice, ffmpeg, ffprobe } : undefined;
42
42
  };
43
43
  /** detached creates a group including nice and its eventual executable; killing the group prevents
@@ -99,25 +99,40 @@ export const runLowPriorityVideoProcess = async ({ command, args, spawnProcess =
99
99
  abort();
100
100
  });
101
101
  };
102
+ /** A host may share this lock across its adapters to enforce a decoder budget.
103
+ * Returns a synchronous try-acquire port; release follows decoder cleanup. @complexity O(1). */
104
+ export function createVideoExtractionLock(_required, _optional = {}) {
105
+ let extracting = false;
106
+ return { tryAcquire() {
107
+ if (extracting)
108
+ return undefined;
109
+ extracting = true;
110
+ let released = false;
111
+ return () => { if (!released) {
112
+ released = true;
113
+ extracting = false;
114
+ } };
115
+ } };
116
+ }
102
117
  // Shared across ALL adapter instances: a second catalog/tenant cannot run a second ffmpeg in this
103
118
  // process. Fail busy instead of holding arbitrary numbers of uploaded videos in a pending queue.
104
- let extracting = false;
119
+ const defaultExtractionLock = createVideoExtractionLock({});
105
120
  const UNAVAILABLE = 'video preview unavailable on this host (nice, ffmpeg and ffprobe are required).';
106
121
  // Eight amortizes process/seek overhead while bounding expensive source decoders to
107
122
  // one eighth of the 64-frame maximum. Thread caps alone do not bound decoder memory.
108
123
  // This is a decoder-count limit, not a measured host RSS guarantee.
109
124
  const SOURCE_BATCH_SIZE = 8;
110
125
  const localInputFlags = ['-threads', '1', '-protocol_whitelist', 'file', '-format_whitelist', 'mov,matroska,webm'];
111
- export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinaries, runProcess = runLowPriorityVideoProcess, nowMs = () => performance.now() }, _optional = {}) {
126
+ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinaries, runProcess = runLowPriorityVideoProcess, nowMs = () => performance.now(), lock = defaultExtractionLock, env, platform }, _optional = {}) {
112
127
  return { extract: async ({ bytes }, options) => {
113
128
  if (bytes.byteLength > VIDEO_MAX_INPUT_BYTES)
114
129
  return { ok: false, reason: 'too-large', message: `Video preview input exceeds ${VIDEO_MAX_INPUT_BYTES} bytes.` };
115
130
  const type = sniffContentType({ bytes });
116
131
  if (type !== 'video/mp4' && type !== 'video/webm')
117
132
  return { ok: false, reason: 'invalid', message: 'Video preview requires stored MP4 or WebM bytes.' };
118
- if (extracting)
133
+ const release = lock.tryAcquire({}, {});
134
+ if (!release)
119
135
  return { ok: false, reason: 'busy', message: 'Video preview is busy. Try again after the current extraction finishes.' };
120
- extracting = true;
121
136
  const deadline = nowMs({}, {}) + VIDEO_EXTRACTION_TIMEOUT_MS;
122
137
  let directory;
123
138
  try {
@@ -125,7 +140,7 @@ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinari
125
140
  throw new VideoProcessError('canceled', 'Video preview canceled.');
126
141
  if (options.layout !== undefined && options.layout !== 'frames' && options.layout !== 'sheet')
127
142
  throw new VideoProcessError('invalid', 'Video layout must be frames or sheet.');
128
- const binaries = await findBinaries({}, {});
143
+ const binaries = await findBinaries({}, { ...(env ? { env } : {}), ...(platform ? { platform } : {}) });
129
144
  if (!binaries)
130
145
  return { ok: false, reason: 'unavailable', message: UNAVAILABLE };
131
146
  directory = await mkdtemp(join(tmpdir(), 'video-preview-'));
@@ -253,7 +268,7 @@ export function createFfmpegVideoFrameExtractor({ findBinaries = findVideoBinari
253
268
  return { ok: false, reason: 'invalid', message: 'Video preview temporary files could not be removed.' };
254
269
  }
255
270
  finally {
256
- extracting = false;
271
+ release();
257
272
  }
258
273
  }
259
274
  } };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Dropdown navigation (`"variant":"dropdown"` menus), following the APG disclosure-navigation
3
+ * pattern: a branch item carries a server-rendered `<button class="jini-menu-toggle" aria-expanded
4
+ * aria-controls>` pointing at its child `<ul id>`, and the root `<ul>` carries `data-jini-dropdown`.
5
+ * No `role="menu"` and no `aria-haspopup` — those make screen readers announce a menu and expect
6
+ * menubar keys, which site nav should not imitate.
7
+ *
8
+ * The toggles are real buttons in the markup (not injected by JS), so without JS Tab still reaches
9
+ * them and a theme's `:focus-within` CSS opens the panel. {@link MENU_DROPDOWN_SCRIPT} adds
10
+ * `jini-menu-js` to each root so a theme can switch that fallback off once JS owns the open state
11
+ * (otherwise Escape could not close a panel while focus sits on its toggle).
12
+ */
13
+ /** A branch item's toggle. Split form (default): an empty button labelled "`label` submenu" that sits
14
+ * after the item's link. `asLabel`: the button IS the item's label (a link-less parent), and carries
15
+ * `menu-item-label` so theme scripts that read `.menu-item-label` treat it as an inert parent.
16
+ * @complexity O(n) over `label`. */
17
+ export declare function renderMenuToggle({ controlsId, label }: {
18
+ controlsId: string;
19
+ label: string;
20
+ }, { asLabel }?: {
21
+ asLabel?: boolean;
22
+ }): string;
23
+ /**
24
+ * The inline script behind every `[data-jini-dropdown]` menu ({@link injectMenuDropdownScript} adds it
25
+ * once per page). State lives in `li.jini-menu-open` and the toggle's `aria-expanded`.
26
+ * - click on a toggle opens/closes its item (mouse, touch tap, Enter/Space); any click or focus
27
+ * elsewhere closes every open item that does not contain the target (siblings, click-outside,
28
+ * tabbing out). Closing an item closes its open descendants too.
29
+ * - Escape closes the nearest open item around the focus and returns focus to its toggle.
30
+ * - ArrowDown on a toggle opens it and focuses the submenu's first link/button; ArrowDown/ArrowUp
31
+ * inside a submenu move between sibling items; ArrowLeft/ArrowRight on a top-level item move between
32
+ * top-level items. No wrap at either end. Handled arrows `preventDefault` so the page does not scroll.
33
+ * Hover stays pure theme CSS and never touches `aria-expanded`.
34
+ */
35
+ export declare const MENU_DROPDOWN_SCRIPT = "(function(){\n var roots=document.querySelectorAll('[data-jini-dropdown]');\n for(var i=0;i<roots.length;i++)roots[i].classList.add('jini-menu-js');\n function inMenu(el){return el&&el.closest&&el.closest('[data-jini-dropdown]')?el:null;}\n function toggleOf(li){return li.querySelector(':scope > .jini-menu-toggle');}\n function panelOf(li){var b=toggleOf(li);return b?document.getElementById(b.getAttribute('aria-controls')):null;}\n function first(li){return li?li.querySelector('a[href], button'):null;}\n function setOpen(li,open){\n var items=open?[li]:[li].concat(Array.prototype.slice.call(li.querySelectorAll('li.jini-menu-open')));\n for(var i=0;i<items.length;i++){items[i].classList[open?'add':'remove']('jini-menu-open');var b=toggleOf(items[i]);if(b)b.setAttribute('aria-expanded',open?'true':'false');}\n }\n function closeAll(keep){\n var open=document.querySelectorAll('[data-jini-dropdown] li.jini-menu-open');\n for(var i=0;i<open.length;i++)if(!(keep&&keep.nodeType===1&&open[i].contains(keep)))setOpen(open[i],false);\n }\n document.addEventListener('focusin',function(e){closeAll(e.target);});\n document.addEventListener('click',function(e){\n closeAll(e.target);\n var b=inMenu(e.target)?e.target.closest('.jini-menu-toggle'):null;\n if(b)setOpen(b.parentNode,b.getAttribute('aria-expanded')!=='true');\n });\n document.addEventListener('keydown',function(e){\n var t=inMenu(e.target);if(!t)return;\n if(e.key==='Escape'){var o=t.closest('li.jini-menu-open');if(o){setOpen(o,false);toggleOf(o).focus();}return;}\n var li=t.closest('li'),next=null;if(!li)return;\n var top=li.parentNode.hasAttribute('data-jini-dropdown');\n if(e.key==='ArrowDown'&&t.classList.contains('jini-menu-toggle')){closeAll(li);setOpen(li,true);var p=panelOf(li);next=p?first(p):null;}\n else if(!top&&e.key==='ArrowDown')next=first(li.nextElementSibling);\n else if(!top&&e.key==='ArrowUp')next=first(li.previousElementSibling);\n else if(top&&e.key==='ArrowRight')next=first(li.nextElementSibling);\n else if(top&&e.key==='ArrowLeft')next=first(li.previousElementSibling);\n else return;\n e.preventDefault();\n if(next)next.focus();\n });\n })();";
36
+ /** Adds the one inline dropdown script before `</body>` when the page carries a `data-jini-dropdown`
37
+ * menu and does not already have the script. @complexity O(n). */
38
+ export declare function injectMenuDropdownScript({ html }: {
39
+ html: string;
40
+ }, _optional?: Record<string, never>): string;
41
+ //# sourceMappingURL=dropdown.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Dropdown navigation (`"variant":"dropdown"` menus), following the APG disclosure-navigation
3
+ * pattern: a branch item carries a server-rendered `<button class="jini-menu-toggle" aria-expanded
4
+ * aria-controls>` pointing at its child `<ul id>`, and the root `<ul>` carries `data-jini-dropdown`.
5
+ * No `role="menu"` and no `aria-haspopup` — those make screen readers announce a menu and expect
6
+ * menubar keys, which site nav should not imitate.
7
+ *
8
+ * The toggles are real buttons in the markup (not injected by JS), so without JS Tab still reaches
9
+ * them and a theme's `:focus-within` CSS opens the panel. {@link MENU_DROPDOWN_SCRIPT} adds
10
+ * `jini-menu-js` to each root so a theme can switch that fallback off once JS owns the open state
11
+ * (otherwise Escape could not close a panel while focus sits on its toggle).
12
+ */
13
+ const SCRIPT_MARKER = 'data-jini-menu-dropdown-script';
14
+ function escapeAttribute(value) {
15
+ return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16
+ }
17
+ /** A branch item's toggle. Split form (default): an empty button labelled "`label` submenu" that sits
18
+ * after the item's link. `asLabel`: the button IS the item's label (a link-less parent), and carries
19
+ * `menu-item-label` so theme scripts that read `.menu-item-label` treat it as an inert parent.
20
+ * @complexity O(n) over `label`. */
21
+ export function renderMenuToggle({ controlsId, label }, { asLabel = false } = {}) {
22
+ const id = escapeAttribute(controlsId);
23
+ const text = escapeAttribute(label);
24
+ return asLabel
25
+ ? `<button type="button" class="menu-item-label jini-menu-toggle" aria-expanded="false" aria-controls="${id}">${text}</button>`
26
+ : `<button type="button" class="jini-menu-toggle" aria-expanded="false" aria-controls="${id}" aria-label="${text} submenu"></button>`;
27
+ }
28
+ /**
29
+ * The inline script behind every `[data-jini-dropdown]` menu ({@link injectMenuDropdownScript} adds it
30
+ * once per page). State lives in `li.jini-menu-open` and the toggle's `aria-expanded`.
31
+ * - click on a toggle opens/closes its item (mouse, touch tap, Enter/Space); any click or focus
32
+ * elsewhere closes every open item that does not contain the target (siblings, click-outside,
33
+ * tabbing out). Closing an item closes its open descendants too.
34
+ * - Escape closes the nearest open item around the focus and returns focus to its toggle.
35
+ * - ArrowDown on a toggle opens it and focuses the submenu's first link/button; ArrowDown/ArrowUp
36
+ * inside a submenu move between sibling items; ArrowLeft/ArrowRight on a top-level item move between
37
+ * top-level items. No wrap at either end. Handled arrows `preventDefault` so the page does not scroll.
38
+ * Hover stays pure theme CSS and never touches `aria-expanded`.
39
+ */
40
+ export const MENU_DROPDOWN_SCRIPT = `(function(){
41
+ var roots=document.querySelectorAll('[data-jini-dropdown]');
42
+ for(var i=0;i<roots.length;i++)roots[i].classList.add('jini-menu-js');
43
+ function inMenu(el){return el&&el.closest&&el.closest('[data-jini-dropdown]')?el:null;}
44
+ function toggleOf(li){return li.querySelector(':scope > .jini-menu-toggle');}
45
+ function panelOf(li){var b=toggleOf(li);return b?document.getElementById(b.getAttribute('aria-controls')):null;}
46
+ function first(li){return li?li.querySelector('a[href], button'):null;}
47
+ function setOpen(li,open){
48
+ var items=open?[li]:[li].concat(Array.prototype.slice.call(li.querySelectorAll('li.jini-menu-open')));
49
+ for(var i=0;i<items.length;i++){items[i].classList[open?'add':'remove']('jini-menu-open');var b=toggleOf(items[i]);if(b)b.setAttribute('aria-expanded',open?'true':'false');}
50
+ }
51
+ function closeAll(keep){
52
+ var open=document.querySelectorAll('[data-jini-dropdown] li.jini-menu-open');
53
+ for(var i=0;i<open.length;i++)if(!(keep&&keep.nodeType===1&&open[i].contains(keep)))setOpen(open[i],false);
54
+ }
55
+ document.addEventListener('focusin',function(e){closeAll(e.target);});
56
+ document.addEventListener('click',function(e){
57
+ closeAll(e.target);
58
+ var b=inMenu(e.target)?e.target.closest('.jini-menu-toggle'):null;
59
+ if(b)setOpen(b.parentNode,b.getAttribute('aria-expanded')!=='true');
60
+ });
61
+ document.addEventListener('keydown',function(e){
62
+ var t=inMenu(e.target);if(!t)return;
63
+ if(e.key==='Escape'){var o=t.closest('li.jini-menu-open');if(o){setOpen(o,false);toggleOf(o).focus();}return;}
64
+ var li=t.closest('li'),next=null;if(!li)return;
65
+ var top=li.parentNode.hasAttribute('data-jini-dropdown');
66
+ if(e.key==='ArrowDown'&&t.classList.contains('jini-menu-toggle')){closeAll(li);setOpen(li,true);var p=panelOf(li);next=p?first(p):null;}
67
+ else if(!top&&e.key==='ArrowDown')next=first(li.nextElementSibling);
68
+ else if(!top&&e.key==='ArrowUp')next=first(li.previousElementSibling);
69
+ else if(top&&e.key==='ArrowRight')next=first(li.nextElementSibling);
70
+ else if(top&&e.key==='ArrowLeft')next=first(li.previousElementSibling);
71
+ else return;
72
+ e.preventDefault();
73
+ if(next)next.focus();
74
+ });
75
+ })();`;
76
+ /** Adds the one inline dropdown script before `</body>` when the page carries a `data-jini-dropdown`
77
+ * menu and does not already have the script. @complexity O(n). */
78
+ export function injectMenuDropdownScript({ html }, _optional = {}) {
79
+ if (!html.includes('data-jini-dropdown') || html.includes(SCRIPT_MARKER))
80
+ return html;
81
+ const script = `<script ${SCRIPT_MARKER}>${MENU_DROPDOWN_SCRIPT}</script>`;
82
+ const bodyClose = html.lastIndexOf('</body>');
83
+ return bodyClose === -1 ? html + script : html.slice(0, bodyClose) + script + html.slice(bodyClose);
84
+ }
85
+ //# sourceMappingURL=dropdown.js.map
@@ -30,6 +30,8 @@ export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndClon
30
30
  export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, type MenuHtmlAuthoring, } from "./menu-html.js";
31
31
  export { resolveForLocation, resolveMenuDoc, type ResolveTargetHrefFn, type ResolvedTargetHref, type ResolveForLocationDeps, type ResolveForLocationServiceInput, type ResolveForLocationRequired, type ResolveForLocationOptional, } from "./resolver.js";
32
32
  export { rebuildNavLocationBindings, type RebuildNavLocationBindingsDeps, type RebuildNavLocationBindingsResult, } from "./reconcile.js";
33
+ /** Dropdown menus (2026-10-11): toggle markup + the inline keyboard/touch script (see `dropdown.ts`). */
34
+ export { renderMenuToggle, injectMenuDropdownScript, MENU_DROPDOWN_SCRIPT } from "./dropdown.js";
33
35
  /** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
34
36
  export { menusAgentToolCatalog, } from "./agent-tools.js";
35
37
  /**
@@ -21,6 +21,8 @@ export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndClon
21
21
  export { MAX_MENU_HTML_LENGTH, isMenuHtmlAuthoring, menuHtmlInputProblem, mergeMenuHtml, } from "./menu-html.js";
22
22
  export { resolveForLocation, resolveMenuDoc, } from "./resolver.js";
23
23
  export { rebuildNavLocationBindings, } from "./reconcile.js";
24
+ /** Dropdown menus (2026-10-11): toggle markup + the inline keyboard/touch script (see `dropdown.ts`). */
25
+ export { renderMenuToggle, injectMenuDropdownScript, MENU_DROPDOWN_SCRIPT } from "./dropdown.js";
24
26
  /** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
25
27
  export { menusAgentToolCatalog, } from "./agent-tools.js";
26
28
  /**
@@ -55,6 +55,8 @@ export interface RedirectSlugChangeCaptureReadDeps {
55
55
  }, optional?: Record<string, never>): Promise<RedirectRecord | null>;
56
56
  }
57
57
  export interface RedirectSlugChangeCaptureDeps {
58
+ /** Optional for existing hosts; route-owning hosts supply their reserved segment policy. */
59
+ reservedSegments?: ReadonlySet<string>;
58
60
  repo: RedirectSlugChangeCaptureReadDeps;
59
61
  db: RedirectDbHandle;
60
62
  clock: ClockPort;
@@ -1,4 +1,5 @@
1
1
  import { nowIso as clockNowIso } from "@jini-ai/core/primitives";
2
+ import { assertRedirectSourceAllowed } from "./redirects.js";
2
3
  import { insertRedirectAndRevision } from "./ports.internal.js";
3
4
  /** Default priority/statusCode/override for an auto-captured rule (behavior.spec.md §3 analog). */
4
5
  const AUTO_CAPTURE_STATUS_CODE = 301;
@@ -21,6 +22,9 @@ export class RedirectSlugChangeCapture {
21
22
  * @complexity O(1) — one read (idempotency check) + two writes.
22
23
  */
23
24
  async onSlugChange(input, _optional = {}) {
25
+ if (this.deps.reservedSegments) {
26
+ assertRedirectSourceAllowed({ fromPattern: input.oldPath, reservedSegments: this.deps.reservedSegments });
27
+ }
24
28
  const existing = await this.deps.repo.lookupExact({
25
29
  workspaceId: input.workspaceId,
26
30
  path: input.oldPath,
@@ -100,6 +100,14 @@ export interface RedirectsWriteDeps {
100
100
  idGen: IdGeneratorPort;
101
101
  outbox: OutboxPort;
102
102
  }
103
+ /** Refuse source patterns whose decoded first segment belongs to a host route.
104
+ * Uses the same decoding and case folding as the target gate; wildcard tails remain patterns.
105
+ * @throws RedirectValidationError before any write. @complexity O(n) in the pattern length.
106
+ */
107
+ export declare function assertRedirectSourceAllowed({ fromPattern, reservedSegments }: {
108
+ fromPattern: string;
109
+ reservedSegments: ReadonlySet<string>;
110
+ }, _optional?: Record<string, never>): void;
103
111
  /**
104
112
  * The site-relative half of the write gate: routing's structural + reserved-path verdict, then the
105
113
  * redirect oracle's own raw-character rule. The read path hands a relative location to that oracle
@@ -144,6 +144,16 @@ function siteRelativeRefusalReason(check) {
144
144
  return "it contains, or decodes to, a backslash or a control character";
145
145
  }
146
146
  }
147
+ /** Refuse source patterns whose decoded first segment belongs to a host route.
148
+ * Uses the same decoding and case folding as the target gate; wildcard tails remain patterns.
149
+ * @throws RedirectValidationError before any write. @complexity O(n) in the pattern length.
150
+ */
151
+ export function assertRedirectSourceAllowed({ fromPattern, reservedSegments }, _optional = {}) {
152
+ const verdict = checkSiteRelativeTarget({ raw: fromPattern, reservedSegments }, {});
153
+ if (verdict.kind === "reserved") {
154
+ throw new RedirectValidationError(`fromPattern '${fromPattern}' is reserved for the '${verdict.surface}' surface`);
155
+ }
156
+ }
147
157
  /**
148
158
  * The site-relative half of the write gate: routing's structural + reserved-path verdict, then the
149
159
  * redirect oracle's own raw-character rule. The read path hands a relative location to that oracle
@@ -257,6 +267,7 @@ export async function checkRedirectFieldsWouldWrite({ deps, fields }, { excludeI
257
267
  validateToTargetLength(fields.toTarget);
258
268
  validateStatusCode(fields.statusCode);
259
269
  validatePriority(fields.priority);
270
+ assertRedirectSourceAllowed({ fromPattern: fields.fromPattern, reservedSegments: deps.reservedSegments });
260
271
  await assertTargetAllowed(deps.originRegistry, fields.workspaceId, fields.toTarget, deps.reservedSegments);
261
272
  const finalTarget = await resolveCollapsedTarget(deps.repo, fields.workspaceId, fields.fromPattern, fields.toTarget);
262
273
  if (finalTarget !== fields.toTarget) {
@@ -300,6 +311,7 @@ export async function createRedirect(required, _optional = {}) {
300
311
  validateStatusCode(input.statusCode);
301
312
  const priority = input.priority ?? DEFAULT_PRIORITY;
302
313
  validatePriority(priority);
314
+ assertRedirectSourceAllowed({ fromPattern: input.fromPattern, reservedSegments: deps.reservedSegments });
303
315
  await assertTargetAllowed(deps.originRegistry, input.workspaceId, input.toTarget, deps.reservedSegments);
304
316
  const finalTarget = await resolveCollapsedTarget(deps.repo, input.workspaceId, input.fromPattern, input.toTarget);
305
317
  if (finalTarget !== input.toTarget) {
@@ -411,6 +423,7 @@ async function validateUpdateFields(deps, workspaceId, fields, existingId) {
411
423
  validateStatusCode(fields.statusCode);
412
424
  validateStatus(fields.status);
413
425
  validatePriority(fields.priority);
426
+ assertRedirectSourceAllowed({ fromPattern: fields.fromPattern, reservedSegments: deps.reservedSegments });
414
427
  await assertTargetAllowed(deps.originRegistry, workspaceId, fields.toTarget, deps.reservedSegments);
415
428
  const finalTarget = await resolveCollapsedTarget(deps.repo, workspaceId, fields.fromPattern, fields.toTarget);
416
429
  if (finalTarget !== fields.toTarget) {
@@ -442,6 +455,7 @@ export async function updateRedirect(required, _optional = {}) {
442
455
  if (!isRestoreOnlyUpdate(input)) {
443
456
  assertEntityLive({ entityType: "redirect", entityId: input.id, state: "trashed" });
444
457
  }
458
+ assertRedirectSourceAllowed({ fromPattern: existing.fromPattern, reservedSegments: deps.reservedSegments });
445
459
  const outcome = await deps.restore({
446
460
  workspaceId: input.workspaceId,
447
461
  id: input.id,
@@ -0,0 +1,5 @@
1
+ export { createRouting, RouteResolutionError } from './routing.js';
2
+ export type { CreateRoutingRequired, CreateRoutingOptional, EmptyOptions, Routing, IsActiveOptional, IsActiveRequired, RegisterNamedRouteInput, RegisterResolvePhaseOptions, ResolveInput, ResolveOptional, ResolveRequired, UrlForOptional, UrlForRequired } from './routing.js';
3
+ export type { RouteResolverDeps, RoutingContentRecord, RoutingContentReadPort, RoutingPorts } from './ports.js';
4
+ export type { EntryRefTarget, RouteRefTarget, RouteResolveContext, RouteResolvePhaseHandler, RouteResolvePhaseName, RouteResolvePhaseOutcome, RouteResolveResult, RouteTarget, RouteTargetKind, RouteUrl, SlugChangeCapture, SlugChangeCaptureInput, TermRefTarget, UrlTarget } from './types.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { createRouting, RouteResolutionError } from './routing.js';
2
+ //# sourceMappingURL=index.js.map