@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.
Files changed (261) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +14 -8
  3. package/dist/comments/errors.d.ts +23 -0
  4. package/dist/comments/errors.js +25 -0
  5. package/dist/comments/express/index.d.ts +2 -0
  6. package/dist/comments/express/index.js +2 -0
  7. package/dist/comments/express/submit-handler.d.ts +30 -0
  8. package/dist/comments/express/submit-handler.js +85 -0
  9. package/dist/comments/hooks.d.ts +39 -0
  10. package/dist/comments/hooks.js +41 -0
  11. package/dist/comments/index.d.ts +11 -0
  12. package/dist/comments/index.js +11 -0
  13. package/dist/comments/ingress.d.ts +57 -0
  14. package/dist/comments/ingress.js +173 -0
  15. package/dist/comments/module.d.ts +94 -0
  16. package/dist/comments/module.js +73 -0
  17. package/dist/comments/ports.d.ts +184 -0
  18. package/dist/comments/ports.js +2 -0
  19. package/dist/comments/repo.memory.d.ts +70 -0
  20. package/dist/comments/repo.memory.js +120 -0
  21. package/dist/comments/sanitize.d.ts +15 -0
  22. package/dist/comments/sanitize.js +31 -0
  23. package/dist/comments/spam.heuristic.d.ts +8 -0
  24. package/dist/comments/spam.heuristic.js +54 -0
  25. package/dist/comments/sql/index.d.ts +3 -0
  26. package/dist/comments/sql/index.js +3 -0
  27. package/dist/comments/sql/repo.d.ts +77 -0
  28. package/dist/comments/sql/repo.js +190 -0
  29. package/dist/comments/sql/repo.rows.d.ts +49 -0
  30. package/dist/comments/sql/repo.rows.js +71 -0
  31. package/dist/comments/types.d.ts +187 -0
  32. package/dist/comments/types.js +39 -0
  33. package/dist/comments/write-service.d.ts +121 -0
  34. package/dist/comments/write-service.js +144 -0
  35. package/dist/core/commands/command.d.ts +2 -9
  36. package/dist/core/commands/command.js +2 -9
  37. package/dist/core/index.d.ts +2 -1
  38. package/dist/core/index.js +1 -0
  39. package/dist/core/keyset-cursor.d.ts +25 -0
  40. package/dist/core/keyset-cursor.js +20 -0
  41. package/dist/core/ports.d.ts +30 -0
  42. package/dist/core/ports.js +0 -1
  43. package/dist/core/tools/args.d.ts +1 -46
  44. package/dist/core/tools/args.js +1 -44
  45. package/dist/forms/delete-submission.d.ts +38 -0
  46. package/dist/forms/delete-submission.js +25 -0
  47. package/dist/forms/duplicate-slug.d.ts +81 -0
  48. package/dist/forms/duplicate-slug.js +115 -0
  49. package/dist/forms/errors.d.ts +53 -0
  50. package/dist/forms/errors.js +25 -0
  51. package/dist/forms/express/handler.d.ts +59 -0
  52. package/dist/forms/express/handler.js +217 -0
  53. package/dist/forms/express/index.d.ts +2 -0
  54. package/dist/forms/express/index.js +2 -0
  55. package/dist/forms/fields-json.d.ts +14 -0
  56. package/dist/forms/fields-json.js +11 -0
  57. package/dist/forms/forms.d.ts +59 -0
  58. package/dist/forms/forms.js +213 -0
  59. package/dist/forms/html/authoring-adapter.d.ts +34 -0
  60. package/dist/forms/html/authoring-adapter.js +48 -0
  61. package/dist/forms/html/html-authoring.d.ts +12 -0
  62. package/dist/forms/html/html-authoring.js +133 -0
  63. package/dist/forms/html/html-submission-adapter.d.ts +10 -0
  64. package/dist/forms/html/html-submission-adapter.js +15 -0
  65. package/dist/forms/html/index.d.ts +4 -0
  66. package/dist/forms/html/index.js +4 -0
  67. package/dist/forms/html-render.d.ts +33 -0
  68. package/dist/forms/html-render.js +31 -0
  69. package/dist/forms/index.d.ts +17 -0
  70. package/dist/forms/index.js +17 -0
  71. package/dist/forms/notify-subscriber.d.ts +32 -0
  72. package/dist/forms/notify-subscriber.js +60 -0
  73. package/dist/forms/ports.d.ts +168 -0
  74. package/dist/forms/ports.js +2 -0
  75. package/dist/forms/rate-limit-key.d.ts +8 -0
  76. package/dist/forms/rate-limit-key.js +7 -0
  77. package/dist/forms/repo.memory.d.ts +120 -0
  78. package/dist/forms/repo.memory.js +188 -0
  79. package/dist/forms/sql/index.d.ts +5 -0
  80. package/dist/forms/sql/index.js +5 -0
  81. package/dist/forms/sql/repo.d.ts +94 -0
  82. package/dist/forms/sql/repo.js +175 -0
  83. package/dist/forms/sql/repo.rows.d.ts +33 -0
  84. package/dist/forms/sql/repo.rows.js +64 -0
  85. package/dist/forms/sql/submission-ip-retention-repo.d.ts +9 -0
  86. package/dist/forms/sql/submission-ip-retention-repo.js +40 -0
  87. package/dist/forms/sql/tables.d.ts +32 -0
  88. package/dist/forms/sql/tables.js +2 -0
  89. package/dist/forms/submission-attempts.d.ts +70 -0
  90. package/dist/forms/submission-attempts.js +70 -0
  91. package/dist/forms/submission-email-validation.d.ts +8 -0
  92. package/dist/forms/submission-email-validation.js +34 -0
  93. package/dist/forms/submission-ip-retention.d.ts +30 -0
  94. package/dist/forms/submission-ip-retention.js +27 -0
  95. package/dist/forms/submit-service.d.ts +78 -0
  96. package/dist/forms/submit-service.js +109 -0
  97. package/dist/forms/types.d.ts +49 -0
  98. package/dist/forms/types.js +2 -0
  99. package/dist/forms/write-service.d.ts +83 -0
  100. package/dist/forms/write-service.js +271 -0
  101. package/dist/media/index.d.ts +0 -3
  102. package/dist/media/index.js +1 -4
  103. package/dist/media/node.d.ts +5 -0
  104. package/dist/media/node.js +6 -0
  105. package/dist/redirects/capture.d.ts +80 -0
  106. package/dist/redirects/capture.js +66 -0
  107. package/dist/redirects/express/index.d.ts +27 -0
  108. package/dist/redirects/express/index.js +40 -0
  109. package/dist/redirects/hit-sink.d.ts +56 -0
  110. package/dist/redirects/hit-sink.js +44 -0
  111. package/dist/redirects/host-config/index.d.ts +40 -0
  112. package/dist/redirects/host-config/index.js +145 -0
  113. package/dist/redirects/index.d.ts +14 -0
  114. package/dist/redirects/index.js +14 -0
  115. package/dist/redirects/matcher.d.ts +58 -0
  116. package/dist/redirects/matcher.js +144 -0
  117. package/dist/redirects/order.d.ts +30 -0
  118. package/dist/redirects/order.js +26 -0
  119. package/dist/redirects/ports.d.ts +242 -0
  120. package/dist/redirects/ports.internal.d.ts +57 -0
  121. package/dist/redirects/ports.internal.js +14 -0
  122. package/dist/redirects/ports.js +2 -0
  123. package/dist/redirects/redirects.d.ts +226 -0
  124. package/dist/redirects/redirects.js +590 -0
  125. package/dist/redirects/referrer-alias.d.ts +31 -0
  126. package/dist/redirects/referrer-alias.js +33 -0
  127. package/dist/redirects/repo.memory.d.ts +51 -0
  128. package/dist/redirects/repo.memory.js +98 -0
  129. package/dist/redirects/reserved-destination.d.ts +58 -0
  130. package/dist/redirects/reserved-destination.js +59 -0
  131. package/dist/redirects/resolver.d.ts +100 -0
  132. package/dist/redirects/resolver.js +87 -0
  133. package/dist/redirects/spa/index.d.ts +30 -0
  134. package/dist/redirects/spa/index.js +27 -0
  135. package/dist/redirects/sql/index.d.ts +4 -0
  136. package/dist/redirects/sql/index.js +4 -0
  137. package/dist/redirects/sql/repo.d.ts +82 -0
  138. package/dist/redirects/sql/repo.js +143 -0
  139. package/dist/redirects/sql/repo.rows.d.ts +49 -0
  140. package/dist/redirects/sql/repo.rows.js +79 -0
  141. package/dist/redirects/sql/row-types.d.ts +34 -0
  142. package/dist/redirects/sql/row-types.js +2 -0
  143. package/dist/redirects/target-gate.d.ts +30 -0
  144. package/dist/redirects/target-gate.js +85 -0
  145. package/dist/redirects/types.d.ts +213 -0
  146. package/dist/redirects/types.js +42 -0
  147. package/dist/seo/absolute-url.d.ts +58 -0
  148. package/dist/seo/absolute-url.js +117 -0
  149. package/dist/seo/dom/index.d.ts +14 -0
  150. package/dist/seo/dom/index.js +52 -0
  151. package/dist/seo/errors.d.ts +35 -0
  152. package/dist/seo/errors.js +35 -0
  153. package/dist/seo/feed.d.ts +49 -0
  154. package/dist/seo/feed.js +61 -0
  155. package/dist/seo/index.d.ts +13 -0
  156. package/dist/seo/index.js +13 -0
  157. package/dist/seo/media.d.ts +57 -0
  158. package/dist/seo/media.js +134 -0
  159. package/dist/seo/page-head.d.ts +18 -0
  160. package/dist/seo/page-head.js +114 -0
  161. package/dist/seo/ports.d.ts +221 -0
  162. package/dist/seo/ports.js +27 -0
  163. package/dist/seo/seo.d.ts +73 -0
  164. package/dist/seo/seo.js +230 -0
  165. package/dist/seo/sitemap.d.ts +139 -0
  166. package/dist/seo/sitemap.js +217 -0
  167. package/dist/seo/types.d.ts +278 -0
  168. package/dist/seo/types.js +2 -0
  169. package/dist/seo/write-service.d.ts +79 -0
  170. package/dist/seo/write-service.js +273 -0
  171. package/dist/seo/xml-escape.d.ts +16 -0
  172. package/dist/seo/xml-escape.js +19 -0
  173. package/dist/settings/express/cms-adapter.d.ts +20 -0
  174. package/dist/settings/express/cms-adapter.js +124 -0
  175. package/dist/settings/express/contracts.d.ts +135 -0
  176. package/dist/settings/express/contracts.js +2 -0
  177. package/dist/settings/express/http.d.ts +10 -0
  178. package/dist/settings/express/http.js +211 -0
  179. package/dist/settings/express/index.d.ts +5 -0
  180. package/dist/settings/express/index.js +5 -0
  181. package/dist/settings/express/shared.d.ts +68 -0
  182. package/dist/settings/express/shared.js +54 -0
  183. package/dist/settings/express/stream.d.ts +24 -0
  184. package/dist/settings/express/stream.js +147 -0
  185. package/dist/settings/index.d.ts +3 -38
  186. package/dist/settings/index.js +3 -35
  187. package/dist/taxonomy/write-service.d.ts +2 -30
  188. package/dist/widgets/authorize-helper.d.ts +42 -0
  189. package/dist/widgets/authorize-helper.js +16 -0
  190. package/dist/widgets/concurrency.d.ts +12 -0
  191. package/dist/widgets/concurrency.js +44 -0
  192. package/dist/widgets/config-validation.d.ts +34 -0
  193. package/dist/widgets/config-validation.js +125 -0
  194. package/dist/widgets/embed-service.d.ts +133 -0
  195. package/dist/widgets/embed-service.js +460 -0
  196. package/dist/widgets/embed-validation.d.ts +23 -0
  197. package/dist/widgets/embed-validation.js +38 -0
  198. package/dist/widgets/entry-payload.d.ts +103 -0
  199. package/dist/widgets/entry-payload.js +160 -0
  200. package/dist/widgets/errors.d.ts +136 -0
  201. package/dist/widgets/errors.js +136 -0
  202. package/dist/widgets/host-ports.d.ts +149 -0
  203. package/dist/widgets/host-ports.js +2 -0
  204. package/dist/widgets/html/html-embeds.d.ts +247 -0
  205. package/dist/widgets/html/html-embeds.js +408 -0
  206. package/dist/widgets/html/index.d.ts +5 -0
  207. package/dist/widgets/html/index.js +4 -0
  208. package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
  209. package/dist/widgets/html/legacy-widget-markers.js +45 -0
  210. package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
  211. package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
  212. package/dist/widgets/index.d.ts +20 -0
  213. package/dist/widgets/index.js +19 -0
  214. package/dist/widgets/markers/html-attributes.d.ts +98 -0
  215. package/dist/widgets/markers/html-attributes.js +216 -0
  216. package/dist/widgets/markers/index.d.ts +3 -0
  217. package/dist/widgets/markers/index.js +3 -0
  218. package/dist/widgets/markers/marker.d.ts +496 -0
  219. package/dist/widgets/markers/marker.js +780 -0
  220. package/dist/widgets/page-embed-types.d.ts +42 -0
  221. package/dist/widgets/page-embed-types.js +42 -0
  222. package/dist/widgets/ports.d.ts +142 -0
  223. package/dist/widgets/ports.js +2 -0
  224. package/dist/widgets/read-service.d.ts +81 -0
  225. package/dist/widgets/read-service.js +79 -0
  226. package/dist/widgets/region-area-service.d.ts +110 -0
  227. package/dist/widgets/region-area-service.js +180 -0
  228. package/dist/widgets/registry.d.ts +217 -0
  229. package/dist/widgets/registry.js +172 -0
  230. package/dist/widgets/repo.memory.d.ts +41 -0
  231. package/dist/widgets/repo.memory.js +48 -0
  232. package/dist/widgets/resolver-service.d.ts +209 -0
  233. package/dist/widgets/resolver-service.js +909 -0
  234. package/dist/widgets/resolvers/contact-form.d.ts +26 -0
  235. package/dist/widgets/resolvers/contact-form.js +54 -0
  236. package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
  237. package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
  238. package/dist/widgets/resolvers/index.d.ts +65 -0
  239. package/dist/widgets/resolvers/index.js +155 -0
  240. package/dist/widgets/resolvers/menu.d.ts +42 -0
  241. package/dist/widgets/resolvers/menu.js +43 -0
  242. package/dist/widgets/resolvers/ports.d.ts +82 -0
  243. package/dist/widgets/resolvers/ports.js +2 -0
  244. package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
  245. package/dist/widgets/resolvers/recent-entries.js +165 -0
  246. package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
  247. package/dist/widgets/resolvers/taxonomy.js +34 -0
  248. package/dist/widgets/sql/index.d.ts +3 -0
  249. package/dist/widgets/sql/index.js +3 -0
  250. package/dist/widgets/sql/repo.d.ts +41 -0
  251. package/dist/widgets/sql/repo.js +82 -0
  252. package/dist/widgets/sql/tables.d.ts +13 -0
  253. package/dist/widgets/sql/tables.js +2 -0
  254. package/dist/widgets/types.d.ts +213 -0
  255. package/dist/widgets/types.js +17 -0
  256. package/dist/widgets/where-used.d.ts +41 -0
  257. package/dist/widgets/where-used.js +11 -0
  258. package/dist/widgets/write-service.d.ts +204 -0
  259. package/dist/widgets/write-service.js +332 -0
  260. package/dist/workspace/delete.d.ts +1 -1
  261. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ports.js.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
@@ -0,0 +1,3 @@
1
+ export * from "./repo.js";
2
+ export * from "./repo.rows.js";
3
+ //# sourceMappingURL=index.d.ts.map