@wtfalch/threads 0.1.0 → 0.2.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/audit.d.ts +74 -0
- package/dist/audit.js +22 -0
- package/dist/bin/migrations.js +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/threads.d.ts +3 -0
- package/dist/threads.js +86 -25
- package/package.json +10 -10
- package/LICENSE +0 -21
package/dist/audit.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Db } from './threads.js';
|
|
2
|
+
/**
|
|
3
|
+
* The moderation events this package can write, when the host hands
|
|
4
|
+
* `createThreads` a writer. Declared here so the host can merge them into
|
|
5
|
+
* its ledger's vocabulary before it binds the writer; the names sit in the
|
|
6
|
+
* `thread` namespace and nowhere else, which is what lets a host bind the
|
|
7
|
+
* writer to that namespace alone. `tenantVisible` is false throughout: a
|
|
8
|
+
* forum is one per app, not one per organisation.
|
|
9
|
+
*/
|
|
10
|
+
export declare const THREADS_AUDIT_EVENTS: {
|
|
11
|
+
readonly 'thread.pinned': {
|
|
12
|
+
readonly tenantVisible: false;
|
|
13
|
+
};
|
|
14
|
+
readonly 'thread.unpinned': {
|
|
15
|
+
readonly tenantVisible: false;
|
|
16
|
+
};
|
|
17
|
+
readonly 'thread.locked': {
|
|
18
|
+
readonly tenantVisible: false;
|
|
19
|
+
};
|
|
20
|
+
readonly 'thread.unlocked': {
|
|
21
|
+
readonly tenantVisible: false;
|
|
22
|
+
};
|
|
23
|
+
readonly 'thread.hidden': {
|
|
24
|
+
readonly tenantVisible: false;
|
|
25
|
+
};
|
|
26
|
+
readonly 'thread.unhidden': {
|
|
27
|
+
readonly tenantVisible: false;
|
|
28
|
+
};
|
|
29
|
+
readonly 'thread.comment_hidden': {
|
|
30
|
+
readonly tenantVisible: false;
|
|
31
|
+
};
|
|
32
|
+
readonly 'thread.comment_unhidden': {
|
|
33
|
+
readonly tenantVisible: false;
|
|
34
|
+
};
|
|
35
|
+
readonly 'thread.moved': {
|
|
36
|
+
readonly tenantVisible: false;
|
|
37
|
+
};
|
|
38
|
+
readonly 'thread.status_changed': {
|
|
39
|
+
readonly tenantVisible: false;
|
|
40
|
+
};
|
|
41
|
+
readonly 'thread.marked_duplicate': {
|
|
42
|
+
readonly tenantVisible: false;
|
|
43
|
+
};
|
|
44
|
+
readonly 'thread.standing_changed': {
|
|
45
|
+
readonly tenantVisible: false;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export type ThreadsAuditAction = keyof typeof THREADS_AUDIT_EVENTS;
|
|
49
|
+
/** One moderation act, as the host's writer receives it. The actor is the `who` the host's own gate admitted. */
|
|
50
|
+
export interface ThreadsAuditEvent {
|
|
51
|
+
readonly action: ThreadsAuditAction;
|
|
52
|
+
readonly actor: {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly display: string;
|
|
55
|
+
};
|
|
56
|
+
readonly target: {
|
|
57
|
+
readonly type: 'thread' | 'thread_comment' | 'person';
|
|
58
|
+
readonly id: string;
|
|
59
|
+
};
|
|
60
|
+
readonly before?: unknown;
|
|
61
|
+
readonly after?: unknown;
|
|
62
|
+
/** The person the act was about, when that is not the actor: a standing change. */
|
|
63
|
+
readonly subject?: {
|
|
64
|
+
readonly class: 'human';
|
|
65
|
+
readonly id: string;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* What a host passes as `createThreads({ audit })`: a writer already bound
|
|
70
|
+
* to its ledger and to the `thread` namespace (`@wtfalch/audit`'s
|
|
71
|
+
* `ledger.writer`, structurally). The package calls it inside the
|
|
72
|
+
* transaction that makes the change, so a refused row rolls the change back.
|
|
73
|
+
*/
|
|
74
|
+
export type ThreadsAudit = (event: ThreadsAuditEvent, tx: Db) => Promise<void>;
|
package/dist/audit.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The moderation events this package can write, when the host hands
|
|
3
|
+
* `createThreads` a writer. Declared here so the host can merge them into
|
|
4
|
+
* its ledger's vocabulary before it binds the writer; the names sit in the
|
|
5
|
+
* `thread` namespace and nowhere else, which is what lets a host bind the
|
|
6
|
+
* writer to that namespace alone. `tenantVisible` is false throughout: a
|
|
7
|
+
* forum is one per app, not one per organisation.
|
|
8
|
+
*/
|
|
9
|
+
export const THREADS_AUDIT_EVENTS = {
|
|
10
|
+
'thread.pinned': { tenantVisible: false },
|
|
11
|
+
'thread.unpinned': { tenantVisible: false },
|
|
12
|
+
'thread.locked': { tenantVisible: false },
|
|
13
|
+
'thread.unlocked': { tenantVisible: false },
|
|
14
|
+
'thread.hidden': { tenantVisible: false },
|
|
15
|
+
'thread.unhidden': { tenantVisible: false },
|
|
16
|
+
'thread.comment_hidden': { tenantVisible: false },
|
|
17
|
+
'thread.comment_unhidden': { tenantVisible: false },
|
|
18
|
+
'thread.moved': { tenantVisible: false },
|
|
19
|
+
'thread.status_changed': { tenantVisible: false },
|
|
20
|
+
'thread.marked_duplicate': { tenantVisible: false },
|
|
21
|
+
'thread.standing_changed': { tenantVisible: false },
|
|
22
|
+
};
|
package/dist/bin/migrations.js
CHANGED
|
File without changes
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/threads.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
|
|
2
|
+
import type { ThreadsAudit } from './audit.js';
|
|
2
3
|
import { type Gates, type Subject, type Who } from './gates.js';
|
|
3
4
|
import { type Category, type Comment, type Person, type Thread, type ThreadStatus, schema } from './schema.js';
|
|
4
5
|
/**
|
|
@@ -22,6 +23,8 @@ export interface ThreadsOptions {
|
|
|
22
23
|
gates: Gates;
|
|
23
24
|
limits?: Partial<Limits>;
|
|
24
25
|
now?: () => Date;
|
|
26
|
+
/** The host's audit writer for moderation, bound to the `thread` namespace. Optional: without it, moderation leaves no trail here. */
|
|
27
|
+
audit?: ThreadsAudit;
|
|
25
28
|
}
|
|
26
29
|
export type Sort = 'activity' | 'newest' | 'votes';
|
|
27
30
|
export interface ListOptions {
|
package/dist/threads.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
1
|
+
import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from 'drizzle-orm';
|
|
2
2
|
import { CATEGORY, ThreadsError, categorySubject, } from './gates.js';
|
|
3
3
|
import { schema, threadCategories, threadComments, threadOutbox, threadPeople, threadSubscriptions, threadVotes, threads, } from './schema.js';
|
|
4
4
|
export const DEFAULT_LIMITS = { threadsPerHour: 5, commentsPerTenMinutes: 30 };
|
|
@@ -18,6 +18,13 @@ export function createThreads(opts) {
|
|
|
18
18
|
const { db, gates } = opts;
|
|
19
19
|
const limits = { ...DEFAULT_LIMITS, ...opts.limits };
|
|
20
20
|
const now = opts.now ?? (() => new Date());
|
|
21
|
+
const display = (who) => who.name ?? who.id;
|
|
22
|
+
/** Records a moderation act on the caller's transaction, when the host wants a trail. */
|
|
23
|
+
async function audited(tx, who, event) {
|
|
24
|
+
if (!opts.audit)
|
|
25
|
+
return;
|
|
26
|
+
await opts.audit({ ...event, actor: { id: who.id, display: display(who) } }, tx);
|
|
27
|
+
}
|
|
21
28
|
async function mayRead(subject, who) {
|
|
22
29
|
if (!(await gates.read(subject, who))) {
|
|
23
30
|
throw new ThreadsError('forbidden', 'not allowed to read here');
|
|
@@ -70,10 +77,13 @@ export function createThreads(opts) {
|
|
|
70
77
|
}
|
|
71
78
|
async function underLimit(table, authorId, windowMs, max, what) {
|
|
72
79
|
const since = new Date(now().getTime() - windowMs);
|
|
80
|
+
// `gt`, not a raw `sql` fragment: the operator maps the Date through the
|
|
81
|
+
// column's type, which every driver accepts; a bare Date parameter is
|
|
82
|
+
// refused by postgres-js and only happened to work on PGlite.
|
|
73
83
|
const [row] = await db
|
|
74
84
|
.select({ n: sql `count(*)::int` })
|
|
75
85
|
.from(table)
|
|
76
|
-
.where(and(eq(table.authorId, authorId),
|
|
86
|
+
.where(and(eq(table.authorId, authorId), gt(table.createdAt, since)));
|
|
77
87
|
if ((row?.n ?? 0) >= max)
|
|
78
88
|
throw new ThreadsError('rate_limited', `too many ${what}, try later`);
|
|
79
89
|
}
|
|
@@ -427,21 +437,40 @@ export function createThreads(opts) {
|
|
|
427
437
|
return { t, who: await mayModerate(subjectOf(t), who) };
|
|
428
438
|
}
|
|
429
439
|
async function setFlag(who, threadId, flag, value) {
|
|
430
|
-
await moderated(who, threadId);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
.
|
|
440
|
+
const { t, who: mod } = await moderated(who, threadId);
|
|
441
|
+
if (t[flag] === value)
|
|
442
|
+
return;
|
|
443
|
+
const action = {
|
|
444
|
+
pinned: value ? 'thread.pinned' : 'thread.unpinned',
|
|
445
|
+
locked: value ? 'thread.locked' : 'thread.unlocked',
|
|
446
|
+
hidden: value ? 'thread.hidden' : 'thread.unhidden',
|
|
447
|
+
}[flag];
|
|
448
|
+
await db.transaction(async (tx) => {
|
|
449
|
+
await tx
|
|
450
|
+
.update(threads)
|
|
451
|
+
.set({ [flag]: value, updatedAt: sql `now()` })
|
|
452
|
+
.where(eq(threads.id, threadId));
|
|
453
|
+
await audited(tx, mod, { action, target: { type: 'thread', id: String(threadId) } });
|
|
454
|
+
});
|
|
435
455
|
}
|
|
436
456
|
async function hideComment(who, commentId, hidden = true) {
|
|
437
457
|
const [c] = await db.select().from(threadComments).where(eq(threadComments.id, commentId));
|
|
438
458
|
if (!c)
|
|
439
459
|
throw new ThreadsError('not_found', 'no such comment');
|
|
440
|
-
await moderated(who, c.threadId);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
460
|
+
const { who: mod } = await moderated(who, c.threadId);
|
|
461
|
+
if (c.hidden === hidden)
|
|
462
|
+
return;
|
|
463
|
+
await db.transaction(async (tx) => {
|
|
464
|
+
await tx
|
|
465
|
+
.update(threadComments)
|
|
466
|
+
.set({ hidden, updatedAt: sql `now()` })
|
|
467
|
+
.where(eq(threadComments.id, commentId));
|
|
468
|
+
await audited(tx, mod, {
|
|
469
|
+
action: hidden ? 'thread.comment_hidden' : 'thread.comment_unhidden',
|
|
470
|
+
target: { type: 'thread_comment', id: String(commentId) },
|
|
471
|
+
after: { threadId: c.threadId },
|
|
472
|
+
});
|
|
473
|
+
});
|
|
445
474
|
}
|
|
446
475
|
async function move(who, threadId, categoryId) {
|
|
447
476
|
const { t } = await moderated(who, threadId);
|
|
@@ -450,18 +479,26 @@ export function createThreads(opts) {
|
|
|
450
479
|
const cat = await categoryById(categoryId);
|
|
451
480
|
if (!cat)
|
|
452
481
|
throw new ThreadsError('not_found', 'no such category');
|
|
453
|
-
await mayModerate(categorySubject(categoryId), who);
|
|
454
|
-
await db
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
482
|
+
const mod = await mayModerate(categorySubject(categoryId), who);
|
|
483
|
+
await db.transaction(async (tx) => {
|
|
484
|
+
await tx
|
|
485
|
+
.update(threads)
|
|
486
|
+
.set({
|
|
487
|
+
subjectId: String(categoryId),
|
|
488
|
+
status: cat.kind === 'feedback' ? (t.status ?? 'open') : null,
|
|
489
|
+
updatedAt: sql `now()`,
|
|
490
|
+
})
|
|
491
|
+
.where(eq(threads.id, threadId));
|
|
492
|
+
await audited(tx, mod, {
|
|
493
|
+
action: 'thread.moved',
|
|
494
|
+
target: { type: 'thread', id: String(threadId) },
|
|
495
|
+
before: { categoryId: t.subjectId },
|
|
496
|
+
after: { categoryId: String(categoryId) },
|
|
497
|
+
});
|
|
498
|
+
});
|
|
462
499
|
}
|
|
463
500
|
async function setStatus(who, threadId, status) {
|
|
464
|
-
const { t } = await moderated(who, threadId);
|
|
501
|
+
const { t, who: mod } = await moderated(who, threadId);
|
|
465
502
|
if (t.status === null)
|
|
466
503
|
throw new ThreadsError('invalid', 'only feedback has a status');
|
|
467
504
|
if (t.status === status)
|
|
@@ -471,6 +508,12 @@ export function createThreads(opts) {
|
|
|
471
508
|
.update(threads)
|
|
472
509
|
.set({ status, updatedAt: sql `now()` })
|
|
473
510
|
.where(eq(threads.id, threadId));
|
|
511
|
+
await audited(tx, mod, {
|
|
512
|
+
action: 'thread.status_changed',
|
|
513
|
+
target: { type: 'thread', id: String(threadId) },
|
|
514
|
+
before: { status: t.status },
|
|
515
|
+
after: { status },
|
|
516
|
+
});
|
|
474
517
|
const listeners = await tx
|
|
475
518
|
.select({ personId: threadSubscriptions.personId })
|
|
476
519
|
.from(threadSubscriptions)
|
|
@@ -487,11 +530,16 @@ export function createThreads(opts) {
|
|
|
487
530
|
}
|
|
488
531
|
/** The duplicate points at the original and its votes move there; a page redirects. */
|
|
489
532
|
async function markDuplicate(who, threadId, ofId) {
|
|
490
|
-
const { t } = await moderated(who, threadId);
|
|
533
|
+
const { t, who: mod } = await moderated(who, threadId);
|
|
491
534
|
const [original] = await db.select().from(threads).where(eq(threads.id, ofId));
|
|
492
535
|
if (!original || original.id === t.id)
|
|
493
536
|
throw new ThreadsError('not_found', 'no such original');
|
|
494
537
|
await db.transaction(async (tx) => {
|
|
538
|
+
await audited(tx, mod, {
|
|
539
|
+
action: 'thread.marked_duplicate',
|
|
540
|
+
target: { type: 'thread', id: String(threadId) },
|
|
541
|
+
after: { duplicateOf: ofId },
|
|
542
|
+
});
|
|
495
543
|
const votes = await tx.select().from(threadVotes).where(eq(threadVotes.threadId, threadId));
|
|
496
544
|
if (votes.length > 0) {
|
|
497
545
|
await tx
|
|
@@ -513,8 +561,21 @@ export function createThreads(opts) {
|
|
|
513
561
|
}
|
|
514
562
|
/** Standing is per person, not per subject; the gate asked is `category:*`, the owner's. */
|
|
515
563
|
async function setStanding(who, personId, standing) {
|
|
516
|
-
await mayModerate(categorySubject('*'), who);
|
|
517
|
-
|
|
564
|
+
const mod = await mayModerate(categorySubject('*'), who);
|
|
565
|
+
const [before] = await db
|
|
566
|
+
.select({ standing: threadPeople.standing })
|
|
567
|
+
.from(threadPeople)
|
|
568
|
+
.where(eq(threadPeople.id, personId));
|
|
569
|
+
await db.transaction(async (tx) => {
|
|
570
|
+
await tx.update(threadPeople).set({ standing }).where(eq(threadPeople.id, personId));
|
|
571
|
+
await audited(tx, mod, {
|
|
572
|
+
action: 'thread.standing_changed',
|
|
573
|
+
target: { type: 'person', id: personId },
|
|
574
|
+
subject: { class: 'human', id: personId },
|
|
575
|
+
before: { standing: before?.standing ?? null },
|
|
576
|
+
after: { standing },
|
|
577
|
+
});
|
|
578
|
+
});
|
|
518
579
|
}
|
|
519
580
|
// ---- outbox ------------------------------------------------------------
|
|
520
581
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wtfalch/threads",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Threads, comments, votes and subscriptions attached to a subject the host names: a forum, a feedback board, a comment section. Per-app Postgres, host-supplied gates, React on @wtfalch/design.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,9 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
|
-
"files": [
|
|
13
|
-
"dist"
|
|
14
|
-
],
|
|
12
|
+
"files": ["dist"],
|
|
15
13
|
"bin": {
|
|
16
14
|
"threads-migrations": "./dist/bin/migrations.js"
|
|
17
15
|
},
|
|
@@ -34,6 +32,12 @@
|
|
|
34
32
|
"engines": {
|
|
35
33
|
"node": ">=22.0.0"
|
|
36
34
|
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && cp src/migrations/*.sql dist/migrations/ && cp src/react/threads.css dist/threads.css",
|
|
37
|
+
"prepack": "pnpm build",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
},
|
|
37
41
|
"peerDependencies": {
|
|
38
42
|
"@wtfalch/design": ">=0.3.0",
|
|
39
43
|
"drizzle-orm": ">=0.39.0",
|
|
@@ -59,14 +63,10 @@
|
|
|
59
63
|
"@wtfalch/design": "^0.3.0",
|
|
60
64
|
"drizzle-orm": "^0.39.3",
|
|
61
65
|
"jsdom": "^30.0.1",
|
|
66
|
+
"postgres": "^3.4.5",
|
|
62
67
|
"react": "^19",
|
|
63
68
|
"react-dom": "^19",
|
|
64
69
|
"typescript": "^5.9.0",
|
|
65
70
|
"vitest": "^4.1.6"
|
|
66
|
-
},
|
|
67
|
-
"scripts": {
|
|
68
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && cp src/migrations/*.sql dist/migrations/ && cp src/react/threads.css dist/threads.css",
|
|
69
|
-
"typecheck": "tsc --noEmit",
|
|
70
|
-
"test": "vitest run"
|
|
71
71
|
}
|
|
72
|
-
}
|
|
72
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 William Tallis Falch
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|