@jini-ai/cms 0.5.2 → 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 (205) hide show
  1. package/CHANGELOG.md +11 -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 +0 -3
  46. package/dist/media/index.js +1 -4
  47. package/dist/media/node.d.ts +5 -0
  48. package/dist/media/node.js +6 -0
  49. package/dist/redirects/capture.d.ts +80 -0
  50. package/dist/redirects/capture.js +66 -0
  51. package/dist/redirects/express/index.d.ts +27 -0
  52. package/dist/redirects/express/index.js +40 -0
  53. package/dist/redirects/hit-sink.d.ts +56 -0
  54. package/dist/redirects/hit-sink.js +44 -0
  55. package/dist/redirects/host-config/index.d.ts +40 -0
  56. package/dist/redirects/host-config/index.js +145 -0
  57. package/dist/redirects/index.d.ts +14 -0
  58. package/dist/redirects/index.js +14 -0
  59. package/dist/redirects/matcher.d.ts +58 -0
  60. package/dist/redirects/matcher.js +144 -0
  61. package/dist/redirects/order.d.ts +30 -0
  62. package/dist/redirects/order.js +26 -0
  63. package/dist/redirects/ports.d.ts +242 -0
  64. package/dist/redirects/ports.internal.d.ts +57 -0
  65. package/dist/redirects/ports.internal.js +14 -0
  66. package/dist/redirects/ports.js +2 -0
  67. package/dist/redirects/redirects.d.ts +226 -0
  68. package/dist/redirects/redirects.js +590 -0
  69. package/dist/redirects/referrer-alias.d.ts +31 -0
  70. package/dist/redirects/referrer-alias.js +33 -0
  71. package/dist/redirects/repo.memory.d.ts +51 -0
  72. package/dist/redirects/repo.memory.js +98 -0
  73. package/dist/redirects/reserved-destination.d.ts +58 -0
  74. package/dist/redirects/reserved-destination.js +59 -0
  75. package/dist/redirects/resolver.d.ts +100 -0
  76. package/dist/redirects/resolver.js +87 -0
  77. package/dist/redirects/spa/index.d.ts +30 -0
  78. package/dist/redirects/spa/index.js +27 -0
  79. package/dist/redirects/sql/index.d.ts +4 -0
  80. package/dist/redirects/sql/index.js +4 -0
  81. package/dist/redirects/sql/repo.d.ts +82 -0
  82. package/dist/redirects/sql/repo.js +143 -0
  83. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  84. package/dist/redirects/sql/repo.rows.js +79 -0
  85. package/dist/redirects/sql/row-types.d.ts +34 -0
  86. package/dist/redirects/sql/row-types.js +2 -0
  87. package/dist/redirects/target-gate.d.ts +30 -0
  88. package/dist/redirects/target-gate.js +85 -0
  89. package/dist/redirects/types.d.ts +213 -0
  90. package/dist/redirects/types.js +42 -0
  91. package/dist/seo/absolute-url.d.ts +58 -0
  92. package/dist/seo/absolute-url.js +117 -0
  93. package/dist/seo/dom/index.d.ts +14 -0
  94. package/dist/seo/dom/index.js +52 -0
  95. package/dist/seo/errors.d.ts +35 -0
  96. package/dist/seo/errors.js +35 -0
  97. package/dist/seo/feed.d.ts +49 -0
  98. package/dist/seo/feed.js +61 -0
  99. package/dist/seo/index.d.ts +13 -0
  100. package/dist/seo/index.js +13 -0
  101. package/dist/seo/media.d.ts +57 -0
  102. package/dist/seo/media.js +134 -0
  103. package/dist/seo/page-head.d.ts +18 -0
  104. package/dist/seo/page-head.js +114 -0
  105. package/dist/seo/ports.d.ts +221 -0
  106. package/dist/seo/ports.js +27 -0
  107. package/dist/seo/seo.d.ts +73 -0
  108. package/dist/seo/seo.js +230 -0
  109. package/dist/seo/sitemap.d.ts +139 -0
  110. package/dist/seo/sitemap.js +217 -0
  111. package/dist/seo/types.d.ts +278 -0
  112. package/dist/seo/types.js +2 -0
  113. package/dist/seo/write-service.d.ts +79 -0
  114. package/dist/seo/write-service.js +273 -0
  115. package/dist/seo/xml-escape.d.ts +16 -0
  116. package/dist/seo/xml-escape.js +19 -0
  117. package/dist/settings/express/cms-adapter.d.ts +20 -0
  118. package/dist/settings/express/cms-adapter.js +124 -0
  119. package/dist/settings/express/contracts.d.ts +135 -0
  120. package/dist/settings/express/contracts.js +2 -0
  121. package/dist/settings/express/http.d.ts +10 -0
  122. package/dist/settings/express/http.js +211 -0
  123. package/dist/settings/express/index.d.ts +5 -0
  124. package/dist/settings/express/index.js +5 -0
  125. package/dist/settings/express/shared.d.ts +68 -0
  126. package/dist/settings/express/shared.js +54 -0
  127. package/dist/settings/express/stream.d.ts +24 -0
  128. package/dist/settings/express/stream.js +147 -0
  129. package/dist/settings/index.d.ts +3 -38
  130. package/dist/settings/index.js +3 -35
  131. package/dist/taxonomy/write-service.d.ts +2 -30
  132. package/dist/widgets/authorize-helper.d.ts +42 -0
  133. package/dist/widgets/authorize-helper.js +16 -0
  134. package/dist/widgets/concurrency.d.ts +12 -0
  135. package/dist/widgets/concurrency.js +44 -0
  136. package/dist/widgets/config-validation.d.ts +34 -0
  137. package/dist/widgets/config-validation.js +125 -0
  138. package/dist/widgets/embed-service.d.ts +133 -0
  139. package/dist/widgets/embed-service.js +460 -0
  140. package/dist/widgets/embed-validation.d.ts +23 -0
  141. package/dist/widgets/embed-validation.js +38 -0
  142. package/dist/widgets/entry-payload.d.ts +103 -0
  143. package/dist/widgets/entry-payload.js +160 -0
  144. package/dist/widgets/errors.d.ts +136 -0
  145. package/dist/widgets/errors.js +136 -0
  146. package/dist/widgets/host-ports.d.ts +149 -0
  147. package/dist/widgets/host-ports.js +2 -0
  148. package/dist/widgets/html/html-embeds.d.ts +247 -0
  149. package/dist/widgets/html/html-embeds.js +408 -0
  150. package/dist/widgets/html/index.d.ts +5 -0
  151. package/dist/widgets/html/index.js +4 -0
  152. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  153. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  154. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  155. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  156. package/dist/widgets/index.d.ts +20 -0
  157. package/dist/widgets/index.js +19 -0
  158. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  159. package/dist/widgets/markers/html-attributes.js +216 -0
  160. package/dist/widgets/markers/index.d.ts +3 -0
  161. package/dist/widgets/markers/index.js +3 -0
  162. package/dist/widgets/markers/marker.d.ts +496 -0
  163. package/dist/widgets/markers/marker.js +780 -0
  164. package/dist/widgets/page-embed-types.d.ts +42 -0
  165. package/dist/widgets/page-embed-types.js +42 -0
  166. package/dist/widgets/ports.d.ts +142 -0
  167. package/dist/widgets/ports.js +2 -0
  168. package/dist/widgets/read-service.d.ts +81 -0
  169. package/dist/widgets/read-service.js +79 -0
  170. package/dist/widgets/region-area-service.d.ts +110 -0
  171. package/dist/widgets/region-area-service.js +180 -0
  172. package/dist/widgets/registry.d.ts +217 -0
  173. package/dist/widgets/registry.js +172 -0
  174. package/dist/widgets/repo.memory.d.ts +41 -0
  175. package/dist/widgets/repo.memory.js +48 -0
  176. package/dist/widgets/resolver-service.d.ts +209 -0
  177. package/dist/widgets/resolver-service.js +909 -0
  178. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  179. package/dist/widgets/resolvers/contact-form.js +54 -0
  180. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  181. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  182. package/dist/widgets/resolvers/index.d.ts +65 -0
  183. package/dist/widgets/resolvers/index.js +155 -0
  184. package/dist/widgets/resolvers/menu.d.ts +42 -0
  185. package/dist/widgets/resolvers/menu.js +43 -0
  186. package/dist/widgets/resolvers/ports.d.ts +82 -0
  187. package/dist/widgets/resolvers/ports.js +2 -0
  188. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  189. package/dist/widgets/resolvers/recent-entries.js +165 -0
  190. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  191. package/dist/widgets/resolvers/taxonomy.js +34 -0
  192. package/dist/widgets/sql/index.d.ts +3 -0
  193. package/dist/widgets/sql/index.js +3 -0
  194. package/dist/widgets/sql/repo.d.ts +41 -0
  195. package/dist/widgets/sql/repo.js +82 -0
  196. package/dist/widgets/sql/tables.d.ts +13 -0
  197. package/dist/widgets/sql/tables.js +2 -0
  198. package/dist/widgets/types.d.ts +213 -0
  199. package/dist/widgets/types.js +17 -0
  200. package/dist/widgets/where-used.d.ts +41 -0
  201. package/dist/widgets/where-used.js +11 -0
  202. package/dist/widgets/write-service.d.ts +204 -0
  203. package/dist/widgets/write-service.js +332 -0
  204. package/dist/workspace/delete.d.ts +1 -1
  205. package/package.json +220 -11
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @file `RedirectSlugChangeCapture` — the `SlugChangeCapture` implementation
3
+ * (SPEC-009 REQ-15/16/17; ADR-PIPE-009 Decision A).
4
+ *
5
+ * Purpose:
6
+ * The single implementer of `routing`'s `SlugChangeCapture` slot. Inserts an
7
+ * `active`, `source: 'auto_slug_change'`, `matchType: 'exact'` redirect rule +
8
+ * its revision when an entry's routable slug/path changes, so no request can
9
+ * observe the moved entry without its redirect already existing (INV-02).
10
+ *
11
+ * Correctness discipline (Decision A, load-bearing): this class performs NO
12
+ * transaction control of its own — no `BEGIN`/`COMMIT`/`ROLLBACK` anywhere in
13
+ * this file. It relies entirely on the caller (the content write chokepoint,
14
+ * once wired — see tasks.md's "Known Scope Boundary": that wiring is OUT OF
15
+ * SCOPE for this feature) already holding an open transaction on the same
16
+ * single-connection `better-sqlite3` handle. Writes go through
17
+ * `ports.internal.ts`'s `insertRedirectAndRevision`, the one shared,
18
+ * non-tx-opening insert path this file and `redirects.ts` both use.
19
+ *
20
+ * Idempotency (REQ-17/AC-20): keyed off the routing-owned
21
+ * `SlugChangeCaptureInput`'s `oldPath`/`newPath` rather than a `changeSetId`
22
+ * DB column — `state.spec.md`'s frozen `RedirectRecord`/`RedirectRevision`
23
+ * shapes (mirrored from the already-written `src/redirects/types.ts`) carry
24
+ * no `changeSetId` field, so this reuses the existing
25
+ * `RedirectRepoPort.lookupExact` read (already on the frozen `ports.ts`)
26
+ * to detect "this exact slug-change was already captured": when the rule that
27
+ * requests for `oldPath` actually follow (`RedirectRepoPort.lookupExact`, the
28
+ * resolver's own newest-wins order) is an `active`, `source: 'auto_slug_change'`
29
+ * rule with `toTarget === newPath`, the same change was already captured, so a
30
+ * retry is a no-op rather than a duplicate insert. It must be the WINNING rule,
31
+ * not just any historical match: after `a→b→a→c→a`, an older `/a → /b` still
32
+ * exists but the newer `/a → /c` wins, so renaming `a→b` again has to insert a
33
+ * fresh `/a → /b` or `/a` keeps resolving to `/c`, whose own rule points back
34
+ * at `/a` — a permanent loop.
35
+ *
36
+ * Architectural role:
37
+ * Feature logic. No Express/route code, no direct SQL — writes go through the
38
+ * injected `RedirectDbHandle`; reads go through the injected `RedirectRepoPort`.
39
+ */
40
+ import type { Clock as ClockPort, IdGenerator as IdGeneratorPort } from "@jini-ai/core/primitives";
41
+ import type { SlugChangeCapture, SlugChangeCaptureInput } from "./ports.js";
42
+ import { type RedirectDbHandle } from "./ports.internal.js";
43
+ import type { RedirectRecord } from "./types.js";
44
+ /**
45
+ * The narrow read this capture needs — `RedirectRepoPort.lookupExact` only
46
+ * (idempotency check against the rule resolution actually picks). Declared as
47
+ * a `Pick` rather than the full `RedirectRepoPort` so this file's dependency
48
+ * surface documents exactly what it reads.
49
+ */
50
+ export interface RedirectSlugChangeCaptureReadDeps {
51
+ lookupExact(required: {
52
+ workspaceId: string;
53
+ path: string;
54
+ includeOverrideOnly: boolean;
55
+ }, optional?: Record<string, never>): Promise<RedirectRecord | null>;
56
+ }
57
+ export interface RedirectSlugChangeCaptureDeps {
58
+ repo: RedirectSlugChangeCaptureReadDeps;
59
+ db: RedirectDbHandle;
60
+ clock: ClockPort;
61
+ idGen: IdGeneratorPort;
62
+ }
63
+ export declare class RedirectSlugChangeCapture implements SlugChangeCapture {
64
+ private readonly deps;
65
+ constructor(deps: RedirectSlugChangeCaptureDeps, _optional?: Record<string, never>);
66
+ /**
67
+ * Insert the auto-capture redirect rule for a slug change, or no-op if the
68
+ * exact same change was already captured (idempotent retry, AC-20).
69
+ *
70
+ * MUST be called by a caller that already holds an open transaction on the
71
+ * shared connection (or accepts the no-transaction-safety consequences of
72
+ * calling it outside one) — this method never opens or closes one itself.
73
+ *
74
+ * @throws whatever `db.insertRedirect`/`db.insertRevision` throws,
75
+ * uncaught, so the caller's own transaction aborts (REQ-16/EC-05).
76
+ * @complexity O(1) — one read (idempotency check) + two writes.
77
+ */
78
+ onSlugChange(input: SlugChangeCaptureInput, _optional?: Record<string, never>): Promise<void>;
79
+ }
80
+ //# sourceMappingURL=capture.d.ts.map
@@ -0,0 +1,66 @@
1
+ import { nowIso as clockNowIso } from "@jini-ai/core/primitives";
2
+ import { insertRedirectAndRevision } from "./ports.internal.js";
3
+ /** Default priority/statusCode/override for an auto-captured rule (behavior.spec.md §3 analog). */
4
+ const AUTO_CAPTURE_STATUS_CODE = 301;
5
+ const AUTO_CAPTURE_PRIORITY = 0;
6
+ export class RedirectSlugChangeCapture {
7
+ deps;
8
+ constructor(deps, _optional = {}) {
9
+ this.deps = deps;
10
+ }
11
+ /**
12
+ * Insert the auto-capture redirect rule for a slug change, or no-op if the
13
+ * exact same change was already captured (idempotent retry, AC-20).
14
+ *
15
+ * MUST be called by a caller that already holds an open transaction on the
16
+ * shared connection (or accepts the no-transaction-safety consequences of
17
+ * calling it outside one) — this method never opens or closes one itself.
18
+ *
19
+ * @throws whatever `db.insertRedirect`/`db.insertRevision` throws,
20
+ * uncaught, so the caller's own transaction aborts (REQ-16/EC-05).
21
+ * @complexity O(1) — one read (idempotency check) + two writes.
22
+ */
23
+ async onSlugChange(input, _optional = {}) {
24
+ const existing = await this.deps.repo.lookupExact({
25
+ workspaceId: input.workspaceId,
26
+ path: input.oldPath,
27
+ includeOverrideOnly: false,
28
+ });
29
+ if (existing && existing.source === "auto_slug_change" && existing.toTarget === input.newPath) {
30
+ // Same change already captured (retry of the same changeSetId) — idempotent no-op.
31
+ return;
32
+ }
33
+ const now = clockNowIso({ clock: this.deps.clock });
34
+ const id = this.deps.idGen.newId();
35
+ const record = {
36
+ id,
37
+ workspaceId: input.workspaceId,
38
+ matchType: "exact",
39
+ fromPattern: input.oldPath,
40
+ toTarget: input.newPath,
41
+ statusCode: AUTO_CAPTURE_STATUS_CODE,
42
+ status: "active",
43
+ override: false,
44
+ priority: AUTO_CAPTURE_PRIORITY,
45
+ source: "auto_slug_change",
46
+ sourceEntryId: input.entryId,
47
+ fromPathAtCapture: input.oldPath,
48
+ toPathAtCapture: input.newPath,
49
+ createdByPrincipal: input.actor,
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ version: 1,
53
+ };
54
+ const revision = {
55
+ redirectId: id,
56
+ workspaceId: input.workspaceId,
57
+ seq: 1,
58
+ state: record,
59
+ tombstoned: false,
60
+ actorId: input.actor,
61
+ recordedAt: now,
62
+ };
63
+ await insertRedirectAndRevision({ db: this.deps.db, record, revision });
64
+ }
65
+ }
66
+ //# sourceMappingURL=capture.js.map
@@ -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