@nexpress/core 0.1.0

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 (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/dist/audit-54XLVCWD.js +14 -0
  4. package/dist/audit-54XLVCWD.js.map +1 -0
  5. package/dist/auth.d.ts +640 -0
  6. package/dist/auth.js +94 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/can-YLUHRJAB.js +19 -0
  9. package/dist/can-YLUHRJAB.js.map +1 -0
  10. package/dist/chunk-2G264RCD.js +68 -0
  11. package/dist/chunk-2G264RCD.js.map +1 -0
  12. package/dist/chunk-2YDGE7YX.js +92 -0
  13. package/dist/chunk-2YDGE7YX.js.map +1 -0
  14. package/dist/chunk-473S4TER.js +538 -0
  15. package/dist/chunk-473S4TER.js.map +1 -0
  16. package/dist/chunk-4ZLMEKFX.js +18 -0
  17. package/dist/chunk-4ZLMEKFX.js.map +1 -0
  18. package/dist/chunk-55FU6WED.js +179 -0
  19. package/dist/chunk-55FU6WED.js.map +1 -0
  20. package/dist/chunk-6YI5K2TI.js +1959 -0
  21. package/dist/chunk-6YI5K2TI.js.map +1 -0
  22. package/dist/chunk-BHK3AD3Q.js +41 -0
  23. package/dist/chunk-BHK3AD3Q.js.map +1 -0
  24. package/dist/chunk-CRUQBZUF.js +39 -0
  25. package/dist/chunk-CRUQBZUF.js.map +1 -0
  26. package/dist/chunk-CTSQ7BRI.js +175 -0
  27. package/dist/chunk-CTSQ7BRI.js.map +1 -0
  28. package/dist/chunk-DK2JBJH7.js +81 -0
  29. package/dist/chunk-DK2JBJH7.js.map +1 -0
  30. package/dist/chunk-DP2PREDU.js +597 -0
  31. package/dist/chunk-DP2PREDU.js.map +1 -0
  32. package/dist/chunk-EQ2Z3KMD.js +24 -0
  33. package/dist/chunk-EQ2Z3KMD.js.map +1 -0
  34. package/dist/chunk-FZ7O6DWI.js +305 -0
  35. package/dist/chunk-FZ7O6DWI.js.map +1 -0
  36. package/dist/chunk-ISLYFQWL.js +1270 -0
  37. package/dist/chunk-ISLYFQWL.js.map +1 -0
  38. package/dist/chunk-JJL74ZPK.js +68 -0
  39. package/dist/chunk-JJL74ZPK.js.map +1 -0
  40. package/dist/chunk-JKXAPSU4.js +24 -0
  41. package/dist/chunk-JKXAPSU4.js.map +1 -0
  42. package/dist/chunk-KU5M27ZC.js +24 -0
  43. package/dist/chunk-KU5M27ZC.js.map +1 -0
  44. package/dist/chunk-LSHHRDVR.js +34 -0
  45. package/dist/chunk-LSHHRDVR.js.map +1 -0
  46. package/dist/chunk-M43PGOQY.js +715 -0
  47. package/dist/chunk-M43PGOQY.js.map +1 -0
  48. package/dist/chunk-MEJAHXIO.js +150 -0
  49. package/dist/chunk-MEJAHXIO.js.map +1 -0
  50. package/dist/chunk-NUCGHWCF.js +101 -0
  51. package/dist/chunk-NUCGHWCF.js.map +1 -0
  52. package/dist/chunk-OK5HOCQI.js +845 -0
  53. package/dist/chunk-OK5HOCQI.js.map +1 -0
  54. package/dist/chunk-OROPGO65.js +13 -0
  55. package/dist/chunk-OROPGO65.js.map +1 -0
  56. package/dist/chunk-PPAS4SZR.js +176 -0
  57. package/dist/chunk-PPAS4SZR.js.map +1 -0
  58. package/dist/chunk-PPBWRKO2.js +171 -0
  59. package/dist/chunk-PPBWRKO2.js.map +1 -0
  60. package/dist/chunk-PZ5AY32C.js +10 -0
  61. package/dist/chunk-PZ5AY32C.js.map +1 -0
  62. package/dist/chunk-QO7LAQZH.js +321 -0
  63. package/dist/chunk-QO7LAQZH.js.map +1 -0
  64. package/dist/chunk-QVJ2HCAX.js +225 -0
  65. package/dist/chunk-QVJ2HCAX.js.map +1 -0
  66. package/dist/chunk-RIPHIRPP.js +68 -0
  67. package/dist/chunk-RIPHIRPP.js.map +1 -0
  68. package/dist/chunk-S27S42QY.js +134 -0
  69. package/dist/chunk-S27S42QY.js.map +1 -0
  70. package/dist/chunk-SBCVAC2Z.js +40 -0
  71. package/dist/chunk-SBCVAC2Z.js.map +1 -0
  72. package/dist/chunk-TFJ4MKPH.js +694 -0
  73. package/dist/chunk-TFJ4MKPH.js.map +1 -0
  74. package/dist/chunk-THX3SHYA.js +75 -0
  75. package/dist/chunk-THX3SHYA.js.map +1 -0
  76. package/dist/chunk-UGQSQO5B.js +222 -0
  77. package/dist/chunk-UGQSQO5B.js.map +1 -0
  78. package/dist/chunk-V2UNHGAP.js +26 -0
  79. package/dist/chunk-V2UNHGAP.js.map +1 -0
  80. package/dist/chunk-VGTPQXNQ.js +2790 -0
  81. package/dist/chunk-VGTPQXNQ.js.map +1 -0
  82. package/dist/chunk-VNIHXQ7W.js +194 -0
  83. package/dist/chunk-VNIHXQ7W.js.map +1 -0
  84. package/dist/chunk-WV272MPW.js +31 -0
  85. package/dist/chunk-WV272MPW.js.map +1 -0
  86. package/dist/chunk-X5KKBOUS.js +26 -0
  87. package/dist/chunk-X5KKBOUS.js.map +1 -0
  88. package/dist/chunk-XANPEOJC.js +17 -0
  89. package/dist/chunk-XANPEOJC.js.map +1 -0
  90. package/dist/chunk-XPVQIHAQ.js +83 -0
  91. package/dist/chunk-XPVQIHAQ.js.map +1 -0
  92. package/dist/chunk-ZCINJSS4.js +75 -0
  93. package/dist/chunk-ZCINJSS4.js.map +1 -0
  94. package/dist/community.d.ts +1425 -0
  95. package/dist/community.js +206 -0
  96. package/dist/community.js.map +1 -0
  97. package/dist/config-2GDU7PCK.js +32 -0
  98. package/dist/config-2GDU7PCK.js.map +1 -0
  99. package/dist/context-MNZ4QXPC.js +16 -0
  100. package/dist/context-MNZ4QXPC.js.map +1 -0
  101. package/dist/db-schema.d.ts +4 -0
  102. package/dist/db-schema.js +102 -0
  103. package/dist/db-schema.js.map +1 -0
  104. package/dist/db.d.ts +7 -0
  105. package/dist/db.js +117 -0
  106. package/dist/db.js.map +1 -0
  107. package/dist/digest-SY42GQSU.js +17 -0
  108. package/dist/digest-SY42GQSU.js.map +1 -0
  109. package/dist/errors-5OS3S2J3.js +22 -0
  110. package/dist/errors-5OS3S2J3.js.map +1 -0
  111. package/dist/host-OBOI4MJK.js +51 -0
  112. package/dist/host-OBOI4MJK.js.map +1 -0
  113. package/dist/i18n.d.ts +301 -0
  114. package/dist/i18n.js +68 -0
  115. package/dist/i18n.js.map +1 -0
  116. package/dist/index-B6-_vr_m.d.ts +590 -0
  117. package/dist/index-CY55LC0u.d.ts +4722 -0
  118. package/dist/index-CeiTvwbp.d.ts +168 -0
  119. package/dist/index-XwP1ET8b.d.ts +61 -0
  120. package/dist/index.d.ts +2037 -0
  121. package/dist/index.js +2205 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/job-log-VZXWQUDK.js +24 -0
  124. package/dist/job-log-VZXWQUDK.js.map +1 -0
  125. package/dist/jobs.d.ts +4 -0
  126. package/dist/jobs.js +76 -0
  127. package/dist/jobs.js.map +1 -0
  128. package/dist/logger-DqGaOU_j.d.ts +29 -0
  129. package/dist/logger-S7REWDNE.js +16 -0
  130. package/dist/logger-S7REWDNE.js.map +1 -0
  131. package/dist/media.d.ts +5 -0
  132. package/dist/media.js +41 -0
  133. package/dist/media.js.map +1 -0
  134. package/dist/mentions-2IHFVSHW.js +23 -0
  135. package/dist/mentions-2IHFVSHW.js.map +1 -0
  136. package/dist/mutes-EWAE5FZR.js +21 -0
  137. package/dist/mutes-EWAE5FZR.js.map +1 -0
  138. package/dist/notification-prefs-VPJDU7I6.js +21 -0
  139. package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
  140. package/dist/observability.d.ts +156 -0
  141. package/dist/observability.js +32 -0
  142. package/dist/observability.js.map +1 -0
  143. package/dist/profanity-adapter-NU2JQSLX.js +12 -0
  144. package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
  145. package/dist/queue-XE5BC75T.js +14 -0
  146. package/dist/queue-XE5BC75T.js.map +1 -0
  147. package/dist/rate-limit.d.ts +99 -0
  148. package/dist/rate-limit.js +14 -0
  149. package/dist/rate-limit.js.map +1 -0
  150. package/dist/registry-XIXDEPVI.js +31 -0
  151. package/dist/registry-XIXDEPVI.js.map +1 -0
  152. package/dist/reputation-JRL2YQHM.js +11 -0
  153. package/dist/reputation-JRL2YQHM.js.map +1 -0
  154. package/dist/routes.d.ts +43 -0
  155. package/dist/routes.js +12 -0
  156. package/dist/routes.js.map +1 -0
  157. package/dist/scheduled-CIQM57HT.js +20 -0
  158. package/dist/scheduled-CIQM57HT.js.map +1 -0
  159. package/dist/seo.d.ts +410 -0
  160. package/dist/seo.js +44 -0
  161. package/dist/seo.js.map +1 -0
  162. package/dist/settings-FOBIESPB.js +17 -0
  163. package/dist/settings-FOBIESPB.js.map +1 -0
  164. package/dist/spam-adapter-XX3G737Z.js +12 -0
  165. package/dist/spam-adapter-XX3G737Z.js.map +1 -0
  166. package/dist/strings-VAE47B2C.js +29 -0
  167. package/dist/strings-VAE47B2C.js.map +1 -0
  168. package/dist/templates-IFVJMCJ6.js +12 -0
  169. package/dist/templates-IFVJMCJ6.js.map +1 -0
  170. package/dist/types-TlsbXS0T.d.ts +871 -0
  171. package/package.json +129 -0
@@ -0,0 +1,1425 @@
1
+ import { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ import { F as NpPrincipal, e as NpAuthUser } from './types-TlsbXS0T.js';
3
+
4
+ /**
5
+ * Community role registry. Maps a role name + scope type to the
6
+ * capabilities a grant of that role unlocks. Plugins extend the registry
7
+ * via `registerCommunityRole(...)` (gated by the `members:write` or
8
+ * `community:moderate` capability — enforced at registration time, not
9
+ * here).
10
+ *
11
+ * The capability vocabulary is the single source of truth for "what
12
+ * actions exist in the community." `memberCan()` (../community/can.ts)
13
+ * looks up grants and matches their roles' capability lists against the
14
+ * requested action.
15
+ */
16
+ type CommunityScope = "site" | "category" | "collection" | "thread";
17
+ /**
18
+ * Action vocabulary. Adding new actions later is fine, but rename with
19
+ * care — built-in role definitions reference these literals and a
20
+ * silent typo widens permissions instead of narrowing them.
21
+ */
22
+ type CommunityCapability = "hide-comment" | "restore-comment" | "edit-any-comment" | "delete-any-comment" | "hide-thread" | "restore-thread" | "lock-thread" | "unlock-thread" | "pin-thread" | "unpin-thread" | "edit-any-thread" | "delete-any-thread" | "edit-own-thread" | "lock-own-thread" | "ban-member" | "unban-member" | "resolve-report" | "manage-category" | "view-staff-tools";
23
+ interface CommunityRoleDefinition {
24
+ /** e.g. `"category-mod"`. Plugins can ship custom roles like `"tag-mod"`. */
25
+ role: string;
26
+ /** What kind of scope a grant of this role applies to. */
27
+ scopeType: CommunityScope;
28
+ /** Capabilities a grant of this role unlocks within its scope. */
29
+ capabilities: readonly CommunityCapability[];
30
+ /**
31
+ * Human-readable label for admin UIs that surface a role picker. Falls
32
+ * back to `role` when omitted.
33
+ */
34
+ label?: string;
35
+ /** Optional plugin id that registered this role; null for built-ins. */
36
+ source?: string;
37
+ }
38
+ /**
39
+ * Plugins call this from setup() to add their own role kinds. Throws
40
+ * when the (role, scopeType) pair is already registered to keep the
41
+ * registry deterministic — a plugin overriding a built-in role would
42
+ * silently widen permissions and is almost always a mistake.
43
+ */
44
+ declare function registerCommunityRole(definition: CommunityRoleDefinition): void;
45
+ /** Look up a role by `(role, scopeType)`. Returns undefined when unknown. */
46
+ declare function getCommunityRole(role: string, scopeType: CommunityScope): CommunityRoleDefinition | undefined;
47
+ /**
48
+ * Returns every role currently registered, built-ins first then
49
+ * plugin-defined. Used by the admin role picker to render selectable
50
+ * options for a given scope.
51
+ */
52
+ declare function listCommunityRoles(scopeType?: CommunityScope): CommunityRoleDefinition[];
53
+ /** Tests reset state between cases; production callers should never need this. */
54
+ declare function resetCommunityRoles(): void;
55
+
56
+ /**
57
+ * Throws `NpForbiddenError` if the member is currently banned for any
58
+ * scope in the chain. Used at the top of community write services
59
+ * before any DB mutation. Pre-existing `memberCan` enforces the same
60
+ * rule for permission-based actions; this helper is the catch-all
61
+ * for write paths that don't go through capability checks.
62
+ */
63
+ declare function assertNotBanned(memberId: string, scopes?: ReadonlyArray<{
64
+ type: CommunityScope;
65
+ id: string;
66
+ }>): Promise<void>;
67
+ /**
68
+ * Structural enforcement of the ban-check gate (#311). Every
69
+ * community write service should run inside this wrapper — the ban
70
+ * check fires before `fn` and a service author can't accidentally
71
+ * ship a new write path that skips it.
72
+ *
73
+ * Pre-validation that doesn't write (input shape, target lookup
74
+ * existence) can run *before* this call; the gate is specifically
75
+ * for the moment between "we know enough to attempt the write" and
76
+ * the first DB mutation.
77
+ *
78
+ * `scopes` is the same chain `assertNotBanned` accepts — pass
79
+ * `[{ type: "collection", id: targetType }]` for collection-scoped
80
+ * actions, leave empty for site-wide-only enforcement (e.g. follows,
81
+ * polymorphic-target reactions where no obvious scope chain exists).
82
+ */
83
+ declare function withMemberWrite<T>(memberId: string, scopes: ReadonlyArray<{
84
+ type: CommunityScope;
85
+ id: string;
86
+ }>, fn: () => Promise<T>): Promise<T>;
87
+ /**
88
+ * Action a member is attempting. Most actions are real
89
+ * `CommunityCapability` literals — those map 1:1 to a role's
90
+ * capability list. The two exceptions are `"edit-own"` and
91
+ * `"delete-own"`, which short-circuit on ownership without consulting
92
+ * grants at all.
93
+ */
94
+ type MemberAction = CommunityCapability | "edit-own" | "delete-own";
95
+ /**
96
+ * Caller-provided context for a permission check. The caller — the
97
+ * comment service, a future thread service, etc. — provides the
98
+ * target's ownership + scope chain rather than `memberCan` looking
99
+ * it up via a polymorphic join. This keeps the resolver decoupled
100
+ * from the per-target table layout, and lets the surface evolve
101
+ * without touching this resolver.
102
+ */
103
+ interface MemberCanTarget {
104
+ /** Free-form target type — `"comment" | "thread" | "reply" | "category" | "report" | "member"`. */
105
+ type: string;
106
+ /** Stable id for logs / future denial reasons. */
107
+ id: string;
108
+ /** Member id of the target's author. Required for own-action checks. */
109
+ ownerId?: string;
110
+ /**
111
+ * Scope chain from most specific to least specific. A reply might
112
+ * provide `[{ type: "thread", id: "<threadId>" }, { type: "category",
113
+ * id: "<categoryId>" }]`; the resolver also checks site-wide grants
114
+ * regardless of what's in the chain.
115
+ */
116
+ scopes?: ReadonlyArray<{
117
+ type: CommunityScope;
118
+ id: string;
119
+ }>;
120
+ }
121
+ interface MemberCanOptions {
122
+ /** Override the DB handle (tests). Defaults to `getDb()`. */
123
+ db?: NodePgDatabase<Record<string, unknown>>;
124
+ /** Reference time for ban/grant expiry checks. Defaults to `new Date()`. */
125
+ now?: Date;
126
+ }
127
+ /**
128
+ * Returns true when `memberId` is allowed to perform `action` on
129
+ * `target`. Walk order:
130
+ *
131
+ * 1. Active scoped ban → deny everything.
132
+ * 2. `edit-own` / `delete-own` → allow only when `target.ownerId === memberId`.
133
+ * 3. Site-wide grants whose role's capability list includes `action`.
134
+ * 4. Scoped grants matching any element of `target.scopes`, whose role
135
+ * includes `action`.
136
+ * 5. Otherwise deny.
137
+ *
138
+ * The resolver ignores staff (`np_users`) entirely. Staff bypass is the
139
+ * caller's responsibility — typically `principalCan(principal, …)` at
140
+ * the API layer, which routes to `memberCan` only when the principal
141
+ * is a member.
142
+ */
143
+ declare function memberCan(memberId: string, action: MemberAction, target: MemberCanTarget, options?: MemberCanOptions): Promise<boolean>;
144
+
145
+ /**
146
+ * Pluggable anti-spam adapter. Plugins call `setSpamAdapter(adapter)`
147
+ * at startup; the community write path consults `getSpamAdapter()
148
+ * .check(text, context)` before inserting and acts on the verdict:
149
+ *
150
+ * - `"pass"` → write proceeds normally (status = `visible`)
151
+ * - `"flag"` → write proceeds but lands as `pending` (visible only
152
+ * to mods; appears in the report queue indirectly via
153
+ * the moderation surface)
154
+ * - `"reject"` → write is refused; the caller surfaces a 400
155
+ * `NpValidationError`. Adapters may attach a `reason`
156
+ * string for the error message.
157
+ *
158
+ * Adapters are intentionally synchronous-friendly (they may also
159
+ * return a Promise). The framework awaits the result so adapters that
160
+ * call out to a network service (Akismet, OpenAI moderation, etc.)
161
+ * work transparently.
162
+ *
163
+ * The default adapter is "no-op pass" — every write proceeds as
164
+ * before. Sites that want spam protection install one explicitly.
165
+ */
166
+ type NpSpamVerdictKind = "pass" | "flag" | "reject";
167
+ interface NpSpamVerdict {
168
+ kind: NpSpamVerdictKind;
169
+ /**
170
+ * Optional human-readable reason. Used as the
171
+ * `NpValidationError` message on `reject`, surfaced to the
172
+ * audit log on `flag`. Don't include PII or provider error text
173
+ * verbatim — operators see this in logs.
174
+ */
175
+ reason?: string;
176
+ /**
177
+ * Free-form metadata the adapter wants to log alongside the
178
+ * verdict (model name, score, classifier id, etc.). Surfaced to
179
+ * the audit log; never echoed to the end user.
180
+ */
181
+ metadata?: Record<string, unknown>;
182
+ }
183
+ interface NpSpamCheckContext {
184
+ /** Member id of the author. Adapters may use this to weight by
185
+ * reputation or recent infraction history. */
186
+ memberId: string;
187
+ /**
188
+ * Collection slug that owns the document the comment is attached
189
+ * to (`"posts"`, `"discussions"`, etc.) — same value as
190
+ * `np_comments.target_type`. The schema is polymorphic over
191
+ * collection, so this is the collection identifier, not a
192
+ * "comment vs thread" classifier.
193
+ */
194
+ targetType: string;
195
+ /** Document id within `targetType` — the post / discussion the
196
+ * comment is attached to. */
197
+ targetId: string;
198
+ /** Parent comment id when this is a reply, otherwise null. */
199
+ parentId?: string | null;
200
+ }
201
+ interface NpSpamAdapter {
202
+ check(text: string, ctx: NpSpamCheckContext): NpSpamVerdict | Promise<NpSpamVerdict>;
203
+ }
204
+ /**
205
+ * Replace the global spam adapter. Call once at app boot, typically
206
+ * from a plugin's `setup()`. Multiple plugins competing for this slot
207
+ * should compose their checks behind a single `setSpamAdapter` call —
208
+ * the framework holds at most one adapter to keep the verdict
209
+ * unambiguous.
210
+ */
211
+ declare function setSpamAdapter(adapter: NpSpamAdapter): void;
212
+ declare function getSpamAdapter(): NpSpamAdapter;
213
+ /** Reset to the default no-op adapter. Tests use this between cases. */
214
+ declare function resetSpamAdapter(): void;
215
+
216
+ /**
217
+ * Pluggable profanity adapter. Sister to `spam-adapter.ts`, but
218
+ * semantically scoped to *language* rather than *intent*: profanity
219
+ * adapters score the words in a piece of content, spam adapters score
220
+ * the likelihood that the post is unwanted commercial / abusive.
221
+ *
222
+ * Many sites want both: an off-the-shelf regex list to scrub slurs
223
+ * plus an ML / Akismet-style classifier for spam. Rather than force
224
+ * those to compose behind a single `setSpamAdapter` call, the
225
+ * framework holds two slots and runs profanity FIRST, then spam.
226
+ * Verdicts combine with the strongest-wins rule:
227
+ *
228
+ * - any `reject` → write is refused with that adapter's reason
229
+ * - any `flag` → write proceeds as `pending` with both adapters'
230
+ * metadata aggregated for the audit row
231
+ * - both `pass` → normal write
232
+ *
233
+ * The default adapter is "no-op pass" — every write proceeds as
234
+ * before. Sites that want profanity protection install one
235
+ * explicitly, typically from a plugin's `setup()`.
236
+ */
237
+ type NpProfanityVerdictKind = "pass" | "flag" | "reject";
238
+ interface NpProfanityVerdict {
239
+ kind: NpProfanityVerdictKind;
240
+ /**
241
+ * Optional human-readable reason. Used as the
242
+ * `NpValidationError` message on `reject`, surfaced to the
243
+ * audit log on `flag`. Don't include the matched word verbatim
244
+ * if you don't want it echoed to the end user on reject.
245
+ */
246
+ reason?: string;
247
+ /**
248
+ * Free-form metadata the adapter wants to log alongside the
249
+ * verdict (matched categories, severity, locale, etc.). Surfaced
250
+ * to the audit log; never echoed to the end user.
251
+ */
252
+ metadata?: Record<string, unknown>;
253
+ }
254
+ interface NpProfanityCheckContext {
255
+ /** Member id of the author. Adapters may use this to weight
256
+ * by reputation or recent infraction history. */
257
+ memberId: string;
258
+ /**
259
+ * Surface the content lives on. For comments this is the
260
+ * collection slug of the parent doc (`"posts"`, `"discussions"`,
261
+ * etc.); for member-authored docs, this is the same collection
262
+ * slug. Mirrors `NpSpamCheckContext.targetType`.
263
+ */
264
+ targetType: string;
265
+ /** Document id the content belongs to. Empty string for a
266
+ * pre-insert doc create — adapters that key off the id should
267
+ * treat empty as "new doc". */
268
+ targetId: string;
269
+ /** Parent comment id when this is a reply, otherwise null /
270
+ * undefined (for doc creates). */
271
+ parentId?: string | null;
272
+ }
273
+ interface NpProfanityAdapter {
274
+ check(text: string, ctx: NpProfanityCheckContext): NpProfanityVerdict | Promise<NpProfanityVerdict>;
275
+ }
276
+ /**
277
+ * Replace the global profanity adapter. Call once at app boot,
278
+ * typically from a plugin's `setup()`. The framework holds at most
279
+ * one adapter; sites that want to layer multiple lists should compose
280
+ * them inside a single adapter (the same convention as the spam
281
+ * adapter).
282
+ */
283
+ declare function setProfanityAdapter(adapter: NpProfanityAdapter): void;
284
+ declare function getProfanityAdapter(): NpProfanityAdapter;
285
+ /** Reset to the default no-op adapter. Tests use this between cases. */
286
+ declare function resetProfanityAdapter(): void;
287
+
288
+ /**
289
+ * Pluggable reputation-rules hook. Sites install an adapter via
290
+ * `setReputationAdapter()` to compute reputation deltas in response
291
+ * to community events; the framework then atomically applies the
292
+ * delta to `np_members.reputation`.
293
+ *
294
+ * Default adapter is "no-op" (every event returns 0) — existing
295
+ * sites' reputation values stay at zero until they opt in.
296
+ *
297
+ * Adapter is single-method by design: a tagged-union `event` is the
298
+ * only argument, the return value is a signed integer delta. This
299
+ * keeps the API surface small while letting sites encode arbitrary
300
+ * weighting (e.g. "+5 for a like on a comment, −10 for a moderator
301
+ * hide, −0 if the reactor is a brand-new account, etc.").
302
+ *
303
+ * Adapters can be sync or async — the framework awaits the result.
304
+ * Throwing aborts only the reputation update, not the underlying
305
+ * community write (fail-soft via observability hook, same pattern
306
+ * as the spam adapter).
307
+ */
308
+ type NpReputationEvent =
309
+ /** A new visible comment was inserted. Flagged / hidden / deleted
310
+ * comments do NOT emit this event. */
311
+ {
312
+ kind: "comment.created";
313
+ commentId: string;
314
+ memberId: string;
315
+ targetType: string;
316
+ targetId: string;
317
+ }
318
+ /** Mod (or member with the right grant) hid a comment. Adapters
319
+ * typically penalize the author. */
320
+ | {
321
+ kind: "comment.hidden";
322
+ commentId: string;
323
+ memberId: string;
324
+ byStaff: boolean;
325
+ reason?: string | null;
326
+ }
327
+ /** Mod-side hard delete (`staffDeleteComment`). The body is wiped;
328
+ * this is harsher than `hidden` and adapters usually penalize
329
+ * more. */
330
+ | {
331
+ kind: "comment.deleted";
332
+ commentId: string;
333
+ memberId: string;
334
+ byStaff: boolean;
335
+ }
336
+ /** Someone reacted to the recipient's content (comment / thread /
337
+ * reply). `recipientId` is the content author; `reactorId` is the
338
+ * member who clicked the reaction. Self-reactions are filtered
339
+ * before the event fires. */
340
+ | {
341
+ kind: "reaction.received";
342
+ reactionKind: string;
343
+ recipientId: string;
344
+ reactorId: string;
345
+ targetType: string;
346
+ targetId: string;
347
+ }
348
+ /** Reactor undid their reaction. Symmetric to `reaction.received`;
349
+ * adapters typically return the negative of the corresponding
350
+ * positive delta. */
351
+ | {
352
+ kind: "reaction.removed";
353
+ reactionKind: string;
354
+ recipientId: string;
355
+ reactorId: string;
356
+ targetType: string;
357
+ targetId: string;
358
+ }
359
+ /** A member created a top-level document in a collection that
360
+ * opted into `community.memberWrite.create` (Phase 9.7a). Fires
361
+ * after the row + revision are persisted; adapters can credit
362
+ * reputation for thread / post creation just like comments. */
363
+ | {
364
+ kind: "document.created";
365
+ collectionSlug: string;
366
+ documentId: string;
367
+ memberId: string;
368
+ }
369
+ /** Author deleted their own document (`memberWrite.delete`,
370
+ * Phase 9.7b). Symmetric to `document.created`; adapters
371
+ * typically debit the original credit so a member can't farm
372
+ * reputation by churn-creating and deleting threads. Mod-side
373
+ * deletes are NOT covered here — those go through the staff
374
+ * path which doesn't emit this event. */
375
+ | {
376
+ kind: "document.deleted";
377
+ collectionSlug: string;
378
+ documentId: string;
379
+ memberId: string;
380
+ };
381
+ interface NpReputationAdapter {
382
+ /** Returns the integer delta to apply to the affected member's
383
+ * reputation. Sign matters: positive credits, negative debits.
384
+ * Non-integer values are truncated; non-finite (NaN/Infinity)
385
+ * values are skipped. Returning 0 is the no-op path. */
386
+ apply(event: NpReputationEvent): number | Promise<number>;
387
+ }
388
+ declare function setReputationAdapter(adapter: NpReputationAdapter): void;
389
+ declare function getReputationAdapter(): NpReputationAdapter;
390
+ /** Reset to the no-op adapter. Tests use this between cases. */
391
+ declare function resetReputationAdapter(): void;
392
+
393
+ /**
394
+ * Calls the registered reputation adapter for `event`, then applies
395
+ * the returned delta to the affected member's reputation atomically:
396
+ *
397
+ * UPDATE np_members SET reputation = reputation + $delta
398
+ * WHERE id = $memberId
399
+ *
400
+ * Failure modes are intentionally fail-soft — a buggy adapter that
401
+ * throws, returns a non-finite value, or hits a transient DB error
402
+ * MUST NOT block the underlying community write (comment insert,
403
+ * reaction toggle, etc.). The caller's transactional state is not
404
+ * touched; we just log + skip.
405
+ */
406
+ declare function applyReputation(memberId: string, event: NpReputationEvent): Promise<void>;
407
+
408
+ /**
409
+ * Site-wide community settings, persisted in the generic `np_settings`
410
+ * table under the `community` key. Sites that never visit the admin UI
411
+ * inherit `DEFAULT_COMMUNITY_SETTINGS` — every read goes through
412
+ * `getCommunitySettings()` which merges the stored value over the
413
+ * defaults so adding a new field doesn't break existing installs.
414
+ *
415
+ * Validation runs on the write path only — readers trust whatever is
416
+ * in the table because the only writer is the admin API which
417
+ * pre-validates. Tests poke values directly into `np_settings` for
418
+ * fault-injection cases.
419
+ */
420
+ /**
421
+ * Per-member upload quota / rate limit. `null` on either field
422
+ * means unlimited (the default — no quota). Both bounds count
423
+ * non-deleted rows on `np_media` keyed by `uploaded_by_member_id`,
424
+ * so admin purges (Phase 9.7l) free up quota the same way a
425
+ * member self-deleting their content would. Staff uploads are
426
+ * never gated.
427
+ */
428
+ interface NpMemberUploadQuota {
429
+ /** Max uploads in the trailing 24h window. `null` = unlimited. */
430
+ perDay: number | null;
431
+ /** Lifetime cap on non-deleted member uploads. `null` = unlimited. */
432
+ total: number | null;
433
+ }
434
+ interface NpCommunitySettings {
435
+ /**
436
+ * Allow-list of reaction `kind` strings. Members can only add
437
+ * reactions whose kind is in this list; values that pass the
438
+ * `KIND_RE` regex but aren't in the list are rejected with a 400.
439
+ * Removal of an already-existing reaction is NOT gated — if a kind
440
+ * is removed from the list, members can still un-react it.
441
+ */
442
+ reactionKinds: string[];
443
+ /**
444
+ * When false, `/api/members/register` refuses new sign-ups with a
445
+ * 403. Existing members can still sign in. Sites that want
446
+ * invite-only flows turn this off and provision via admin tooling.
447
+ */
448
+ registrationEnabled: boolean;
449
+ /** Per-member upload limits. See `NpMemberUploadQuota`. */
450
+ memberUploadQuota: NpMemberUploadQuota;
451
+ }
452
+ declare const DEFAULT_COMMUNITY_SETTINGS: NpCommunitySettings;
453
+ declare function getCommunitySettings(): Promise<NpCommunitySettings>;
454
+ /**
455
+ * Validates an incoming partial patch from the admin UI. Returns the
456
+ * fully-merged settings object that should be persisted. Throws
457
+ * `NpValidationError` with field-level errors on any malformed input.
458
+ */
459
+ declare function validateCommunitySettingsPatch(current: NpCommunitySettings, patch: unknown): NpCommunitySettings;
460
+ declare function updateCommunitySettings(patch: unknown, updatedBy: string | null): Promise<NpCommunitySettings>;
461
+
462
+ /**
463
+ * Public-facing member profile. Hand-picked from `np_members` to
464
+ * exclude PII (email, password hash, login attempts, reset tokens,
465
+ * notification prefs, plugin meta) — page authors building public
466
+ * surfaces (`/u/[handle]` etc.) get a safe-to-render shape without
467
+ * having to remember which columns are sensitive.
468
+ *
469
+ * Suspended / deleted members are filtered out — calling
470
+ * `getMemberProfile` for a hidden member returns `null`. The
471
+ * "imported" status (Phase 21 WordPress-import provisional members)
472
+ * IS exposed because those profiles are visible on the public site
473
+ * by design. Bans are a separate, scope-based concept (`np_bans`)
474
+ * and don't hide the profile shell — they restrict posting; the
475
+ * profile page itself stays reachable like Reddit / Discourse.
476
+ */
477
+ interface NpMemberProfile {
478
+ id: string;
479
+ handle: string;
480
+ displayName: string;
481
+ avatarUrl: string | null;
482
+ bio: string | null;
483
+ reputation: number;
484
+ joinedAt: Date;
485
+ }
486
+ /**
487
+ * Fetch a public member profile by id or handle.
488
+ *
489
+ * Resolves the avatar to a public URL (via `getMediaUrl`) so the
490
+ * caller doesn't need to know about the storage adapter. Pass an
491
+ * explicit `variant` to fetch a sized avatar — defaults to
492
+ * `"thumbnail"` since profile cards typically render at small
493
+ * sizes. Pass `"original"` for the full avatar (e.g. on the
494
+ * profile detail page itself).
495
+ *
496
+ * Returns `null` when:
497
+ * - no row matches the id / handle,
498
+ * - the member's status is `suspended` or `deleted` (treat as
499
+ * "not found" for public surfaces).
500
+ */
501
+ declare function getMemberProfile(idOrHandle: string, options?: {
502
+ avatarVariant?: "original" | "thumbnail" | "small" | "medium" | "large" | (string & {});
503
+ }): Promise<NpMemberProfile | null>;
504
+ /**
505
+ * Batch variant of `getMemberProfile` for listings (discussion
506
+ * indexes, comment threads, follower lists, …). Single SELECT
507
+ * for the rows; avatar URLs resolve in parallel via `Promise.all`.
508
+ *
509
+ * The caller passes member IDs (the `memberAuthorId` /
510
+ * `memberId` foreign keys most listing rows already carry).
511
+ * Handle-based batches aren't supported — list rows that
512
+ * reference a handle and not an id are rare; pass IDs.
513
+ *
514
+ * Returns a `Map<id, NpMemberProfile>` with one entry per id
515
+ * that matched (suspended / deleted members are dropped, so the
516
+ * map size may be smaller than the input). Order isn't preserved
517
+ * because callers typically use `byId.get(row.memberId)` per row
518
+ * rather than a parallel array.
519
+ *
520
+ * Empty input → empty map (no DB query).
521
+ */
522
+ declare function getMemberProfiles(ids: readonly string[], options?: {
523
+ avatarVariant?: "original" | "thumbnail" | "small" | "medium" | "large" | (string & {});
524
+ }): Promise<Map<string, NpMemberProfile>>;
525
+
526
+ /**
527
+ * Tiny safe markdown renderer for comment bodies. Deliberately minimal:
528
+ * we escape every byte first, then pattern-match a small set of inline
529
+ * + block constructs so the output HTML can only ever contain the
530
+ * limited tag set listed below. No raw HTML pass-through, ever.
531
+ *
532
+ * Supported:
533
+ * - Bold `**text**` → `<strong>text</strong>`
534
+ * - Italic `*text*` → `<em>text</em>`
535
+ * - Inline code `` `code` `` → `<code>code</code>`
536
+ * - Code block ``` … ``` → `<pre><code>…</code></pre>`
537
+ * - Link `[t](url)` → `<a href="url" rel="…">t</a>`
538
+ * (URL must start with http://, https://, or mailto:)
539
+ * - Paragraph break: blank line
540
+ * - Hard break single \n → `<br/>`
541
+ *
542
+ * NOT supported (deliberate, to keep the renderer tight + safe):
543
+ * raw HTML, headings, lists, blockquotes, images, tables. If a
544
+ * site needs richer formatting, plug `marked` + `dompurify` here
545
+ * without changing the public function shape.
546
+ */
547
+ /**
548
+ * Render a comment body markdown source to safe HTML. Pure function;
549
+ * idempotent; safe to call on the write path AND on display (we still
550
+ * persist the rendered version to avoid re-rendering on every read).
551
+ */
552
+ declare function renderCommentMarkdown(source: string): string;
553
+
554
+ type CommentStatus = "visible" | "pending" | "hidden" | "deleted";
555
+ interface NpCommentRow {
556
+ id: string;
557
+ targetType: string;
558
+ targetId: string;
559
+ parentId: string | null;
560
+ memberId: string;
561
+ bodyMd: string;
562
+ bodyHtml: string;
563
+ status: CommentStatus;
564
+ hiddenReason: string | null;
565
+ editedAt: Date | null;
566
+ /** Tenant the comment belongs to. Phase 18 added the column; the type was incomplete until #364. */
567
+ siteId: string;
568
+ createdAt: Date;
569
+ /**
570
+ * Phase 21.11 — author's `np_members.status` at read time.
571
+ * `listComments` joins against `np_members` so callers can render
572
+ * a `(imported)` badge without a second round trip. Older callers
573
+ * that don't read this field stay unaffected — the column is
574
+ * nullable on the type because the underlying join is `LEFT JOIN`
575
+ * and `createComment` returns the row before the join is wired.
576
+ */
577
+ authorStatus?: string | null;
578
+ }
579
+ interface NpCommentCreateInput {
580
+ targetType: string;
581
+ targetId: string;
582
+ parentId?: string | null;
583
+ memberId: string;
584
+ bodyMd: string;
585
+ }
586
+ declare function createComment(input: NpCommentCreateInput): Promise<NpCommentRow>;
587
+ /**
588
+ * Comment ordering options.
589
+ *
590
+ * - `newest` — created_at DESC (default; matches the
591
+ * surface a fresh thread should show)
592
+ * - `oldest` — created_at ASC (chronological reads)
593
+ * - `top` — reactions DESC, then created_at DESC as
594
+ * tiebreaker. Useful for high-traffic threads where the
595
+ * "best" comment should bubble up regardless of when
596
+ * it was posted.
597
+ */
598
+ type NpCommentSort = "newest" | "oldest" | "top";
599
+ interface NpCommentListOptions {
600
+ /** Default 50, max 200. */
601
+ limit?: number;
602
+ /** Default 0. */
603
+ offset?: number;
604
+ /** Newest first by default. */
605
+ order?: NpCommentSort;
606
+ /** Override visibility — staff/mods may want to see hidden rows. */
607
+ includeHidden?: boolean;
608
+ /**
609
+ * Phase 16.1 — when set, the viewer's mute list is applied
610
+ * so authors they've muted disappear from the result. The
611
+ * filter only kicks in for the logged-in viewer; anonymous
612
+ * viewers see every visible comment.
613
+ */
614
+ viewerMemberId?: string;
615
+ }
616
+ interface NpCommentListResult {
617
+ comments: NpCommentRow[];
618
+ totalDocs: number;
619
+ }
620
+ declare function listComments(targetType: string, targetId: string, options?: NpCommentListOptions): Promise<NpCommentListResult>;
621
+ interface NpCommentUpdateInput {
622
+ commentId: string;
623
+ memberId: string;
624
+ bodyMd: string;
625
+ }
626
+ declare function updateComment(input: NpCommentUpdateInput): Promise<NpCommentRow>;
627
+ interface NpCommentDeleteInput {
628
+ commentId: string;
629
+ memberId: string;
630
+ }
631
+ declare function deleteComment(input: NpCommentDeleteInput): Promise<void>;
632
+ interface NpCommentHideInput {
633
+ commentId: string;
634
+ memberId: string;
635
+ reason?: string | null;
636
+ }
637
+ declare function hideComment(input: NpCommentHideInput): Promise<void>;
638
+ interface NpCommentRestoreInput {
639
+ commentId: string;
640
+ memberId: string;
641
+ }
642
+ declare function restoreComment(input: NpCommentRestoreInput): Promise<void>;
643
+ declare function staffHideComment(commentId: string, staffUserId: string, reason?: string | null): Promise<void>;
644
+ declare function staffRestoreComment(commentId: string, staffUserId: string): Promise<void>;
645
+ declare function staffDeleteComment(commentId: string, staffUserId: string): Promise<void>;
646
+
647
+ /**
648
+ * Reactions service. `kind` is gated by both:
649
+ * 1. `KIND_RE` — a syntactic check (lowercase token, ≤30 chars)
650
+ * that runs on every add/remove call without a DB round-trip.
651
+ * 2. The site's reaction allow-list, persisted in
652
+ * `np_settings.community.reactionKinds` and edited from the
653
+ * admin community settings page. v1 ships with `["like"]` as
654
+ * the only allowed kind. Removal is NOT gated against the
655
+ * allow-list — if a site retires a reaction, members can still
656
+ * undo their old reactions of that kind.
657
+ */
658
+ declare const DEFAULT_REACTION_KINDS: readonly ["like"];
659
+ interface NpReactionRow {
660
+ id: string;
661
+ targetType: string;
662
+ targetId: string;
663
+ memberId: string;
664
+ kind: string;
665
+ createdAt: Date;
666
+ }
667
+ interface NpReactToInput {
668
+ targetType: string;
669
+ targetId: string;
670
+ memberId: string;
671
+ kind: string;
672
+ }
673
+ /**
674
+ * Adds a reaction. Idempotent: if `(target_type, target_id, member_id,
675
+ * kind)` already exists, returns the existing row instead of bumping
676
+ * the unique-constraint into an error. The first time a member reacts
677
+ * to a comment we also fire a notification to the comment author.
678
+ */
679
+ declare function addReaction(input: NpReactToInput): Promise<NpReactionRow>;
680
+ declare function removeReaction(input: NpReactToInput): Promise<void>;
681
+ /**
682
+ * Per-target counts grouped by kind. Returns `{ like: 12 }`-style
683
+ * objects; missing kinds are absent (caller defaults to 0).
684
+ */
685
+ declare function countReactions(targetType: string, targetId: string): Promise<Record<string, number>>;
686
+ /**
687
+ * Returns the kinds the given member has reacted with on a target.
688
+ * Used by the site UI to render the like button as toggled-on.
689
+ */
690
+ declare function listMemberReactions(targetType: string, targetId: string, memberId: string): Promise<string[]>;
691
+ /**
692
+ * Internal helper — assert that the target exists for the given kind.
693
+ * Today only `comment` is supported. The polymorphic shape leaves
694
+ * room for `thread` / `reply` once a thread schema lands; the forum
695
+ * plugin shipped without one (it reuses `np_comments` under the
696
+ * `discussions` collection), so widening this surface is on hold
697
+ * until a separate threads design.
698
+ */
699
+ declare function assertReactableExists(targetType: string, targetId: string): Promise<void>;
700
+
701
+ /**
702
+ * Follow graph service. v1 supports `member` follows; `thread` and
703
+ * `tag` lands when those subjects exist. Self-follow is rejected so
704
+ * the recommended-follows / "people you follow" reads don't have to
705
+ * special-case it.
706
+ */
707
+ declare const SUPPORTED_TARGETS$1: readonly ["member", "thread", "tag"];
708
+ type FollowTarget = (typeof SUPPORTED_TARGETS$1)[number];
709
+ interface NpFollowRow {
710
+ id: string;
711
+ followerId: string;
712
+ targetType: string;
713
+ targetId: string;
714
+ createdAt: Date;
715
+ }
716
+ interface NpFollowInput {
717
+ followerId: string;
718
+ targetType: FollowTarget;
719
+ targetId: string;
720
+ }
721
+ declare function follow(input: NpFollowInput): Promise<NpFollowRow>;
722
+ declare function unfollow(input: NpFollowInput): Promise<void>;
723
+ declare function isFollowing(input: NpFollowInput): Promise<boolean>;
724
+ /**
725
+ * "Who am I following?" — paged. Used by the site UI to populate a
726
+ * member's profile or settings page.
727
+ */
728
+ declare function listFollowing(followerId: string, options?: {
729
+ targetType?: FollowTarget;
730
+ limit?: number;
731
+ offset?: number;
732
+ }): Promise<NpFollowRow[]>;
733
+
734
+ /**
735
+ * Per-member notification inbox. v1 is synchronous: every event that
736
+ * generates a notification writes a row immediately. The inbox is
737
+ * in-app only — email fan-out and per-member frequency preferences
738
+ * are out of scope for the shipped roadmap.
739
+ *
740
+ * `kind` is a free-form string. The current vocabulary:
741
+ * - `comment.reply` — your comment got a reply
742
+ * - `reaction.received` — someone reacted to your content
743
+ * - `follow.received` — someone followed you
744
+ * Plugins can write their own kinds; the recipient UI fans them out
745
+ * to whichever rendering it knows.
746
+ */
747
+ interface NpNotificationRow {
748
+ id: string;
749
+ memberId: string;
750
+ kind: string;
751
+ payload: Record<string, unknown>;
752
+ readAt: Date | null;
753
+ createdAt: Date;
754
+ }
755
+ interface CreateNotificationInput {
756
+ /** The recipient — whose inbox this lands in. */
757
+ memberId: string;
758
+ kind: string;
759
+ payload?: Record<string, unknown>;
760
+ /**
761
+ * Phase 16.1 — the member whose action triggered the
762
+ * notification (e.g. the comment author, the reactor, the
763
+ * follower). When set, the recipient's mute list is
764
+ * consulted: if the recipient has muted the actor, the
765
+ * notification is silently dropped. Returns `null` from
766
+ * the call site.
767
+ *
768
+ * Optional because some kinds are actor-less (system
769
+ * notices, scheduled reminders).
770
+ */
771
+ actorMemberId?: string | null;
772
+ }
773
+ declare function createNotification(input: CreateNotificationInput): Promise<NpNotificationRow | null>;
774
+ interface ListNotificationsOptions {
775
+ /** Default 50, max 200. */
776
+ limit?: number;
777
+ /** Default 0. */
778
+ offset?: number;
779
+ /** When true, returns only unread. */
780
+ unreadOnly?: boolean;
781
+ }
782
+ interface NpNotificationListResult {
783
+ notifications: NpNotificationRow[];
784
+ totalDocs: number;
785
+ unread: number;
786
+ }
787
+ declare function listNotifications(memberId: string, options?: ListNotificationsOptions): Promise<NpNotificationListResult>;
788
+ declare function unreadNotificationCount(memberId: string): Promise<number>;
789
+ interface MarkReadInput {
790
+ memberId: string;
791
+ notificationIds: string[];
792
+ }
793
+ declare function markNotificationsRead(input: MarkReadInput): Promise<number>;
794
+ declare function markAllNotificationsRead(memberId: string): Promise<number>;
795
+ /**
796
+ * Internal sanity check used by the API: throws when one principal
797
+ * tries to read another member's notification. Centralised here
798
+ * because every per-id route gets the same rule.
799
+ */
800
+ declare function assertOwnsNotification(memberId: string, notificationId: string): Promise<void>;
801
+
802
+ /**
803
+ * Unified permission check. Staff routes pass `{ kind: "staff", user }`;
804
+ * member routes pass `{ kind: "member", memberId }`. Staff with
805
+ * `admin`, `editor`, or `moderator` role short-circuit to allow all
806
+ * community-mod actions — they're trusted by virtue of being CMS
807
+ * staff. Other staff roles (author, viewer) and members fall through
808
+ * to the member-side resolver, which checks role grants in
809
+ * `np_member_roles`.
810
+ *
811
+ * `edit-own` / `delete-own` actions still require ownership even for
812
+ * staff — the API layer should already check ownership for self-only
813
+ * routes, but the ownership rule here is belt-and-braces.
814
+ */
815
+ type Principal = NpPrincipal;
816
+ declare function principalCan(principal: Principal, action: MemberAction, target: MemberCanTarget): Promise<boolean>;
817
+
818
+ /**
819
+ * Append-only moderation audit log. Every hide / restore / ban /
820
+ * role-grant write goes through here so admins can later answer
821
+ * "who took this action and when?" without diffing application logs.
822
+ *
823
+ * Writes are best-effort: a failed audit insert MUST NOT prevent the
824
+ * underlying mod action from succeeding (logged via the observability
825
+ * hooks instead). Reads are paginated and indexed by target.
826
+ */
827
+ type AuditActorKind = "staff" | "member" | "system";
828
+ interface AuditActor {
829
+ kind: AuditActorKind;
830
+ /** Set only for `kind: "staff"`. */
831
+ userId?: string;
832
+ /** Set only for `kind: "member"`. */
833
+ memberId?: string;
834
+ }
835
+ interface RecordAuditEventInput {
836
+ actor: AuditActor;
837
+ action: string;
838
+ targetType?: string;
839
+ targetId?: string;
840
+ payload?: Record<string, unknown>;
841
+ /**
842
+ * Phase 17 — site this event belongs to. When omitted the
843
+ * writer reads `getCurrentSiteId()` so request-driven calls
844
+ * automatically scope to the resolving tenant. Pass `null`
845
+ * explicitly to record an unscoped event (super-admin
846
+ * cross-site action, background job).
847
+ */
848
+ siteId?: string | null;
849
+ }
850
+ interface AuditEventRow {
851
+ id: string;
852
+ actorKind: AuditActorKind;
853
+ actorUserId: string | null;
854
+ actorMemberId: string | null;
855
+ action: string;
856
+ targetType: string | null;
857
+ targetId: string | null;
858
+ payload: Record<string, unknown>;
859
+ siteId: string | null;
860
+ createdAt: Date;
861
+ }
862
+ declare function recordAuditEvent(input: RecordAuditEventInput): Promise<void>;
863
+ interface ListAuditOptions {
864
+ /** Filter to audit events targeting one specific row. */
865
+ targetType?: string;
866
+ targetId?: string;
867
+ /** Filter to events caused by a specific actor. */
868
+ actorUserId?: string;
869
+ actorMemberId?: string;
870
+ /**
871
+ * Filter to events whose `action` matches. Common operational
872
+ * query: "show every ban issued this week" →
873
+ * `action="member.ban.issue"` plus `since`.
874
+ */
875
+ action?: string;
876
+ /** Lower-bound `created_at` (inclusive). */
877
+ since?: Date;
878
+ /** Upper-bound `created_at` (exclusive). */
879
+ until?: Date;
880
+ /**
881
+ * Phase 17 — site filter. `undefined` means "use current
882
+ * request's site" (the typical admin-page query). Pass an
883
+ * explicit string to view another site's audit log
884
+ * (super-admin cross-site triage). Pass `null` to skip the
885
+ * filter entirely (every site's events).
886
+ */
887
+ siteId?: string | null;
888
+ limit?: number;
889
+ offset?: number;
890
+ }
891
+ declare function listAuditEvents(options?: ListAuditOptions): Promise<{
892
+ events: AuditEventRow[];
893
+ totalDocs: number;
894
+ }>;
895
+
896
+ declare const SUPPORTED_TARGETS: readonly ["comment", "thread", "reply", "member"];
897
+ type ReportTarget = (typeof SUPPORTED_TARGETS)[number];
898
+ interface NpReportRow {
899
+ id: string;
900
+ reporterId: string;
901
+ targetType: string;
902
+ targetId: string;
903
+ reason: string;
904
+ resolvedAt: Date | null;
905
+ resolvedByUserId: string | null;
906
+ resolvedByMemberId: string | null;
907
+ resolution: string | null;
908
+ siteId: string;
909
+ createdAt: Date;
910
+ }
911
+ interface FileReportInput {
912
+ reporterId: string;
913
+ targetType: ReportTarget;
914
+ targetId: string;
915
+ reason: string;
916
+ }
917
+ /**
918
+ * Members file reports against a piece of community content. The
919
+ * reason is free-form; mods triage it via `listReports` and
920
+ * `resolveReport`.
921
+ */
922
+ declare function fileReport(input: FileReportInput): Promise<NpReportRow>;
923
+ interface ListReportsOptions {
924
+ /** Default: only unresolved. Pass `"all"` to include resolved. */
925
+ status?: "unresolved" | "resolved" | "all";
926
+ /** Filter to a specific target type. */
927
+ targetType?: string;
928
+ /**
929
+ * Phase 18 — site scope. `undefined` (default) → use the
930
+ * request resolver's site. Pass an explicit string to view
931
+ * another tenant's queue (super-admin) or `null` to skip
932
+ * the filter entirely.
933
+ */
934
+ siteId?: string | null;
935
+ limit?: number;
936
+ offset?: number;
937
+ }
938
+ interface ListReportsResult {
939
+ reports: NpReportRow[];
940
+ totalDocs: number;
941
+ }
942
+ declare function listReports(options?: ListReportsOptions): Promise<ListReportsResult>;
943
+ interface ResolveReportInput {
944
+ reportId: string;
945
+ /** Free-form short label: e.g. `"hidden"`, `"banned"`, `"dismissed"`. */
946
+ resolution: string;
947
+ actor: Principal;
948
+ }
949
+ /**
950
+ * Marks a report resolved. Caller is responsible for taking the
951
+ * actual moderation action (hide, ban, etc.) — this only flips the
952
+ * report row and writes an audit entry.
953
+ */
954
+ declare function resolveReport(input: ResolveReportInput): Promise<NpReportRow>;
955
+ /** Cheap "is anything in the queue?" probe for the admin badge. */
956
+ declare function unresolvedReportCount(): Promise<number>;
957
+
958
+ /**
959
+ * Ban service. The 9.1a schema already had `np_bans`; this layer
960
+ * adds the issue / list / revoke flow plus audit logging.
961
+ *
962
+ * Scope rules in v1:
963
+ * - `site` — issuer must be staff (admin / editor / moderator).
964
+ * - `category`, `collection` — issuer must be a community-mod or
965
+ * a staff mod. We don't currently verify the issuer holds the
966
+ * matching scoped grant; the API layer is responsible for that
967
+ * check via `principalCan` before calling `issueBan`. The audit
968
+ * log records the issuer either way for forensic review.
969
+ */
970
+ type BanScope = "site" | "category" | "collection";
971
+ type BanKind = "temporary" | "permanent";
972
+ interface NpBanRow {
973
+ id: string;
974
+ memberId: string;
975
+ scopeType: BanScope;
976
+ scopeId: string | null;
977
+ kind: BanKind;
978
+ expiresAt: Date | null;
979
+ reason: string | null;
980
+ byUserId: string | null;
981
+ byMemberId: string | null;
982
+ /** Tenant the ban belongs to. Phase 18 added the column; the type was incomplete until #364. */
983
+ siteId: string;
984
+ createdAt: Date;
985
+ }
986
+ interface IssueBanInput {
987
+ memberId: string;
988
+ scopeType: BanScope;
989
+ scopeId?: string | null;
990
+ kind: BanKind;
991
+ /** Required when `kind === "temporary"`. */
992
+ expiresAt?: Date | null;
993
+ reason?: string | null;
994
+ actor: Principal;
995
+ }
996
+ declare function issueBan(input: IssueBanInput): Promise<NpBanRow>;
997
+ declare function listBansForMember(memberId: string): Promise<NpBanRow[]>;
998
+ interface RevokeBanInput {
999
+ banId: string;
1000
+ actor: Principal;
1001
+ }
1002
+ /**
1003
+ * "Revoking" a ban means deleting the row outright. The audit log
1004
+ * preserves the history (issue + revoke each leave an entry), so we
1005
+ * don't need a soft-delete column.
1006
+ */
1007
+ declare function revokeBan(input: RevokeBanInput): Promise<void>;
1008
+
1009
+ /**
1010
+ * Member role grant service. Wraps `np_member_roles` writes with
1011
+ * registry validation, audit logging, and friendly errors for the
1012
+ * `(member, role, scope_type, scope_id)` unique conflict that
1013
+ * Postgres surfaces as a 23505 raw error.
1014
+ *
1015
+ * Read path (`memberCan` in `community/can.ts`) already filters by
1016
+ * `expires_at IS NULL OR expires_at > now`, so an expired grant
1017
+ * disappears from the resolver automatically — `listMemberRoleGrants`
1018
+ * mirrors that filter so the admin UI doesn't show ghost rows.
1019
+ *
1020
+ * Permission gating is the API layer's job (today: admin-only). The
1021
+ * core helpers don't re-check, so a privileged programmatic caller
1022
+ * can grant on behalf of any actor.
1023
+ */
1024
+ interface NpMemberRoleGrantRow {
1025
+ id: string;
1026
+ memberId: string;
1027
+ role: string;
1028
+ scopeType: CommunityScope;
1029
+ scopeId: string | null;
1030
+ grantedBy: string | null;
1031
+ grantedAt: Date;
1032
+ expiresAt: Date | null;
1033
+ /** Tenant the grant belongs to. Phase 18 added the column; the type was incomplete until #364. */
1034
+ siteId: string;
1035
+ }
1036
+ interface GrantMemberRoleInput {
1037
+ memberId: string;
1038
+ role: string;
1039
+ scopeType: CommunityScope;
1040
+ /** Required when `scopeType !== "site"`; ignored otherwise. */
1041
+ scopeId?: string | null;
1042
+ /** Optional time-boxed grant. `null` = perpetual. */
1043
+ expiresAt?: Date | null;
1044
+ /** Staff user issuing the grant — recorded on the row + audit. */
1045
+ grantedByUserId: string;
1046
+ }
1047
+ declare function grantMemberRole(input: GrantMemberRoleInput): Promise<NpMemberRoleGrantRow>;
1048
+ /**
1049
+ * List currently-active grants for a member. Mirrors the
1050
+ * `memberCan` filter so expired rows are hidden.
1051
+ */
1052
+ declare function listMemberRoleGrants(memberId: string): Promise<NpMemberRoleGrantRow[]>;
1053
+ interface RevokeMemberRoleInput {
1054
+ grantId: string;
1055
+ revokedByUserId: string;
1056
+ }
1057
+ /**
1058
+ * Revoke = hard delete. Audit trail preserves history. Mirrors
1059
+ * `revokeBan`'s semantic — the grant either exists and counts, or
1060
+ * it doesn't; soft-deleted rows would only confuse the resolver.
1061
+ */
1062
+ declare function revokeMemberRole(input: RevokeMemberRoleInput): Promise<void>;
1063
+
1064
+ /**
1065
+ * Aggregate result of a member content purge. Comments are
1066
+ * counted as deleted regardless of soft-vs-hard semantic (the
1067
+ * underlying `staffDeleteComment` is a soft delete that wipes the
1068
+ * body). Documents are reported per-collection because the staff
1069
+ * UI typically wants to call out "X discussions, Y posts" rather
1070
+ * than a flat total. Media has a `skipped` bucket because
1071
+ * `deleteMedia` refuses rows that are still referenced from a
1072
+ * doc (`np_media_refs`) — those need to be unlinked first; the
1073
+ * mod can re-run after the reference is gone.
1074
+ */
1075
+ interface NpMemberPurgeResult {
1076
+ comments: number;
1077
+ documents: Record<string, number>;
1078
+ media: {
1079
+ deleted: number;
1080
+ skipped: number;
1081
+ };
1082
+ }
1083
+ /**
1084
+ * Wipes everything a single member authored: comments, top-level
1085
+ * docs in any collection that opted into `community.memberWrite`,
1086
+ * and uploaded media. Used by the moderation tooling to clean up
1087
+ * after a spam wave or a banned account.
1088
+ *
1089
+ * Failure mode is idempotent rather than atomic — if a transient
1090
+ * error interrupts the purge mid-way, the operator re-runs and
1091
+ * the helper skips items already removed (it always re-queries
1092
+ * the live state before each loop). The aggregate audit event
1093
+ * records the actual counts performed, not the intent.
1094
+ *
1095
+ * Out of scope (deliberately): banning, identity revocation,
1096
+ * follower / following links, reputation reset. Each of those is
1097
+ * a separate moderation action with its own UI; bundling them
1098
+ * into a single "purge" hides intent.
1099
+ */
1100
+ declare function purgeMemberContent(memberId: string, staffUser: NpAuthUser): Promise<NpMemberPurgeResult>;
1101
+
1102
+ /**
1103
+ * Phase 16.1 — member-to-member mute. One-directional: A
1104
+ * muting B hides B from A's surfaces (comments, notification
1105
+ * fan-out). B keeps posting normally.
1106
+ *
1107
+ * Distinct from `np_bans` (staff-issued, global write block).
1108
+ * Mutes are always self-service: a member calls these helpers
1109
+ * for their own mute list, never for someone else's.
1110
+ */
1111
+ interface NpMemberMuteRow {
1112
+ memberId: string;
1113
+ targetId: string;
1114
+ createdAt: Date;
1115
+ }
1116
+ interface MuteMemberInput {
1117
+ /** The muter — the current member taking the action. */
1118
+ memberId: string;
1119
+ /** The muted — whose content should disappear. */
1120
+ targetId: string;
1121
+ }
1122
+ declare function muteMember(input: MuteMemberInput): Promise<void>;
1123
+ declare function unmuteMember(input: MuteMemberInput): Promise<boolean>;
1124
+ /**
1125
+ * `true` when `memberId` has muted `targetId` on the current
1126
+ * site. Used by comment listing + notification fan-out to
1127
+ * filter views and skip alerts.
1128
+ */
1129
+ declare function isMuted(input: MuteMemberInput): Promise<boolean>;
1130
+ /**
1131
+ * Returns the set of `targetId`s the given member has muted on
1132
+ * the current site. Used to filter listComments output in one
1133
+ * DB round-trip rather than `isMuted()` per row.
1134
+ */
1135
+ declare function getMutedTargetIds(memberId: string): Promise<Set<string>>;
1136
+ interface NpMemberMuteSummary {
1137
+ targetId: string;
1138
+ handle: string;
1139
+ displayName: string;
1140
+ createdAt: string;
1141
+ }
1142
+ interface ListMutesOptions {
1143
+ /** Default 50, max 200. */
1144
+ limit?: number;
1145
+ }
1146
+ /**
1147
+ * Surfaces the muter's list with the muted member's display
1148
+ * info joined in, so the settings UI doesn't have to round-
1149
+ * trip through `/api/members/[handle]` for every row.
1150
+ */
1151
+ declare function listMutes(memberId: string, options?: ListMutesOptions): Promise<NpMemberMuteSummary[]>;
1152
+
1153
+ /**
1154
+ * Phase 16.2 — @mention extraction + notification fan-out.
1155
+ *
1156
+ * The mention vocabulary mirrors the handle constraint enforced
1157
+ * during registration (`/^[a-z0-9][a-z0-9_-]{2,29}$/`). The matcher
1158
+ * uses a negative lookbehind so `email@host.com` doesn't trigger a
1159
+ * mention, plus a negative lookahead so `@alice-` (handle followed
1160
+ * by a hyphen that's not part of the handle) is rejected — handles
1161
+ * end at non-handle characters, never mid-symbol.
1162
+ *
1163
+ * Fan-out semantics:
1164
+ * - Self-mentions are skipped (the author already knows).
1165
+ * - Caller-supplied `exclude` set lets the comment write path
1166
+ * skip the parent author so they don't get both `comment.reply`
1167
+ * AND `comment.mention`.
1168
+ * - Caller-supplied `previousHandles` lets the edit path only
1169
+ * notify newly-added mentions (otherwise toggling a single
1170
+ * other word in a comment would re-notify everyone).
1171
+ * - Inactive / banned / deleted members are filtered out at
1172
+ * resolve time.
1173
+ * - Mute is enforced inside `createNotification` (the
1174
+ * recipient's mute list drops actor-keyed notifications).
1175
+ */
1176
+ /** Source-of-truth handle pattern, kept in sync with `apps/web` register routes. */
1177
+ declare const MENTION_HANDLE_RE: RegExp;
1178
+ interface NpMentionTarget {
1179
+ id: string;
1180
+ handle: string;
1181
+ }
1182
+ /**
1183
+ * Extract unique mention handles from plain text or markdown source.
1184
+ * Order is preserved (first appearance wins) so a UI that wants to
1185
+ * display "you mentioned @alice and @bob" gets the same order as
1186
+ * the body text.
1187
+ */
1188
+ declare function extractMentionHandles(source: string): string[];
1189
+ /**
1190
+ * Walk a Lexical-shaped rich-text payload, concatenate its text
1191
+ * nodes, and run the mention extractor over the joined result.
1192
+ * Mirrors the search-index walker (`collections/search.ts`) so a
1193
+ * mention split across two adjacent text spans (e.g. `@` and
1194
+ * `alice` in different runs because of formatting toggles) still
1195
+ * resolves correctly — text nodes are joined without separators.
1196
+ */
1197
+ declare function extractMentionHandlesFromRichText(content: unknown): string[];
1198
+ /**
1199
+ * Scan a collection-document data payload (the same shape passed
1200
+ * to `createMemberDocument` / `updateMemberDocument`) and pull
1201
+ * out every mention handle it contains. String values are scanned
1202
+ * with the markdown extractor; object values shaped like Lexical
1203
+ * rich text (`{ root: { children: [...] } }`) are walked. Other
1204
+ * values are ignored.
1205
+ *
1206
+ * Field names are not assumed: any string or rich-text field
1207
+ * contributes. The mention pattern is anchored to `@<handle>`
1208
+ * with handle-shape constraints, so unrelated string fields
1209
+ * (`category: "news"`) won't trigger false positives.
1210
+ */
1211
+ declare function extractMentionHandlesFromDocData(data: Record<string, unknown>): string[];
1212
+ /**
1213
+ * Resolve handles to active member ids. Inactive / banned /
1214
+ * deleted members are filtered out so a mention of an account
1215
+ * the site no longer wants to notify is silently dropped (rather
1216
+ * than raising an error to the writer — the writer can't tell the
1217
+ * difference between "typo" and "account closed", and either way
1218
+ * the right behaviour is "no notification").
1219
+ *
1220
+ * Lookups are case-insensitive on the handle (the storage column
1221
+ * stores the canonical lowercased form).
1222
+ */
1223
+ declare function resolveMentionedMembers(handles: string[]): Promise<NpMentionTarget[]>;
1224
+ interface FanOutMentionsInput {
1225
+ /** The author whose write triggered the fan-out. Self-mentions are skipped. */
1226
+ actorMemberId: string;
1227
+ /** Notification `kind` (e.g. `"comment.mention"`, `"discussion.mention"`). */
1228
+ kind: string;
1229
+ /**
1230
+ * Plain text or markdown to scan. Either `source` or `content`
1231
+ * (or both) must be provided; if both are set the handles are
1232
+ * unioned.
1233
+ */
1234
+ source?: string;
1235
+ /** Lexical-shaped rich-text JSON to scan. */
1236
+ content?: unknown;
1237
+ /**
1238
+ * Collection-document data payload to scan. All string +
1239
+ * rich-text fields contribute. Useful for the
1240
+ * `createMemberDocument` / `updateMemberDocument` paths.
1241
+ */
1242
+ data?: Record<string, unknown>;
1243
+ /**
1244
+ * Recipients that already received a notification for this same
1245
+ * event (e.g. the parent author got `comment.reply`). They are
1246
+ * skipped to avoid the "two pings for one comment" pattern.
1247
+ */
1248
+ exclude?: ReadonlySet<string>;
1249
+ /** Merged into the notification payload. `mentionedMemberId` is added automatically. */
1250
+ payload?: Record<string, unknown>;
1251
+ /**
1252
+ * Edit path: handles that were present in the prior revision
1253
+ * are skipped so toggling unrelated words doesn't re-notify
1254
+ * everyone already mentioned.
1255
+ */
1256
+ previousHandles?: ReadonlySet<string>;
1257
+ }
1258
+ /**
1259
+ * Fan-out mention notifications. Returns the number of
1260
+ * notifications actually inserted (mute / inactive / self / dedup
1261
+ * exclusions all reduce the count).
1262
+ */
1263
+ declare function fanOutMentionNotifications(input: FanOutMentionsInput): Promise<number>;
1264
+
1265
+ /**
1266
+ * Phase 16.3 — per-member notification preferences.
1267
+ *
1268
+ * The persisted shape is a JSONB blob on `np_members.notification_prefs`
1269
+ * so adding fields (digest cadence in 16.4, channel toggles later)
1270
+ * stays a typescript-only change. Today we honor:
1271
+ *
1272
+ * - `disabled: string[]` — kinds the member opted out of. The
1273
+ * `createNotification` gate consults this and silently drops
1274
+ * the row. Default empty (= every kind enabled).
1275
+ *
1276
+ * The vocabulary of `kinds` is defined here so the UI has a single
1277
+ * source of truth — settings page renders a toggle for each entry,
1278
+ * and the API only accepts kinds that appear in the list (so a
1279
+ * forged client can't disable arbitrary strings to bloat the JSONB).
1280
+ */
1281
+ interface NpNotificationKindMeta {
1282
+ kind: string;
1283
+ /** Short human label. */
1284
+ label: string;
1285
+ /** Description rendered next to the toggle. */
1286
+ description: string;
1287
+ }
1288
+ /** Plugin-extensible registration. Idempotent on `kind`. */
1289
+ declare function registerNotificationKind(meta: NpNotificationKindMeta): void;
1290
+ /** Returns the union of builtin + plugin-registered kinds. */
1291
+ declare function listNotificationKinds(): NpNotificationKindMeta[];
1292
+ type NpDigestCadence = "off" | "daily" | "weekly";
1293
+ interface NpNotificationPrefs {
1294
+ /** Kinds the member opted out of. Empty / missing = all kinds enabled. */
1295
+ disabled: string[];
1296
+ /**
1297
+ * Phase 16.4 — email digest cadence. `off` (default) disables
1298
+ * the digest. `daily` and `weekly` opt the member into a
1299
+ * batched email of unread notifications, scheduled by the
1300
+ * `notifications:sendDigest` recurring job.
1301
+ */
1302
+ digest: NpDigestCadence;
1303
+ /**
1304
+ * Set when the digest sweep last sent an email to this member.
1305
+ * Used to scope each digest to "unread since the last send" so
1306
+ * members aren't repeatedly emailed about the same row. Stored
1307
+ * as ISO-8601 string in the JSONB blob; `null` for accounts
1308
+ * that have never received a digest.
1309
+ *
1310
+ * Issue #218 — superseded by `lastDigestAtBySite` once a member
1311
+ * receives a digest under the per-site fan-out path. The legacy
1312
+ * field is preserved for forward-compat reads (single-site
1313
+ * deploys still see + write it via the fallback chain) and as
1314
+ * a "any digest, ever?" marker for analytics.
1315
+ */
1316
+ lastDigestAt: string | null;
1317
+ /**
1318
+ * Issue #218 — per-(site, cadence) timestamp map. Replaces the
1319
+ * single `lastDigestAt` for multi-site deployments. Empty when
1320
+ * the member has never received a digest under the site-scoped
1321
+ * sweep.
1322
+ */
1323
+ lastDigestAtBySite: Record<string, Partial<Record<NpDigestCadence, string>>>;
1324
+ }
1325
+ declare function getMemberNotificationPrefs(memberId: string): Promise<NpNotificationPrefs>;
1326
+ interface SetMemberNotificationPrefsInput {
1327
+ memberId: string;
1328
+ /**
1329
+ * Replacement deny-list. Only kinds listed in
1330
+ * `listNotificationKinds()` are accepted; unknown strings
1331
+ * raise NpValidationError so a forged client can't bloat the
1332
+ * JSONB or hide future framework kinds via a stale list.
1333
+ * Optional — when omitted the existing list is preserved.
1334
+ */
1335
+ disabled?: string[];
1336
+ /**
1337
+ * Phase 16.4 — email digest cadence. Optional; when omitted
1338
+ * the existing setting is preserved. `off` clears the
1339
+ * member's enrollment.
1340
+ */
1341
+ digest?: NpDigestCadence;
1342
+ }
1343
+ declare function setMemberNotificationPrefs(input: SetMemberNotificationPrefsInput): Promise<NpNotificationPrefs>;
1344
+ /**
1345
+ * Phase 16.4 — bookkeeping helper called by the digest sweep
1346
+ * after a successful email send. Stamps `lastDigestAt` so the
1347
+ * next run scopes its query to the correct window. Read-merge
1348
+ * to preserve other JSONB keys.
1349
+ *
1350
+ * Issue #218 — when a `siteId` + `cadence` pair is supplied,
1351
+ * the per-site / per-cadence map is updated so the next sweep
1352
+ * for that tenant scopes to the correct "since" window. The
1353
+ * legacy single `lastDigestAt` field is also stamped for
1354
+ * forward-compat with single-site deploys (and as a "received
1355
+ * any digest, ever?" marker for analytics).
1356
+ */
1357
+ declare function recordDigestSent(memberId: string, sentAt: Date, scope?: {
1358
+ siteId: string;
1359
+ cadence: NpDigestCadence;
1360
+ }): Promise<void>;
1361
+ /**
1362
+ * Inbox-side gate consulted by `createNotification`. Returns
1363
+ * `false` when the recipient explicitly opted out of `kind`.
1364
+ * Errors fail-open (return `true`) so a transient DB blip
1365
+ * doesn't silently swallow notifications.
1366
+ */
1367
+ declare function isNotificationKindEnabled(memberId: string, kind: string): Promise<boolean>;
1368
+
1369
+ /**
1370
+ * Phase 16.4 — email digest fan-out. The `notifications:sendDigest`
1371
+ * recurring job calls `runDigestSweep(cadence)` on a daily and a
1372
+ * weekly schedule; the function fetches every active member who
1373
+ * opted into that cadence, builds an inbox summary scoped to "since
1374
+ * last digest" (falling back to the cadence window when the member
1375
+ * has never received one), renders an email through the configured
1376
+ * `NpEmailAdapter`, and stamps `lastDigestAt` on success.
1377
+ *
1378
+ * The job is idempotent enough for production use: a sweep that
1379
+ * runs twice for the same window won't re-email members because
1380
+ * `lastDigestAt` advances on the first send. Failures inside the
1381
+ * loop are logged-and-continued — one stuck member doesn't block
1382
+ * the rest of the sweep.
1383
+ */
1384
+ interface NpDigestNotificationSummary {
1385
+ id: string;
1386
+ kind: string;
1387
+ payload: Record<string, unknown>;
1388
+ createdAt: Date;
1389
+ }
1390
+ interface NpDigestEmailContent {
1391
+ subject: string;
1392
+ text: string;
1393
+ html: string;
1394
+ }
1395
+ interface BuildDigestEmailInput {
1396
+ member: {
1397
+ displayName: string;
1398
+ handle: string;
1399
+ };
1400
+ notifications: NpDigestNotificationSummary[];
1401
+ cadence: NpDigestCadence;
1402
+ /** Site display name; defaults to "your site" so the noop adapter is still readable. */
1403
+ siteName?: string;
1404
+ }
1405
+ /**
1406
+ * Pure renderer; exposed so plugins / tests can call it without
1407
+ * the DB read path.
1408
+ */
1409
+ declare function buildDigestEmail(input: BuildDigestEmailInput): NpDigestEmailContent;
1410
+ interface RunDigestSweepInput {
1411
+ cadence: "daily" | "weekly";
1412
+ /** Defaults to `new Date()`. Tests override for determinism. */
1413
+ now?: Date;
1414
+ /** Site name woven into subject + body. Defaults to `"your site"`. */
1415
+ siteName?: string;
1416
+ }
1417
+ interface RunDigestSweepResult {
1418
+ considered: number;
1419
+ sent: number;
1420
+ skipped: number;
1421
+ failed: number;
1422
+ }
1423
+ declare function runDigestSweep(input: RunDigestSweepInput): Promise<RunDigestSweepResult>;
1424
+
1425
+ export { type AuditActor, type AuditActorKind, type AuditEventRow, type BanKind, type BanScope, type BuildDigestEmailInput, type CommentStatus, type CommunityCapability, type CommunityRoleDefinition, type CommunityScope, type CreateNotificationInput, DEFAULT_COMMUNITY_SETTINGS, DEFAULT_REACTION_KINDS, type FanOutMentionsInput, type FileReportInput, type GrantMemberRoleInput, type IssueBanInput, type ListAuditOptions, type ListMutesOptions, type ListNotificationsOptions, type ListReportsOptions, type ListReportsResult, MENTION_HANDLE_RE, type MarkReadInput, type MemberAction, type MemberCanTarget, type MuteMemberInput, type NpBanRow, type NpCommentCreateInput, type NpCommentDeleteInput, type NpCommentHideInput, type NpCommentListOptions, type NpCommentListResult, type NpCommentRestoreInput, type NpCommentRow, type NpCommentSort, type NpCommentUpdateInput, type NpCommunitySettings, type NpDigestCadence, type NpDigestEmailContent, type NpDigestNotificationSummary, type NpFollowInput, type NpFollowRow, type NpMemberMuteRow, type NpMemberMuteSummary, type NpMemberProfile, type NpMemberPurgeResult, type NpMemberRoleGrantRow, type NpMemberUploadQuota, type NpMentionTarget, type NpNotificationKindMeta, type NpNotificationListResult, type NpNotificationPrefs, type NpNotificationRow, type NpProfanityAdapter, type NpProfanityCheckContext, type NpProfanityVerdict, type NpProfanityVerdictKind, type NpReactToInput, type NpReactionRow, type NpReportRow, type NpReputationAdapter, type NpReputationEvent, type NpSpamAdapter, type NpSpamCheckContext, type NpSpamVerdict, type NpSpamVerdictKind, type Principal, type RecordAuditEventInput, type ResolveReportInput, type RevokeBanInput, type RevokeMemberRoleInput, type RunDigestSweepInput, type RunDigestSweepResult, type SetMemberNotificationPrefsInput, addReaction, applyReputation, assertNotBanned, assertOwnsNotification, assertReactableExists, buildDigestEmail, countReactions, createComment, createNotification, deleteComment, extractMentionHandles, extractMentionHandlesFromDocData, extractMentionHandlesFromRichText, fanOutMentionNotifications, fileReport, follow, getCommunityRole, getCommunitySettings, getMemberNotificationPrefs, getMemberProfile, getMemberProfiles, getMutedTargetIds, getProfanityAdapter, getReputationAdapter, getSpamAdapter, grantMemberRole, hideComment, isFollowing, isMuted, isNotificationKindEnabled, issueBan, listAuditEvents, listBansForMember, listComments, listCommunityRoles, listFollowing, listMemberReactions, listMemberRoleGrants, listMutes, listNotificationKinds, listNotifications, listReports, markAllNotificationsRead, markNotificationsRead, memberCan, muteMember, principalCan, purgeMemberContent, recordAuditEvent, recordDigestSent, registerCommunityRole, registerNotificationKind, removeReaction, renderCommentMarkdown, resetCommunityRoles, resetProfanityAdapter, resetReputationAdapter, resetSpamAdapter, resolveMentionedMembers, resolveReport, restoreComment, revokeBan, revokeMemberRole, runDigestSweep, setMemberNotificationPrefs, setProfanityAdapter, setReputationAdapter, setSpamAdapter, staffDeleteComment, staffHideComment, staffRestoreComment, unfollow, unmuteMember, unreadNotificationCount, unresolvedReportCount, updateComment, updateCommunitySettings, validateCommunitySettingsPatch, withMemberWrite };