@bunizao/contracts 0.5.0 → 0.7.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.
package/dist/admin.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ClientFingerprint, CommentAuthAtWrite, CommentClaimMethod, CommentStatus, Interaction } from './comments';
1
2
  import type { DeliveryMode, NotifyAuditEventType, NotifyChannel, SubscriberRecord, SubscriberStatus } from './notify';
2
3
  export interface SubscriberChannelCount {
3
4
  total: number;
@@ -85,3 +86,548 @@ export interface BroadcastSendResult {
85
86
  failedCount: number;
86
87
  status: BroadcastRecord['status'];
87
88
  }
89
+ /** The queue's status filter; the same four values as `CommentStatus`, in
90
+ the order the portal tabs show them. */
91
+ export type AdminCommentStatus = CommentStatus;
92
+ /** One row of the comment queue. The public `Comment` never carries the
93
+ writer's standing or the moderation verdict; this does, and adds the
94
+ actor block that says where the row came from. */
95
+ export interface AdminCommentRecord {
96
+ id: string;
97
+ postId: string;
98
+ parentId: string | null;
99
+ author: string;
100
+ /** True when the writer had a confirmed identity at the time of writing. */
101
+ verified: boolean;
102
+ body: string;
103
+ status: AdminCommentStatus;
104
+ moderationAction: string | null;
105
+ moderationReason: string | null;
106
+ moderationNote: string | null;
107
+ moderationModel: string | null;
108
+ country: string | null;
109
+ createdAt: string;
110
+ editedAt: string | null;
111
+ /** Filled from the post registry; null when it could not say. */
112
+ postTitle: string | null;
113
+ postSlug: string | null;
114
+ actor: AdminCommentActor;
115
+ }
116
+ /** One page of the moderation queue. */
117
+ export interface AdminCommentListResult {
118
+ comments: AdminCommentRecord[];
119
+ /** Rows matching the filter, ignoring the page window. */
120
+ total: number;
121
+ /** `offset + comments.length`, or null on the last page. */
122
+ nextOffset: number | null;
123
+ }
124
+ /** What a ban can hold onto. Values are hashes (or the ASN as text) for
125
+ every kind but the two domain kinds, which are stored raw -- a domain is
126
+ not personal data. `client_fp` matches either the exact or the stable
127
+ device hash. */
128
+ export type AdminBanKeyType = 'email' | 'session' | 'ip' | 'ip24' | 'fp' | 'asn' | 'client_fp' | 'domain' | 'email_domain';
129
+ export type AdminBanSource = 'portal' | 'telegram' | 'script';
130
+ /** One key a queue row, a reaction, or a source profile can be pivoted on:
131
+ `GET /admin/comments?key=&value=`, `GET /admin/reactions?key=&value=`
132
+ and `GET /admin/sources/:type/:value` all take one of these. Every ban
133
+ key type is also a source key type; the rest are cluster keys that can be
134
+ looked at but not banned. */
135
+ export type AdminSourceKeyType = AdminBanKeyType | 'client_fp_stable' | 'storage_id' | 'body_hash';
136
+ /** The `signals` blob on a row: the request's network and header set,
137
+ serialised once at insert and nulled by the 90-day sweep. */
138
+ export interface ActorDetail {
139
+ colo: string | null;
140
+ region: string | null;
141
+ timezone: string | null;
142
+ httpProtocol: string | null;
143
+ tlsVersion: string | null;
144
+ tlsCipher: string | null;
145
+ tlsCiphersSha1: string | null;
146
+ tlsExtensionsSha1: string | null;
147
+ tlsHelloLength: number | null;
148
+ rttMs: number | null;
149
+ /** From the static hosting-ASN list; a vpn hint, not a bot hint. */
150
+ asnKind: 'hosting' | 'other' | null;
151
+ acceptLanguage: string | null;
152
+ acceptEncoding: string | null;
153
+ chUa: string | null;
154
+ chPlatform: string | null;
155
+ chMobile: string | null;
156
+ /** `sec-fetch-dest/mode/site` joined with `/`. */
157
+ secFetch: string | null;
158
+ priority: string | null;
159
+ referer: string | null;
160
+ origin: string | null;
161
+ /** `cf-worker` header present: the request came out of another Worker. */
162
+ viaWorker: boolean;
163
+ }
164
+ /** The `client` blob on a row: the fingerprint components and interaction
165
+ aggregates exactly as the browser sent them (bounded), plus the hint
166
+ lists the server derived from them and the headers together. */
167
+ export interface ActorClient {
168
+ components: ClientFingerprint | null;
169
+ interaction: Interaction | null;
170
+ /** Contradictions that count into the row's `botHints` total. */
171
+ botHints: string[];
172
+ /** Contradictions shown and not counted: what a VPN looks like. */
173
+ vpnHints: string[];
174
+ }
175
+ /** Behavioural integers, kept past the sweep. Comments carry dwell, the
176
+ Turnstile age, the link count and the MX result; reactions carry the
177
+ Turnstile age and which door the heart came through. */
178
+ export interface AdminActorBehaviour {
179
+ dwellMs?: number | null;
180
+ turnstileAgeMs?: number | null;
181
+ linkCount?: number | null;
182
+ emailMx?: boolean | null;
183
+ emailGravatar?: boolean | null;
184
+ auth?: 'turnstile' | 'pass' | 'verified' | null;
185
+ }
186
+ /** The row's keys, at full length: the portal shortens a hash to its first
187
+ eight characters for the eye and keeps the whole value for the pivot
188
+ link. Domains are raw, and `session` is '' when the row predates
189
+ sessions. */
190
+ export interface AdminActorKeys {
191
+ session: string;
192
+ ip: string | null;
193
+ ip24: string | null;
194
+ fp: string | null;
195
+ email: string | null;
196
+ clientFp: string | null;
197
+ clientFpStable: string | null;
198
+ storageId: string | null;
199
+ emailDomain: string | null;
200
+ bodyHash: string | null;
201
+ linkDomains: string[];
202
+ }
203
+ export type AdminClusterKey = 'session' | 'ip' | 'ip24' | 'fp' | 'email' | 'clientFp' | 'clientFpStable' | 'storageId' | 'emailDomain' | 'bodyHash';
204
+ export interface AdminClusterCount {
205
+ comments: number;
206
+ held: number;
207
+ reactions: number;
208
+ }
209
+ /** Where a row came from, as far as the write path could tell. Shared by
210
+ comments and reactions; a reaction's block has no email, no link domains
211
+ and no body hash. */
212
+ export interface AdminCommentActor {
213
+ readerId: string | null;
214
+ /** Missing on older responses means unknown, not authenticated. */
215
+ authAtWrite?: CommentAuthAtWrite;
216
+ claimedAt?: string | null;
217
+ claimMethod?: CommentClaimMethod | null;
218
+ /** Plaintext while the row still holds it (verified: with the row;
219
+ unverified: seven days). */
220
+ email: string | null;
221
+ /** Plaintext while inside the 90-day window. */
222
+ ip: string | null;
223
+ ua: string | null;
224
+ browser: string | null;
225
+ os: string | null;
226
+ country: string | null;
227
+ city: string | null;
228
+ asn: number | null;
229
+ asOrg: string | null;
230
+ /** This write minted the anonymous session cookie. */
231
+ sessionNew: boolean;
232
+ /** Count of tripped bot hints; the names are in `client.botHints`. */
233
+ botHints: number;
234
+ /** The `signals` blob, parsed. Null once the sweep has run. */
235
+ detail: ActorDetail | null;
236
+ /** The `client` blob, parsed. Null when the write carried neither
237
+ fingerprint nor interaction, or once the sweep has run. */
238
+ client: ActorClient | null;
239
+ behaviour: AdminActorBehaviour;
240
+ keys: AdminActorKeys;
241
+ /** Which of this row's keys are on the ban list right now. */
242
+ banned: AdminBanKeyType[];
243
+ /** Other rows sharing each key in the last 90 days, excluding this one. */
244
+ cluster: Record<AdminClusterKey, AdminClusterCount>;
245
+ /** Same, per link domain on this row. */
246
+ domainCluster: Array<{
247
+ domain: string;
248
+ comments: number;
249
+ held: number;
250
+ banned: boolean;
251
+ }>;
252
+ /** Published comments at this email domain in the last 90 days.
253
+ Missing or null means the broad domain ban cannot be evaluated. */
254
+ emailDomainPublishedComments?: number | null;
255
+ }
256
+ /** One reaction row as the reactions list shows it. */
257
+ export interface AdminReactionRecord {
258
+ /** ULID, like a comment id. */
259
+ id: string;
260
+ targetType: 'post' | 'comment';
261
+ targetId: string;
262
+ postId: string | null;
263
+ postTitle: string | null;
264
+ postSlug: string | null;
265
+ emoji: string;
266
+ createdAt: string;
267
+ actor: AdminCommentActor;
268
+ }
269
+ export interface AdminReactionListResult {
270
+ reactions: AdminReactionRecord[];
271
+ total: number;
272
+ nextOffset: number | null;
273
+ }
274
+ export interface AdminBan {
275
+ keyType: AdminBanKeyType;
276
+ keyValue: string;
277
+ note: string | null;
278
+ source: AdminBanSource;
279
+ createdAt: string;
280
+ expiresAt: string | null;
281
+ /** Rows in the last 90 days, both tables, matching this key. */
282
+ hits: number;
283
+ }
284
+ /** `POST /admin/bans`: every key in one write. With `purge`, the source's
285
+ comments from the last 90 days are soft-deleted and its reaction rows
286
+ removed, each logged. `revokeReaderId` flips `notify_subscribers.banned`
287
+ for a verified writer -- the account lever, offered on verified rows. */
288
+ export interface AdminBanInput {
289
+ keys: Array<{
290
+ type: AdminBanKeyType;
291
+ value: string;
292
+ }>;
293
+ note?: string;
294
+ expiresAt?: string | null;
295
+ purge?: boolean;
296
+ revokeReaderId?: string | null;
297
+ }
298
+ export interface AdminBanResult {
299
+ bans: AdminBan[];
300
+ purged: {
301
+ comments: number;
302
+ reactions: number;
303
+ };
304
+ operation?: AdminBanOperation | null;
305
+ }
306
+ export interface AdminBanPreview {
307
+ accounts: number;
308
+ sessions: number;
309
+ comments: Record<AdminCommentStatus | 'total', number>;
310
+ reactions: number;
311
+ purge: {
312
+ comments: number;
313
+ reactions: number;
314
+ };
315
+ windowDays: 90;
316
+ purgeLimit: number;
317
+ purgeAllowed: boolean;
318
+ }
319
+ export interface AdminBanOperation {
320
+ id: string;
321
+ createdAt: string;
322
+ source: AdminBanSource;
323
+ keys: AdminBanInput['keys'];
324
+ note: string | null;
325
+ purged: {
326
+ comments: number;
327
+ reactions: number;
328
+ };
329
+ restoredAt: string | null;
330
+ restorableUntil: string;
331
+ restored: {
332
+ comments: number;
333
+ reactions: number;
334
+ };
335
+ skipped: {
336
+ comments: number;
337
+ reactions: number;
338
+ };
339
+ }
340
+ export interface AdminBanOperationListResult {
341
+ operations: AdminBanOperation[];
342
+ }
343
+ export interface AdminBanRestoreResult {
344
+ operation: AdminBanOperation;
345
+ }
346
+ export interface AdminBanListResult {
347
+ bans: AdminBan[];
348
+ }
349
+ /** Everything about one key in one response --
350
+ `GET /admin/sources/:type/:value`. */
351
+ export interface AdminSourceProfile {
352
+ key: {
353
+ type: AdminSourceKeyType;
354
+ value: string;
355
+ };
356
+ identitySummary?: {
357
+ verifiedAccounts: number;
358
+ anonymousSessions: number;
359
+ claimedComments: number;
360
+ sharedStorage: boolean;
361
+ sharedFingerprint: boolean;
362
+ };
363
+ firstSeenAt: string | null;
364
+ lastSeenAt: string | null;
365
+ comments: {
366
+ total: number;
367
+ byStatus: Record<AdminCommentStatus, number>;
368
+ /** Newest 50. */
369
+ rows: AdminCommentRecord[];
370
+ };
371
+ reactions: {
372
+ total: number;
373
+ byTarget: Array<{
374
+ targetType: 'post' | 'comment';
375
+ targetId: string;
376
+ postTitle: string | null;
377
+ count: number;
378
+ }>;
379
+ /** Newest 50. */
380
+ rows: AdminReactionRecord[];
381
+ };
382
+ /** Distinct observed values. Shared environments do not establish a person. */
383
+ spread: Record<'session' | 'ip' | 'ip24' | 'fp' | 'clientFp' | 'clientFpStable' | 'storageId' | 'email' | 'asn' | 'ua', number>;
384
+ /** Every bot and vpn hint this source has tripped, with how often. */
385
+ hints: Array<{
386
+ hint: string;
387
+ kind: 'bot' | 'vpn';
388
+ count: number;
389
+ }>;
390
+ /** Links describe verified-at-write account evidence or shared storage.
391
+ clientFpStable remains zero for compatibility; it never creates links. */
392
+ linked: {
393
+ sessions: number;
394
+ via: Record<'email' | 'clientFpStable' | 'storageId', number>;
395
+ carried: Record<'ip24' | 'clientFpStable' | 'email' | 'storageId', number>;
396
+ comments: number;
397
+ held: number;
398
+ reactions: number;
399
+ };
400
+ /** Writes per hour over the source's last 7 days. */
401
+ hourly: Array<{
402
+ hour: string;
403
+ comments: number;
404
+ reactions: number;
405
+ }>;
406
+ behaviour: {
407
+ dwellMsMedian: number | null;
408
+ turnstileAgeMsMedian: number | null;
409
+ /** 0..1 */
410
+ newSessionShare: number;
411
+ authMix: Record<'turnstile' | 'pass' | 'verified', number>;
412
+ };
413
+ bans: AdminBan[];
414
+ }
415
+ /** One grouped row in an insights table. `held` and `heldRate` (0..1) are
416
+ null on the reaction tables, where nothing is held. */
417
+ export interface AdminInsightRow {
418
+ count: number;
419
+ held: number | null;
420
+ heldRate: number | null;
421
+ /** Distinct sessions behind the count. */
422
+ sessions: number;
423
+ }
424
+ export type AdminCommentInsightsWindow = '7d' | '30d' | '90d';
425
+ export type AdminReactionInsightsWindow = '48h' | '7d';
426
+ export interface AdminCommentQuality {
427
+ since: string;
428
+ collectedSince: string | null;
429
+ available: {
430
+ moderation: boolean;
431
+ requests: boolean;
432
+ clientReports: boolean;
433
+ };
434
+ /** A cohort of automatically held comments and the first later owner decision. */
435
+ moderation: {
436
+ held: number;
437
+ reviewed: number;
438
+ released: number;
439
+ releasedShare: number | null;
440
+ };
441
+ /** Request outcomes include retries. Authenticated does not mean known benign. */
442
+ requests: {
443
+ attempts: number;
444
+ failures: number;
445
+ failureShare: number | null;
446
+ authenticatedAttempts: number;
447
+ authenticatedFailures: number;
448
+ authenticatedFailureShare: number | null;
449
+ outcomes: Array<{
450
+ kind: 'comment' | 'reaction';
451
+ outcome: string;
452
+ count: number;
453
+ }>;
454
+ };
455
+ /** Self-reported and incomplete when the browser cannot deliver telemetry. */
456
+ clientReports: {
457
+ reports: number;
458
+ failures: number;
459
+ networkFailures: number;
460
+ challengedAttempts: number;
461
+ repeatedChallenges: number;
462
+ untrusted: true;
463
+ };
464
+ }
465
+ /** `GET /admin/comments/insights?window=`. Every table carries a held rate,
466
+ because a source is only interesting relative to how the automatic pass
467
+ treats it. NULL groups show as their own row, never dropped. */
468
+ export interface AdminCommentInsights {
469
+ window: AdminCommentInsightsWindow;
470
+ since: string;
471
+ quality?: AdminCommentQuality;
472
+ networks: Array<AdminInsightRow & {
473
+ asn: number | null;
474
+ asOrg: string | null;
475
+ }>;
476
+ countries: Array<AdminInsightRow & {
477
+ country: string | null;
478
+ }>;
479
+ subnets: Array<AdminInsightRow & {
480
+ ip24: string | null;
481
+ sampleIp: string | null;
482
+ }>;
483
+ browsers: Array<AdminInsightRow & {
484
+ browser: string | null;
485
+ os: string | null;
486
+ }>;
487
+ devices: Array<AdminInsightRow & {
488
+ clientFp: string | null;
489
+ renderer: string | null;
490
+ screen: string | null;
491
+ platform: string | null;
492
+ subnets: number;
493
+ }>;
494
+ botHints: Array<{
495
+ hint: string;
496
+ count: number;
497
+ held: number;
498
+ heldRate: number;
499
+ }>;
500
+ vpnHints: Array<{
501
+ hint: string;
502
+ count: number;
503
+ held: number;
504
+ heldRate: number;
505
+ }>;
506
+ linkDomains: Array<AdminInsightRow & {
507
+ domain: string;
508
+ banned: boolean;
509
+ }>;
510
+ duplicates: Array<AdminInsightRow & {
511
+ bodyHash: string;
512
+ sample: string;
513
+ }>;
514
+ emailDomains: Array<AdminInsightRow & {
515
+ domain: string;
516
+ mxShare: number | null;
517
+ banned: boolean;
518
+ }>;
519
+ tlsStacks: Array<{
520
+ browser: string | null;
521
+ ciphersSha1: string | null;
522
+ count: number;
523
+ held: number;
524
+ heldRate: number;
525
+ }>;
526
+ typing: Array<{
527
+ bucket: string;
528
+ count: number;
529
+ held: number;
530
+ heldRate: number;
531
+ }>;
532
+ dailyByStatus: Array<{
533
+ day: string;
534
+ published: number;
535
+ held: number;
536
+ rejected: number;
537
+ deleted: number;
538
+ }>;
539
+ dwell: Array<{
540
+ bucket: string;
541
+ count: number;
542
+ held: number;
543
+ heldRate: number;
544
+ }>;
545
+ sessionAge: Array<{
546
+ day: string;
547
+ writes: number;
548
+ newShare: number;
549
+ }>;
550
+ email: Array<{
551
+ kind: 'without' | 'verified' | 'unverified' | 'disposable';
552
+ count: number;
553
+ held: number;
554
+ heldRate: number;
555
+ }>;
556
+ overturns: Array<{
557
+ week: string;
558
+ falsePositives: number;
559
+ falseNegatives: number;
560
+ }>;
561
+ banHits: Array<{
562
+ keyType: AdminBanKeyType;
563
+ keyValue: string;
564
+ note: string | null;
565
+ hits: number;
566
+ }>;
567
+ }
568
+ /** `GET /admin/reactions/insights?window=`. */
569
+ export interface AdminReactionInsights {
570
+ window: AdminReactionInsightsWindow;
571
+ since: string;
572
+ hourly: Array<{
573
+ hour: string;
574
+ reactions: number;
575
+ sessions: number;
576
+ subnets: number;
577
+ }>;
578
+ authMix: Array<{
579
+ day: string;
580
+ turnstile: number;
581
+ pass: number;
582
+ verified: number;
583
+ }>;
584
+ targets: Array<{
585
+ targetType: 'post' | 'comment';
586
+ targetId: string;
587
+ postTitle: string | null;
588
+ reactions: number;
589
+ subnets: number;
590
+ fps: number;
591
+ }>;
592
+ networks: Array<AdminInsightRow & {
593
+ asn: number | null;
594
+ asOrg: string | null;
595
+ }>;
596
+ countries: Array<AdminInsightRow & {
597
+ country: string | null;
598
+ }>;
599
+ subnets: Array<AdminInsightRow & {
600
+ ip24: string | null;
601
+ sampleIp: string | null;
602
+ }>;
603
+ browsers: Array<AdminInsightRow & {
604
+ browser: string | null;
605
+ os: string | null;
606
+ }>;
607
+ devices: Array<AdminInsightRow & {
608
+ clientFp: string | null;
609
+ renderer: string | null;
610
+ screen: string | null;
611
+ platform: string | null;
612
+ subnets: number;
613
+ }>;
614
+ botHints: Array<{
615
+ hint: string;
616
+ count: number;
617
+ }>;
618
+ tlsStacks: Array<{
619
+ browser: string | null;
620
+ ciphersSha1: string | null;
621
+ count: number;
622
+ }>;
623
+ sessionAge: Array<{
624
+ day: string;
625
+ writes: number;
626
+ newShare: number;
627
+ }>;
628
+ timeToTap: Array<{
629
+ bucket: string;
630
+ count: number;
631
+ sessions: number;
632
+ }>;
633
+ }
@@ -145,6 +145,131 @@ export type ReactionTargetKey = string;
145
145
  export interface ReactionBatchResult {
146
146
  reactions: Record<ReactionTargetKey, ReactionSummary[]>;
147
147
  }
148
+ /** What the browser says about itself, collected by the lazily loaded
149
+ `fingerprint.ts` module on the first interaction with a compose box or a
150
+ reaction bar and sent as `clientFp` on both write bodies. Every field is
151
+ optional; the server treats a missing or malformed object as no object
152
+ and never refuses the write for it -- these are evidence, not a door.
153
+ Bounds the server enforces: strings at most 128 characters, `fonts` at
154
+ most 32 entries, every number a bounded integer (`timezoneOffset` is the
155
+ one that may be negative), the whole object under 4 KiB. The server
156
+ hashes the canonical component JSON itself; the client never names its
157
+ own hash. See plans/comment-actor-identity.md "Client fingerprint". */
158
+ export interface ClientFingerprint {
159
+ navigator?: {
160
+ platform?: string;
161
+ languages?: string[];
162
+ hardwareConcurrency?: number;
163
+ deviceMemory?: number;
164
+ maxTouchPoints?: number;
165
+ webdriver?: boolean;
166
+ pdfViewerEnabled?: boolean;
167
+ /** `navigator.plugins.length`. */
168
+ plugins?: number;
169
+ cookieEnabled?: boolean;
170
+ /** `userAgentData.brands` as `"Chromium 128"` strings. */
171
+ uaBrands?: string[];
172
+ uaPlatform?: string;
173
+ uaMobile?: boolean;
174
+ uaPlatformVersion?: string;
175
+ uaArchitecture?: string;
176
+ uaModel?: string;
177
+ };
178
+ screen?: {
179
+ width?: number;
180
+ height?: number;
181
+ availWidth?: number;
182
+ availHeight?: number;
183
+ colorDepth?: number;
184
+ /** `devicePixelRatio` in percent (200 = 2x), so it stays an integer. */
185
+ dprPct?: number;
186
+ outerWidth?: number;
187
+ outerHeight?: number;
188
+ };
189
+ /** `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
190
+ timezone?: string;
191
+ /** `Date#getTimezoneOffset()`, minutes, negative east of UTC. */
192
+ timezoneOffset?: number;
193
+ /** SHA-256 hex of an offscreen canvas `toDataURL()`. */
194
+ canvas?: string;
195
+ webgl?: {
196
+ vendor?: string;
197
+ renderer?: string;
198
+ maxTextureSize?: number;
199
+ maxViewport?: number;
200
+ };
201
+ /** Sum of `OfflineAudioContext` samples, formatted to a fixed precision. */
202
+ audio?: string;
203
+ /** Families detected by a width probe, in the probe list's order. */
204
+ fonts?: string[];
205
+ media?: {
206
+ colorScheme?: string;
207
+ reducedMotion?: string;
208
+ pointer?: string;
209
+ hover?: string;
210
+ colorGamut?: string;
211
+ dynamicRange?: string;
212
+ };
213
+ presence?: {
214
+ chrome?: boolean;
215
+ /** `Notification.permission`, or absent when the API is missing. */
216
+ notification?: string;
217
+ performanceMemory?: boolean;
218
+ indexedDb?: boolean;
219
+ localStorage?: boolean;
220
+ };
221
+ }
222
+ /** How the form was filled, as aggregates only: counts and one spread
223
+ figure, never the key sequence or the intervals themselves. Collected by
224
+ the same lazy module from listeners on the compose box and the reaction
225
+ bar, sent as `interaction` beside `clientFp`. Same bounds and the same
226
+ never-a-gate rule as `ClientFingerprint`. Comment-only and reaction-only
227
+ fields are marked; the rest apply to both. */
228
+ export interface Interaction {
229
+ /** `performance.now()` at first compose focus (comments). */
230
+ loadToFocusMs?: number;
231
+ /** `performance.now()` at first heart tap (reactions). */
232
+ loadToTapMs?: number;
233
+ /** First focus to submit (comments). */
234
+ composeMs?: number;
235
+ /** Counts on the body field (comments). */
236
+ keyEvents?: number;
237
+ inputEvents?: number;
238
+ pasteEvents?: number;
239
+ /** Coefficient of variation of inter-key intervals, per mille (comments). */
240
+ keyIntervalCv?: number;
241
+ /** The submit or tap's `pointerType`. */
242
+ pointerType?: string;
243
+ /** `pointermove` count on the page before the submit or tap. */
244
+ pointerMoves?: number;
245
+ /** Distance from the button's centre, CSS px, of the submit click or tap. */
246
+ clickOffset?: number;
247
+ scrollEvents?: number;
248
+ /** Max `scrollY / (docHeight - innerHeight)` as a percentage. */
249
+ scrollDepth?: number;
250
+ /** `visibilitychange` to hidden before the submit or tap. */
251
+ hiddenCount?: number;
252
+ /** `document.hasFocus()` at submit. */
253
+ hasFocus?: boolean;
254
+ historyLength?: number;
255
+ /** Client-side rejections before the successful submit (comments). */
256
+ validationErrors?: number;
257
+ /** Widget render to token callback. */
258
+ turnstileSolveMs?: number;
259
+ /** Whether `before-interactive-callback` fired. */
260
+ turnstileInteractive?: boolean;
261
+ /** Reaction taps on this page so far (reactions). */
262
+ tapsThisPage?: number;
263
+ }
264
+ /** The optional client evidence both write bodies may carry. `storageId` is
265
+ a random 32-hex value the lazy module reads or creates in IndexedDB; the
266
+ server stores only its HMAC and never uses it to set, restore or extend
267
+ a cookie. */
268
+ export interface ClientEvidence {
269
+ clientFp?: ClientFingerprint;
270
+ interaction?: Interaction;
271
+ storageId?: string;
272
+ }
148
273
  export interface ReactionToggleInput {
149
274
  targetType: ReactionTargetType;
150
275
  targetId: string;
@@ -155,9 +280,20 @@ export interface ReactionToggleInput {
155
280
  stack" step 2. The widget solves invisibly (managed mode), so this
156
281
  never costs the reader a prompt or a round trip of their own. */
157
282
  turnstileToken: string;
283
+ /** Client evidence, optional and never a gate -- see `ClientEvidence`. */
284
+ clientFp?: ClientFingerprint;
285
+ interaction?: Interaction;
286
+ storageId?: string;
158
287
  }
159
288
  export interface ReactionToggleResult {
160
289
  reaction: ReactionSummary;
290
+ /** Epoch ms until which this browser holds a reader pass: the server has
291
+ set a session-bound cookie that stands in for `turnstileToken` on
292
+ later reactions. A client that sees this can stop minting tokens until
293
+ then and send `turnstileToken: ''`; a `400 turnstile_failed` on such a
294
+ request means the pass is gone and a token is needed again. Absent
295
+ only when the server has no session secret to sign one. */
296
+ passUntil?: number;
161
297
  }
162
298
  /** Public author view on a comment row. Never includes email or email_hash. */
163
299
  export interface CommentAuthor {
@@ -235,6 +371,10 @@ export interface CommentCreateInput {
235
371
  once the address is verified. */
236
372
  notifyReplies?: boolean;
237
373
  locale?: CommentLocale;
374
+ /** Client evidence, optional and never a gate -- see `ClientEvidence`. */
375
+ clientFp?: ClientFingerprint;
376
+ interaction?: Interaction;
377
+ storageId?: string;
238
378
  }
239
379
  export declare const COMMENT_CREATE_OUTCOMES: readonly ['published', 'held'];
240
380
  export type CommentCreateOutcome = (typeof COMMENT_CREATE_OUTCOMES)[number];
@@ -351,3 +491,30 @@ export declare function commentPolicyFromTags(tags: readonly CommentPolicyTagLik
351
491
  `readonly` and `off` answer no; the difference between them is drawn, not
352
492
  enforced. */
353
493
  export declare const acceptsComments: (policy: CommentPolicy) => boolean;
494
+ /** Evidence recorded when the comment was written, never upgraded by claiming. */
495
+ export type CommentAuthAtWrite = 'unknown' | 'anonymous' | 'verified';
496
+ export type CommentClaimMethod = 'session' | 'confirmed';
497
+ export interface ReaderClaimCandidate {
498
+ id: string;
499
+ surface: CommentSurface;
500
+ postId: string;
501
+ body: string;
502
+ createdAt: string;
503
+ authorName: string;
504
+ }
505
+ export interface ReaderClaimsResult {
506
+ comments: ReaderClaimCandidate[];
507
+ hasMore: boolean;
508
+ }
509
+ export interface ReaderClaimsInput {
510
+ commentIds: string[];
511
+ }
512
+ export interface ReaderClaimResult {
513
+ claimedIds: string[];
514
+ }
515
+ /** Optional client reports are operational observations, never identity evidence. */
516
+ export interface CommentTelemetryInput {
517
+ kind: 'comment' | 'reaction';
518
+ outcome: 'accepted' | 'http_error' | 'network_error' | 'challenge_failed';
519
+ challenges: number;
520
+ }
package/dist/index.js CHANGED
@@ -10,7 +10,6 @@ var BLOG_ANALYTICS_EVENTS_DEFAULT_LIMIT = 50;
10
10
  var BLOG_ANALYTICS_READ_THRESHOLD_MS = 5000;
11
11
  var BLOG_ANALYTICS_COMPLETION_SCROLL_DEPTH = 0.9;
12
12
  var BLOG_ANALYTICS_RANGE_OPTIONS = [7, 30, 90];
13
-
14
13
  // src/comments.ts
15
14
  var READER_PROVIDERS = ["email", "github", "google"];
16
15
  var COMMENT_LOCALES = ["zh", "en"];
@@ -73,7 +72,6 @@ function commentPolicyFromTags(tags, base = DEFAULT_COMMENT_POLICY) {
73
72
  return policy;
74
73
  }
75
74
  var acceptsComments = (policy) => policy.mode === "open";
76
-
77
75
  // src/content.ts
78
76
  var CONTENT_DOCUMENT_SOURCES = ["mood", "post"];
79
77
  var POST_LOCALE_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/;
@@ -99,15 +97,12 @@ var MESSAGE_MIN_BODY_LENGTH = 2;
99
97
  var MESSAGE_MAX_BODY_LENGTH = 4000;
100
98
  var MESSAGE_MAX_NAME_LENGTH = 32;
101
99
  var MESSAGE_STATES = ["new", "read", "replied", "archived", "spam"];
102
-
103
100
  // src/mood.ts
104
101
  var MOOD_SENTIMENT_LABELS = ["joy", "calm", "melancholy", "anger", "anxiety", "neutral"];
105
102
  var MOOD_AI_MODELS = ["gpt-5.5", "gpt-5", "claude-sonnet-4.6"];
106
103
  var MOOD_COMMENT_ORIGINS = ["telegram", "web"];
107
-
108
104
  // src/notify.ts
109
105
  var NOTIFY_CHANNELS = ["mood", "blog", "privacy", "announcement"];
110
-
111
106
  // src/routes.ts
112
107
  var API_PREFIX = "/api";
113
108
  var HEALTH_PATH = "/health";
@@ -147,14 +142,14 @@ var LEGACY_NOTIFY_BASE_PATH = "/v2/notify";
147
142
  var LEGACY_GHOST_WEBHOOK_PATH = "/v2/ghost/webhook";
148
143
  var LEGACY_MUSICKIT_TOKEN_PATH = "/v2/musickit/token";
149
144
  var LEGACY_HEALTH_PATH = "/v2/health";
150
-
151
145
  // src/telegram-ops.ts
152
146
  var TELEGRAM_OPS_WEBHOOK_PATH = "/webhooks/telegram-ops";
153
147
  var TELEGRAM_OPS_COMMENT_CALLBACK_PREFIXES = {
154
148
  reply: "comment:reply:",
155
149
  approve: "comment:approve:",
156
150
  hide: "comment:hide:",
157
- delete: "comment:delete:"
151
+ delete: "comment:delete:",
152
+ ban: "comment:ban:"
158
153
  };
159
154
  var NOTIFY_GATE_PATH = "/admin/notify-gate";
160
155
  var NOTIFY_GATE_RELEASE_PATH = `${NOTIFY_GATE_PATH}/release`;
@@ -15,6 +15,9 @@ export declare const TELEGRAM_OPS_COMMENT_CALLBACK_PREFIXES: {
15
15
  readonly approve: 'comment:approve:';
16
16
  readonly hide: 'comment:hide:';
17
17
  readonly delete: 'comment:delete:';
18
+ /** Bans the comment's pre-ticked source keys, no purge -- see
19
+ plans/comment-actor-identity.md "Actions by source". */
20
+ readonly ban: 'comment:ban:';
18
21
  };
19
22
  export declare const NOTIFY_GATE_PATH: '/admin/notify-gate';
20
23
  export declare const NOTIFY_GATE_RELEASE_PATH: "/admin/notify-gate/release";
@@ -4,7 +4,8 @@ var TELEGRAM_OPS_COMMENT_CALLBACK_PREFIXES = {
4
4
  reply: "comment:reply:",
5
5
  approve: "comment:approve:",
6
6
  hide: "comment:hide:",
7
- delete: "comment:delete:"
7
+ delete: "comment:delete:",
8
+ ban: "comment:ban:"
8
9
  };
9
10
  var NOTIFY_GATE_PATH = "/admin/notify-gate";
10
11
  var NOTIFY_GATE_RELEASE_PATH = `${NOTIFY_GATE_PATH}/release`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunizao/contracts",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Shared TypeScript contracts for buxx.me services and clients.",
5
5
  "type": "module",
6
6
  "sideEffects": false,