@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.
- package/CHANGELOG.md +11 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- package/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- 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
|