@meith/db 0.16.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/LICENSE.md +165 -0
- package/migrations/0000_initial_schema.sql +538 -0
- package/migrations/0001_seed_usergroups.sql +111 -0
- package/migrations/0002_group_promotions.sql +25 -0
- package/migrations/0003_counter_maintenance.sql +34 -0
- package/migrations/0004_post_render_cache.sql +7 -0
- package/migrations/0005_reports.sql +51 -0
- package/migrations/0006_warnings.sql +91 -0
- package/migrations/0007_notifications.sql +44 -0
- package/migrations/0008_subscriptions.sql +48 -0
- package/migrations/0009_member_preferences.sql +13 -0
- package/migrations/0010_profile_fields.sql +53 -0
- package/migrations/0011_private_messages.sql +63 -0
- package/migrations/0012_user_relations.sql +18 -0
- package/migrations/0013_reputation.sql +45 -0
- package/migrations/0014_signatures.sql +16 -0
- package/migrations/0015_admin_sessions.sql +24 -0
- package/migrations/0016_attachments.sql +51 -0
- package/migrations/0017_avatars.sql +33 -0
- package/migrations/0018_mass_mail.sql +15 -0
- package/migrations/0019_word_filters.sql +8 -0
- package/migrations/0020_searches.sql +15 -0
- package/migrations/0021_presence_stats.sql +18 -0
- package/migrations/0022_api_tokens_webhooks.sql +58 -0
- package/migrations/0023_install_state.sql +6 -0
- package/migrations/0024_component_versions.sql +13 -0
- package/migrations/0025_import.sql +25 -0
- package/migrations/0026_polls.sql +40 -0
- package/migrations/0027_drafts.sql +13 -0
- package/migrations/0028_board_vocabulary.sql +32 -0
- package/migrations/0029_announcements.sql +16 -0
- package/migrations/0030_antispam.sql +18 -0
- package/migrations/0031_markdown.sql +24 -0
- package/migrations/0032_theme_enablement.sql +1 -0
- package/migrations/0033_group_identity.sql +5 -0
- package/migrations/0034_search_document_version.sql +4 -0
- package/migrations/0035_categories_hold_no_threads.sql +1 -0
- package/migrations/0036_expiring_group_grants.sql +6 -0
- package/migrations/0037_automatic_timezone.sql +3 -0
- package/migrations/0038_primary_group_grants.sql +3 -0
- package/migrations/0039_moderator_log_forum_scope.sql +1 -0
- package/migrations/0040_moderator_restore_grants.sql +1 -0
- package/migrations/0041_drop_placebo_moderator_rights.sql +3 -0
- package/migrations/0042_drop_placebo_delete_others.sql +2 -0
- package/migrations/0043_federated_identity.sql +28 -0
- package/migrations/0044_second_factor.sql +34 -0
- package/migrations/0045_drop_dead_task_due_index.sql +1 -0
- package/migrations/0046_member_locale.sql +1 -0
- package/migrations/0047_navigation_items.sql +34 -0
- package/migrations/0048_web_push.sql +16 -0
- package/migrations/0049_plugin_health.sql +7 -0
- package/migrations/0050_drop_forum_password_grants.sql +1 -0
- package/migrations/0051_glossy_barracuda.sql +1 -0
- package/migrations/0052_many_professor_monster.sql +2 -0
- package/migrations/0053_strange_maximus.sql +14 -0
- package/migrations/0054_legacy_import_sources.sql +25 -0
- package/migrations/0055_poll_parity.sql +14 -0
- package/migrations/0056_marketplace_catalog.sql +11 -0
- package/migrations/meta/0000_snapshot.json +4412 -0
- package/migrations/meta/0006_snapshot.json +5411 -0
- package/migrations/meta/0007_snapshot.json +5634 -0
- package/migrations/meta/0008_snapshot.json +5779 -0
- package/migrations/meta/0009_snapshot.json +5816 -0
- package/migrations/meta/0010_snapshot.json +6117 -0
- package/migrations/meta/0011_snapshot.json +6406 -0
- package/migrations/meta/0012_snapshot.json +6517 -0
- package/migrations/meta/0013_snapshot.json +6722 -0
- package/migrations/meta/0014_snapshot.json +6777 -0
- package/migrations/meta/0015_snapshot.json +6914 -0
- package/migrations/meta/0016_snapshot.json +7192 -0
- package/migrations/meta/0017_snapshot.json +7248 -0
- package/migrations/meta/0018_snapshot.json +7355 -0
- package/migrations/meta/0019_snapshot.json +7419 -0
- package/migrations/meta/0020_snapshot.json +7562 -0
- package/migrations/meta/0021_snapshot.json +7653 -0
- package/migrations/meta/0031_snapshot.json +8162 -0
- package/migrations/meta/0032_snapshot.json +8169 -0
- package/migrations/meta/0033_snapshot.json +8193 -0
- package/migrations/meta/0034_snapshot.json +8221 -0
- package/migrations/meta/0036_snapshot.json +8262 -0
- package/migrations/meta/0037_snapshot.json +8262 -0
- package/migrations/meta/0038_snapshot.json +8293 -0
- package/migrations/meta/0039_snapshot.json +8308 -0
- package/migrations/meta/0041_snapshot.json +8287 -0
- package/migrations/meta/0042_snapshot.json +8274 -0
- package/migrations/meta/0043_snapshot.json +8521 -0
- package/migrations/meta/0044_snapshot.json +8814 -0
- package/migrations/meta/0045_snapshot.json +8797 -0
- package/migrations/meta/0046_snapshot.json +8804 -0
- package/migrations/meta/0047_snapshot.json +9018 -0
- package/migrations/meta/0048_snapshot.json +9145 -0
- package/migrations/meta/0049_snapshot.json +9190 -0
- package/migrations/meta/0050_snapshot.json +9126 -0
- package/migrations/meta/0051_snapshot.json +9132 -0
- package/migrations/meta/0052_snapshot.json +9148 -0
- package/migrations/meta/0053_snapshot.json +9268 -0
- package/migrations/meta/0054_snapshot.json +9378 -0
- package/migrations/meta/0056_snapshot.json +9447 -0
- package/migrations/meta/_journal.json +405 -0
- package/package.json +64 -0
- package/src/account-repos.ts +580 -0
- package/src/actor-builder.ts +130 -0
- package/src/admin-repo.ts +85 -0
- package/src/admin-session-repo.ts +205 -0
- package/src/admin-undo-repo.ts +128 -0
- package/src/announcement-repo.ts +176 -0
- package/src/antispam-repo.ts +145 -0
- package/src/api-repo.ts +276 -0
- package/src/attachment-admin-repo.ts +161 -0
- package/src/attachment-repo.ts +240 -0
- package/src/authorization-source.ts +138 -0
- package/src/avatar-repo.ts +185 -0
- package/src/ban-repos.ts +146 -0
- package/src/client.ts +85 -0
- package/src/content-admin-repo.ts +298 -0
- package/src/content-counters.ts +152 -0
- package/src/counter-recount.ts +263 -0
- package/src/cursor.ts +16 -0
- package/src/denormalised-username.ts +34 -0
- package/src/discovery-repo.ts +140 -0
- package/src/draft-repo.ts +59 -0
- package/src/feed-repo.ts +219 -0
- package/src/forum-admin-repo.ts +310 -0
- package/src/forum-repo.ts +206 -0
- package/src/group-admin-repo.ts +256 -0
- package/src/group-identity-repo.ts +84 -0
- package/src/identity-link-repo.ts +145 -0
- package/src/import-repo.ts +123 -0
- package/src/import-sink.ts +1377 -0
- package/src/index.ts +316 -0
- package/src/inline-moderation.ts +344 -0
- package/src/install-repo.ts +67 -0
- package/src/latest-repo.ts +115 -0
- package/src/maintenance-repo.ts +54 -0
- package/src/marketplace-repo.ts +111 -0
- package/src/member-profile-repo.ts +82 -0
- package/src/member-settings-repo.ts +160 -0
- package/src/message-repo.ts +384 -0
- package/src/migrate-run.ts +18 -0
- package/src/migrate.ts +129 -0
- package/src/modcp-repo.ts +253 -0
- package/src/moderation-queue.ts +279 -0
- package/src/navigation-repo.ts +288 -0
- package/src/notification-repo.ts +402 -0
- package/src/orphan-keys.ts +19 -0
- package/src/outbox-repo.ts +78 -0
- package/src/permission-version.ts +24 -0
- package/src/permissions-map.ts +52 -0
- package/src/pglite.fixture.ts +87 -0
- package/src/plugin-data.ts +88 -0
- package/src/plugin-grants.ts +369 -0
- package/src/plugin-health-repo.ts +80 -0
- package/src/plugin-purge-repo.ts +91 -0
- package/src/plugin-users.ts +39 -0
- package/src/poll-repo.ts +334 -0
- package/src/post-repo.ts +261 -0
- package/src/post-writes.ts +227 -0
- package/src/presence-repo.ts +220 -0
- package/src/profile-field-repo.ts +182 -0
- package/src/promotion-repo.ts +152 -0
- package/src/read-state-repo.ts +81 -0
- package/src/relation-repo.ts +113 -0
- package/src/render-backfill.ts +110 -0
- package/src/render-signature.ts +33 -0
- package/src/report-repo.ts +349 -0
- package/src/reputation-repo.ts +295 -0
- package/src/result-rows.ts +13 -0
- package/src/row-values.ts +7 -0
- package/src/schema/content.ts +451 -0
- package/src/schema/identity.ts +699 -0
- package/src/schema/index.ts +10 -0
- package/src/schema/messages.ts +76 -0
- package/src/schema/permission-columns.ts +34 -0
- package/src/schema/platform.ts +376 -0
- package/src/schema/structure.ts +164 -0
- package/src/search-repo.ts +359 -0
- package/src/search-store.ts +113 -0
- package/src/seed-groups.ts +11 -0
- package/src/settings-repo.ts +32 -0
- package/src/signature-repo.ts +102 -0
- package/src/sql-lists.ts +17 -0
- package/src/staff-groups.ts +50 -0
- package/src/stats-repo.ts +151 -0
- package/src/subscription-repo.ts +350 -0
- package/src/system-health-repo.ts +130 -0
- package/src/task-repo.ts +104 -0
- package/src/theme-admin-repo.ts +172 -0
- package/src/theme-repo.ts +43 -0
- package/src/thread-audience.ts +45 -0
- package/src/thread-counters.ts +107 -0
- package/src/thread-repo.ts +255 -0
- package/src/thread-surgery.ts +282 -0
- package/src/thread-tools.ts +311 -0
- package/src/thread-views.ts +41 -0
- package/src/thread-writes.ts +356 -0
- package/src/two-factor-repo.ts +235 -0
- package/src/upgrade-repo.ts +66 -0
- package/src/user-admin-repo.ts +367 -0
- package/src/user-bulk-repo.ts +397 -0
- package/src/user-merge-map.ts +111 -0
- package/src/user-merge-repo.ts +304 -0
- package/src/visibility-counters.ts +190 -0
- package/src/visibility.ts +17 -0
- package/src/vocabulary-repo.ts +54 -0
- package/src/warning-repo.ts +328 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { inArray, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { DomainEventName, OutboxReader, OutboxRecord } from '@meith/events'
|
|
4
|
+
|
|
5
|
+
import type { Database } from './client'
|
|
6
|
+
import { resultRows } from './result-rows'
|
|
7
|
+
import { outbox } from './schema'
|
|
8
|
+
|
|
9
|
+
const MAX_CLAIMS = 10
|
|
10
|
+
|
|
11
|
+
export class PostgresOutboxReader implements OutboxReader {
|
|
12
|
+
constructor(private readonly db: Database) {}
|
|
13
|
+
|
|
14
|
+
async claimUnrelayed(limit: number): Promise<OutboxRecord[]> {
|
|
15
|
+
const result = await this.db.execute(sql`
|
|
16
|
+
update ${outbox}
|
|
17
|
+
set attempts = ${outbox.attempts} + 1
|
|
18
|
+
where id in (
|
|
19
|
+
select id from ${outbox}
|
|
20
|
+
where dispatched_at is null
|
|
21
|
+
and attempts < ${MAX_CLAIMS}
|
|
22
|
+
order by id
|
|
23
|
+
limit ${limit}
|
|
24
|
+
for update skip locked
|
|
25
|
+
)
|
|
26
|
+
returning id, topic, payload, created_at, dispatched_at
|
|
27
|
+
`)
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
resultRows(result) as Array<{
|
|
31
|
+
id: number
|
|
32
|
+
topic: string
|
|
33
|
+
payload: unknown
|
|
34
|
+
created_at: Date | string
|
|
35
|
+
dispatched_at: Date | string | null
|
|
36
|
+
}>
|
|
37
|
+
).map((row) => ({
|
|
38
|
+
id: Number(row.id),
|
|
39
|
+
name: row.topic as DomainEventName,
|
|
40
|
+
payload: row.payload,
|
|
41
|
+
dedupeKey: null,
|
|
42
|
+
createdAt: new Date(row.created_at),
|
|
43
|
+
relayedAt: row.dispatched_at === null ? null : new Date(row.dispatched_at),
|
|
44
|
+
}))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async markRelayed(ids: number[]): Promise<void> {
|
|
48
|
+
if (ids.length === 0) return
|
|
49
|
+
await this.db.update(outbox).set({ dispatchedAt: new Date() }).where(inArray(outbox.id, ids))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async stuck(limit = 50): Promise<OutboxRecord[]> {
|
|
53
|
+
const rows = await this.db.execute(sql`
|
|
54
|
+
select id, topic, payload, created_at, dispatched_at
|
|
55
|
+
from ${outbox}
|
|
56
|
+
where dispatched_at is null and attempts >= ${MAX_CLAIMS}
|
|
57
|
+
order by id
|
|
58
|
+
limit ${limit}
|
|
59
|
+
`)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
resultRows(rows) as Array<{
|
|
63
|
+
id: number
|
|
64
|
+
topic: string
|
|
65
|
+
payload: unknown
|
|
66
|
+
created_at: Date | string
|
|
67
|
+
dispatched_at: Date | string | null
|
|
68
|
+
}>
|
|
69
|
+
).map((row) => ({
|
|
70
|
+
id: Number(row.id),
|
|
71
|
+
name: row.topic as DomainEventName,
|
|
72
|
+
payload: row.payload,
|
|
73
|
+
dedupeKey: null,
|
|
74
|
+
createdAt: new Date(row.created_at),
|
|
75
|
+
relayedAt: row.dispatched_at === null ? null : new Date(row.dispatched_at),
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import type { Database } from './client'
|
|
4
|
+
|
|
5
|
+
export type Tx = Parameters<Parameters<Database['transaction']>[0]>[0]
|
|
6
|
+
|
|
7
|
+
export async function bumpPermissionVersion(tx: Tx): Promise<void> {
|
|
8
|
+
await tx.execute(sql`
|
|
9
|
+
insert into cache_versions (key, version) values ('permissions', 1)
|
|
10
|
+
on conflict (key) do update
|
|
11
|
+
set version = cache_versions.version + 1, bumped_at = now()
|
|
12
|
+
`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function withPermissionVersionBump<T>(
|
|
16
|
+
db: Database,
|
|
17
|
+
work: (tx: Tx) => Promise<T>,
|
|
18
|
+
): Promise<T> {
|
|
19
|
+
return db.transaction(async (tx) => {
|
|
20
|
+
const result = await work(tx)
|
|
21
|
+
await bumpPermissionVersion(tx)
|
|
22
|
+
return result
|
|
23
|
+
})
|
|
24
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emptyPermissionSet,
|
|
3
|
+
FORUM_PERMISSION_FIELDS,
|
|
4
|
+
type ForumPermissions,
|
|
5
|
+
PERMISSION_FIELDS,
|
|
6
|
+
type PermissionField,
|
|
7
|
+
type PermissionSet,
|
|
8
|
+
} from '@meith/core'
|
|
9
|
+
|
|
10
|
+
export type PermissionRow = Record<string, unknown>
|
|
11
|
+
|
|
12
|
+
function coerceField(field: PermissionField, raw: unknown): boolean | number | undefined {
|
|
13
|
+
if (raw === null || raw === undefined) return undefined
|
|
14
|
+
|
|
15
|
+
if (field.kind === 'numeric') {
|
|
16
|
+
const n = typeof raw === 'number' ? raw : Number(raw)
|
|
17
|
+
if (!Number.isFinite(n)) {
|
|
18
|
+
throw new TypeError(
|
|
19
|
+
`Permission field "${field.key}" expected a number, got ${JSON.stringify(raw)}`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return n
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof raw === 'boolean') return raw
|
|
26
|
+
if (raw === 't' || raw === 1 || raw === '1') return true
|
|
27
|
+
if (raw === 'f' || raw === 0 || raw === '0') return false
|
|
28
|
+
|
|
29
|
+
throw new TypeError(
|
|
30
|
+
`Permission field "${field.key}" expected a boolean, got ${JSON.stringify(raw)}`,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function groupRowToPermissionSet(row: PermissionRow): PermissionSet {
|
|
35
|
+
const out = emptyPermissionSet()
|
|
36
|
+
for (const field of PERMISSION_FIELDS) {
|
|
37
|
+
const value = coerceField(field, row[field.key])
|
|
38
|
+
if (value !== undefined) {
|
|
39
|
+
;(out as Record<string, boolean | number>)[field.key] = value
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function forumRowToOverride(row: PermissionRow): Partial<ForumPermissions> {
|
|
46
|
+
const out: Record<string, boolean | number> = {}
|
|
47
|
+
for (const field of FORUM_PERMISSION_FIELDS) {
|
|
48
|
+
const value = coerceField(field, row[field.key])
|
|
49
|
+
if (value !== undefined) out[field.key] = value
|
|
50
|
+
}
|
|
51
|
+
return out as Partial<ForumPermissions>
|
|
52
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
import { PGlite } from '@electric-sql/pglite'
|
|
6
|
+
import { drizzle } from 'drizzle-orm/pglite'
|
|
7
|
+
|
|
8
|
+
import type { Database } from './client'
|
|
9
|
+
import * as schema from './schema'
|
|
10
|
+
|
|
11
|
+
export interface QueryLog {
|
|
12
|
+
readonly count: number
|
|
13
|
+
readonly statements: readonly string[]
|
|
14
|
+
reset(): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TestDb {
|
|
18
|
+
readonly db: Database
|
|
19
|
+
readonly queries: QueryLog
|
|
20
|
+
/**
|
|
21
|
+
* The engine underneath, for the few tests that need to send it something
|
|
22
|
+
* drizzle cannot: `exec()` takes a script of several statements, which is how
|
|
23
|
+
* the migration files arrive and what a prepared statement refuses.
|
|
24
|
+
*/
|
|
25
|
+
readonly client: PGlite
|
|
26
|
+
close(): Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The checked-in migrations, as one script `client.exec()` will accept. */
|
|
30
|
+
export function migrationScript(): string {
|
|
31
|
+
return migrationSql().split('--> statement-breakpoint').join('\n')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function migrationSql(): string {
|
|
35
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
36
|
+
const dir = path.resolve(here, '..', 'migrations')
|
|
37
|
+
|
|
38
|
+
const journal = JSON.parse(readFileSync(path.join(dir, 'meta', '_journal.json'), 'utf8')) as {
|
|
39
|
+
entries: { idx: number; tag: string }[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return [...journal.entries]
|
|
43
|
+
.sort((a, b) => a.idx - b.idx)
|
|
44
|
+
.map((entry) => readFileSync(path.join(dir, `${entry.tag}.sql`), 'utf8'))
|
|
45
|
+
.join('\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function createTestDb(): Promise<TestDb> {
|
|
49
|
+
const client = new PGlite()
|
|
50
|
+
|
|
51
|
+
await client.exec(migrationScript())
|
|
52
|
+
|
|
53
|
+
const statements: string[] = []
|
|
54
|
+
|
|
55
|
+
const originalQuery = client.query.bind(client)
|
|
56
|
+
;(client as unknown as { query: typeof originalQuery }).query = ((
|
|
57
|
+
sqlText: string,
|
|
58
|
+
...rest: unknown[]
|
|
59
|
+
) => {
|
|
60
|
+
statements.push(sqlText)
|
|
61
|
+
return (originalQuery as (...a: unknown[]) => unknown)(sqlText, ...rest)
|
|
62
|
+
}) as typeof originalQuery
|
|
63
|
+
|
|
64
|
+
const db = drizzle(client, {
|
|
65
|
+
schema,
|
|
66
|
+
casing: 'snake_case',
|
|
67
|
+
}) as unknown as Database
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
db,
|
|
71
|
+
client,
|
|
72
|
+
queries: {
|
|
73
|
+
get count() {
|
|
74
|
+
return statements.length
|
|
75
|
+
},
|
|
76
|
+
get statements() {
|
|
77
|
+
return [...statements]
|
|
78
|
+
},
|
|
79
|
+
reset() {
|
|
80
|
+
statements.length = 0
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
async close() {
|
|
84
|
+
await client.close()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type SQL, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
import type { PluginData } from '@meith/plugin-kit'
|
|
5
|
+
|
|
6
|
+
import type { Database } from './client'
|
|
7
|
+
import type { Tx } from './permission-version'
|
|
8
|
+
import { resultRows } from './result-rows'
|
|
9
|
+
|
|
10
|
+
export interface PluginDataOptions {
|
|
11
|
+
readonly statementTimeoutMs?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 3_000
|
|
15
|
+
|
|
16
|
+
export function bindPluginSql(
|
|
17
|
+
text: string,
|
|
18
|
+
params: readonly unknown[],
|
|
19
|
+
where = 'plugin query',
|
|
20
|
+
): SQL {
|
|
21
|
+
const out = sql.empty()
|
|
22
|
+
let last = 0
|
|
23
|
+
|
|
24
|
+
for (const match of text.matchAll(/\$(\d+)/g)) {
|
|
25
|
+
const index = Number(match[1]) - 1
|
|
26
|
+
if (index < 0 || index >= params.length) {
|
|
27
|
+
throw new ValidationError(
|
|
28
|
+
`${where}: the statement names $${match[1]} but only ${params.length} parameter(s) were passed.`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
out.append(sql.raw(text.slice(last, match.index)))
|
|
32
|
+
out.append(sql`${params[index]}`)
|
|
33
|
+
last = match.index + match[0].length
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
out.append(sql.raw(text.slice(last)))
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onExecutor(executor: Tx, where: string): PluginData {
|
|
41
|
+
const run = async (text: string, params: readonly unknown[] = []) => {
|
|
42
|
+
return resultRows<Record<string, unknown>>(
|
|
43
|
+
await executor.execute(bindPluginSql(text, params, where)),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const data: PluginData = {
|
|
48
|
+
async query(text, params) {
|
|
49
|
+
return (await run(text, params ?? [])) as never
|
|
50
|
+
},
|
|
51
|
+
async one(text, params) {
|
|
52
|
+
const rows = await run(text, params ?? [])
|
|
53
|
+
return (rows[0] ?? null) as never
|
|
54
|
+
},
|
|
55
|
+
async tx(work) {
|
|
56
|
+
return work(data)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
return data
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function pluginData(
|
|
63
|
+
db: Database,
|
|
64
|
+
pluginKey: string,
|
|
65
|
+
options: PluginDataOptions = {},
|
|
66
|
+
): PluginData {
|
|
67
|
+
const timeoutMs = Math.max(1, Math.trunc(options.statementTimeoutMs ?? DEFAULT_TIMEOUT_MS))
|
|
68
|
+
const where = `plugin "${pluginKey}"`
|
|
69
|
+
|
|
70
|
+
const inTransaction = async <T>(work: (data: PluginData) => Promise<T>): Promise<T> => {
|
|
71
|
+
return db.transaction(async (tx) => {
|
|
72
|
+
await tx.execute(sql.raw(`set local statement_timeout = ${timeoutMs}`))
|
|
73
|
+
return work(onExecutor(tx, where))
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
async query(text, params) {
|
|
79
|
+
return inTransaction(async (data) => data.query(text, params))
|
|
80
|
+
},
|
|
81
|
+
async one(text, params) {
|
|
82
|
+
return inTransaction(async (data) => data.one(text, params))
|
|
83
|
+
},
|
|
84
|
+
async tx(work) {
|
|
85
|
+
return inTransaction(work)
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { and, eq, sql } from 'drizzle-orm'
|
|
2
|
+
|
|
3
|
+
import { ValidationError } from '@meith/core'
|
|
4
|
+
import type { PluginGrantRow, PluginGrants } from '@meith/plugin-kit'
|
|
5
|
+
|
|
6
|
+
import type { Database } from './client'
|
|
7
|
+
import { bumpPermissionVersion } from './permission-version'
|
|
8
|
+
import { groupRowToPermissionSet } from './permissions-map'
|
|
9
|
+
import { resultRows } from './result-rows'
|
|
10
|
+
import { userGroupMemberships, usergroups, users } from './schema'
|
|
11
|
+
import { permissionsCarryPower } from './staff-groups'
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
PLUGIN_UNGRANTABLE_PERMISSIONS,
|
|
15
|
+
permissionsCarryPower,
|
|
16
|
+
type UngrantablePermission,
|
|
17
|
+
} from './staff-groups'
|
|
18
|
+
|
|
19
|
+
const MAX_GRANT_MS = 2 * 366 * 24 * 60 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
export const DISPLACED_PRIMARY_REASON =
|
|
22
|
+
'the group this member is primary in whenever no plugin grant stands in front of it'
|
|
23
|
+
|
|
24
|
+
interface GrantableGroup {
|
|
25
|
+
readonly id: number
|
|
26
|
+
readonly key: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Tx = Parameters<Parameters<Database['transaction']>[0]>[0]
|
|
30
|
+
|
|
31
|
+
export async function isStaffGroup(tx: Tx, groupId: number): Promise<boolean> {
|
|
32
|
+
const rows = resultRows(
|
|
33
|
+
await tx.execute(sql`select * from usergroups where id = ${groupId}`),
|
|
34
|
+
) as Array<Record<string, unknown>>
|
|
35
|
+
|
|
36
|
+
const row = rows[0]
|
|
37
|
+
if (row === undefined) return false
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
row.is_staff_group === true || permissionsCarryPower(groupRowToPermissionSet(camelise(row)))
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function promotePrimary(tx: Tx, userId: number, groupId: number): Promise<void> {
|
|
45
|
+
const userRows = resultRows(
|
|
46
|
+
await tx.execute(sql`select primary_group_id from users where id = ${userId} for update`),
|
|
47
|
+
) as Array<{ primary_group_id: number }>
|
|
48
|
+
|
|
49
|
+
const current = userRows[0]
|
|
50
|
+
if (current === undefined) return
|
|
51
|
+
|
|
52
|
+
const held = Number(current.primary_group_id)
|
|
53
|
+
if (held === groupId) return
|
|
54
|
+
if (await isStaffGroup(tx, held)) return
|
|
55
|
+
|
|
56
|
+
const displacedRows = resultRows(
|
|
57
|
+
await tx.execute(sql`
|
|
58
|
+
select previous_primary_group_id
|
|
59
|
+
from user_group_memberships
|
|
60
|
+
where user_id = ${userId} and group_id = ${held}
|
|
61
|
+
`),
|
|
62
|
+
) as Array<{ previous_primary_group_id: number | null }>
|
|
63
|
+
|
|
64
|
+
const rooted = displacedRows[0]?.previous_primary_group_id
|
|
65
|
+
const displaced = rooted === null || rooted === undefined ? held : Number(rooted)
|
|
66
|
+
|
|
67
|
+
await tx.execute(sql`
|
|
68
|
+
insert into user_group_memberships (user_id, group_id, grant_reason)
|
|
69
|
+
values (${userId}, ${displaced}, ${DISPLACED_PRIMARY_REASON})
|
|
70
|
+
on conflict (user_id, group_id) do nothing
|
|
71
|
+
`)
|
|
72
|
+
|
|
73
|
+
await tx.execute(sql`
|
|
74
|
+
update user_group_memberships
|
|
75
|
+
set previous_primary_group_id = ${displaced}
|
|
76
|
+
where user_id = ${userId} and group_id = ${groupId}
|
|
77
|
+
`)
|
|
78
|
+
|
|
79
|
+
await tx.execute(sql`
|
|
80
|
+
update users set primary_group_id = ${groupId}, updated_at = now() where id = ${userId}
|
|
81
|
+
`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function restorePrimary(
|
|
85
|
+
tx: Tx,
|
|
86
|
+
userId: number,
|
|
87
|
+
groupId: number,
|
|
88
|
+
displaced: number,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
const changed = resultRows(
|
|
91
|
+
await tx.execute(sql`
|
|
92
|
+
update users
|
|
93
|
+
set primary_group_id = ${displaced},
|
|
94
|
+
display_group_id = case when display_group_id = ${groupId} then null else display_group_id end,
|
|
95
|
+
updated_at = now()
|
|
96
|
+
where id = ${userId} and primary_group_id = ${groupId}
|
|
97
|
+
returning id
|
|
98
|
+
`),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if (changed.length === 0) return
|
|
102
|
+
|
|
103
|
+
await tx.execute(sql`
|
|
104
|
+
delete from user_group_memberships
|
|
105
|
+
where user_id = ${userId}
|
|
106
|
+
and group_id = ${displaced}
|
|
107
|
+
and granted_by_plugin is null
|
|
108
|
+
and expires_at is null
|
|
109
|
+
and grant_reason = ${DISPLACED_PRIMARY_REASON}
|
|
110
|
+
`)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function camelise(row: Record<string, unknown>): Record<string, unknown> {
|
|
114
|
+
const out: Record<string, unknown> = {}
|
|
115
|
+
for (const [key, value] of Object.entries(row)) {
|
|
116
|
+
out[key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())] = value
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function grantableGroup(
|
|
122
|
+
db: Database,
|
|
123
|
+
pluginKey: string,
|
|
124
|
+
groupKey: string,
|
|
125
|
+
): Promise<GrantableGroup> {
|
|
126
|
+
const rows = resultRows(
|
|
127
|
+
await db.execute(sql`select * from usergroups where key = ${groupKey} limit 1`),
|
|
128
|
+
) as Array<Record<string, unknown>>
|
|
129
|
+
|
|
130
|
+
const row = rows[0]
|
|
131
|
+
const where = `plugin "${pluginKey}"`
|
|
132
|
+
|
|
133
|
+
if (row === undefined) {
|
|
134
|
+
throw new ValidationError(`${where}: no group is keyed "${groupKey}".`)
|
|
135
|
+
}
|
|
136
|
+
if (row.is_system === true) {
|
|
137
|
+
throw new ValidationError(
|
|
138
|
+
`${where}: "${groupKey}" is a system group. The board resolves it by key; membership is not a plugin's to hand out.`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
if (row.is_staff_group === true) {
|
|
142
|
+
throw new ValidationError(
|
|
143
|
+
`${where}: "${groupKey}" is a staff group, and staff is appointed, not granted by code.`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
if (permissionsCarryPower(groupRowToPermissionSet(camelise(row)))) {
|
|
147
|
+
throw new ValidationError(
|
|
148
|
+
`${where}: "${groupKey}" carries administrative or moderation power, so no plugin may grant it.`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (row.plugin_grantable !== true) {
|
|
152
|
+
throw new ValidationError(
|
|
153
|
+
`${where}: "${groupKey}" is not marked as grantable by plugins. An administrator opts a group in under Admin → Groups.`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { id: Number(row.id), key: String(row.key) }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function checkedUntil(pluginKey: string, until: Date, now: Date): Date {
|
|
161
|
+
if (!(until instanceof Date) || Number.isNaN(until.getTime())) {
|
|
162
|
+
throw new ValidationError(`plugin "${pluginKey}": the grant needs a valid expiry date.`)
|
|
163
|
+
}
|
|
164
|
+
if (until.getTime() <= now.getTime()) {
|
|
165
|
+
throw new ValidationError(
|
|
166
|
+
`plugin "${pluginKey}": a grant expiring in the past is a grant that never was. Got ${until.toISOString()}.`,
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (until.getTime() - now.getTime() > MAX_GRANT_MS) {
|
|
170
|
+
throw new ValidationError(
|
|
171
|
+
`plugin "${pluginKey}": grants are capped at two years. A longer arrangement is an operator decision, made in the panel.`,
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
return until
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function pluginGrants(
|
|
178
|
+
db: Database,
|
|
179
|
+
pluginKey: string,
|
|
180
|
+
clock: () => Date = () => new Date(),
|
|
181
|
+
): PluginGrants {
|
|
182
|
+
return {
|
|
183
|
+
async grant({ userId, groupKey, until, reason, primary }) {
|
|
184
|
+
const now = clock()
|
|
185
|
+
const expiry = checkedUntil(pluginKey, until, now)
|
|
186
|
+
if (reason.trim() === '') {
|
|
187
|
+
throw new ValidationError(
|
|
188
|
+
`plugin "${pluginKey}": a grant needs a reason — it is the row's audit trail.`,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const group = await grantableGroup(db, pluginKey, groupKey)
|
|
193
|
+
|
|
194
|
+
const userRows = await db
|
|
195
|
+
.select({ id: users.id })
|
|
196
|
+
.from(users)
|
|
197
|
+
.where(eq(users.id, userId))
|
|
198
|
+
.limit(1)
|
|
199
|
+
if (userRows.length === 0) {
|
|
200
|
+
throw new ValidationError(`plugin "${pluginKey}": no user ${userId}.`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await db.transaction(async (tx) => {
|
|
204
|
+
const existing = await tx
|
|
205
|
+
.select({
|
|
206
|
+
grantedByPlugin: userGroupMemberships.grantedByPlugin,
|
|
207
|
+
expiresAt: userGroupMemberships.expiresAt,
|
|
208
|
+
})
|
|
209
|
+
.from(userGroupMemberships)
|
|
210
|
+
.where(
|
|
211
|
+
and(
|
|
212
|
+
eq(userGroupMemberships.userId, userId),
|
|
213
|
+
eq(userGroupMemberships.groupId, group.id),
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
.limit(1)
|
|
217
|
+
|
|
218
|
+
const current = existing[0]
|
|
219
|
+
if (current === undefined) {
|
|
220
|
+
await tx.insert(userGroupMemberships).values({
|
|
221
|
+
userId,
|
|
222
|
+
groupId: group.id,
|
|
223
|
+
expiresAt: expiry,
|
|
224
|
+
grantedByPlugin: pluginKey,
|
|
225
|
+
grantReason: reason,
|
|
226
|
+
})
|
|
227
|
+
if (primary === true) await promotePrimary(tx, userId, group.id)
|
|
228
|
+
await bumpPermissionVersion(tx)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (current.grantedByPlugin !== pluginKey) {
|
|
233
|
+
throw new ValidationError(
|
|
234
|
+
`plugin "${pluginKey}": user ${userId} is already a member of "${groupKey}" by someone else's hand. That membership is not this plugin's to change.`,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let touched = false
|
|
239
|
+
if (current.expiresAt === null || current.expiresAt.getTime() < expiry.getTime()) {
|
|
240
|
+
await tx
|
|
241
|
+
.update(userGroupMemberships)
|
|
242
|
+
.set({ expiresAt: expiry, grantReason: reason })
|
|
243
|
+
.where(
|
|
244
|
+
and(
|
|
245
|
+
eq(userGroupMemberships.userId, userId),
|
|
246
|
+
eq(userGroupMemberships.groupId, group.id),
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
touched = true
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (primary === true) {
|
|
253
|
+
await promotePrimary(tx, userId, group.id)
|
|
254
|
+
touched = true
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (touched) await bumpPermissionVersion(tx)
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async extend({ userId, groupKey, until }) {
|
|
262
|
+
const now = clock()
|
|
263
|
+
const expiry = checkedUntil(pluginKey, until, now)
|
|
264
|
+
const group = await grantableGroup(db, pluginKey, groupKey)
|
|
265
|
+
|
|
266
|
+
await db.transaction(async (tx) => {
|
|
267
|
+
const result = await tx.execute(sql`
|
|
268
|
+
update user_group_memberships
|
|
269
|
+
set expires_at = ${expiry}
|
|
270
|
+
where user_id = ${userId}
|
|
271
|
+
and group_id = ${group.id}
|
|
272
|
+
and granted_by_plugin = ${pluginKey}
|
|
273
|
+
and expires_at < ${expiry}
|
|
274
|
+
returning user_id
|
|
275
|
+
`)
|
|
276
|
+
void resultRows(result)
|
|
277
|
+
})
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async revoke({ userId, groupKey, reason }) {
|
|
281
|
+
if (reason.trim() === '') {
|
|
282
|
+
throw new ValidationError(
|
|
283
|
+
`plugin "${pluginKey}": a revocation needs a reason, for the same audit trail as the grant.`,
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const groupRows = await db
|
|
288
|
+
.select({ id: usergroups.id })
|
|
289
|
+
.from(usergroups)
|
|
290
|
+
.where(eq(usergroups.key, groupKey))
|
|
291
|
+
.limit(1)
|
|
292
|
+
const group = groupRows[0]
|
|
293
|
+
if (group === undefined) return
|
|
294
|
+
|
|
295
|
+
await db.transaction(async (tx) => {
|
|
296
|
+
const result = await tx.execute(sql`
|
|
297
|
+
delete from user_group_memberships
|
|
298
|
+
where user_id = ${userId}
|
|
299
|
+
and group_id = ${group.id}
|
|
300
|
+
and granted_by_plugin = ${pluginKey}
|
|
301
|
+
returning previous_primary_group_id
|
|
302
|
+
`)
|
|
303
|
+
const gone = resultRows(result) as Array<{ previous_primary_group_id: number | null }>
|
|
304
|
+
if (gone.length === 0) return
|
|
305
|
+
|
|
306
|
+
const displaced = gone[0]?.previous_primary_group_id
|
|
307
|
+
if (displaced !== null && displaced !== undefined) {
|
|
308
|
+
await restorePrimary(tx, userId, group.id, Number(displaced))
|
|
309
|
+
}
|
|
310
|
+
await bumpPermissionVersion(tx)
|
|
311
|
+
})
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
async list(userId) {
|
|
315
|
+
const rows = await db
|
|
316
|
+
.select({
|
|
317
|
+
groupKey: usergroups.key,
|
|
318
|
+
expiresAt: userGroupMemberships.expiresAt,
|
|
319
|
+
})
|
|
320
|
+
.from(userGroupMemberships)
|
|
321
|
+
.innerJoin(usergroups, eq(usergroups.id, userGroupMemberships.groupId))
|
|
322
|
+
.where(
|
|
323
|
+
and(
|
|
324
|
+
eq(userGroupMemberships.userId, userId),
|
|
325
|
+
eq(userGroupMemberships.grantedByPlugin, pluginKey),
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return rows
|
|
330
|
+
.filter((row): row is { groupKey: string; expiresAt: Date } => row.expiresAt !== null)
|
|
331
|
+
.map((row): PluginGrantRow => ({ groupKey: row.groupKey, expiresAt: row.expiresAt }))
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export async function expireTimedGroupMemberships(db: Database, limit: number): Promise<number> {
|
|
337
|
+
return db.transaction(async (tx) => {
|
|
338
|
+
const result = await tx.execute(sql`
|
|
339
|
+
delete from user_group_memberships
|
|
340
|
+
where (user_id, group_id) in (
|
|
341
|
+
select user_id, group_id
|
|
342
|
+
from user_group_memberships
|
|
343
|
+
where expires_at is not null
|
|
344
|
+
and expires_at <= now()
|
|
345
|
+
limit ${limit}
|
|
346
|
+
)
|
|
347
|
+
returning user_id, group_id, previous_primary_group_id
|
|
348
|
+
`)
|
|
349
|
+
|
|
350
|
+
const gone = resultRows(result) as Array<{
|
|
351
|
+
user_id: number
|
|
352
|
+
group_id: number
|
|
353
|
+
previous_primary_group_id: number | null
|
|
354
|
+
}>
|
|
355
|
+
|
|
356
|
+
for (const row of gone) {
|
|
357
|
+
if (row.previous_primary_group_id === null) continue
|
|
358
|
+
await restorePrimary(
|
|
359
|
+
tx,
|
|
360
|
+
Number(row.user_id),
|
|
361
|
+
Number(row.group_id),
|
|
362
|
+
Number(row.previous_primary_group_id),
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (gone.length > 0) await bumpPermissionVersion(tx)
|
|
367
|
+
return gone.length
|
|
368
|
+
})
|
|
369
|
+
}
|