@jini-ai/cms 0.5.1 → 0.5.3

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 (209) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +14 -8
  3. package/dist/comments/errors.d.ts +23 -0
  4. package/dist/comments/errors.js +25 -0
  5. package/dist/comments/express/index.d.ts +2 -0
  6. package/dist/comments/express/index.js +2 -0
  7. package/dist/comments/express/submit-handler.d.ts +30 -0
  8. package/dist/comments/express/submit-handler.js +85 -0
  9. package/dist/comments/hooks.d.ts +39 -0
  10. package/dist/comments/hooks.js +41 -0
  11. package/dist/comments/index.d.ts +11 -0
  12. package/dist/comments/index.js +11 -0
  13. package/dist/comments/ingress.d.ts +57 -0
  14. package/dist/comments/ingress.js +173 -0
  15. package/dist/comments/module.d.ts +94 -0
  16. package/dist/comments/module.js +73 -0
  17. package/dist/comments/ports.d.ts +184 -0
  18. package/dist/comments/ports.js +2 -0
  19. package/dist/comments/repo.memory.d.ts +70 -0
  20. package/dist/comments/repo.memory.js +120 -0
  21. package/dist/comments/sanitize.d.ts +15 -0
  22. package/dist/comments/sanitize.js +31 -0
  23. package/dist/comments/spam.heuristic.d.ts +8 -0
  24. package/dist/comments/spam.heuristic.js +54 -0
  25. package/dist/comments/sql/index.d.ts +3 -0
  26. package/dist/comments/sql/index.js +3 -0
  27. package/dist/comments/sql/repo.d.ts +77 -0
  28. package/dist/comments/sql/repo.js +190 -0
  29. package/dist/comments/sql/repo.rows.d.ts +49 -0
  30. package/dist/comments/sql/repo.rows.js +71 -0
  31. package/dist/comments/types.d.ts +187 -0
  32. package/dist/comments/types.js +39 -0
  33. package/dist/comments/write-service.d.ts +121 -0
  34. package/dist/comments/write-service.js +144 -0
  35. package/dist/core/commands/command.d.ts +2 -9
  36. package/dist/core/commands/command.js +2 -9
  37. package/dist/core/index.d.ts +2 -1
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/keyset-cursor.d.ts +25 -0
  40. package/dist/core/keyset-cursor.js +20 -0
  41. package/dist/core/ports.d.ts +30 -0
  42. package/dist/core/ports.js +0 -1
  43. package/dist/core/tools/args.d.ts +1 -46
  44. package/dist/core/tools/args.js +1 -44
  45. package/dist/media/index.d.ts +1 -2
  46. package/dist/media/index.js +2 -2
  47. package/dist/media/node.d.ts +5 -0
  48. package/dist/media/node.js +6 -0
  49. package/dist/media/video-frames.d.ts +94 -0
  50. package/dist/media/video-frames.ffmpeg.d.ts +39 -0
  51. package/dist/media/video-frames.ffmpeg.js +261 -0
  52. package/dist/media/video-frames.js +97 -0
  53. package/dist/redirects/capture.d.ts +80 -0
  54. package/dist/redirects/capture.js +66 -0
  55. package/dist/redirects/express/index.d.ts +27 -0
  56. package/dist/redirects/express/index.js +40 -0
  57. package/dist/redirects/hit-sink.d.ts +56 -0
  58. package/dist/redirects/hit-sink.js +44 -0
  59. package/dist/redirects/host-config/index.d.ts +40 -0
  60. package/dist/redirects/host-config/index.js +145 -0
  61. package/dist/redirects/index.d.ts +14 -0
  62. package/dist/redirects/index.js +14 -0
  63. package/dist/redirects/matcher.d.ts +58 -0
  64. package/dist/redirects/matcher.js +144 -0
  65. package/dist/redirects/order.d.ts +30 -0
  66. package/dist/redirects/order.js +26 -0
  67. package/dist/redirects/ports.d.ts +242 -0
  68. package/dist/redirects/ports.internal.d.ts +57 -0
  69. package/dist/redirects/ports.internal.js +14 -0
  70. package/dist/redirects/ports.js +2 -0
  71. package/dist/redirects/redirects.d.ts +226 -0
  72. package/dist/redirects/redirects.js +590 -0
  73. package/dist/redirects/referrer-alias.d.ts +31 -0
  74. package/dist/redirects/referrer-alias.js +33 -0
  75. package/dist/redirects/repo.memory.d.ts +51 -0
  76. package/dist/redirects/repo.memory.js +98 -0
  77. package/dist/redirects/reserved-destination.d.ts +58 -0
  78. package/dist/redirects/reserved-destination.js +59 -0
  79. package/dist/redirects/resolver.d.ts +100 -0
  80. package/dist/redirects/resolver.js +87 -0
  81. package/dist/redirects/spa/index.d.ts +30 -0
  82. package/dist/redirects/spa/index.js +27 -0
  83. package/dist/redirects/sql/index.d.ts +4 -0
  84. package/dist/redirects/sql/index.js +4 -0
  85. package/dist/redirects/sql/repo.d.ts +82 -0
  86. package/dist/redirects/sql/repo.js +143 -0
  87. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  88. package/dist/redirects/sql/repo.rows.js +79 -0
  89. package/dist/redirects/sql/row-types.d.ts +34 -0
  90. package/dist/redirects/sql/row-types.js +2 -0
  91. package/dist/redirects/target-gate.d.ts +30 -0
  92. package/dist/redirects/target-gate.js +85 -0
  93. package/dist/redirects/types.d.ts +213 -0
  94. package/dist/redirects/types.js +42 -0
  95. package/dist/seo/absolute-url.d.ts +58 -0
  96. package/dist/seo/absolute-url.js +117 -0
  97. package/dist/seo/dom/index.d.ts +14 -0
  98. package/dist/seo/dom/index.js +52 -0
  99. package/dist/seo/errors.d.ts +35 -0
  100. package/dist/seo/errors.js +35 -0
  101. package/dist/seo/feed.d.ts +49 -0
  102. package/dist/seo/feed.js +61 -0
  103. package/dist/seo/index.d.ts +13 -0
  104. package/dist/seo/index.js +13 -0
  105. package/dist/seo/media.d.ts +57 -0
  106. package/dist/seo/media.js +134 -0
  107. package/dist/seo/page-head.d.ts +18 -0
  108. package/dist/seo/page-head.js +114 -0
  109. package/dist/seo/ports.d.ts +221 -0
  110. package/dist/seo/ports.js +27 -0
  111. package/dist/seo/seo.d.ts +73 -0
  112. package/dist/seo/seo.js +230 -0
  113. package/dist/seo/sitemap.d.ts +139 -0
  114. package/dist/seo/sitemap.js +217 -0
  115. package/dist/seo/types.d.ts +278 -0
  116. package/dist/seo/types.js +2 -0
  117. package/dist/seo/write-service.d.ts +79 -0
  118. package/dist/seo/write-service.js +273 -0
  119. package/dist/seo/xml-escape.d.ts +16 -0
  120. package/dist/seo/xml-escape.js +19 -0
  121. package/dist/settings/express/cms-adapter.d.ts +20 -0
  122. package/dist/settings/express/cms-adapter.js +124 -0
  123. package/dist/settings/express/contracts.d.ts +135 -0
  124. package/dist/settings/express/contracts.js +2 -0
  125. package/dist/settings/express/http.d.ts +10 -0
  126. package/dist/settings/express/http.js +211 -0
  127. package/dist/settings/express/index.d.ts +5 -0
  128. package/dist/settings/express/index.js +5 -0
  129. package/dist/settings/express/shared.d.ts +68 -0
  130. package/dist/settings/express/shared.js +54 -0
  131. package/dist/settings/express/stream.d.ts +24 -0
  132. package/dist/settings/express/stream.js +147 -0
  133. package/dist/settings/index.d.ts +3 -38
  134. package/dist/settings/index.js +3 -35
  135. package/dist/taxonomy/write-service.d.ts +2 -30
  136. package/dist/widgets/authorize-helper.d.ts +42 -0
  137. package/dist/widgets/authorize-helper.js +16 -0
  138. package/dist/widgets/concurrency.d.ts +12 -0
  139. package/dist/widgets/concurrency.js +44 -0
  140. package/dist/widgets/config-validation.d.ts +34 -0
  141. package/dist/widgets/config-validation.js +125 -0
  142. package/dist/widgets/embed-service.d.ts +133 -0
  143. package/dist/widgets/embed-service.js +460 -0
  144. package/dist/widgets/embed-validation.d.ts +23 -0
  145. package/dist/widgets/embed-validation.js +38 -0
  146. package/dist/widgets/entry-payload.d.ts +103 -0
  147. package/dist/widgets/entry-payload.js +160 -0
  148. package/dist/widgets/errors.d.ts +136 -0
  149. package/dist/widgets/errors.js +136 -0
  150. package/dist/widgets/host-ports.d.ts +149 -0
  151. package/dist/widgets/host-ports.js +2 -0
  152. package/dist/widgets/html/html-embeds.d.ts +247 -0
  153. package/dist/widgets/html/html-embeds.js +408 -0
  154. package/dist/widgets/html/index.d.ts +5 -0
  155. package/dist/widgets/html/index.js +4 -0
  156. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  157. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  158. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  159. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  160. package/dist/widgets/index.d.ts +20 -0
  161. package/dist/widgets/index.js +19 -0
  162. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  163. package/dist/widgets/markers/html-attributes.js +216 -0
  164. package/dist/widgets/markers/index.d.ts +3 -0
  165. package/dist/widgets/markers/index.js +3 -0
  166. package/dist/widgets/markers/marker.d.ts +496 -0
  167. package/dist/widgets/markers/marker.js +780 -0
  168. package/dist/widgets/page-embed-types.d.ts +42 -0
  169. package/dist/widgets/page-embed-types.js +42 -0
  170. package/dist/widgets/ports.d.ts +142 -0
  171. package/dist/widgets/ports.js +2 -0
  172. package/dist/widgets/read-service.d.ts +81 -0
  173. package/dist/widgets/read-service.js +79 -0
  174. package/dist/widgets/region-area-service.d.ts +110 -0
  175. package/dist/widgets/region-area-service.js +180 -0
  176. package/dist/widgets/registry.d.ts +217 -0
  177. package/dist/widgets/registry.js +172 -0
  178. package/dist/widgets/repo.memory.d.ts +41 -0
  179. package/dist/widgets/repo.memory.js +48 -0
  180. package/dist/widgets/resolver-service.d.ts +209 -0
  181. package/dist/widgets/resolver-service.js +909 -0
  182. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  183. package/dist/widgets/resolvers/contact-form.js +54 -0
  184. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  185. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  186. package/dist/widgets/resolvers/index.d.ts +65 -0
  187. package/dist/widgets/resolvers/index.js +155 -0
  188. package/dist/widgets/resolvers/menu.d.ts +42 -0
  189. package/dist/widgets/resolvers/menu.js +43 -0
  190. package/dist/widgets/resolvers/ports.d.ts +82 -0
  191. package/dist/widgets/resolvers/ports.js +2 -0
  192. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  193. package/dist/widgets/resolvers/recent-entries.js +165 -0
  194. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  195. package/dist/widgets/resolvers/taxonomy.js +34 -0
  196. package/dist/widgets/sql/index.d.ts +3 -0
  197. package/dist/widgets/sql/index.js +3 -0
  198. package/dist/widgets/sql/repo.d.ts +41 -0
  199. package/dist/widgets/sql/repo.js +82 -0
  200. package/dist/widgets/sql/tables.d.ts +13 -0
  201. package/dist/widgets/sql/tables.js +2 -0
  202. package/dist/widgets/types.d.ts +213 -0
  203. package/dist/widgets/types.js +17 -0
  204. package/dist/widgets/where-used.d.ts +41 -0
  205. package/dist/widgets/where-used.js +11 -0
  206. package/dist/widgets/write-service.d.ts +204 -0
  207. package/dist/widgets/write-service.js +332 -0
  208. package/dist/workspace/delete.d.ts +1 -1
  209. package/package.json +220 -11
@@ -0,0 +1,27 @@
1
+ /** Express binding; origin and reserved-path decisions remain in the required resolver. */
2
+ import type { NextFunction, Request, Response } from "express";
3
+ import type { RedirectResolutionReport, RedirectResolverWithRefusals } from "../ports.js";
4
+ import type { RedirectResolvePhase } from "../types.js";
5
+ export declare class RedirectMiddlewareContextError extends Error {
6
+ constructor(_required?: Record<string, never>, _optional?: Record<string, never>);
7
+ }
8
+ export type RedirectMiddlewareResult = RedirectResolutionReport | {
9
+ error: unknown;
10
+ };
11
+ export type RedirectMiddleware = (request: Request, response: Response, next: NextFunction) => Promise<RedirectMiddlewareResult>;
12
+ /**
13
+ * Adapt a gate-owning resolver to Express. The host supplies res.locals.redirectContext;
14
+ * workspace identity is never inferred from an untrusted Host header or from a product default.
15
+ * @param required Resolver with the required oracle/reserved-segment gate and refusal reporting.
16
+ * @param optional Phase; mount pre_content before content routes or post_content after them.
17
+ * @returns Middleware: redirects approved Locations, exposes refusals in locals/return, otherwise next().
18
+ * @throws Errors are forwarded to next(error) and retained in the returned error outcome.
19
+ * @complexity O(1) adapter work, plus the required resolver's bounded work.
20
+ * @example app.use(createRedirectMiddleware({ resolver }, { phase: "post_content" }));
21
+ */
22
+ export declare function createRedirectMiddleware({ resolver }: {
23
+ resolver: RedirectResolverWithRefusals;
24
+ }, { phase }?: {
25
+ phase?: RedirectResolvePhase;
26
+ }): RedirectMiddleware;
27
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,40 @@
1
+ export class RedirectMiddlewareContextError extends Error {
2
+ constructor(_required = {}, _optional = {}) {
3
+ super("redirect middleware requires res.locals.redirectContext.workspaceId");
4
+ this.name = "RedirectMiddlewareContextError";
5
+ }
6
+ }
7
+ /**
8
+ * Adapt a gate-owning resolver to Express. The host supplies res.locals.redirectContext;
9
+ * workspace identity is never inferred from an untrusted Host header or from a product default.
10
+ * @param required Resolver with the required oracle/reserved-segment gate and refusal reporting.
11
+ * @param optional Phase; mount pre_content before content routes or post_content after them.
12
+ * @returns Middleware: redirects approved Locations, exposes refusals in locals/return, otherwise next().
13
+ * @throws Errors are forwarded to next(error) and retained in the returned error outcome.
14
+ * @complexity O(1) adapter work, plus the required resolver's bounded work.
15
+ * @example app.use(createRedirectMiddleware({ resolver }, { phase: "post_content" }));
16
+ */
17
+ export function createRedirectMiddleware({ resolver }, { phase = "post_content" } = {}) {
18
+ return async (request, response, next) => {
19
+ try {
20
+ const context = response.locals.redirectContext;
21
+ if (typeof context?.workspaceId !== "string" || context.workspaceId.length === 0)
22
+ throw new RedirectMiddlewareContextError({}, {});
23
+ const report = await resolver.resolveWithRefusals({ workspaceId: context.workspaceId, path: request.path, phase }, {});
24
+ response.locals.redirectRefusals = report.refused;
25
+ if (!report.resolution.matched) {
26
+ next();
27
+ return report;
28
+ }
29
+ // INV-03: the required resolver has checked this exact interpolated Location. No second
30
+ // template substitution or target reconstruction occurs between that verdict and Express.
31
+ response.redirect(report.resolution.statusCode, report.resolution.location);
32
+ return report;
33
+ }
34
+ catch (error) {
35
+ next(error);
36
+ return { error };
37
+ }
38
+ };
39
+ }
40
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file `RedirectHitSink` implementation + `registerRedirectHitOutboxHandler`
3
+ * (SPEC-009 REQ-21; ADR-PIPE-009 C-009/C-010).
4
+ *
5
+ * Purpose:
6
+ * Off-hot-path hit aggregation. `phase-handler.ts` enqueues a `redirect.hit`
7
+ * outbox event on every match (W-008, fire-and-forget); this file's outbox
8
+ * subscriber folds it into the in-process hit-stats store via `record()`.
9
+ * Best-effort by design: a dropped/failed increment loses a statistic, never
10
+ * a redirect (INV-06).
11
+ *
12
+ * Storage note (disclosed simplification, matches existing precedent): this
13
+ * is an in-memory-only sink, the same pattern `analytics/repo.memory.ts`'s
14
+ * `LocalBufferSink` already uses in BOTH `server/app.ts` (test/dev) and
15
+ * `server/deps.ts` (the real running server) — no SQLite adapter exists yet
16
+ * for either. The `redirect_hits` table (`db/schema.sqlite.ts`) is reserved
17
+ * for a future real adapter; REQ-21/AC-26 are fully testable and functional
18
+ * against this in-memory sink.
19
+ *
20
+ * Architectural role:
21
+ * INTERNAL SEAM (not an ADR-006 port, see `ports.ts`'s file header) + a thin
22
+ * composition-root registration function.
23
+ */
24
+ import type { EventBusPort } from "../core/index.js";
25
+ import type { RedirectHitSink } from "./ports.js";
26
+ import type { RedirectHitStats } from "./types.js";
27
+ export declare class RedirectHitSinkImpl implements RedirectHitSink {
28
+ private readonly stats;
29
+ record(required: {
30
+ workspaceId: string;
31
+ redirectId: string;
32
+ at: string;
33
+ }, _optional?: Record<string, never>): Promise<void>;
34
+ getStats(required: {
35
+ workspaceId: string;
36
+ redirectId: string;
37
+ }, _optional?: Record<string, never>): Promise<RedirectHitStats | null>;
38
+ listStats(required: {
39
+ workspaceId: string;
40
+ }, _optional?: Record<string, never>): Promise<RedirectHitStats[]>;
41
+ }
42
+ export interface RegisterRedirectHitOutboxHandlerDeps {
43
+ bus: EventBusPort;
44
+ hitSink: RedirectHitSink;
45
+ }
46
+ /**
47
+ * Subscribe `hitSink.record` to the `redirect.hit` outbox event (W-009).
48
+ * Any failure is swallowed here (logged, never rethrown) so a subscriber
49
+ * failure never surfaces to the outbox dispatcher or any request path
50
+ * (REQ-21/AC-26/INV-06) — `EventBusPort.publish` does not itself catch
51
+ * subscriber errors (see `core/events/memory-bus.ts`), so this handler must.
52
+ *
53
+ * @complexity O(1) registration.
54
+ */
55
+ export declare function registerRedirectHitOutboxHandler(deps: RegisterRedirectHitOutboxHandlerDeps, _optional?: Record<string, never>): Promise<() => Promise<void>>;
56
+ //# sourceMappingURL=hit-sink.d.ts.map
@@ -0,0 +1,44 @@
1
+ export class RedirectHitSinkImpl {
2
+ stats = new Map();
3
+ async record(required, _optional = {}) {
4
+ const existing = this.stats.get(required.redirectId);
5
+ this.stats.set(required.redirectId, {
6
+ redirectId: required.redirectId,
7
+ workspaceId: required.workspaceId,
8
+ hitCount: (existing?.hitCount ?? 0) + 1,
9
+ lastHitAt: required.at,
10
+ });
11
+ }
12
+ async getStats(required, _optional = {}) {
13
+ const found = this.stats.get(required.redirectId);
14
+ return found && found.workspaceId === required.workspaceId ? { ...found } : null;
15
+ }
16
+ async listStats(required, _optional = {}) {
17
+ return [...this.stats.values()].filter((s) => s.workspaceId === required.workspaceId).map((s) => ({ ...s }));
18
+ }
19
+ }
20
+ /**
21
+ * Subscribe `hitSink.record` to the `redirect.hit` outbox event (W-009).
22
+ * Any failure is swallowed here (logged, never rethrown) so a subscriber
23
+ * failure never surfaces to the outbox dispatcher or any request path
24
+ * (REQ-21/AC-26/INV-06) — `EventBusPort.publish` does not itself catch
25
+ * subscriber errors (see `core/events/memory-bus.ts`), so this handler must.
26
+ *
27
+ * @complexity O(1) registration.
28
+ */
29
+ export async function registerRedirectHitOutboxHandler(deps, _optional = {}) {
30
+ return deps.bus.subscribe({ eventName: "redirect.hit", handler: async (event) => {
31
+ try {
32
+ await deps.hitSink.record({
33
+ workspaceId: event.payload.workspaceId,
34
+ redirectId: event.payload.redirectId,
35
+ at: event.payload.at,
36
+ });
37
+ }
38
+ catch {
39
+ // Best-effort — a failed hit-count fold is logged elsewhere (future
40
+ // operability improvement) but never rethrown (INV-06).
41
+ }
42
+ } });
43
+ }
44
+ //# sourceMappingURL=hit-sink.js.map
@@ -0,0 +1,40 @@
1
+ import { type RedirectLocationRefusalReason, type RedirectTargetOracle } from "../target-gate.js";
2
+ import type { RedirectRecord, RedirectStatusCode } from "../types.js";
3
+ export interface HostRedirectRequired {
4
+ rules: readonly RedirectRecord[];
5
+ oracle: RedirectTargetOracle;
6
+ reservedSegments: ReadonlySet<string>;
7
+ }
8
+ export type HostRedirectRefusalReason = RedirectLocationRefusalReason | "oracle-error" | "inactive-rule" | "mixed-workspaces" | "shadowed-rule" | "host-match-would-widen" | "dynamic-target-requires-runtime-oracle" | "interpolated-absolute-target" | "host-template-syntax" | "host-phase-would-widen";
9
+ export interface HostRedirectRefusal {
10
+ redirectId: string;
11
+ reason: HostRedirectRefusalReason;
12
+ }
13
+ export interface VercelRedirect {
14
+ source: string;
15
+ destination: string;
16
+ statusCode: RedirectStatusCode;
17
+ }
18
+ export interface HostRedirectExport<T> {
19
+ emitted: T;
20
+ refused: HostRedirectRefusal[];
21
+ }
22
+ /**
23
+ * Render an _redirects file with every unrepresentable/unsafe rule reported explicitly.
24
+ * @param required Rules, required verified-origin oracle and host-owned reserved segments.
25
+ * @returns File contents and all refusals. Override rules retain Netlify's force marker.
26
+ * @complexity O(n log n + output length) for literal rules; dynamic overrides add O(n*m log m). O(n + output length) space, plus oracle reads.
27
+ * @example await toNetlifyRedirects({ rules, oracle, reservedSegments }, {});
28
+ */
29
+ export declare function toNetlifyRedirects(required: HostRedirectRequired, _optional?: Record<string, never>): Promise<HostRedirectExport<string>>;
30
+ /**
31
+ * Render the redirects member of vercel.json; post-content rules are explicitly refused.
32
+ * @param required Rules, required verified-origin oracle and host-owned reserved segments.
33
+ * @returns Configuration and all refusals; status codes are preserved, including 301/302.
34
+ * @complexity O(n log n + output length) for literal rules; dynamic overrides add O(n*m log m). O(n) output space, plus oracle reads.
35
+ * @example await toVercelRedirects({ rules, oracle, reservedSegments }, {});
36
+ */
37
+ export declare function toVercelRedirects(required: HostRedirectRequired, _optional?: Record<string, never>): Promise<HostRedirectExport<{
38
+ redirects: VercelRedirect[];
39
+ }>>;
40
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,145 @@
1
+ /** Static host configuration: emit only rules whose match and security verdict stay invariant. */
2
+ import { checkSiteRelativeTarget } from "@jini-ai/http-kit/verified-origin";
3
+ import { match } from "../matcher.js";
4
+ import { checkRedirectLocation } from "../target-gate.js";
5
+ /** Only literal source paths with no host pattern tokens, encoding or URL normalization changes.
6
+ * @complexity O(source length). The URL parser and the shared host path check own normalization.
7
+ */
8
+ function isLiteralHostPath(rule, reservedSegments) {
9
+ if (!/^\/[a-z0-9_./~-]*$/i.test(rule.fromPattern))
10
+ return false;
11
+ const parsed = new URL(rule.fromPattern, "https://host-config.invalid");
12
+ if (parsed.pathname !== rule.fromPattern)
13
+ return false;
14
+ return checkSiteRelativeTarget({ raw: rule.fromPattern, reservedSegments }, {}).kind === "ok";
15
+ }
16
+ /** Decide representation limits before any configuration is emitted.
17
+ * @complexity O(pattern + target length). No effects.
18
+ */
19
+ function representationRefusal(rule, reservedSegments, host) {
20
+ const hasCaptures = /\$\d+/.test(rule.toTarget);
21
+ const absolute = /^(?:\/\/|[a-z][a-z0-9+.-]*:)/i.test(rule.toTarget);
22
+ if (rule.matchType !== "exact" && hasCaptures && absolute)
23
+ return "interpolated-absolute-target";
24
+ // Prefix tails and wildcard captures are unbounded input. A static file cannot re-run INV-03
25
+ // after interpolation: even /safe/$1 can become /safe/../admin through encoded dot segments.
26
+ // Netlify splats also accept empty/multi-segment input that the root '*' never matches.
27
+ if (rule.matchType === "prefix")
28
+ return "dynamic-target-requires-runtime-oracle";
29
+ if (rule.matchType !== "exact" || !isLiteralHostPath(rule, reservedSegments))
30
+ return "host-match-would-widen";
31
+ // Vercel redirects always precede its filesystem. A post-content rule would override live
32
+ // content there; Netlify's non-forced redirect retains the filesystem fallback semantics.
33
+ if (host === "vercel" && !rule.override)
34
+ return "host-phase-would-widen";
35
+ // A destination token cannot be delegated to a host's unrelated capture/template language.
36
+ if (hasCaptures || /[:$][a-z0-9_{]/i.test(rule.toTarget.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "")))
37
+ return "host-template-syntax";
38
+ if (!absolute && !rule.toTarget.startsWith("/"))
39
+ return "host-template-syntax";
40
+ return null;
41
+ }
42
+ /** Select exact-rule winners just as the root matcher does, and retain every refusal.
43
+ * @complexity O(n log n) for literal rules; O(n*m log m) with m dynamic override rules, plus pattern and oracle cost. O(n) space.
44
+ */
45
+ async function exportableRules(required, host) {
46
+ const { rules, oracle, reservedSegments } = required;
47
+ const refused = [];
48
+ if (new Set(rules.map(rule => rule.workspaceId)).size > 1) {
49
+ return { rules: [], refused: rules.map(rule => ({ redirectId: rule.id, reason: "mixed-workspaces" })) };
50
+ }
51
+ const active = rules.filter(rule => rule.status === "active");
52
+ // Winners must be chosen before validating exportability. Exporting a lower-priority fallback
53
+ // when the runtime's winner is unsafe would invent behavior the runtime explicitly refuses.
54
+ const winners = new Map();
55
+ for (const rule of active) {
56
+ const previous = winners.get(rule.fromPattern);
57
+ if (!previous) {
58
+ winners.set(rule.fromPattern, rule);
59
+ continue;
60
+ }
61
+ // pre_content runs first: an override winner takes precedence over any post-content rule,
62
+ // regardless of their relative priorities. Within a phase the root matcher owns ordering.
63
+ const pair = [previous, rule];
64
+ const preContent = pair.filter(candidate => candidate.override);
65
+ const resolution = match({
66
+ request: { workspaceId: rule.workspaceId, path: rule.fromPattern, phase: "post_content" },
67
+ rules: preContent.length > 0 ? preContent : pair,
68
+ }, {});
69
+ if (resolution.matched && resolution.redirectId === rule.id)
70
+ winners.set(rule.fromPattern, rule);
71
+ }
72
+ const dynamicOverrides = active.filter(rule => rule.override && rule.matchType !== "exact");
73
+ const emitted = [];
74
+ for (const rule of rules) {
75
+ let reason = null;
76
+ if (rule.status !== "active")
77
+ reason = "inactive-rule";
78
+ else if (winners.get(rule.fromPattern) !== rule)
79
+ reason = "shadowed-rule";
80
+ else
81
+ reason = representationRefusal(rule, reservedSegments, host);
82
+ if (reason === null && !rule.override && dynamicOverrides.length > 0) {
83
+ // A refused dynamic override can still own this runtime path before content. Emitting a
84
+ // post-content literal in its place would invent a different target. This static scan costs
85
+ // O(n*m log m); it runs at export time, never on the request path.
86
+ const earlier = match({ request: { workspaceId: rule.workspaceId, path: rule.fromPattern, phase: "pre_content" }, rules: dynamicOverrides }, {});
87
+ if (earlier.matched)
88
+ reason = "shadowed-rule";
89
+ }
90
+ if (reason !== null) {
91
+ refused.push({ redirectId: rule.id, reason });
92
+ continue;
93
+ }
94
+ // INV-03: use the root matcher even for a literal export. Only its finished Location is
95
+ // oracle-checked; configuration must never rely on a verdict about an unexpanded template.
96
+ const resolution = match({ request: { workspaceId: rule.workspaceId, path: rule.fromPattern, phase: "post_content" }, rules: [rule] }, {});
97
+ if (!resolution.matched) {
98
+ refused.push({ redirectId: rule.id, reason: "host-match-would-widen" });
99
+ continue;
100
+ }
101
+ try {
102
+ const verdict = await checkRedirectLocation({
103
+ context: { workspaceId: rule.workspaceId }, location: resolution.location, oracle, reservedSegments,
104
+ }, {});
105
+ if (verdict.kind === "refused")
106
+ refused.push({ redirectId: rule.id, reason: verdict.reason });
107
+ else
108
+ emitted.push({ ...rule, toTarget: resolution.location });
109
+ }
110
+ catch {
111
+ // Export is a batch decision: one unavailable oracle does not hide the refusal or abort
112
+ // the report for other rules. No raw infrastructure message is exposed in the result.
113
+ refused.push({ redirectId: rule.id, reason: "oracle-error" });
114
+ }
115
+ }
116
+ emitted.sort((a, b) => a.fromPattern < b.fromPattern ? -1 : a.fromPattern > b.fromPattern ? 1 : 0);
117
+ return { rules: emitted, refused };
118
+ }
119
+ /**
120
+ * Render an _redirects file with every unrepresentable/unsafe rule reported explicitly.
121
+ * @param required Rules, required verified-origin oracle and host-owned reserved segments.
122
+ * @returns File contents and all refusals. Override rules retain Netlify's force marker.
123
+ * @complexity O(n log n + output length) for literal rules; dynamic overrides add O(n*m log m). O(n + output length) space, plus oracle reads.
124
+ * @example await toNetlifyRedirects({ rules, oracle, reservedSegments }, {});
125
+ */
126
+ export async function toNetlifyRedirects(required, _optional = {}) {
127
+ const result = await exportableRules(required, "netlify");
128
+ const lines = result.rules.map(rule => `${rule.fromPattern} ${rule.toTarget} ${rule.statusCode}${rule.override ? "!" : ""}`);
129
+ return { emitted: lines.length === 0 ? "" : `${lines.join("\n")}\n`, refused: result.refused };
130
+ }
131
+ /**
132
+ * Render the redirects member of vercel.json; post-content rules are explicitly refused.
133
+ * @param required Rules, required verified-origin oracle and host-owned reserved segments.
134
+ * @returns Configuration and all refusals; status codes are preserved, including 301/302.
135
+ * @complexity O(n log n + output length) for literal rules; dynamic overrides add O(n*m log m). O(n) output space, plus oracle reads.
136
+ * @example await toVercelRedirects({ rules, oracle, reservedSegments }, {});
137
+ */
138
+ export async function toVercelRedirects(required, _optional = {}) {
139
+ const result = await exportableRules(required, "vercel");
140
+ return {
141
+ emitted: { redirects: result.rules.map(rule => ({ source: rule.fromPattern, destination: rule.toTarget, statusCode: rule.statusCode })) },
142
+ refused: result.refused,
143
+ };
144
+ }
145
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,14 @@
1
+ /** Framework-free redirect model, matching, write path and resolution. SQL and HTTP live in separate entries. */
2
+ export * from "./types.js";
3
+ export * from "./ports.js";
4
+ export * from "./matcher.js";
5
+ export * from "./order.js";
6
+ export * from "./referrer-alias.js";
7
+ export * from "./reserved-destination.js";
8
+ export * from "./repo.memory.js";
9
+ export * from "./capture.js";
10
+ export * from "./resolver.js";
11
+ export * from "./hit-sink.js";
12
+ export * from "./redirects.js";
13
+ export * from "./target-gate.js";
14
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,14 @@
1
+ /** Framework-free redirect model, matching, write path and resolution. SQL and HTTP live in separate entries. */
2
+ export * from "./types.js";
3
+ export * from "./ports.js";
4
+ export * from "./matcher.js";
5
+ export * from "./order.js";
6
+ export * from "./referrer-alias.js";
7
+ export * from "./reserved-destination.js";
8
+ export * from "./repo.memory.js";
9
+ export * from "./capture.js";
10
+ export * from "./resolver.js";
11
+ export * from "./hit-sink.js";
12
+ export * from "./redirects.js";
13
+ export * from "./target-gate.js";
14
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @file `RedirectMatcher` implementation (SPEC-009 REQ-07/19/20/22;
3
+ * behavior.spec.md §1, §2, §4, §7).
4
+ *
5
+ * Purpose:
6
+ * Pure, bounded, no-I/O matching of a request path against a set of redirect
7
+ * rules: match-type precedence (`exact` > `prefix` (longest) > `wildcard`),
8
+ * the priority/recency/id tie-break chain, and wildcard capture
9
+ * interpolation. Also owns `validatePattern`, the write-time pattern safety
10
+ * gate that hard-rejects `matchType: 'regex'` in v1 (REQ-22/INV-05).
11
+ *
12
+ * How it relates to the project:
13
+ * `phase-handler.ts` (C-006) gathers phase-eligible candidate rules from
14
+ * `RedirectRepoPort` (exact/prefix/dynamic lookups) into one array and hands
15
+ * them to `match()`, which applies the FULL precedence + tie-break logic
16
+ * itself — this keeps every precedence/tie-break rule in one pure,
17
+ * exhaustively unit-testable place (matcher.test.ts), rather than splitting
18
+ * it between the repo adapters and this file. `RedirectRepoPort.lookupExact`/
19
+ * `lookupLongestPrefix`/`listDynamic` remain index-backed *retrieval*
20
+ * optimizations for the real request-serving hot path; they do not need to
21
+ * duplicate this file's tie-break logic since `match()` re-derives the
22
+ * correct winner regardless of the input array's order (idempotent to
23
+ * already-sorted input, matching behavior.spec.md §2.2's "never re-sort what
24
+ * the repo returns" invariant for the already-optimal case).
25
+ *
26
+ * Architectural role: INTERNAL SEAM (not an ADR-006 port, see `ports.ts`'s
27
+ * file header) — pure functions only, no I/O.
28
+ */
29
+ import type { RedirectMatcher } from "./ports.js";
30
+ import type { RedirectMatchType, RedirectRecord, RedirectRequest } from "./types.js";
31
+ export interface ValidatePatternRequired {
32
+ matchType: RedirectMatchType;
33
+ fromPattern: string;
34
+ }
35
+ /**
36
+ * Write-time pattern safety gate (REQ-07/19/20/22). Total — never throws.
37
+ *
38
+ * @complexity O(1).
39
+ */
40
+ export declare function validatePattern(required: ValidatePatternRequired, _optional?: Record<string, never>): {
41
+ ok: true;
42
+ } | {
43
+ ok: false;
44
+ reason: string;
45
+ };
46
+ export interface MatchRequired {
47
+ request: RedirectRequest;
48
+ rules: RedirectRecord[];
49
+ }
50
+ /**
51
+ * Resolve the highest-precedence, tie-broken match for `request.path` among
52
+ * `rules` (behavior.spec.md §1.1, §2.1, §2.2, §6.1). Total — never throws.
53
+ *
54
+ * @complexity O(n) in `rules.length`.
55
+ */
56
+ export declare const match: RedirectMatcher["match"];
57
+ export declare const redirectMatcher: RedirectMatcher;
58
+ //# sourceMappingURL=matcher.d.ts.map
@@ -0,0 +1,144 @@
1
+ const MIN_PATTERN_LENGTH = 1;
2
+ const MAX_PATTERN_LENGTH = 2048;
3
+ /** Precedence order, highest first — index doubles as the sort key. */
4
+ const PRECEDENCE_ORDER = {
5
+ exact: 0,
6
+ prefix: 1,
7
+ wildcard: 2,
8
+ regex: 3, // never actually reachable — validatePattern always rejects it at write time.
9
+ };
10
+ /**
11
+ * Write-time pattern safety gate (REQ-07/19/20/22). Total — never throws.
12
+ *
13
+ * @complexity O(1).
14
+ */
15
+ export function validatePattern(required, _optional = {}) {
16
+ const { matchType, fromPattern } = required;
17
+ if (matchType === "regex") {
18
+ return { ok: false, reason: "matchType 'regex' is not enabled in v1 (REQ-22)" };
19
+ }
20
+ const length = fromPattern.length;
21
+ if (length < MIN_PATTERN_LENGTH || length > MAX_PATTERN_LENGTH) {
22
+ return {
23
+ ok: false,
24
+ reason: `fromPattern must be ${MIN_PATTERN_LENGTH}-${MAX_PATTERN_LENGTH} characters (got ${length})`,
25
+ };
26
+ }
27
+ if (!fromPattern.startsWith("/")) {
28
+ return { ok: false, reason: "fromPattern must start with '/'" };
29
+ }
30
+ return { ok: true };
31
+ }
32
+ function matchExact(rule, path) {
33
+ if (rule.matchType !== "exact")
34
+ return null;
35
+ if (rule.fromPattern !== path)
36
+ return null;
37
+ return { rule, location: rule.toTarget, specificity: rule.fromPattern.length };
38
+ }
39
+ function matchPrefix(rule, path) {
40
+ if (rule.matchType !== "prefix")
41
+ return null;
42
+ const pattern = rule.fromPattern;
43
+ const isExactPrefix = path === pattern;
44
+ const isNestedPrefix = path.startsWith(pattern.endsWith("/") ? pattern : `${pattern}/`);
45
+ if (!isExactPrefix && !isNestedPrefix)
46
+ return null;
47
+ const tail = isExactPrefix ? "" : path.slice(pattern.length);
48
+ return { rule, location: `${rule.toTarget}${tail}`, specificity: pattern.length };
49
+ }
50
+ /** Compile a `*`-glob wildcard pattern into an anchored, non-backtracking capture regex. */
51
+ function compileWildcard(pattern) {
52
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
53
+ const withCaptures = escaped.replace(/\*/g, "([^/]+)");
54
+ return new RegExp(`^${withCaptures}$`);
55
+ }
56
+ function interpolate(template, captures) {
57
+ return template.replace(/\$(\d+)/g, (whole, indexStr) => {
58
+ const index = Number(indexStr) - 1;
59
+ return index >= 0 && index < captures.length ? captures[index] : whole;
60
+ });
61
+ }
62
+ function matchWildcard(rule, path) {
63
+ if (rule.matchType !== "wildcard")
64
+ return null;
65
+ const regex = compileWildcard(rule.fromPattern);
66
+ const result = regex.exec(path);
67
+ if (!result)
68
+ return null;
69
+ const captures = result.slice(1);
70
+ return {
71
+ rule,
72
+ location: interpolate(rule.toTarget, captures),
73
+ specificity: rule.fromPattern.length,
74
+ };
75
+ }
76
+ function matchOne(rule, path) {
77
+ switch (rule.matchType) {
78
+ case "exact":
79
+ return matchExact(rule, path);
80
+ case "prefix":
81
+ return matchPrefix(rule, path);
82
+ case "wildcard":
83
+ return matchWildcard(rule, path);
84
+ case "regex":
85
+ // Deferred (ADR-033 §8) — never authored in v1 (validatePattern rejects it at write
86
+ // time), so no live rule should ever carry this matchType. Defensive no-match.
87
+ return null;
88
+ default:
89
+ return null;
90
+ }
91
+ }
92
+ /**
93
+ * Tie-break comparator (behavior.spec.md §2.1/§6.1): higher `priority` wins;
94
+ * else more recent `updatedAt` (falling back to `createdAt`) wins; else the
95
+ * lexicographically smaller `id` wins. Returns <0 if `a` should win over `b`.
96
+ */
97
+ function compareTieBreak(a, b) {
98
+ if (a.rule.priority !== b.rule.priority)
99
+ return b.rule.priority - a.rule.priority;
100
+ const aRecency = a.rule.updatedAt || a.rule.createdAt;
101
+ const bRecency = b.rule.updatedAt || b.rule.createdAt;
102
+ if (aRecency !== bRecency)
103
+ return aRecency > bRecency ? -1 : 1;
104
+ return a.rule.id < b.rule.id ? -1 : a.rule.id > b.rule.id ? 1 : 0;
105
+ }
106
+ /** Within a precedence band, longest/most-specific `fromPattern` wins first, then the tie-break chain. */
107
+ function compareWithinBand(a, b) {
108
+ if (a.specificity !== b.specificity)
109
+ return b.specificity - a.specificity;
110
+ return compareTieBreak(a, b);
111
+ }
112
+ /**
113
+ * Resolve the highest-precedence, tie-broken match for `request.path` among
114
+ * `rules` (behavior.spec.md §1.1, §2.1, §2.2, §6.1). Total — never throws.
115
+ *
116
+ * @complexity O(n) in `rules.length`.
117
+ */
118
+ export const match = (required, _optional = {}) => {
119
+ const { request, rules } = required;
120
+ const candidates = [];
121
+ for (const rule of rules) {
122
+ const candidate = matchOne(rule, request.path);
123
+ if (candidate)
124
+ candidates.push(candidate);
125
+ }
126
+ if (candidates.length === 0)
127
+ return { matched: false };
128
+ candidates.sort((a, b) => {
129
+ const precedenceDelta = PRECEDENCE_ORDER[a.rule.matchType] - PRECEDENCE_ORDER[b.rule.matchType];
130
+ if (precedenceDelta !== 0)
131
+ return precedenceDelta;
132
+ return compareWithinBand(a, b);
133
+ });
134
+ const winner = candidates[0];
135
+ return {
136
+ matched: true,
137
+ redirectId: winner.rule.id,
138
+ location: winner.location,
139
+ statusCode: winner.rule.statusCode,
140
+ matchType: winner.rule.matchType,
141
+ };
142
+ };
143
+ export const redirectMatcher = { match, validatePattern };
144
+ //# sourceMappingURL=matcher.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file The rule orderings both `RedirectRepoPort` adapters apply (`repo.ts` on SQL, `repo.memory.ts`
3
+ * in memory). One module so the two adapters cannot drift: the shared contract suite
4
+ * (`repo.contract.test.ts`) only checks the cases it seeds, while every comparator arm is pinned
5
+ * here directly (`order.test.ts`).
6
+ */
7
+ import type { RedirectRecord } from "./types.js";
8
+ /**
9
+ * Shared tie-break between rules that match equally well: higher `priority` first, then the more
10
+ * recent `updatedAt` (falling back to `createdAt` when `updatedAt` is empty), then `id` ascending.
11
+ * Returns 0 only for the same id, as a sort comparator must.
12
+ */
13
+ export declare function compareTieBreak({ a, b }: {
14
+ a: RedirectRecord;
15
+ b: RedirectRecord;
16
+ }, _optional?: Record<string, never>): number;
17
+ /** Longest pattern first, then the shared tie-break (`lookupLongestPrefix`, `listDynamic`). */
18
+ export declare function compareLongestFirst({ a, b }: {
19
+ a: RedirectRecord;
20
+ b: RedirectRecord;
21
+ }, _optional?: Record<string, never>): number;
22
+ /**
23
+ * `findByFromPattern`'s order among active rules sharing one `fromPattern`: an `exact` rule before
24
+ * any other match type, then `id` ascending.
25
+ */
26
+ export declare function compareSamePatternExactFirst({ a, b }: {
27
+ a: RedirectRecord;
28
+ b: RedirectRecord;
29
+ }, _optional?: Record<string, never>): number;
30
+ //# sourceMappingURL=order.d.ts.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared tie-break between rules that match equally well: higher `priority` first, then the more
3
+ * recent `updatedAt` (falling back to `createdAt` when `updatedAt` is empty), then `id` ascending.
4
+ * Returns 0 only for the same id, as a sort comparator must.
5
+ */
6
+ export function compareTieBreak({ a, b }, _optional = {}) {
7
+ if (a.priority !== b.priority)
8
+ return b.priority - a.priority;
9
+ const aRecency = a.updatedAt || a.createdAt;
10
+ const bRecency = b.updatedAt || b.createdAt;
11
+ if (aRecency !== bRecency)
12
+ return aRecency > bRecency ? -1 : 1;
13
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
14
+ }
15
+ /** Longest pattern first, then the shared tie-break (`lookupLongestPrefix`, `listDynamic`). */
16
+ export function compareLongestFirst({ a, b }, _optional = {}) {
17
+ return b.fromPattern.length - a.fromPattern.length || compareTieBreak({ a, b }, {});
18
+ }
19
+ /**
20
+ * `findByFromPattern`'s order among active rules sharing one `fromPattern`: an `exact` rule before
21
+ * any other match type, then `id` ascending.
22
+ */
23
+ export function compareSamePatternExactFirst({ a, b }, _optional = {}) {
24
+ return (a.matchType === "exact" ? 0 : 1) - (b.matchType === "exact" ? 0 : 1) || (a.id < b.id ? -1 : 1);
25
+ }
26
+ //# sourceMappingURL=order.js.map