@jini-ai/cms 0.5.2 → 0.5.4
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 +15 -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/forms/delete-submission.d.ts +38 -0
- package/dist/forms/delete-submission.js +25 -0
- package/dist/forms/duplicate-slug.d.ts +81 -0
- package/dist/forms/duplicate-slug.js +115 -0
- package/dist/forms/errors.d.ts +53 -0
- package/dist/forms/errors.js +25 -0
- package/dist/forms/express/handler.d.ts +59 -0
- package/dist/forms/express/handler.js +217 -0
- package/dist/forms/express/index.d.ts +2 -0
- package/dist/forms/express/index.js +2 -0
- package/dist/forms/fields-json.d.ts +14 -0
- package/dist/forms/fields-json.js +11 -0
- package/dist/forms/forms.d.ts +59 -0
- package/dist/forms/forms.js +213 -0
- package/dist/forms/html/authoring-adapter.d.ts +34 -0
- package/dist/forms/html/authoring-adapter.js +48 -0
- package/dist/forms/html/html-authoring.d.ts +12 -0
- package/dist/forms/html/html-authoring.js +133 -0
- package/dist/forms/html/html-submission-adapter.d.ts +10 -0
- package/dist/forms/html/html-submission-adapter.js +15 -0
- package/dist/forms/html/index.d.ts +4 -0
- package/dist/forms/html/index.js +4 -0
- package/dist/forms/html-render.d.ts +33 -0
- package/dist/forms/html-render.js +31 -0
- package/dist/forms/index.d.ts +17 -0
- package/dist/forms/index.js +17 -0
- package/dist/forms/notify-subscriber.d.ts +32 -0
- package/dist/forms/notify-subscriber.js +60 -0
- package/dist/forms/ports.d.ts +168 -0
- package/dist/forms/ports.js +2 -0
- package/dist/forms/rate-limit-key.d.ts +8 -0
- package/dist/forms/rate-limit-key.js +7 -0
- package/dist/forms/repo.memory.d.ts +120 -0
- package/dist/forms/repo.memory.js +188 -0
- package/dist/forms/sql/index.d.ts +5 -0
- package/dist/forms/sql/index.js +5 -0
- package/dist/forms/sql/repo.d.ts +94 -0
- package/dist/forms/sql/repo.js +175 -0
- package/dist/forms/sql/repo.rows.d.ts +33 -0
- package/dist/forms/sql/repo.rows.js +64 -0
- package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
- package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
- package/dist/forms/sql/tables.d.ts +32 -0
- package/dist/forms/sql/tables.js +2 -0
- package/dist/forms/submission-attempts.d.ts +70 -0
- package/dist/forms/submission-attempts.js +70 -0
- package/dist/forms/submission-email-validation.d.ts +8 -0
- package/dist/forms/submission-email-validation.js +34 -0
- package/dist/forms/submission-ip-retention.d.ts +30 -0
- package/dist/forms/submission-ip-retention.js +27 -0
- package/dist/forms/submit-service.d.ts +78 -0
- package/dist/forms/submit-service.js +109 -0
- package/dist/forms/types.d.ts +49 -0
- package/dist/forms/types.js +2 -0
- package/dist/forms/write-service.d.ts +83 -0
- package/dist/forms/write-service.js +271 -0
- 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 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## 0.5.4 — 2026-10-08
|
|
2
|
+
|
|
3
|
+
- Ships `dist/forms/**`: 0.5.3's `files` negation `!forms/**` also stripped `dist/forms`, so `@jini-ai/cms/forms` (and `/html`, `/sql`, `/express`) failed to resolve. The negation is removed: `files` already ships only `dist`, and the nested `forms/` package is never packed.
|
|
4
|
+
|
|
5
|
+
## 0.5.3 — 2026-10-08
|
|
6
|
+
|
|
7
|
+
- `./widgets*`: Add isolated widgets, import-free markers, HTML embeds, resolver ports and StorageKernel region-binding SQL entries; preserve stored formats, permission gates and rendering contracts.
|
|
8
|
+
- Fold forms into ./forms with isolated HTML, SQL and Express adapters and host-supplied authoring, validation, attempts and dispatch ports.
|
|
9
|
+
- Add isolated comments, SQL and Express entries with injected settings, rate limits, table names and IP salt; preserve ingress, moderation and audit contracts.
|
|
10
|
+
- Add isolated redirects, SQL, SPA, host-config and Express entries with required verified-origin/path-policy ports and explicit adapter refusal reporting.
|
|
11
|
+
- Add isolated universal SEO and browser DOM head entries, host settings/post/media/routing/liveness ports, and per-instance sitemap hooks/cache; retain output and write contracts.
|
|
12
|
+
- Move generic settings to `@jini-ai/core/settings` and the Express adapter to `@jini-ai/core/settings/express`; retain both former CMS subpaths as identity-preserving compatibility re-exports for one release.
|
|
13
|
+
|
|
14
|
+
- Move filesystem, sharp and ffmpeg media adapters to `@jini-ai/cms/media/node`; share `TransactionalRepoPort` through core while retaining the taxonomy type export; rename the settings HTTP entry to `@jini-ai/cms/settings/express`.
|
|
15
|
+
|
|
1
16
|
## 0.5.2 — 2026-10-06
|
|
2
17
|
|
|
3
18
|
- Video previews (`@jini-ai/cms/media`): `planVideoFrames` / `planVideoContactSheets` / `getVideoFrameEdge` / `parseVideoProbe` plus the `VideoFrameExtractor` port and an ffmpeg adapter (`createFfmpegVideoFrameExtractor`, `findVideoBinaries`, `runLowPriorityVideoProcess`). Default 16 frames, up to 64 (frames past 16 shrink to bound image tokens), `every`/`start` spacing, explicit `at` times, or up to 4 contact sheets of ≤16 tiles with a timestamp map.
|
package/README.md
CHANGED
|
@@ -13,25 +13,30 @@ See [API integration](API-INTEGRATION.md) for current signatures and migration e
|
|
|
13
13
|
| subpath | runtime | contents |
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `.` / `./core` | universal | Content contracts and types, ports, pure domain services, kernel registries. No Express, no `node:*`, no DOM. |
|
|
16
|
-
| `./media` | node | Media services,
|
|
17
|
-
| `./
|
|
16
|
+
| `./media` | node | Media services, transform ports and preview policies. |
|
|
17
|
+
| `./media/node` | node | Filesystem blob stores, sharp image transforms and ffmpeg adapters. |
|
|
18
|
+
| `./settings` | universal | One-release compatibility re-export of `@jini-ai/core/settings`. |
|
|
19
|
+
| `./settings/express` | node | One-release compatibility re-export of `@jini-ai/core/settings/express`. |
|
|
18
20
|
|
|
19
21
|
Node-bound domain adapters depend on `/core`. The reverse is a boundary violation — if a core module wants
|
|
20
22
|
something from a Node adapter, the dependency is backwards and the fix is a port (interface) in core,
|
|
21
23
|
not a widened export.
|
|
22
24
|
|
|
23
|
-
Domain HTTP adapters live under
|
|
24
|
-
|
|
25
|
+
Domain HTTP adapters live under their domain subpaths. Hosts can compose them
|
|
26
|
+
using `@jini-ai/http-kit` and `@jini-ai/server`.
|
|
25
27
|
Identity tool registration composition belongs to `@jini-ai/user-management/server`.
|
|
26
28
|
|
|
27
|
-
`@jini-ai/
|
|
29
|
+
Generic settings now belong to `@jini-ai/core/settings`; the former CMS settings subpaths
|
|
30
|
+
remain thin compatibility re-exports for one release.
|
|
31
|
+
|
|
32
|
+
`@jini-ai/core/settings/express` exports `registerSettingsRoutes`, the settings HTTP
|
|
28
33
|
contracts and the CMS service/change-feed factories. Hosts provide route paths,
|
|
29
34
|
permissions, workspace, readiness, principal resolution, authorization, scheduler,
|
|
30
35
|
service and feed ports. Registration returns a disposer that closes active feeds.
|
|
31
36
|
CMS adapters pass named request objects to settings dispatch and change-feed services;
|
|
32
37
|
queryable index-name helpers return promises that callers must await.
|
|
33
|
-
Install the optional
|
|
34
|
-
|
|
38
|
+
Install the optional peer `express` when using `./settings/express`.
|
|
39
|
+
The HTTP toolkit is a host composition choice and is not a CMS dependency.
|
|
35
40
|
Readiness or principal-resolution failures return `401 UNAUTHENTICATED`.
|
|
36
41
|
Stream diagnostics are best-effort: a throwing reporter cannot prevent retries,
|
|
37
42
|
cancellation of other schedules or ending the response.
|
|
@@ -70,7 +75,8 @@ coordination.
|
|
|
70
75
|
|
|
71
76
|
**Landed** (directories under `src/`, each with its own subpath export):
|
|
72
77
|
`core`, `content-types`, `entries`, `media`, `navigation`, `presentation`, `settings`,
|
|
73
|
-
`taxonomy`, `workspace`, and `trash`.
|
|
78
|
+
`taxonomy`, `workspace`, and `trash`. `settings` is now a compatibility shim, with implementation
|
|
79
|
+
in `@jini-ai/core/settings`. `./core/tools` re-exports the shared core permission helpers; `./media/import` exports bounded image fetching with injected policies and ports.
|
|
74
80
|
|
|
75
81
|
**Still to move:** `post`, `seo`, `comments`, `redirects`, `widgets`, `newsletter`,
|
|
76
82
|
`members`. The unimplemented `./widgets` export has been removed.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Typed domain errors for `comments` (ADR-031, SPEC-035).
|
|
3
|
+
*
|
|
4
|
+
* One class per error this module originates, mirroring `src/seo/errors.ts`'s one-class-per-code
|
|
5
|
+
* convention. Route handlers (`src/server/routes/admin/comments/*.ts`) map these 1:1 to HTTP codes.
|
|
6
|
+
*/
|
|
7
|
+
export declare class CommentsSettingsValidationError extends Error {
|
|
8
|
+
}
|
|
9
|
+
/** A comment id the caller named does not exist in this workspace (tool + route not-found arm). */
|
|
10
|
+
export declare class CommentNotFoundError extends Error {
|
|
11
|
+
constructor(required: {
|
|
12
|
+
commentId: string;
|
|
13
|
+
}, _optional?: Record<string, never>);
|
|
14
|
+
}
|
|
15
|
+
/** The caller's `expectedVersion` is stale; `currentVersion` is what a retry should send. */
|
|
16
|
+
export declare class CommentVersionConflictError extends Error {
|
|
17
|
+
readonly currentVersion: number | undefined;
|
|
18
|
+
constructor(required: {
|
|
19
|
+
commentId: string;
|
|
20
|
+
currentVersion?: number;
|
|
21
|
+
}, _optional?: Record<string, never>);
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Typed domain errors for `comments` (ADR-031, SPEC-035).
|
|
3
|
+
*
|
|
4
|
+
* One class per error this module originates, mirroring `src/seo/errors.ts`'s one-class-per-code
|
|
5
|
+
* convention. Route handlers (`src/server/routes/admin/comments/*.ts`) map these 1:1 to HTTP codes.
|
|
6
|
+
*/
|
|
7
|
+
export class CommentsSettingsValidationError extends Error {
|
|
8
|
+
}
|
|
9
|
+
/** A comment id the caller named does not exist in this workspace (tool + route not-found arm). */
|
|
10
|
+
export class CommentNotFoundError extends Error {
|
|
11
|
+
constructor(required, _optional = {}) {
|
|
12
|
+
super(`comment '${required.commentId}' was not found`);
|
|
13
|
+
this.name = "CommentNotFoundError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The caller's `expectedVersion` is stale; `currentVersion` is what a retry should send. */
|
|
17
|
+
export class CommentVersionConflictError extends Error {
|
|
18
|
+
currentVersion;
|
|
19
|
+
constructor(required, _optional = {}) {
|
|
20
|
+
super(`comment '${required.commentId}' was modified concurrently (current version is ${required.currentVersion ?? "unknown"})`);
|
|
21
|
+
this.name = "CommentVersionConflictError";
|
|
22
|
+
this.currentVersion = required.currentVersion;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from "express";
|
|
2
|
+
import type { CommentIngressPolicy } from "../ports.js";
|
|
3
|
+
import type { CommentRateLimiter } from "../ingress.js";
|
|
4
|
+
/**
|
|
5
|
+
* @file ADR-031 §4 (SPEC-033) — the public, unauthenticated comment submission route. The ONLY
|
|
6
|
+
* route a hostile visitor can reach into the Comments plugin; everything downstream of this file
|
|
7
|
+
* is `CommentIngressPolicy` (rate-limit, honeypot, sanitize, spam-classify — see `comments/ingress.ts`).
|
|
8
|
+
*
|
|
9
|
+
* `authorIpHash` is computed HERE, at the HTTP boundary, from the raw request IP — the raw IP
|
|
10
|
+
* itself is never passed into `ingressContext` or stored (mirrors `analytics/ingest.ts`'s
|
|
11
|
+
* discard-raw-IP-at-the-boundary discipline). The host provides the salt at construction.
|
|
12
|
+
*/
|
|
13
|
+
export interface CommentSubmitRequired {
|
|
14
|
+
ingressPolicy: CommentIngressPolicy;
|
|
15
|
+
workspaceId: string;
|
|
16
|
+
ipHashSalt: string;
|
|
17
|
+
/** Host-owned HTTP request budget, separate from the ingress policy's submission budget. */
|
|
18
|
+
rateLimiter: CommentRateLimiter;
|
|
19
|
+
/** Host-selected trusted proxy policy; the raw address is hashed only at this boundary. */
|
|
20
|
+
clientIp: (required: {
|
|
21
|
+
request: Request;
|
|
22
|
+
}, optional?: Record<string, never>) => string;
|
|
23
|
+
}
|
|
24
|
+
/** Builds the anonymous submission handler; the host owns route mounting and salt policy.
|
|
25
|
+
* @throws {TypeError} When ingressPolicy, workspaceId, ipHashSalt, rateLimiter or clientIp is missing or invalid.
|
|
26
|
+
* @returns An Express handler preserving the source route's normalization and HTTP responses.
|
|
27
|
+
* @complexity O(body length) normalization and hashing plus the ingress port's work.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createCommentSubmitHandler(deps: CommentSubmitRequired, _optional?: Record<string, never>): RequestHandler;
|
|
30
|
+
//# sourceMappingURL=submit-handler.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** Resolve with the host's trust policy and hash before the address leaves HTTP. @complexity O(IP length). */
|
|
3
|
+
function hashClientIp(req, deps) {
|
|
4
|
+
const ip = deps.clientIp({ request: req }, {});
|
|
5
|
+
return createHash("sha256").update(`${ip}:${deps.ipHashSalt}`).digest("hex");
|
|
6
|
+
}
|
|
7
|
+
/** The per-request signal bag (honeypot value, IP hash) — split out of `buildCommentSubmission` so
|
|
8
|
+
* that function's own field count stays clear of the complexity ceiling. @complexity O(1). */
|
|
9
|
+
function buildIngressContext(req, deps, body) {
|
|
10
|
+
return {
|
|
11
|
+
authorIpHash: hashClientIp(req, deps),
|
|
12
|
+
honeypotValue: typeof body.website === "string" ? body.website : "", // conventional honeypot field name
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Normalizes the untyped request body into a `CommentSubmission`. Every field below defaults or
|
|
17
|
+
* clamps independently (undefined-means-empty for required strings, falsy-means-null for optional
|
|
18
|
+
* ones) — same behavior as before this was a named function, just no longer inline in the handler.
|
|
19
|
+
*
|
|
20
|
+
* @complexity O(1).
|
|
21
|
+
*/
|
|
22
|
+
function buildCommentSubmission(req, body, deps) {
|
|
23
|
+
return {
|
|
24
|
+
workspaceId: deps.workspaceId,
|
|
25
|
+
entryId: String(body.entryId ?? ""),
|
|
26
|
+
parentId: body.parentId ? String(body.parentId) : null,
|
|
27
|
+
authorName: String(body.authorName ?? "").slice(0, 200),
|
|
28
|
+
authorEmail: body.authorEmail ? String(body.authorEmail).slice(0, 320) : null,
|
|
29
|
+
authorUrl: body.authorUrl ? String(body.authorUrl).slice(0, 2000) : null,
|
|
30
|
+
bodyRaw: String(body.body ?? ""),
|
|
31
|
+
authorPrincipalId: null, // v1: anonymous-only public route; member-attributed submission is a named deferral
|
|
32
|
+
ingressContext: buildIngressContext(req, deps, body),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Builds the anonymous submission handler; the host owns route mounting and salt policy.
|
|
36
|
+
* @throws {TypeError} When ingressPolicy, workspaceId, ipHashSalt, rateLimiter or clientIp is missing or invalid.
|
|
37
|
+
* @returns An Express handler preserving the source route's normalization and HTTP responses.
|
|
38
|
+
* @complexity O(body length) normalization and hashing plus the ingress port's work.
|
|
39
|
+
*/
|
|
40
|
+
export function createCommentSubmitHandler(deps, _optional = {}) {
|
|
41
|
+
if (!deps?.ingressPolicy || typeof deps.ingressPolicy.submit !== "function") {
|
|
42
|
+
throw new TypeError("comments submit handler requires ingressPolicy");
|
|
43
|
+
}
|
|
44
|
+
if (typeof deps.workspaceId !== "string" || deps.workspaceId.length === 0) {
|
|
45
|
+
throw new TypeError("comments submit handler requires workspaceId");
|
|
46
|
+
}
|
|
47
|
+
if (typeof deps.ipHashSalt !== "string" || deps.ipHashSalt.length === 0) {
|
|
48
|
+
throw new TypeError("comments submit handler requires ipHashSalt");
|
|
49
|
+
}
|
|
50
|
+
if (!deps.rateLimiter || typeof deps.rateLimiter.check !== "function") {
|
|
51
|
+
throw new TypeError("comments submit handler requires rateLimiter");
|
|
52
|
+
}
|
|
53
|
+
if (typeof deps.clientIp !== "function") {
|
|
54
|
+
throw new TypeError("comments submit handler requires clientIp");
|
|
55
|
+
}
|
|
56
|
+
return async (req, res) => {
|
|
57
|
+
try {
|
|
58
|
+
const body = req.body;
|
|
59
|
+
const submission = buildCommentSubmission(req, body, deps);
|
|
60
|
+
const rateLimit = await deps.rateLimiter.check({ key: String(submission.ingressContext.authorIpHash) });
|
|
61
|
+
let result;
|
|
62
|
+
if (rateLimit.allowed) {
|
|
63
|
+
result = await deps.ingressPolicy.submit(submission);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
result = { ok: false, reason: "rate-limited" };
|
|
67
|
+
}
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
// No oracle: every rejection reason maps to the SAME generic 422, distinguishable only in
|
|
70
|
+
// the response body's `reason` for legitimate client-side form UX, never a status-code tell.
|
|
71
|
+
res.status(422).json({ error: "comment was not accepted", reason: result.reason });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
res.status(201).json({ id: result.comment.id, status: result.comment.status });
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
// Public, unauthenticated route (this file's own header) — any visitor's malformed or
|
|
78
|
+
// hostile submission must still get a fast, honest response, never a hang; an unguarded
|
|
79
|
+
// failure here is reachable by anyone, not just an authenticated admin.
|
|
80
|
+
console.error("[site/comments-submit] unexpected error", err);
|
|
81
|
+
res.status(500).json({ error: "internal error", code: "INTERNAL_ERROR" });
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=submit-handler.js.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `hooks.ts` — fixed-order, fail-closed hook dispatch for the two typed hook points
|
|
3
|
+
* `ports.ts#CommentHookPoints` declares (ADR-009 §3, ADR-024 §7). Mirrors
|
|
4
|
+
* `newsletter/hooks.ts`'s factory-not-singleton shape (`createHookRegistry`) — avoids a hidden
|
|
5
|
+
* cross-test/cross-instance module-level singleton.
|
|
6
|
+
*
|
|
7
|
+
* `comments.beforeSubmit` handlers run in registration order; the first `reject` short-circuits
|
|
8
|
+
* (fail-closed on veto, same as a throw). A THROWING handler is also fail-closed rejection, never
|
|
9
|
+
* "keep by default" — the spam-check adapter is expected to attach here as one such handler.
|
|
10
|
+
*/
|
|
11
|
+
import type { CommentHookPoints, CommentIngressRejection } from "./ports.js";
|
|
12
|
+
import type { CommentSubmission } from "./types.js";
|
|
13
|
+
export type BeforeSubmitHook = CommentHookPoints["comments.beforeSubmit"];
|
|
14
|
+
export type StatusChangedHook = CommentHookPoints["comments.statusChanged"];
|
|
15
|
+
export type BeforeSubmitChainResult = {
|
|
16
|
+
submission: CommentSubmission;
|
|
17
|
+
} | {
|
|
18
|
+
reject: CommentIngressRejection;
|
|
19
|
+
};
|
|
20
|
+
export interface CommentHookRegistry {
|
|
21
|
+
/** Adds a filter in registration order; throws during dispatch reject the submission.
|
|
22
|
+
* @complexity Amortized O(1) registration time and space.
|
|
23
|
+
* @example registry.registerBeforeSubmitHook({ hook: async ({ submission }) => ({ submission }) }, {})
|
|
24
|
+
*/
|
|
25
|
+
registerBeforeSubmitHook(required: {
|
|
26
|
+
hook: BeforeSubmitHook;
|
|
27
|
+
}, optional?: Record<string, never>): void;
|
|
28
|
+
/** Adds a post-commit notification; errors during dispatch never undo committed writes.
|
|
29
|
+
* @complexity Amortized O(1) registration time and space.
|
|
30
|
+
* @example registry.registerStatusChangedHook({ hook: async () => {} }, {})
|
|
31
|
+
*/
|
|
32
|
+
registerStatusChangedHook(required: {
|
|
33
|
+
hook: StatusChangedHook;
|
|
34
|
+
}, optional?: Record<string, never>): void;
|
|
35
|
+
runBeforeSubmitChain(submission: CommentSubmission, optional?: Record<string, never>): Promise<BeforeSubmitChainResult>;
|
|
36
|
+
runStatusChangedHooks(payload: Parameters<StatusChangedHook>[0], optional?: Record<string, never>): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export declare function createCommentHookRegistry(_required?: Record<string, never>, _optional?: Record<string, never>): CommentHookRegistry;
|
|
39
|
+
//# sourceMappingURL=hooks.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function createCommentHookRegistry(_required = {}, _optional = {}) {
|
|
2
|
+
const beforeSubmitHooks = [];
|
|
3
|
+
const statusChangedHooks = [];
|
|
4
|
+
return {
|
|
5
|
+
registerBeforeSubmitHook({ hook }, _optional = {}) {
|
|
6
|
+
beforeSubmitHooks.push(hook);
|
|
7
|
+
},
|
|
8
|
+
registerStatusChangedHook({ hook }, _optional = {}) {
|
|
9
|
+
statusChangedHooks.push(hook);
|
|
10
|
+
},
|
|
11
|
+
async runBeforeSubmitChain(initial) {
|
|
12
|
+
let submission = initial;
|
|
13
|
+
for (const hook of beforeSubmitHooks) {
|
|
14
|
+
let result;
|
|
15
|
+
try {
|
|
16
|
+
result = await hook({ submission });
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// A throwing filter is fail-closed rejection, never "keep by default".
|
|
20
|
+
return { reject: "invalid" };
|
|
21
|
+
}
|
|
22
|
+
if ("reject" in result)
|
|
23
|
+
return result;
|
|
24
|
+
submission = result.submission;
|
|
25
|
+
}
|
|
26
|
+
return { submission };
|
|
27
|
+
},
|
|
28
|
+
async runStatusChangedHooks(payload) {
|
|
29
|
+
for (const hook of statusChangedHooks) {
|
|
30
|
+
try {
|
|
31
|
+
await hook(payload);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Action hooks are post-commit notifications — a throwing one must not undo the
|
|
35
|
+
// already-committed status change; it is swallowed, not fail-closed like beforeSubmit.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=hooks.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./ports.js";
|
|
3
|
+
export * from "./hooks.js";
|
|
4
|
+
export * from "./ingress.js";
|
|
5
|
+
export * from "./write-service.js";
|
|
6
|
+
export * from "./spam.heuristic.js";
|
|
7
|
+
export * from "./sanitize.js";
|
|
8
|
+
export * from "./errors.js";
|
|
9
|
+
export * from "./repo.memory.js";
|
|
10
|
+
export * from "./module.js";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./ports.js";
|
|
3
|
+
export * from "./hooks.js";
|
|
4
|
+
export * from "./ingress.js";
|
|
5
|
+
export * from "./write-service.js";
|
|
6
|
+
export * from "./spam.heuristic.js";
|
|
7
|
+
export * from "./sanitize.js";
|
|
8
|
+
export * from "./errors.js";
|
|
9
|
+
export * from "./repo.memory.js";
|
|
10
|
+
export * from "./module.js";
|
|
11
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file ADR-031 §4 — `CommentIngressPolicy`, the single core-mediated public-submission
|
|
3
|
+
* boundary (mirrors ADR-027's one `MediaIngressPolicy`). Enforces, in order:
|
|
4
|
+
* comments-enabled → entry-open → parent-exists → depth-cap → rate-limit → honeypot/size/link
|
|
5
|
+
* caps → sanitize → hook chain (veto/transform) → spam classification → write.
|
|
6
|
+
*
|
|
7
|
+
* Spam is stored silently (`status: "spam"`), never rejected at the boundary — a rejection is an
|
|
8
|
+
* oracle a spammer tunes against (ADR-031 §4's own explicit instruction).
|
|
9
|
+
*
|
|
10
|
+
* `entryLookup` is a narrow, caller-injected dependency (ADR-046 Phase 3's "narrow typed
|
|
11
|
+
* dependencies" convention) rather than a direct import of the entries feature module — its
|
|
12
|
+
* `commentsClosed` field is where `CommentsSettings.closeAfterDays` math against the entry's own
|
|
13
|
+
* publish date belongs; this file does not duplicate that logic.
|
|
14
|
+
*
|
|
15
|
+
* `authorIpHash`/honeypot are read from `submission.ingressContext` (already computed/salted by
|
|
16
|
+
* the HTTP route layer, per `CommentSubmission`'s own doc: "the raw IP is never stored" — this
|
|
17
|
+
* file never sees it) under the fixed keys `authorIpHash` and `honeypotValue`.
|
|
18
|
+
*
|
|
19
|
+
* `getSettings` (SPEC-035, ADR-028 Settings Layered Ledger wiring) is a per-call RESOLVER, not a
|
|
20
|
+
* boot-captured snapshot — `submit()` reads it fresh at the top of every call, so an operator's
|
|
21
|
+
* settings change via the admin `PUT .../comments/settings` route (`settings.ts`) takes effect on
|
|
22
|
+
* the very next submission, not only after a restart. `index.ts` builds this resolver from
|
|
23
|
+
* `settingsRepo` when the composition root supplies one, falling back to a fixed snapshot
|
|
24
|
+
* otherwise (hermetic tests that don't wire the ledger).
|
|
25
|
+
*/
|
|
26
|
+
import type { Clock as ClockPort, IdGenerator as IdGeneratorPort, UUID } from "@jini-ai/core/primitives";
|
|
27
|
+
import type { OutboxPort } from "../core/index.js";
|
|
28
|
+
import type { RateLimiter as HttpRateLimiter } from "@jini-ai/http-kit/rate-limit";
|
|
29
|
+
/** Only the request check belongs to ingress; limiter construction and diagnostics are host policy. */
|
|
30
|
+
export type CommentRateLimiter = Pick<HttpRateLimiter, "check">;
|
|
31
|
+
import type { CommentHookRegistry } from "./hooks.js";
|
|
32
|
+
import type { CommentIngressPolicy, CommentRepoPort, SpamCheckPort } from "./ports.js";
|
|
33
|
+
import type { CommentsSettings } from "./types.js";
|
|
34
|
+
export interface EntryLookupResult {
|
|
35
|
+
id: UUID;
|
|
36
|
+
/** True when the entry itself is closed to new comments (per-entry override or `closeAfterDays`). */
|
|
37
|
+
commentsClosed: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface CommentIngressDeps {
|
|
40
|
+
repo: CommentRepoPort;
|
|
41
|
+
spamCheck: SpamCheckPort;
|
|
42
|
+
hooks: CommentHookRegistry;
|
|
43
|
+
clock: ClockPort;
|
|
44
|
+
idGen: IdGeneratorPort;
|
|
45
|
+
getSettings: (workspaceId: UUID) => Promise<CommentsSettings>;
|
|
46
|
+
entryLookup: (required: {
|
|
47
|
+
workspaceId: UUID;
|
|
48
|
+
entryId: UUID;
|
|
49
|
+
}) => Promise<EntryLookupResult | null>;
|
|
50
|
+
rateLimiter: CommentRateLimiter;
|
|
51
|
+
/** ADR-031 §8 — every successful submission emits `comments.submitted` (whatever status
|
|
52
|
+
* resulted, including an auto-classified `spam`); a moderator's LATER explicit action is what
|
|
53
|
+
* emits `comments.marked_spam` (see `write-service.ts`) — the two are deliberately distinct. */
|
|
54
|
+
outbox: OutboxPort;
|
|
55
|
+
}
|
|
56
|
+
export declare function createCommentIngressPolicy(deps: CommentIngressDeps, _optional?: Record<string, never>): CommentIngressPolicy;
|
|
57
|
+
//# sourceMappingURL=ingress.d.ts.map
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { nowIso } from "@jini-ai/core/primitives";
|
|
2
|
+
import { countLinks, sanitizeCommentBody } from "./sanitize.js";
|
|
3
|
+
import { COMMENTS_INGRESS_SYSTEM_PRINCIPAL_ID } from "./types.js";
|
|
4
|
+
const MAX_BODY_LENGTH = 10_000;
|
|
5
|
+
const MAX_LINKS = 5;
|
|
6
|
+
function readIngressString(value) {
|
|
7
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
8
|
+
}
|
|
9
|
+
/** entry-exists + entry-open gate (comments-enabled → entry-open, per this file's header). */
|
|
10
|
+
async function checkEntryGate(deps, submission) {
|
|
11
|
+
const entry = await deps.entryLookup({ workspaceId: submission.workspaceId, entryId: submission.entryId });
|
|
12
|
+
if (!entry)
|
|
13
|
+
return { ok: false, reason: "entry-not-found" };
|
|
14
|
+
if (entry.commentsClosed)
|
|
15
|
+
return { ok: false, reason: "entry-closed" };
|
|
16
|
+
return { ok: true };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Resolves the depth + thread-root a submission's optional `parentId` implies (parent-exists →
|
|
20
|
+
* depth-cap, per this file's header). A top-level submission (`parentId: null`) trivially passes
|
|
21
|
+
* at depth 0 with no thread root yet (the comment being created becomes its own root — see
|
|
22
|
+
* `submit()`'s own `threadRootId: parentThreadRootId ?? id`).
|
|
23
|
+
*
|
|
24
|
+
* BUG FIX (this pass — found auditing this exact function for the complexity refactor, not
|
|
25
|
+
* introduced by it): the parent lookup never checked that the found parent actually belongs to
|
|
26
|
+
* the SAME entry as this submission — `CommentRepoPort.findById` is keyed only on
|
|
27
|
+
* `(workspaceId, id)`, not `entryId`. A submission could supply an `entryId` for one (open,
|
|
28
|
+
* comments-enabled) entry and a `parentId` that is really a comment on a completely different
|
|
29
|
+
* entry, silently grafting the new comment onto that other entry's thread (wrong `threadRootId`,
|
|
30
|
+
* a `depth` computed against an unrelated thread). `types.ts`'s own header says this referential
|
|
31
|
+
* integrity is "validated at the write chokepoint, NOT by a DB foreign key" (i.e. here) — this was
|
|
32
|
+
* the gap. Treated as `parent-not-found`, not a new rejection reason: from the submitter's point
|
|
33
|
+
* of view, the parent they asked for does not exist ON THIS ENTRY. Regression:
|
|
34
|
+
* `ingress.test.ts` ("a parentId belonging to a different entry...").
|
|
35
|
+
*/
|
|
36
|
+
async function resolveParentContext(deps, submission, settings) {
|
|
37
|
+
if (!submission.parentId)
|
|
38
|
+
return { ok: true, depth: 0, parentThreadRootId: null };
|
|
39
|
+
const parent = await deps.repo.findById({ workspaceId: submission.workspaceId, id: submission.parentId });
|
|
40
|
+
if (!parent || parent.entryId !== submission.entryId)
|
|
41
|
+
return { ok: false, reason: "parent-not-found" };
|
|
42
|
+
const depth = parent.depth + 1;
|
|
43
|
+
if (depth > settings.maxDepth)
|
|
44
|
+
return { ok: false, reason: "max-depth-exceeded" };
|
|
45
|
+
return { ok: true, depth, parentThreadRootId: parent.threadRootId };
|
|
46
|
+
}
|
|
47
|
+
/** rate-limit → honeypot gate (per this file's header). */
|
|
48
|
+
async function checkRateLimitAndHoneypot(deps, submission) {
|
|
49
|
+
const authorIpHash = readIngressString(submission.ingressContext.authorIpHash);
|
|
50
|
+
if (!(await deps.rateLimiter.check({ key: authorIpHash ?? "unknown" })).allowed)
|
|
51
|
+
return { ok: false, reason: "rate-limited" };
|
|
52
|
+
if (readIngressString(submission.ingressContext.honeypotValue))
|
|
53
|
+
return { ok: false, reason: "honeypot-tripped" };
|
|
54
|
+
return { ok: true, authorIpHash };
|
|
55
|
+
}
|
|
56
|
+
/** size/link caps, applied to the ALREADY-sanitized body (sanitize → size/link caps is this
|
|
57
|
+
* function's own order — see this file's header — so the caps see what will actually be stored). */
|
|
58
|
+
function sanitizeAndCapBody(bodyRaw) {
|
|
59
|
+
const sanitizedBody = sanitizeCommentBody({ raw: bodyRaw }, {});
|
|
60
|
+
if (sanitizedBody.length > MAX_BODY_LENGTH)
|
|
61
|
+
return { ok: false, reason: "body-too-large" };
|
|
62
|
+
if (countLinks({ text: sanitizedBody }, {}) > MAX_LINKS)
|
|
63
|
+
return { ok: false, reason: "too-many-links" };
|
|
64
|
+
return { ok: true, sanitizedBody };
|
|
65
|
+
}
|
|
66
|
+
/** spam-classification decision (score → status), isolated so `submit()` reads as one line instead
|
|
67
|
+
* of a nested ternary (also clears the `sonarjs/no-nested-conditional` finding this line used to
|
|
68
|
+
* trip). Spam is never a rejection at this boundary — see this file's header. */
|
|
69
|
+
function classifyCommentStatus(verdict, settings) {
|
|
70
|
+
if (verdict.score >= settings.spamAutoRejectScore)
|
|
71
|
+
return "spam";
|
|
72
|
+
return settings.requireModeration ? "pending" : "approved";
|
|
73
|
+
}
|
|
74
|
+
function buildCommentRecord(params) {
|
|
75
|
+
const { submission, hookSubmission, authorIpHash, depth, parentThreadRootId, status, verdict, id, now } = params;
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
workspaceId: submission.workspaceId,
|
|
79
|
+
entryId: submission.entryId,
|
|
80
|
+
parentId: submission.parentId,
|
|
81
|
+
threadRootId: parentThreadRootId ?? id,
|
|
82
|
+
depth,
|
|
83
|
+
status,
|
|
84
|
+
authorPrincipalId: hookSubmission.authorPrincipalId,
|
|
85
|
+
authorName: hookSubmission.authorName,
|
|
86
|
+
authorEmail: hookSubmission.authorEmail,
|
|
87
|
+
authorUrl: hookSubmission.authorUrl,
|
|
88
|
+
authorIpHash,
|
|
89
|
+
bodyText: hookSubmission.bodyRaw,
|
|
90
|
+
spamScore: verdict.score,
|
|
91
|
+
spamProvider: verdict.provider,
|
|
92
|
+
createdAt: now,
|
|
93
|
+
updatedAt: now,
|
|
94
|
+
version: 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// OQ-3 resolution (ADR-031 round-2 fold, SPEC-035): every ingress-created comment gets a `submit`
|
|
98
|
+
// moderation_log row, attributed to the seeded system principal — the ingress has no real operator
|
|
99
|
+
// principal to attribute this to (the visitor is anonymous by definition), and the
|
|
100
|
+
// auto-classification (pending/approved/spam) IS itself a moderation decision, just one core made
|
|
101
|
+
// instead of a human. `fromStatus: null` (nothing existed before this write).
|
|
102
|
+
function buildSubmitLogEntry(deps, record, now) {
|
|
103
|
+
return {
|
|
104
|
+
id: deps.idGen.newId(),
|
|
105
|
+
workspaceId: record.workspaceId,
|
|
106
|
+
commentId: record.id,
|
|
107
|
+
actorPrincipalId: COMMENTS_INGRESS_SYSTEM_PRINCIPAL_ID,
|
|
108
|
+
action: "submit",
|
|
109
|
+
fromStatus: null,
|
|
110
|
+
toStatus: record.status,
|
|
111
|
+
at: now,
|
|
112
|
+
note: null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function enqueueSubmittedEvent(deps, record, now) {
|
|
116
|
+
// Inline object literal (not a `CommentDomainEvent`-typed intermediate) — matches this
|
|
117
|
+
// codebase's established `outbox.enqueue()` call-site convention (e.g.
|
|
118
|
+
// `features/entries/write-service.ts`): TS only infers a `Record<string, unknown>`-
|
|
119
|
+
// compatible index signature for a FRESH object literal argument, not for a value already
|
|
120
|
+
// typed against a concrete interface without one.
|
|
121
|
+
await deps.outbox.enqueue({
|
|
122
|
+
id: deps.idGen.newId(),
|
|
123
|
+
name: "comments.submitted",
|
|
124
|
+
occurredAt: now,
|
|
125
|
+
aggregateId: record.id,
|
|
126
|
+
workspaceId: record.workspaceId,
|
|
127
|
+
payload: { commentId: record.id, entryId: record.entryId, status: record.status },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
export function createCommentIngressPolicy(deps, _optional = {}) {
|
|
131
|
+
return {
|
|
132
|
+
async submit(submission) {
|
|
133
|
+
const settings = await deps.getSettings(submission.workspaceId);
|
|
134
|
+
if (!settings.enabled)
|
|
135
|
+
return { ok: false, reason: "comments-disabled" };
|
|
136
|
+
const entryGate = await checkEntryGate(deps, submission);
|
|
137
|
+
if (!entryGate.ok)
|
|
138
|
+
return entryGate;
|
|
139
|
+
const parentContext = await resolveParentContext(deps, submission, settings);
|
|
140
|
+
if (!parentContext.ok)
|
|
141
|
+
return parentContext;
|
|
142
|
+
const rateGate = await checkRateLimitAndHoneypot(deps, submission);
|
|
143
|
+
if (!rateGate.ok)
|
|
144
|
+
return rateGate;
|
|
145
|
+
const bodyResult = sanitizeAndCapBody(submission.bodyRaw);
|
|
146
|
+
if (!bodyResult.ok)
|
|
147
|
+
return bodyResult;
|
|
148
|
+
const sanitizedSubmission = { ...submission, bodyRaw: bodyResult.sanitizedBody };
|
|
149
|
+
const hookResult = await deps.hooks.runBeforeSubmitChain(sanitizedSubmission);
|
|
150
|
+
if ("reject" in hookResult)
|
|
151
|
+
return { ok: false, reason: hookResult.reject };
|
|
152
|
+
const verdict = await deps.spamCheck.check(hookResult.submission);
|
|
153
|
+
const status = classifyCommentStatus(verdict, settings);
|
|
154
|
+
const id = deps.idGen.newId();
|
|
155
|
+
const now = nowIso({ clock: deps.clock });
|
|
156
|
+
const record = buildCommentRecord({
|
|
157
|
+
submission,
|
|
158
|
+
hookSubmission: hookResult.submission,
|
|
159
|
+
authorIpHash: rateGate.authorIpHash,
|
|
160
|
+
depth: parentContext.depth,
|
|
161
|
+
parentThreadRootId: parentContext.parentThreadRootId,
|
|
162
|
+
status,
|
|
163
|
+
verdict,
|
|
164
|
+
id,
|
|
165
|
+
now,
|
|
166
|
+
});
|
|
167
|
+
await deps.repo.create({ record }, { submitLog: buildSubmitLogEntry(deps, record, now) });
|
|
168
|
+
await enqueueSubmittedEvent(deps, record, now);
|
|
169
|
+
return { ok: true, comment: record, autoClassified: status };
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=ingress.js.map
|