@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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Composition factory for the Comments plugin (ADR-031, SPEC-033/SPEC-035) — wires the
|
|
3
|
+
* repo, spam check, hooks, ingress policy, and write-service together given the low-level deps a
|
|
4
|
+
* composition root (`server/deps.ts`/`app.ts`) already has. Both composition roots call this
|
|
5
|
+
* instead of each hand-assembling the same 5-piece wiring twice.
|
|
6
|
+
*
|
|
7
|
+
* `entryLookup` adapts `EntryRepoPort.findById` (already on `RouteDeps`) into the narrow shape
|
|
8
|
+
* `ingress.ts` expects, computing `commentsClosed` from the entry's own `status`/`publishedAt` +
|
|
9
|
+
* `CommentsSettings.closeAfterDays` — this file owns that math so `ingress.ts` itself stays free of
|
|
10
|
+
* any entries-feature knowledge (ADR-046 Phase 3's "narrow typed dependencies" convention).
|
|
11
|
+
*
|
|
12
|
+
* BUG FIX (2026-09-03, found auditing `ingress.ts` for an unrelated complexity refactor): an entry
|
|
13
|
+
* that has never been published (`status: "draft"`, `publishedAt: null`) OR was explicitly
|
|
14
|
+
* unpublished (`status: "unpublished"`) was previously readable as "open" — nothing anywhere in the
|
|
15
|
+
* ingress path checked `EntryRecord.status` at all, only `publishedAt`. A visitor who knew or
|
|
16
|
+
* guessed either entry's id could post a public, world-visible-once-moderated comment on content
|
|
17
|
+
* nobody has ever been shown, or content that was deliberately retracted. Checked ADR-031/SPEC-033/
|
|
18
|
+
* SPEC-035 (this file's own citations) for a deliberate "drafts/retracted entries are commentable"
|
|
19
|
+
* design decision — REQ-04 says only "entry-open"; no spec anywhere defines that term against
|
|
20
|
+
* publish state, names a preview/staging use case, or otherwise addresses this. Treated as spec-
|
|
21
|
+
* silent, not spec-permitted: fixed as the fail-closed reading (`isEntryOpenForComments` below),
|
|
22
|
+
* reusing the existing `entry-closed` rejection reason rather than adding a new one to `ports.ts`'s
|
|
23
|
+
* `CommentIngressRejection` union — from a submitter's point of view "never published" and
|
|
24
|
+
* "retracted" are both just "not currently open for comments." This is a deliberate BEHAVIOR
|
|
25
|
+
* CHANGE with no spec mandate behind it, disclosed here and in this commit's message rather than
|
|
26
|
+
* smuggled in as if it were spec-required.
|
|
27
|
+
*
|
|
28
|
+
* Checked for a second call site before fixing (the exact failure class a sibling session found
|
|
29
|
+
* elsewhere in this campaign — a correct primitive wired into only one of several routes): grepped
|
|
30
|
+
* the whole host source tree for every construction of `CommentIngressDeps.entryLookup` and
|
|
31
|
+
* every call to `createCommentIngressPolicy`/`repo.create` for a comment row. There is exactly ONE
|
|
32
|
+
* of each — this file's own `entryLookup` below, `createCommentsModule`'s own call to
|
|
33
|
+
* `createCommentIngressPolicy`, and `ingress.ts#submit()`'s own `repo.create()` — reached by both
|
|
34
|
+
* real composition roots (`server/runtime/composition/{app,deps}.ts`) through this one factory and
|
|
35
|
+
* by no other path (`write-service.ts` only ever calls `applyModeration`/`purge` on an EXISTING
|
|
36
|
+
* comment, never `create`). Fixing it here closes every write call site, not just the one this
|
|
37
|
+
* audit started from.
|
|
38
|
+
*
|
|
39
|
+
* Also checked the READ side for the same class of leak (does the public read path show comments
|
|
40
|
+
* on an unpublished entry, not just accept new ones on it): `CommentRepoPort.listThreadForEntry`
|
|
41
|
+
* has zero route call sites anywhere in this codebase today — grepped for every reference; the only
|
|
42
|
+
* hits are its own port declaration, its two adapter implementations, and `repo.contract.test.ts`.
|
|
43
|
+
* There is no live public comment-read route yet (ADR-031 §10's origin-isolated widget, this file's
|
|
44
|
+
* own header already cites, remains deferred — "no ADR-025 host exists yet" per SPEC-033's own
|
|
45
|
+
* Scope Note). So there is nothing to fix on the read side today; flagged here as a note for
|
|
46
|
+
* whoever builds that route next, not a bug in the current tree.
|
|
47
|
+
*
|
|
48
|
+
*/
|
|
49
|
+
import type { Clock as ClockPort, IdGenerator as IdGeneratorPort, UUID } from "@jini-ai/core/primitives";
|
|
50
|
+
import type { OutboxPort } from "../core/index.js";
|
|
51
|
+
import type { CommentRateLimiter } from "./ingress.js";
|
|
52
|
+
import type { CommentIngressPolicy, CommentRepoPort, SpamCheckPort } from "./ports.js";
|
|
53
|
+
import type { CommentsSettings } from "./types.js";
|
|
54
|
+
import type { CommentTransactionRunner, CommentWriteService, ForgetRemovedCommentFn, RemoveCommentFn } from "./write-service.js";
|
|
55
|
+
/** The publish state and date are the only entry data the comment gate needs. */
|
|
56
|
+
export interface CommentEntryView {
|
|
57
|
+
status: string;
|
|
58
|
+
publishedAt: string | null;
|
|
59
|
+
}
|
|
60
|
+
export interface CommentsModuleDeps {
|
|
61
|
+
commentRepo: CommentRepoPort;
|
|
62
|
+
entryLookup: (required: {
|
|
63
|
+
workspaceId: UUID;
|
|
64
|
+
entryId: UUID;
|
|
65
|
+
}) => Promise<CommentEntryView | null>;
|
|
66
|
+
getSettings: (workspaceId: UUID) => Promise<CommentsSettings>;
|
|
67
|
+
rateLimiter: CommentRateLimiter;
|
|
68
|
+
outbox: OutboxPort;
|
|
69
|
+
clock: ClockPort;
|
|
70
|
+
idGen: IdGeneratorPort;
|
|
71
|
+
/** Caller-chosen spam-check adapter; both real composition roots use `HeuristicSpamCheck`.
|
|
72
|
+
* This module never picks one on its own, so the choice remains a real DI seam. */
|
|
73
|
+
spamCheck: SpamCheckPort;
|
|
74
|
+
/**
|
|
75
|
+
* The local admin Trash's three seams, passed straight through to the moderation write-service.
|
|
76
|
+
* Required, not optional: a default no-op would silently drop comments out of the Trash screen,
|
|
77
|
+
* and "deleted but unrecoverable" is exactly the failure this feature exists to prevent. See
|
|
78
|
+
* `write-service.ts`'s `RemoveCommentFn` for why none of them is a trash type.
|
|
79
|
+
*/
|
|
80
|
+
remove: RemoveCommentFn;
|
|
81
|
+
forgetRemoved: ForgetRemovedCommentFn;
|
|
82
|
+
runInTransaction: CommentTransactionRunner;
|
|
83
|
+
}
|
|
84
|
+
export interface CommentsModule {
|
|
85
|
+
commentRepo: CommentRepoPort;
|
|
86
|
+
ingressPolicy: CommentIngressPolicy;
|
|
87
|
+
writeService: CommentWriteService;
|
|
88
|
+
}
|
|
89
|
+
/** Wires the comment gate and moderation service using host-owned settings, rate limits and removal.
|
|
90
|
+
* @returns The supplied repo plus ingress and moderation services.
|
|
91
|
+
* @complexity O(1) setup; calls inherit the injected ports' costs.
|
|
92
|
+
*/
|
|
93
|
+
export declare function createCommentsModule(deps: CommentsModuleDeps, _optional?: Record<string, never>): CommentsModule;
|
|
94
|
+
//# sourceMappingURL=module.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { nowIso } from "@jini-ai/core/primitives";
|
|
2
|
+
import { createCommentHookRegistry } from "./hooks.js";
|
|
3
|
+
import { createCommentIngressPolicy } from "./ingress.js";
|
|
4
|
+
import { createCommentWriteService } from "./write-service.js";
|
|
5
|
+
/** `closeAfterDays` age math ONLY — assumes the entry is otherwise eligible. Does not, and must
|
|
6
|
+
* not, decide publish state; see {@link isEntryOpenForComments}, which composes this with the
|
|
7
|
+
* publish-state check this file's header documents. */
|
|
8
|
+
function isPastCloseWindow(publishedAt, closeAfterDays, nowIso) {
|
|
9
|
+
if (closeAfterDays === null || publishedAt === null)
|
|
10
|
+
return false;
|
|
11
|
+
const publishedMs = Date.parse(publishedAt);
|
|
12
|
+
const nowMs = Date.parse(nowIso);
|
|
13
|
+
if (Number.isNaN(publishedMs) || Number.isNaN(nowMs))
|
|
14
|
+
return false;
|
|
15
|
+
const ageDays = (nowMs - publishedMs) / (1000 * 60 * 60 * 24);
|
|
16
|
+
return ageDays > closeAfterDays;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether an entry may currently receive a new public comment: it must actually be `published`
|
|
20
|
+
* — not `draft` (never shown to anyone) and not `unpublished` (deliberately retracted) — AND not
|
|
21
|
+
* past its `closeAfterDays` window. `status !== "published"` is checked FIRST and short-circuits:
|
|
22
|
+
* `publishedAt` alone cannot distinguish "live" from "retracted", because `unpublishEntry`
|
|
23
|
+
* (`@jini-ai/cms/entries` `write-service.ts#transitionEntryStatus`) deliberately does not clear
|
|
24
|
+
* `publishedAt` on retraction (`publishedAt: target.status === "published" ? now : current.
|
|
25
|
+
* publishedAt`) — it stays at whatever it was, so a stale `publishedAt` from before retraction
|
|
26
|
+
* would otherwise still pass the age check. See this file's header for the bug this closes.
|
|
27
|
+
*/
|
|
28
|
+
function isEntryOpenForComments(entry, closeAfterDays, nowIso) {
|
|
29
|
+
if (entry.status !== "published")
|
|
30
|
+
return false;
|
|
31
|
+
return !isPastCloseWindow(entry.publishedAt, closeAfterDays, nowIso);
|
|
32
|
+
}
|
|
33
|
+
/** Wires the comment gate and moderation service using host-owned settings, rate limits and removal.
|
|
34
|
+
* @returns The supplied repo plus ingress and moderation services.
|
|
35
|
+
* @complexity O(1) setup; calls inherit the injected ports' costs.
|
|
36
|
+
*/
|
|
37
|
+
export function createCommentsModule(deps, _optional = {}) {
|
|
38
|
+
const hooks = createCommentHookRegistry({}, {});
|
|
39
|
+
const spamCheck = deps.spamCheck;
|
|
40
|
+
const entryLookup = async (required) => {
|
|
41
|
+
const entry = await deps.entryLookup(required);
|
|
42
|
+
if (!entry)
|
|
43
|
+
return null;
|
|
44
|
+
const settings = await deps.getSettings(required.workspaceId);
|
|
45
|
+
return {
|
|
46
|
+
id: required.entryId,
|
|
47
|
+
commentsClosed: !isEntryOpenForComments(entry, settings.closeAfterDays, nowIso({ clock: deps.clock })),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const ingressPolicy = createCommentIngressPolicy({
|
|
51
|
+
repo: deps.commentRepo,
|
|
52
|
+
spamCheck,
|
|
53
|
+
hooks,
|
|
54
|
+
clock: deps.clock,
|
|
55
|
+
idGen: deps.idGen,
|
|
56
|
+
getSettings: deps.getSettings,
|
|
57
|
+
entryLookup,
|
|
58
|
+
rateLimiter: deps.rateLimiter,
|
|
59
|
+
outbox: deps.outbox,
|
|
60
|
+
});
|
|
61
|
+
const writeService = createCommentWriteService({
|
|
62
|
+
repo: deps.commentRepo,
|
|
63
|
+
outbox: deps.outbox,
|
|
64
|
+
hooks,
|
|
65
|
+
clock: deps.clock,
|
|
66
|
+
idGen: deps.idGen,
|
|
67
|
+
remove: deps.remove,
|
|
68
|
+
forgetRemoved: deps.forgetRemoved,
|
|
69
|
+
runInTransaction: deps.runInTransaction,
|
|
70
|
+
});
|
|
71
|
+
return { commentRepo: deps.commentRepo, ingressPolicy, writeService };
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=module.js.map
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Port contracts introduced by the Comments bundled plugin (ADR-031).
|
|
3
|
+
*
|
|
4
|
+
* Two ports, each passing ADR-006's rule-of-two (two plausible adapters, one built now):
|
|
5
|
+
* 1. `CommentRepoPort` — in-memory (now) + SQLite-over-dataModule (now). The typed, core-owned
|
|
6
|
+
* write path ADR-023 §7 mandates for plugin tables: no raw plugin write SQL, so the ADR-022
|
|
7
|
+
* write chokepoint (revision + `pluginId` attribution) is preserved.
|
|
8
|
+
* 2. `SpamCheckPort` — heuristic/local (built now) + external service (plausible
|
|
9
|
+
* next). This is the brief's anti-spam seam.
|
|
10
|
+
*
|
|
11
|
+
* `CommentIngressPolicy` is the core-mediated public-submission boundary (NOT a rule-of-two port —
|
|
12
|
+
* one policy, like ADR-027's single `MediaIngressPolicy`): anonymous submission cannot ride
|
|
13
|
+
* `authorize()` RBAC, so it enters here — rate-limited, spam-checked, sanitized — and is the only
|
|
14
|
+
* write path a hostile visitor can reach.
|
|
15
|
+
*
|
|
16
|
+
* All cross-seam payloads are async + serializable, no live core objects (ADR-024 §3, ADR-025 §2).
|
|
17
|
+
* INTERFACES + TYPES ONLY — no feature logic lives here.
|
|
18
|
+
*/
|
|
19
|
+
import type { ISODateTime, UUID } from "@jini-ai/core/primitives";
|
|
20
|
+
import type { DomainEvent } from "../core/index.js";
|
|
21
|
+
import type { CommentRecord, CommentStatus, CommentSubmission, ModerationAction, ModerationLogEntry, ModerationQueuePage, CommentThreadNode, SpamVerdict } from "./types.js";
|
|
22
|
+
/** Every read/write is workspace-scoped (ADR-007); all methods async (ADR-024 §3). */
|
|
23
|
+
export interface CommentRepoPort {
|
|
24
|
+
findById(required: {
|
|
25
|
+
workspaceId: UUID;
|
|
26
|
+
id: UUID;
|
|
27
|
+
}, optional?: Record<string, never>): Promise<CommentRecord | null>;
|
|
28
|
+
/** Public/threaded read for the widget — approved-only unless `includeStatuses` is passed. */
|
|
29
|
+
listThreadForEntry(required: {
|
|
30
|
+
workspaceId: UUID;
|
|
31
|
+
entryId: UUID;
|
|
32
|
+
includeStatuses?: readonly CommentStatus[];
|
|
33
|
+
}, optional?: Record<string, never>): Promise<CommentThreadNode[]>;
|
|
34
|
+
/** The moderation queue: filter by status, keyset-paginated (ADR-022 §3).
|
|
35
|
+
* @throws {ToolInputError} `invalid cursor` when `cursor` names no comment in the workspace. */
|
|
36
|
+
listModerationQueue(required: {
|
|
37
|
+
workspaceId: UUID;
|
|
38
|
+
status: CommentStatus;
|
|
39
|
+
limit: number;
|
|
40
|
+
cursor?: string | null;
|
|
41
|
+
}, optional?: Record<string, never>): Promise<ModerationQueuePage>;
|
|
42
|
+
/** Count for a status (e.g. the pending badge) — derived, not a denormalized column in v1. */
|
|
43
|
+
countByStatus(required: {
|
|
44
|
+
workspaceId: UUID;
|
|
45
|
+
entryId?: UUID;
|
|
46
|
+
status: CommentStatus;
|
|
47
|
+
}, optional?: Record<string, never>): Promise<number>;
|
|
48
|
+
/**
|
|
49
|
+
* Insert a new comment through the typed chokepoint (ADR-023 §7).
|
|
50
|
+
*
|
|
51
|
+
* `submitLog`, when present, must be durably recorded ATOMICALLY with `record` — OQ-3's
|
|
52
|
+
* resolution (SPEC-035): every ingress-created comment gets a `submit` `moderation_log` row,
|
|
53
|
+
* attributed to the seeded `COMMENTS_INGRESS_SYSTEM_PRINCIPAL_ID` (`types.ts`). Mirrors
|
|
54
|
+
* `ChangeSetRepoPort.insert()`'s optional third-argument co-persistence pattern (ADR-046 BR-04
|
|
55
|
+
* resolution): a SQLite adapter wraps both inserts in one transaction; the in-memory adapter
|
|
56
|
+
* pushes both synchronously. Omit `submitLog` for a comment created outside the public ingress
|
|
57
|
+
* (e.g. test fixtures, a future member-authored write path with its own attribution story).
|
|
58
|
+
* @example repo.create({ record }, { submitLog })
|
|
59
|
+
*/
|
|
60
|
+
create(required: {
|
|
61
|
+
record: CommentRecord;
|
|
62
|
+
}, optional?: {
|
|
63
|
+
submitLog?: ModerationLogEntry;
|
|
64
|
+
}): Promise<void>;
|
|
65
|
+
/** The append-only audit trail for one comment, oldest-first (admin "history" view; also how
|
|
66
|
+
* OQ-3's ingress-time `submit` row is verified in tests). */
|
|
67
|
+
listModerationLog(required: {
|
|
68
|
+
workspaceId: UUID;
|
|
69
|
+
commentId: UUID;
|
|
70
|
+
}, optional?: Record<string, never>): Promise<ModerationLogEntry[]>;
|
|
71
|
+
/**
|
|
72
|
+
* OCC status flip + audit-log append, executed by CORE as ONE atomic unit — the multi-table,
|
|
73
|
+
* both-or-neither write ADR-026's envelope exists for (comment row + `moderation_log` row).
|
|
74
|
+
* Returns the updated record, or a serializable conflict (never throws a live object).
|
|
75
|
+
*/
|
|
76
|
+
applyModeration(required: {
|
|
77
|
+
workspaceId: UUID;
|
|
78
|
+
id: UUID;
|
|
79
|
+
expectedVersion: number;
|
|
80
|
+
action: ModerationAction;
|
|
81
|
+
toStatus: CommentStatus;
|
|
82
|
+
actorPrincipalId: UUID;
|
|
83
|
+
note: string | null;
|
|
84
|
+
at: ISODateTime;
|
|
85
|
+
}, optional?: Record<string, never>): Promise<{
|
|
86
|
+
ok: true;
|
|
87
|
+
record: CommentRecord;
|
|
88
|
+
log: ModerationLogEntry;
|
|
89
|
+
} | {
|
|
90
|
+
ok: false;
|
|
91
|
+
reason: "not-found" | "conflict";
|
|
92
|
+
currentVersion?: number | undefined;
|
|
93
|
+
}>;
|
|
94
|
+
/**
|
|
95
|
+
* ADR-031 §9's trash→purge ladder's hard-delete half (SPEC-033 — additive widening; `purge` was
|
|
96
|
+
* a valid `ModerationAction` value with no repo method to execute it in the originally-accepted
|
|
97
|
+
* interfaces). A REAL row delete, unlike `applyModeration`'s status flip — this is the one
|
|
98
|
+
* irreversible act in the ladder. The `moderation_log` row is appended and RETAINED after the
|
|
99
|
+
* comment row is gone (chokepoint-validated, not FK-enforced, per ADR-031 §2 — an orphaned log
|
|
100
|
+
* row is the intended, permanent audit trail of a purge, not a dangling reference to clean up).
|
|
101
|
+
*/
|
|
102
|
+
purge(required: {
|
|
103
|
+
workspaceId: UUID;
|
|
104
|
+
id: UUID;
|
|
105
|
+
actorPrincipalId: UUID;
|
|
106
|
+
note: string | null;
|
|
107
|
+
at: ISODateTime;
|
|
108
|
+
}, optional?: Record<string, never>): Promise<{
|
|
109
|
+
ok: true;
|
|
110
|
+
log: ModerationLogEntry;
|
|
111
|
+
} | {
|
|
112
|
+
ok: false;
|
|
113
|
+
reason: "not-found";
|
|
114
|
+
}>;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The anti-spam seam (ADR-006 rule-of-two). Async + serializable (ADR-024 §3); a network-backed
|
|
118
|
+
* adapter is a core-mediated capability, never a raw `fetch` from plugin code (ADR-025 §3).
|
|
119
|
+
*/
|
|
120
|
+
export interface SpamCheckPort {
|
|
121
|
+
/** Classify a submission. Adapters: local heuristic (now) / external service (plausible next). */
|
|
122
|
+
check(submission: CommentSubmission, optional?: Record<string, never>): Promise<SpamVerdict>;
|
|
123
|
+
/** Feed a moderator ham/spam correction back to the classifier; no-op locally. */
|
|
124
|
+
report?(required: {
|
|
125
|
+
workspaceId: UUID;
|
|
126
|
+
comment: CommentRecord;
|
|
127
|
+
verdict: "ham" | "spam";
|
|
128
|
+
}, optional?: Record<string, never>): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Core-mediated public-submission boundary. The single entry point for visitor-authored comments;
|
|
132
|
+
* enforces rate-limit, honeypot, size/link caps, sanitization, and spam classification before a
|
|
133
|
+
* `pending`/`spam` row is written. Mirrors ADR-027's `MediaIngressPolicy` as one policy object.
|
|
134
|
+
*/
|
|
135
|
+
export interface CommentIngressPolicy {
|
|
136
|
+
submit(submission: CommentSubmission, optional?: Record<string, never>): Promise<CommentIngressResult>;
|
|
137
|
+
}
|
|
138
|
+
export type CommentIngressResult = {
|
|
139
|
+
ok: true;
|
|
140
|
+
comment: CommentRecord;
|
|
141
|
+
autoClassified: CommentStatus;
|
|
142
|
+
} | {
|
|
143
|
+
ok: false;
|
|
144
|
+
reason: CommentIngressRejection;
|
|
145
|
+
};
|
|
146
|
+
/** Fail-closed rejection reasons (spam is stored silently, not rejected — no oracle for spammers). */
|
|
147
|
+
export type CommentIngressRejection = "comments-disabled" | "entry-not-found" | "entry-closed" | "parent-not-found" | "max-depth-exceeded" | "rate-limited" | "body-too-large" | "too-many-links" | "honeypot-tripped" | "invalid";
|
|
148
|
+
/**
|
|
149
|
+
* Typed hook points the Comments plugin DECLARES (anti-hook-soup: declared owner + signature,
|
|
150
|
+
* ADR-009 §3 / §3-kernel). Handlers are async, serializable-only, with explicit priority +
|
|
151
|
+
* deterministic order + fail-closed behavior (ADR-024 §7). Spam adapters attach at `beforeSubmit`.
|
|
152
|
+
*/
|
|
153
|
+
export interface CommentHookPoints {
|
|
154
|
+
/** Filter: transform or veto a submission pre-persist. Returning `reject` drops it fail-closed. */
|
|
155
|
+
"comments.beforeSubmit": (payload: {
|
|
156
|
+
submission: CommentSubmission;
|
|
157
|
+
}) => Promise<{
|
|
158
|
+
submission: CommentSubmission;
|
|
159
|
+
} | {
|
|
160
|
+
reject: CommentIngressRejection;
|
|
161
|
+
}>;
|
|
162
|
+
/** Action: a comment's moderation status changed (post-commit notification hook). */
|
|
163
|
+
"comments.statusChanged": (payload: {
|
|
164
|
+
workspaceId: UUID;
|
|
165
|
+
commentId: UUID;
|
|
166
|
+
fromStatus: CommentStatus | null;
|
|
167
|
+
toStatus: CommentStatus;
|
|
168
|
+
}) => Promise<void>;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Domain events emitted to the outbox for async side effects (ADR-009 §2): notify the entry
|
|
172
|
+
* author, invalidate the rendered page cache, feed search/AI memory. Serializable envelopes,
|
|
173
|
+
* workspace-stamped (ADR-007). Names are frozen strings for subscribers.
|
|
174
|
+
*/
|
|
175
|
+
export type CommentEventName = "comments.submitted" | "comments.approved" | "comments.marked_spam" | "comments.trashed" | "comments.purged";
|
|
176
|
+
export interface CommentEventPayload {
|
|
177
|
+
commentId: UUID;
|
|
178
|
+
entryId: UUID;
|
|
179
|
+
status: CommentStatus;
|
|
180
|
+
}
|
|
181
|
+
export type CommentDomainEvent = DomainEvent<CommentEventPayload> & {
|
|
182
|
+
name: CommentEventName;
|
|
183
|
+
};
|
|
184
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CommentRepoPort } from "./ports.js";
|
|
2
|
+
import type { CommentRecord, CommentStatus, CommentThreadNode, ModerationLogEntry, ModerationQueuePage } from "./types.js";
|
|
3
|
+
export declare class InMemoryCommentRepo implements CommentRepoPort {
|
|
4
|
+
private comments;
|
|
5
|
+
private log;
|
|
6
|
+
constructor({ initialComments, initialLog }?: {
|
|
7
|
+
initialComments?: CommentRecord[];
|
|
8
|
+
initialLog?: ModerationLogEntry[];
|
|
9
|
+
}, _optional?: Record<string, never>);
|
|
10
|
+
findById(required: {
|
|
11
|
+
workspaceId: string;
|
|
12
|
+
id: string;
|
|
13
|
+
}, _optional?: Record<string, never>): Promise<CommentRecord | null>;
|
|
14
|
+
/**
|
|
15
|
+
* Direct upsert of one row. NOT on {@link CommentRepoPort} — the durable half flips its marker
|
|
16
|
+
* with column SQL and needs no such method. This exists so the hermetic composition root can
|
|
17
|
+
* drive a record-store `TrashAdapter` over this double (`server/runtime/composition/app.ts`), the
|
|
18
|
+
* same way the in-memory redirect repo's `insertRedirect` is used there.
|
|
19
|
+
*
|
|
20
|
+
* @complexity O(n) over the held rows.
|
|
21
|
+
*/
|
|
22
|
+
save(record: CommentRecord, _optional?: Record<string, never>): Promise<void>;
|
|
23
|
+
listThreadForEntry(required: {
|
|
24
|
+
workspaceId: string;
|
|
25
|
+
entryId: string;
|
|
26
|
+
includeStatuses?: readonly CommentStatus[];
|
|
27
|
+
}, _optional?: Record<string, never>): Promise<CommentThreadNode[]>;
|
|
28
|
+
listModerationQueue(required: {
|
|
29
|
+
workspaceId: string;
|
|
30
|
+
status: CommentStatus;
|
|
31
|
+
limit: number;
|
|
32
|
+
cursor?: string | null;
|
|
33
|
+
}, _optional?: Record<string, never>): Promise<ModerationQueuePage>;
|
|
34
|
+
countByStatus(required: {
|
|
35
|
+
workspaceId: string;
|
|
36
|
+
entryId?: string;
|
|
37
|
+
status: CommentStatus;
|
|
38
|
+
}, _optional?: Record<string, never>): Promise<number>;
|
|
39
|
+
/** Copies the comment and optional audit row synchronously so they appear atomically.
|
|
40
|
+
* @complexity O(1) time and space for fixed-shape rows.
|
|
41
|
+
* @example repo.create({ record }, { submitLog })
|
|
42
|
+
*/
|
|
43
|
+
create({ record }: {
|
|
44
|
+
record: CommentRecord;
|
|
45
|
+
}, { submitLog }?: {
|
|
46
|
+
submitLog?: ModerationLogEntry;
|
|
47
|
+
}): Promise<void>;
|
|
48
|
+
listModerationLog(required: {
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
commentId: string;
|
|
51
|
+
}, _optional?: Record<string, never>): Promise<ModerationLogEntry[]>;
|
|
52
|
+
applyModeration(required: {
|
|
53
|
+
workspaceId: string;
|
|
54
|
+
id: string;
|
|
55
|
+
expectedVersion: number;
|
|
56
|
+
action: ModerationLogEntry["action"];
|
|
57
|
+
toStatus: CommentStatus;
|
|
58
|
+
actorPrincipalId: string;
|
|
59
|
+
note: string | null;
|
|
60
|
+
at: string;
|
|
61
|
+
}, _optional?: Record<string, never>): ReturnType<CommentRepoPort["applyModeration"]>;
|
|
62
|
+
purge(required: {
|
|
63
|
+
workspaceId: string;
|
|
64
|
+
id: string;
|
|
65
|
+
actorPrincipalId: string;
|
|
66
|
+
note: string | null;
|
|
67
|
+
at: string;
|
|
68
|
+
}, _optional?: Record<string, never>): ReturnType<CommentRepoPort["purge"]>;
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=repo.memory.d.ts.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file In-memory `CommentRepoPort` adapter (ADR-006 rule-of-two "test/dev" half;
|
|
3
|
+
* `repo.sqlite.ts` is the durable half).
|
|
4
|
+
*/
|
|
5
|
+
import { decodeKeysetCursor, encodeKeysetCursor } from "../core/keyset-cursor.js";
|
|
6
|
+
function buildThread(comments, parentId) {
|
|
7
|
+
return comments
|
|
8
|
+
.filter((c) => c.parentId === parentId)
|
|
9
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
|
|
10
|
+
.map((comment) => ({ comment, replies: buildThread(comments, comment.id) }));
|
|
11
|
+
}
|
|
12
|
+
export class InMemoryCommentRepo {
|
|
13
|
+
comments;
|
|
14
|
+
log;
|
|
15
|
+
constructor({ initialComments = [], initialLog = [] } = {}, _optional = {}) {
|
|
16
|
+
this.comments = [...initialComments];
|
|
17
|
+
this.log = [...initialLog];
|
|
18
|
+
}
|
|
19
|
+
async findById(required, _optional = {}) {
|
|
20
|
+
return this.comments.find((c) => c.workspaceId === required.workspaceId && c.id === required.id) ?? null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Direct upsert of one row. NOT on {@link CommentRepoPort} — the durable half flips its marker
|
|
24
|
+
* with column SQL and needs no such method. This exists so the hermetic composition root can
|
|
25
|
+
* drive a record-store `TrashAdapter` over this double (`server/runtime/composition/app.ts`), the
|
|
26
|
+
* same way the in-memory redirect repo's `insertRedirect` is used there.
|
|
27
|
+
*
|
|
28
|
+
* @complexity O(n) over the held rows.
|
|
29
|
+
*/
|
|
30
|
+
async save(record, _optional = {}) {
|
|
31
|
+
const index = this.comments.findIndex((c) => c.workspaceId === record.workspaceId && c.id === record.id);
|
|
32
|
+
if (index === -1)
|
|
33
|
+
this.comments.push(record);
|
|
34
|
+
else
|
|
35
|
+
this.comments[index] = record;
|
|
36
|
+
}
|
|
37
|
+
async listThreadForEntry(required, _optional = {}) {
|
|
38
|
+
const statuses = required.includeStatuses ?? ["approved"];
|
|
39
|
+
const scoped = this.comments.filter((c) => c.workspaceId === required.workspaceId && c.entryId === required.entryId && statuses.includes(c.status));
|
|
40
|
+
return buildThread(scoped, null);
|
|
41
|
+
}
|
|
42
|
+
async listModerationQueue(required, _optional = {}) {
|
|
43
|
+
const scoped = this.comments
|
|
44
|
+
.filter((c) => c.workspaceId === required.workspaceId && c.status === required.status)
|
|
45
|
+
.sort((a, b) => (a.createdAt === b.createdAt ? a.id.localeCompare(b.id) : a.createdAt.localeCompare(b.createdAt)));
|
|
46
|
+
// Same keyset contract as `repo.ts`: the cursor carries the last row's position itself, so the
|
|
47
|
+
// page resumes after it even if that comment has since left this status or been purged, and a
|
|
48
|
+
// malformed cursor is refused.
|
|
49
|
+
const marker = required.cursor ? decodeKeysetCursor({ cursor: required.cursor }) : undefined;
|
|
50
|
+
const startIndex = marker
|
|
51
|
+
? scoped.filter((c) => c.createdAt < marker.createdAt || (c.createdAt === marker.createdAt && c.id.localeCompare(marker.id) <= 0)).length
|
|
52
|
+
: 0;
|
|
53
|
+
const page = scoped.slice(startIndex, startIndex + required.limit);
|
|
54
|
+
const last = page[page.length - 1];
|
|
55
|
+
const nextCursor = startIndex + required.limit < scoped.length && last ? encodeKeysetCursor(last) : null;
|
|
56
|
+
return { items: page, nextCursor };
|
|
57
|
+
}
|
|
58
|
+
async countByStatus(required, _optional = {}) {
|
|
59
|
+
return this.comments.filter((c) => c.workspaceId === required.workspaceId && c.status === required.status && (required.entryId === undefined || c.entryId === required.entryId)).length;
|
|
60
|
+
}
|
|
61
|
+
/** Copies the comment and optional audit row synchronously so they appear atomically.
|
|
62
|
+
* @complexity O(1) time and space for fixed-shape rows.
|
|
63
|
+
* @example repo.create({ record }, { submitLog })
|
|
64
|
+
*/
|
|
65
|
+
async create({ record }, { submitLog } = {}) {
|
|
66
|
+
this.comments.push({ ...record });
|
|
67
|
+
if (submitLog)
|
|
68
|
+
this.log.push({ ...submitLog });
|
|
69
|
+
}
|
|
70
|
+
async listModerationLog(required, _optional = {}) {
|
|
71
|
+
return this.log
|
|
72
|
+
.filter((entry) => entry.workspaceId === required.workspaceId && entry.commentId === required.commentId)
|
|
73
|
+
.sort((a, b) => (a.at === b.at ? a.id.localeCompare(b.id) : a.at.localeCompare(b.at)));
|
|
74
|
+
}
|
|
75
|
+
async applyModeration(required, _optional = {}) {
|
|
76
|
+
const index = this.comments.findIndex((c) => c.workspaceId === required.workspaceId && c.id === required.id);
|
|
77
|
+
if (index === -1)
|
|
78
|
+
return { ok: false, reason: "not-found" };
|
|
79
|
+
const current = this.comments[index];
|
|
80
|
+
if (current.version !== required.expectedVersion) {
|
|
81
|
+
return { ok: false, reason: "conflict", currentVersion: current.version };
|
|
82
|
+
}
|
|
83
|
+
const updated = { ...current, status: required.toStatus, updatedAt: required.at, version: current.version + 1 };
|
|
84
|
+
const logEntry = {
|
|
85
|
+
id: `modlog-${required.id}-${this.log.length}`,
|
|
86
|
+
workspaceId: required.workspaceId,
|
|
87
|
+
commentId: required.id,
|
|
88
|
+
actorPrincipalId: required.actorPrincipalId,
|
|
89
|
+
action: required.action,
|
|
90
|
+
fromStatus: current.status,
|
|
91
|
+
toStatus: required.toStatus,
|
|
92
|
+
at: required.at,
|
|
93
|
+
note: required.note,
|
|
94
|
+
};
|
|
95
|
+
this.comments[index] = updated;
|
|
96
|
+
this.log.push(logEntry);
|
|
97
|
+
return { ok: true, record: updated, log: logEntry };
|
|
98
|
+
}
|
|
99
|
+
async purge(required, _optional = {}) {
|
|
100
|
+
const index = this.comments.findIndex((c) => c.workspaceId === required.workspaceId && c.id === required.id);
|
|
101
|
+
if (index === -1)
|
|
102
|
+
return { ok: false, reason: "not-found" };
|
|
103
|
+
const current = this.comments[index];
|
|
104
|
+
const logEntry = {
|
|
105
|
+
id: `modlog-${required.id}-${this.log.length}`,
|
|
106
|
+
workspaceId: required.workspaceId,
|
|
107
|
+
commentId: required.id,
|
|
108
|
+
actorPrincipalId: required.actorPrincipalId,
|
|
109
|
+
action: "purge",
|
|
110
|
+
fromStatus: current.status,
|
|
111
|
+
toStatus: current.status,
|
|
112
|
+
at: required.at,
|
|
113
|
+
note: required.note,
|
|
114
|
+
};
|
|
115
|
+
this.comments.splice(index, 1);
|
|
116
|
+
this.log.push(logEntry);
|
|
117
|
+
return { ok: true, log: logEntry };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=repo.memory.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Strips tags and normalizes whitespace; returns plain text without mutating the input.
|
|
2
|
+
* @complexity O(n) time and space for n input characters.
|
|
3
|
+
* @example sanitizeCommentBody({ raw: "<b>Hello</b>" }, {})
|
|
4
|
+
*/
|
|
5
|
+
export declare function sanitizeCommentBody({ raw }: {
|
|
6
|
+
raw: string;
|
|
7
|
+
}, _optional?: Record<string, never>): string;
|
|
8
|
+
/** Counts HTTP(S) links in text; performs no I/O.
|
|
9
|
+
* @complexity O(n) time and space for n input characters.
|
|
10
|
+
* @example countLinks({ text: "https://example.com" }, {})
|
|
11
|
+
*/
|
|
12
|
+
export declare function countLinks({ text }: {
|
|
13
|
+
text: string;
|
|
14
|
+
}, _optional?: Record<string, never>): number;
|
|
15
|
+
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A comments-scoped body sanitizer (SPEC-033 Non-Goals — no shared core `text` library
|
|
3
|
+
* exists anywhere in this codebase for this to extract into; ADR-031 §2/§4's "core `text`
|
|
4
|
+
* library" reference is design-forward, not something this spec can reuse). Strips HTML tags
|
|
5
|
+
* entirely (comments render as plain text, never `dangerouslySetInnerHTML`-equivalent raw HTML on
|
|
6
|
+
* any read path this spec builds) and collapses excess whitespace. Bounded, non-Turing-complete —
|
|
7
|
+
* a fixed regex pass, not a parser.
|
|
8
|
+
*/
|
|
9
|
+
const TAG_PATTERN = /<[^>]*>/g;
|
|
10
|
+
const EXCESS_WHITESPACE = /[ \t]{2,}/g;
|
|
11
|
+
const EXCESS_BLANK_LINES = /\n{3,}/g;
|
|
12
|
+
/** Strips tags and normalizes whitespace; returns plain text without mutating the input.
|
|
13
|
+
* @complexity O(n) time and space for n input characters.
|
|
14
|
+
* @example sanitizeCommentBody({ raw: "<b>Hello</b>" }, {})
|
|
15
|
+
*/
|
|
16
|
+
export function sanitizeCommentBody({ raw }, _optional = {}) {
|
|
17
|
+
return raw
|
|
18
|
+
.replace(TAG_PATTERN, "")
|
|
19
|
+
.replace(EXCESS_WHITESPACE, " ")
|
|
20
|
+
.replace(EXCESS_BLANK_LINES, "\n\n")
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
23
|
+
const LINK_PATTERN = /https?:\/\/\S+/gi;
|
|
24
|
+
/** Counts HTTP(S) links in text; performs no I/O.
|
|
25
|
+
* @complexity O(n) time and space for n input characters.
|
|
26
|
+
* @example countLinks({ text: "https://example.com" }, {})
|
|
27
|
+
*/
|
|
28
|
+
export function countLinks({ text }, _optional = {}) {
|
|
29
|
+
return (text.match(LINK_PATTERN) ?? []).length;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=sanitize.js.map
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SpamCheckPort } from "./ports.js";
|
|
2
|
+
import type { CommentSubmission, SpamVerdict } from "./types.js";
|
|
3
|
+
export declare class HeuristicSpamCheck implements SpamCheckPort {
|
|
4
|
+
check(submission: CommentSubmission, _optional?: Record<string, never>): Promise<SpamVerdict>;
|
|
5
|
+
/** No feedback loop to update locally — this adapter has no model to correct. */
|
|
6
|
+
report(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=spam.heuristic.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file ADR-031 §5 — the local heuristic `SpamCheckPort` adapter (ADR-006 rule-of-two "built
|
|
3
|
+
* now" half; an external service adapter remains a plausible second adapter).
|
|
4
|
+
* No network I/O.
|
|
5
|
+
*
|
|
6
|
+
* Signals, combined into a `[0,1]` score:
|
|
7
|
+
* - link density in the body (a common comment-spam signal — many URLs per short body);
|
|
8
|
+
* - a small keyword list of common spam phrases;
|
|
9
|
+
* - an implausibly long body (wall-of-text link farms);
|
|
10
|
+
* - an implausibly short body with a link (drive-by link drops).
|
|
11
|
+
*/
|
|
12
|
+
import { countLinks } from "./sanitize.js";
|
|
13
|
+
const SPAM_KEYWORDS = [
|
|
14
|
+
"viagra",
|
|
15
|
+
"cialis",
|
|
16
|
+
"casino",
|
|
17
|
+
"crypto giveaway",
|
|
18
|
+
"make money fast",
|
|
19
|
+
"click here now",
|
|
20
|
+
"weight loss miracle",
|
|
21
|
+
"bit.ly",
|
|
22
|
+
];
|
|
23
|
+
const LONG_BODY_THRESHOLD = 4000;
|
|
24
|
+
const SHORT_BODY_WITH_LINK_THRESHOLD = 40;
|
|
25
|
+
function keywordHits(bodyLower) {
|
|
26
|
+
return SPAM_KEYWORDS.reduce((count, kw) => (bodyLower.includes(kw) ? count + 1 : count), 0);
|
|
27
|
+
}
|
|
28
|
+
export class HeuristicSpamCheck {
|
|
29
|
+
async check(submission, _optional = {}) {
|
|
30
|
+
const body = submission.bodyRaw;
|
|
31
|
+
const bodyLower = body.toLowerCase();
|
|
32
|
+
const links = countLinks({ text: body }, {});
|
|
33
|
+
const keywords = keywordHits(bodyLower);
|
|
34
|
+
let score = 0;
|
|
35
|
+
// Link density: each link past the first adds risk; heavily link-laden bodies are the
|
|
36
|
+
// single strongest comment-spam signal.
|
|
37
|
+
score += Math.min(0.6, Math.max(0, links - 1) * 0.2);
|
|
38
|
+
// Any spam keyword is a strong, near-conclusive signal.
|
|
39
|
+
score += Math.min(0.7, keywords * 0.35);
|
|
40
|
+
// A short body that still carries a link reads as a drive-by drop.
|
|
41
|
+
if (body.length < SHORT_BODY_WITH_LINK_THRESHOLD && links > 0)
|
|
42
|
+
score += 0.3;
|
|
43
|
+
// An implausibly long body padded with links/keywords.
|
|
44
|
+
if (body.length > LONG_BODY_THRESHOLD && (links > 0 || keywords > 0))
|
|
45
|
+
score += 0.2;
|
|
46
|
+
score = Math.min(1, score);
|
|
47
|
+
return { isSpam: score >= 0.5, score, provider: "heuristic" };
|
|
48
|
+
}
|
|
49
|
+
/** No feedback loop to update locally — this adapter has no model to correct. */
|
|
50
|
+
async report() {
|
|
51
|
+
// no-op — documented in ports.ts as the local adapter's expected behavior.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=spam.heuristic.js.map
|