@devchitchat/chat 0.0.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 (131) hide show
  1. package/README.md +313 -0
  2. package/index.js +148 -0
  3. package/migrate/001-drop-channel-invites.js +3 -0
  4. package/migrate/002-invite-initial-roles.js +5 -0
  5. package/migrate/003-dm-channels.js +35 -0
  6. package/migrate/004-notifications.js +15 -0
  7. package/migrate/005-uploads.js +21 -0
  8. package/migrate/006-mention-priority.js +3 -0
  9. package/migrate/007-push-subscriptions.js +14 -0
  10. package/migrate/008-messages-channel-seq-index.js +3 -0
  11. package/migrate/009-message-reactions.js +15 -0
  12. package/migrate/010-edit-messages.js +7 -0
  13. package/package.json +51 -0
  14. package/pages/_error.html +12 -0
  15. package/pages/_layout.html +31 -0
  16. package/pages/_layout.js +13 -0
  17. package/pages/admin/_layout.html +52 -0
  18. package/pages/admin/_layout.js +8 -0
  19. package/pages/admin/bots/[userId].js +88 -0
  20. package/pages/admin/bots/[userId].phtml +89 -0
  21. package/pages/admin/bots/index.js +41 -0
  22. package/pages/admin/bots/index.phtml +58 -0
  23. package/pages/admin/index.js +8 -0
  24. package/pages/admin/invites/index.js +72 -0
  25. package/pages/admin/invites/index.phtml +88 -0
  26. package/pages/admin/users/[userId].js +60 -0
  27. package/pages/admin/users/[userId].phtml +57 -0
  28. package/pages/admin/users/index.js +20 -0
  29. package/pages/admin/users/index.phtml +37 -0
  30. package/pages/api/uploads/index.js +66 -0
  31. package/pages/api/user/settings.js +26 -0
  32. package/pages/auth/signout.js +14 -0
  33. package/pages/channels/[channelId].js +99 -0
  34. package/pages/channels/[channelId].phtml +173 -0
  35. package/pages/index.js +33 -0
  36. package/pages/invite/[token].js +10 -0
  37. package/pages/login/index.js +57 -0
  38. package/pages/login/index.phtml +29 -0
  39. package/pages/public/client/action-sheet.js +77 -0
  40. package/pages/public/client/app.js +38 -0
  41. package/pages/public/client/auth-tabs.js +13 -0
  42. package/pages/public/client/emoji-data.js +197 -0
  43. package/pages/public/client/islands/call.js +1770 -0
  44. package/pages/public/client/islands/sidebar.js +1197 -0
  45. package/pages/public/client/long-press.js +59 -0
  46. package/pages/public/client/modal.js +50 -0
  47. package/pages/public/client/router.js +87 -0
  48. package/pages/public/client/rtc-peer-manager.js +344 -0
  49. package/pages/public/client/settings-sync.js +76 -0
  50. package/pages/public/client/shared/messages.js +147 -0
  51. package/pages/public/client/swipe-nav.js +98 -0
  52. package/pages/public/client/theme.js +27 -0
  53. package/pages/public/client/ws.js +71 -0
  54. package/pages/public/favicon.ico +0 -0
  55. package/pages/public/favicon.png +0 -0
  56. package/pages/public/icon.png +0 -0
  57. package/pages/public/manifest.json +11 -0
  58. package/pages/public/sw.js +38 -0
  59. package/pages/public/themes/base.css +1786 -0
  60. package/pages/public/themes/dark.css +22 -0
  61. package/pages/public/themes/forest.css +22 -0
  62. package/pages/public/themes/light.css +23 -0
  63. package/pages/public/themes/ocean.css +22 -0
  64. package/pages/public/themes/rose.css +22 -0
  65. package/pages/registration/index.js +35 -0
  66. package/pages/registration/index.phtml +38 -0
  67. package/pages/uploads/[uploadId]/[filename].js +45 -0
  68. package/src/adapters/InMemoryAuthRepository.js +74 -0
  69. package/src/adapters/InMemoryChannelRepository.js +138 -0
  70. package/src/adapters/InMemoryDeliveryRepository.js +52 -0
  71. package/src/adapters/InMemoryFileStore.js +53 -0
  72. package/src/adapters/InMemoryHubRepository.js +85 -0
  73. package/src/adapters/InMemoryMessageRepository.js +35 -0
  74. package/src/adapters/InMemoryReactionRepository.js +45 -0
  75. package/src/adapters/InMemorySearchRepository.js +37 -0
  76. package/src/adapters/InMemorySignalingRepository.js +35 -0
  77. package/src/adapters/InMemoryUploadRepository.js +36 -0
  78. package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
  79. package/src/adapters/LocalFileStore.js +40 -0
  80. package/src/adapters/SqliteAuthRepository.js +184 -0
  81. package/src/adapters/SqliteChannelRepository.js +149 -0
  82. package/src/adapters/SqliteDeliveryRepository.js +53 -0
  83. package/src/adapters/SqliteHubRepository.js +99 -0
  84. package/src/adapters/SqliteMessageRepository.js +90 -0
  85. package/src/adapters/SqlitePushRepository.js +39 -0
  86. package/src/adapters/SqliteReactionRepository.js +50 -0
  87. package/src/adapters/SqliteSearchRepository.js +42 -0
  88. package/src/adapters/SqliteSignalingRepository.js +50 -0
  89. package/src/adapters/SqliteUploadRepository.js +34 -0
  90. package/src/adapters/SqliteUserSettingsRepository.js +23 -0
  91. package/src/adminAuth.js +25 -0
  92. package/src/config.js +11 -0
  93. package/src/context.js +77 -0
  94. package/src/core/dm.js +10 -0
  95. package/src/core/mentions.js +27 -0
  96. package/src/core/messages.js +21 -0
  97. package/src/core/reactions.js +6 -0
  98. package/src/core/roles.js +5 -0
  99. package/src/core/uploads.js +107 -0
  100. package/src/db/initDb.js +225 -0
  101. package/src/db/openDb.js +18 -0
  102. package/src/db/runMigrations.js +45 -0
  103. package/src/db/transaction.js +11 -0
  104. package/src/ports/IFileStore.js +34 -0
  105. package/src/services/AuthService.js +208 -0
  106. package/src/services/BotService.js +148 -0
  107. package/src/services/ChannelService.js +176 -0
  108. package/src/services/DeliveryService.js +28 -0
  109. package/src/services/HubService.js +133 -0
  110. package/src/services/MessageService.js +122 -0
  111. package/src/services/NotificationService.js +45 -0
  112. package/src/services/PresenceService.js +55 -0
  113. package/src/services/ReactionService.js +57 -0
  114. package/src/services/SearchService.js +21 -0
  115. package/src/services/SignalingService.js +177 -0
  116. package/src/services/UploadService.js +111 -0
  117. package/src/services/UserSettingsService.js +30 -0
  118. package/src/services/WebPushService.js +217 -0
  119. package/src/util/crypto.js +21 -0
  120. package/src/util/errors.js +14 -0
  121. package/src/util/ids.js +3 -0
  122. package/src/util/logger.js +21 -0
  123. package/src/ws/ChatServer.js +478 -0
  124. package/src/ws/handlers/authHandlers.js +152 -0
  125. package/src/ws/handlers/channelHandlers.js +166 -0
  126. package/src/ws/handlers/hubHandlers.js +82 -0
  127. package/src/ws/handlers/messageHandlers.js +88 -0
  128. package/src/ws/handlers/pushHandlers.js +25 -0
  129. package/src/ws/handlers/reactionHandlers.js +27 -0
  130. package/src/ws/handlers/rtcHandlers.js +126 -0
  131. package/styles.css +22 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pure functions for upload validation.
3
+ * No I/O — trivially testable.
4
+ */
5
+
6
+ // Magic byte signatures → MIME type
7
+ const MAGIC = [
8
+ // Images
9
+ { bytes: [0xff, 0xd8, 0xff], mime: 'image/jpeg' },
10
+ { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], mime: 'image/png' },
11
+ { bytes: [0x00, 0x00, 0x01, 0x00], mime: 'image/x-icon' },
12
+ { bytes: [0x47, 0x49, 0x46, 0x38], mime: 'image/gif' },
13
+ { bytes: [0x52, 0x49, 0x46, 0x46], mime: 'image/webp', offset: 0, extra: { offset: 8, bytes: [0x57, 0x45, 0x42, 0x50] } },
14
+ // PDF
15
+ { bytes: [0x25, 0x50, 0x44, 0x46], mime: 'application/pdf' },
16
+ // ZIP (also docx, xlsx, etc. — we only allow plain zip)
17
+ { bytes: [0x50, 0x4b, 0x03, 0x04], mime: 'application/zip' },
18
+ { bytes: [0x50, 0x4b, 0x05, 0x06], mime: 'application/zip' },
19
+ // GZip
20
+ { bytes: [0x1f, 0x8b], mime: 'application/gzip' },
21
+ // MP3
22
+ { bytes: [0x49, 0x44, 0x33], mime: 'audio/mpeg' },
23
+ { bytes: [0xff, 0xfb], mime: 'audio/mpeg' },
24
+ { bytes: [0xff, 0xf3], mime: 'audio/mpeg' },
25
+ { bytes: [0xff, 0xf2], mime: 'audio/mpeg' },
26
+ // OGG
27
+ { bytes: [0x4f, 0x67, 0x67, 0x53], mime: 'audio/ogg' },
28
+ // WAV
29
+ { bytes: [0x52, 0x49, 0x46, 0x46], mime: 'audio/wav', extra: { offset: 8, bytes: [0x57, 0x41, 0x56, 0x45] } },
30
+ // MP4
31
+ { bytes: [0x66, 0x74, 0x79, 0x70], offset: 4, mime: 'video/mp4' },
32
+ // WebM
33
+ { bytes: [0x1a, 0x45, 0xdf, 0xa3], mime: 'video/webm' },
34
+ ]
35
+
36
+ // Plain-text extensions we serve as text/plain (no magic check needed)
37
+ const TEXT_EXTENSIONS = new Set([
38
+ 'txt', 'md', 'markdown', 'json', 'csv', 'js', 'ts', 'jsx', 'tsx',
39
+ 'html', 'css', 'yaml', 'yml', 'toml', 'sh', 'bash', 'py', 'rb',
40
+ 'go', 'rs', 'java', 'c', 'cpp', 'h', 'xml', 'svg',
41
+ ])
42
+
43
+ // MIME types that browsers / OSes can execute — force Content-Disposition: attachment
44
+ const FORCED_DOWNLOAD_MIMES = new Set([
45
+ 'application/javascript',
46
+ 'application/x-sh',
47
+ 'application/octet-stream',
48
+ 'application/x-executable',
49
+ 'application/x-msdownload',
50
+ 'application/x-msdos-program',
51
+ 'text/html',
52
+ 'application/xhtml+xml',
53
+ ])
54
+
55
+ /**
56
+ * Detect MIME type from the first bytes of a file buffer.
57
+ * Returns the MIME string on success, or throws an Error with code 'UNSUPPORTED_TYPE'.
58
+ *
59
+ * @param {Uint8Array|Buffer} buf First N bytes (at least 16 recommended)
60
+ * @param {string} filename Original filename (used for text fallback)
61
+ * @returns {string} Detected MIME type
62
+ */
63
+ export function validateMimeType(buf, filename = '') {
64
+ // 1. Try text extension fallback first so .md, .json, etc. always work
65
+ const ext = filename.split('.').pop()?.toLowerCase() ?? ''
66
+ if (TEXT_EXTENSIONS.has(ext)) {
67
+ // Quick sanity: must not start with binary-looking bytes
68
+ const isBinary = Array.from(buf.slice(0, 8)).some(b => b < 0x09 && b !== 0x00)
69
+ if (!isBinary) return 'text/plain'
70
+ }
71
+
72
+ // 2. Try SVG (XML text)
73
+ if (ext === 'svg') {
74
+ return 'image/svg+xml'
75
+ }
76
+
77
+ // 3. Magic byte scan
78
+ for (const sig of MAGIC) {
79
+ const start = sig.offset ?? 0
80
+ const slice = buf.slice(start, start + sig.bytes.length)
81
+ const match = sig.bytes.every((b, i) => slice[i] === b)
82
+ if (!match) continue
83
+
84
+ // Optional extra check (e.g., WEBP vs WAV both start with RIFF)
85
+ if (sig.extra) {
86
+ const extra = buf.slice(sig.extra.offset, sig.extra.offset + sig.extra.bytes.length)
87
+ if (!sig.extra.bytes.every((b, i) => extra[i] === b)) continue
88
+ }
89
+
90
+ return sig.mime
91
+ }
92
+
93
+ const err = new Error(`Unsupported file type: ${filename}`)
94
+ err.code = 'UNSUPPORTED_TYPE'
95
+ throw err
96
+ }
97
+
98
+ /**
99
+ * Returns true if the MIME type should be served with Content-Disposition: attachment
100
+ * to prevent execution in the browser.
101
+ *
102
+ * @param {string} mime
103
+ * @returns {boolean}
104
+ */
105
+ export function isForcedDownload(mime) {
106
+ return FORCED_DOWNLOAD_MIMES.has(mime)
107
+ }
@@ -0,0 +1,225 @@
1
+ // Pure DDL — safe to call before migrations. No writes.
2
+ export const createSchema = (db) => {
3
+ db.exec(`
4
+ CREATE TABLE IF NOT EXISTS users (
5
+ user_id TEXT PRIMARY KEY,
6
+ handle TEXT NOT NULL UNIQUE,
7
+ display_name TEXT NOT NULL,
8
+ roles_json TEXT NOT NULL,
9
+ password_hash TEXT,
10
+ created_at INTEGER NOT NULL
11
+ );
12
+
13
+ CREATE TABLE IF NOT EXISTS sessions (
14
+ session_id TEXT PRIMARY KEY,
15
+ user_id TEXT NOT NULL,
16
+ token_hash TEXT NOT NULL,
17
+ created_at INTEGER NOT NULL,
18
+ expires_at INTEGER NOT NULL,
19
+ revoked_at INTEGER,
20
+ last_seen_at INTEGER,
21
+ FOREIGN KEY(user_id) REFERENCES users(user_id)
22
+ );
23
+
24
+ CREATE TABLE IF NOT EXISTS invites (
25
+ invite_id TEXT PRIMARY KEY,
26
+ token_hash TEXT NOT NULL,
27
+ created_by_user_id TEXT NOT NULL,
28
+ created_at INTEGER NOT NULL,
29
+ expires_at INTEGER NOT NULL,
30
+ max_uses INTEGER NOT NULL,
31
+ uses INTEGER NOT NULL,
32
+ redeemed_by_user_id TEXT,
33
+ note TEXT,
34
+ initial_roles_json TEXT,
35
+ FOREIGN KEY(created_by_user_id) REFERENCES users(user_id)
36
+ );
37
+
38
+ CREATE TABLE IF NOT EXISTS hubs (
39
+ hub_id TEXT PRIMARY KEY,
40
+ name TEXT NOT NULL,
41
+ description TEXT,
42
+ visibility TEXT NOT NULL,
43
+ created_by_user_id TEXT NOT NULL,
44
+ created_at INTEGER NOT NULL,
45
+ deleted_at INTEGER,
46
+ FOREIGN KEY(created_by_user_id) REFERENCES users(user_id)
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS hub_members (
50
+ hub_id TEXT NOT NULL,
51
+ user_id TEXT NOT NULL,
52
+ joined_at INTEGER NOT NULL,
53
+ left_at INTEGER,
54
+ PRIMARY KEY (hub_id, user_id),
55
+ FOREIGN KEY(hub_id) REFERENCES hubs(hub_id),
56
+ FOREIGN KEY(user_id) REFERENCES users(user_id)
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS channels (
60
+ channel_id TEXT PRIMARY KEY,
61
+ hub_id TEXT,
62
+ kind TEXT NOT NULL,
63
+ name TEXT NOT NULL,
64
+ topic TEXT,
65
+ visibility TEXT NOT NULL,
66
+ sort_order INTEGER NOT NULL DEFAULT 0,
67
+ created_by_user_id TEXT NOT NULL,
68
+ created_at INTEGER NOT NULL,
69
+ deleted_at INTEGER,
70
+ FOREIGN KEY(created_by_user_id) REFERENCES users(user_id)
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS channel_members (
74
+ channel_id TEXT NOT NULL,
75
+ user_id TEXT NOT NULL,
76
+ role TEXT NOT NULL,
77
+ joined_at INTEGER NOT NULL,
78
+ left_at INTEGER,
79
+ banned_at INTEGER,
80
+ PRIMARY KEY (channel_id, user_id),
81
+ FOREIGN KEY(channel_id) REFERENCES channels(channel_id),
82
+ FOREIGN KEY(user_id) REFERENCES users(user_id)
83
+ );
84
+
85
+ CREATE TABLE IF NOT EXISTS events (
86
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
87
+ ts INTEGER NOT NULL,
88
+ actor_user_id TEXT NOT NULL,
89
+ scope_kind TEXT NOT NULL,
90
+ scope_id TEXT NOT NULL,
91
+ type TEXT NOT NULL,
92
+ body_json TEXT NOT NULL,
93
+ trace TEXT
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS messages (
97
+ msg_id TEXT PRIMARY KEY,
98
+ channel_id TEXT NOT NULL,
99
+ seq INTEGER NOT NULL,
100
+ user_id TEXT NOT NULL,
101
+ ts INTEGER NOT NULL,
102
+ text TEXT NOT NULL,
103
+ client_msg_id TEXT,
104
+ deleted_at INTEGER,
105
+ priority TEXT NOT NULL DEFAULT 'normal',
106
+ attachments_json TEXT,
107
+ edited_at INTEGER,
108
+ FOREIGN KEY(channel_id) REFERENCES channels(channel_id),
109
+ FOREIGN KEY(user_id) REFERENCES users(user_id)
110
+ );
111
+
112
+ CREATE TABLE IF NOT EXISTS uploads (
113
+ upload_id TEXT PRIMARY KEY,
114
+ uploader_user_id TEXT NOT NULL REFERENCES users(user_id),
115
+ channel_id TEXT NOT NULL REFERENCES channels(channel_id),
116
+ msg_id TEXT REFERENCES messages(msg_id),
117
+ original_name TEXT NOT NULL,
118
+ stored_name TEXT NOT NULL,
119
+ mime_type TEXT NOT NULL,
120
+ size_bytes INTEGER NOT NULL,
121
+ created_at INTEGER NOT NULL
122
+ );
123
+
124
+ CREATE TABLE IF NOT EXISTS deliveries (
125
+ delivery_id TEXT PRIMARY KEY,
126
+ user_id TEXT NOT NULL,
127
+ channel_id TEXT NOT NULL,
128
+ after_seq INTEGER NOT NULL,
129
+ mention_seq INTEGER NOT NULL DEFAULT 0,
130
+ mention_priority TEXT NOT NULL DEFAULT 'normal',
131
+ last_delivered_at INTEGER,
132
+ status TEXT NOT NULL,
133
+ FOREIGN KEY(user_id) REFERENCES users(user_id),
134
+ FOREIGN KEY(channel_id) REFERENCES channels(channel_id)
135
+ );
136
+
137
+ CREATE TABLE IF NOT EXISTS user_settings (
138
+ user_id TEXT NOT NULL PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
139
+ settings_json TEXT NOT NULL DEFAULT '{}',
140
+ updated_at INTEGER NOT NULL DEFAULT 0
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS calls (
144
+ call_id TEXT NOT NULL PRIMARY KEY,
145
+ channel_id TEXT NOT NULL REFERENCES channels(channel_id),
146
+ created_by_user_id TEXT NOT NULL REFERENCES users(user_id),
147
+ topology TEXT NOT NULL DEFAULT 'mesh',
148
+ started_at INTEGER NOT NULL DEFAULT (unixepoch()),
149
+ ended_at INTEGER
150
+ );
151
+
152
+ CREATE TABLE IF NOT EXISTS call_participants (
153
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
154
+ call_id TEXT NOT NULL REFERENCES calls(call_id),
155
+ user_id TEXT NOT NULL REFERENCES users(user_id),
156
+ peer_id TEXT NOT NULL,
157
+ joined_at INTEGER NOT NULL DEFAULT (unixepoch()),
158
+ left_at INTEGER
159
+ );
160
+
161
+ CREATE INDEX IF NOT EXISTS idx_calls_channel_active
162
+ ON calls (channel_id) WHERE ended_at IS NULL;
163
+ `)
164
+
165
+ // Add sort_order to existing databases that pre-date this column
166
+ try { db.exec(`ALTER TABLE channels ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) } catch { /* already exists */ }
167
+ try { db.exec(`ALTER TABLE hubs ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) } catch { /* already exists */ }
168
+ // Add mention_seq to existing deliveries tables that pre-date this column
169
+ try { db.exec(`ALTER TABLE deliveries ADD COLUMN mention_seq INTEGER NOT NULL DEFAULT 0`) } catch { /* already exists */ }
170
+ // Add mention_priority to existing deliveries tables
171
+ try { db.exec(`ALTER TABLE deliveries ADD COLUMN mention_priority TEXT NOT NULL DEFAULT 'normal'`) } catch { /* already exists */ }
172
+ // Add priority + attachments_json to existing messages tables
173
+ try { db.exec(`ALTER TABLE messages ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal'`) } catch { /* already exists */ }
174
+ try { db.exec(`ALTER TABLE messages ADD COLUMN attachments_json TEXT`) } catch { /* already exists */ }
175
+ try { db.exec(`ALTER TABLE messages ADD COLUMN edited_at INTEGER`) } catch { /* already exists */ }
176
+
177
+ // Bot tokens — added after initial schema
178
+ db.exec(`
179
+ CREATE TABLE IF NOT EXISTS bot_tokens (
180
+ token_id TEXT PRIMARY KEY,
181
+ user_id TEXT NOT NULL,
182
+ token_hash TEXT NOT NULL UNIQUE,
183
+ label TEXT,
184
+ created_at INTEGER NOT NULL,
185
+ expires_at INTEGER,
186
+ last_used_at INTEGER,
187
+ revoked_at INTEGER,
188
+ FOREIGN KEY(user_id) REFERENCES users(user_id)
189
+ );
190
+ `)
191
+ // Add expires_at to existing bot_tokens tables that predate this column
192
+ try { db.exec('ALTER TABLE bot_tokens ADD COLUMN expires_at INTEGER') } catch { /* already exists */ }
193
+
194
+ try {
195
+ db.exec(`
196
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts_messages USING fts5(
197
+ text,
198
+ channel_id UNINDEXED,
199
+ msg_id UNINDEXED,
200
+ seq UNINDEXED,
201
+ user_id UNINDEXED,
202
+ ts UNINDEXED
203
+ );
204
+ `)
205
+ } catch {
206
+ db.exec(`
207
+ CREATE TABLE IF NOT EXISTS fts_messages (
208
+ text TEXT NOT NULL,
209
+ channel_id TEXT NOT NULL,
210
+ msg_id TEXT NOT NULL,
211
+ seq INTEGER NOT NULL,
212
+ user_id TEXT NOT NULL,
213
+ ts INTEGER NOT NULL
214
+ );
215
+ `)
216
+ }
217
+ }
218
+
219
+ export const initDb = (db) => {
220
+ createSchema(db)
221
+
222
+ // Close any calls left open by a previous crash or unclean shutdown.
223
+ // Live call state is in-memory; on restart there are no active peers.
224
+ db.exec(`UPDATE calls SET ended_at = unixepoch() WHERE ended_at IS NULL`)
225
+ }
@@ -0,0 +1,18 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { mkdirSync, existsSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ const ensureDir = (filePath) => {
6
+ const dir = dirname(filePath)
7
+ if (dir && dir !== '.' && !existsSync(dir)) {
8
+ mkdirSync(dir, { recursive: true })
9
+ }
10
+ }
11
+
12
+ export const openDatabase = (filePath) => {
13
+ ensureDir(filePath)
14
+ const db = new Database(filePath)
15
+ db.exec('PRAGMA journal_mode = WAL')
16
+ db.exec('PRAGMA foreign_keys = ON')
17
+ return db
18
+ }
@@ -0,0 +1,45 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+ const MIGRATE_DIR = join(__dirname, '../../migrate')
7
+
8
+ /**
9
+ * Apply all pending migrations to the given database.
10
+ * Already-applied migrations are skipped. Safe to call on every boot.
11
+ */
12
+ export async function runMigrations(db, { logger } = {}) {
13
+ db.exec(`
14
+ CREATE TABLE IF NOT EXISTS _migrations (
15
+ filename TEXT PRIMARY KEY,
16
+ applied_at INTEGER NOT NULL
17
+ )
18
+ `)
19
+
20
+ const files = readdirSync(MIGRATE_DIR).filter(f => f.endsWith('.js')).sort()
21
+ const applied = new Set(
22
+ db.prepare('SELECT filename FROM _migrations').all().map(r => r.filename)
23
+ )
24
+ const pending = files.filter(f => !applied.has(f))
25
+
26
+ if (pending.length === 0) return
27
+
28
+ const insertApplied = db.prepare(
29
+ 'INSERT INTO _migrations (filename, applied_at) VALUES (?, ?)'
30
+ )
31
+
32
+ for (const file of pending) {
33
+ const { run } = await import(join(MIGRATE_DIR, file))
34
+ db.exec('PRAGMA foreign_keys = OFF')
35
+ try {
36
+ db.transaction(() => {
37
+ run(db)
38
+ insertApplied.run(file, Date.now())
39
+ })()
40
+ } finally {
41
+ db.exec('PRAGMA foreign_keys = ON')
42
+ }
43
+ logger?.info('migration.applied', { file })
44
+ }
45
+ }
@@ -0,0 +1,11 @@
1
+ export const runTransaction = (db, fn) => {
2
+ db.exec('BEGIN')
3
+ try {
4
+ const result = fn()
5
+ db.exec('COMMIT')
6
+ return result
7
+ } catch (error) {
8
+ db.exec('ROLLBACK')
9
+ throw error
10
+ }
11
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * IFileStore — port for file persistence.
3
+ *
4
+ * Implemented by:
5
+ * LocalFileStore writes to data/uploads/{uploadId}/{storedName}
6
+ * InMemoryFileStore stores buffers in a Map (for tests)
7
+ *
8
+ * @interface
9
+ */
10
+ export class IFileStore {
11
+ /**
12
+ * Persist a file stream.
13
+ * @param {{ uploadId: string, storedName: string, stream: ReadableStream }} params
14
+ * @returns {Promise<void>}
15
+ */
16
+ // eslint-disable-next-line no-unused-vars
17
+ async write({ uploadId, storedName, stream }) { throw new Error('Not implemented') }
18
+
19
+ /**
20
+ * Open a file for reading.
21
+ * @param {{ uploadId: string, storedName: string }} params
22
+ * @returns {Promise<ReadableStream>}
23
+ */
24
+ // eslint-disable-next-line no-unused-vars
25
+ async read({ uploadId, storedName }) { throw new Error('Not implemented') }
26
+
27
+ /**
28
+ * Delete a stored file.
29
+ * @param {{ uploadId: string, storedName: string }} params
30
+ * @returns {Promise<void>}
31
+ */
32
+ // eslint-disable-next-line no-unused-vars
33
+ async delete({ uploadId, storedName }) { throw new Error('Not implemented') }
34
+ }
@@ -0,0 +1,208 @@
1
+ import { newId } from '../util/ids.js'
2
+ import { randomToken, hashToken, hashPassword, verifyPassword } from '../util/crypto.js'
3
+ import { ServiceError } from '../util/errors.js'
4
+
5
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
6
+ const DEFAULT_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000
7
+
8
+ export class AuthService {
9
+ constructor({ authRepo, nowFn = () => Date.now(), sessionTtlMs = DEFAULT_SESSION_TTL_MS, bootstrapToken = null }) {
10
+ this.authRepo = authRepo
11
+ this.nowFn = nowFn
12
+ this.sessionTtlMs = sessionTtlMs
13
+ this.bootstrapToken = bootstrapToken
14
+ }
15
+
16
+ createInvite({ createdByUserId, ttlMs = DEFAULT_TTL_MS, maxUses = 1, note = null, roles = ['user'] }) {
17
+ this.requireAdmin(createdByUserId)
18
+ if (!Array.isArray(roles) || roles.length === 0) throw new ServiceError('BAD_REQUEST', 'roles must be a non-empty array')
19
+ const inviteToken = randomToken()
20
+ const inviteId = newId('invite')
21
+ const now = this.nowFn()
22
+ const expiresAt = now + ttlMs
23
+ this.authRepo.insertInvite({ inviteId, tokenHash: hashToken(inviteToken), createdByUserId, now, expiresAt, maxUses, note, initialRolesJson: JSON.stringify(roles) })
24
+ return { inviteToken, inviteId, expiresAt, maxUses, roles }
25
+ }
26
+
27
+ listInvites({ requestingUserId }) {
28
+ this.requireAdmin(requestingUserId)
29
+ return this.authRepo.listInvites()
30
+ }
31
+
32
+ revokeInvite({ inviteId, requestingUserId }) {
33
+ this.requireAdmin(requestingUserId)
34
+ this.authRepo.deleteInvite({ inviteId })
35
+ }
36
+
37
+ async redeemInvite({ inviteToken, profile, password }) {
38
+ const invite = this.authRepo.findInviteByTokenHash({ tokenHash: hashToken(inviteToken) })
39
+ const now = this.nowFn()
40
+ if (!invite) {
41
+ const bootstrap = await this.tryBootstrap({ inviteToken, profile, now, password })
42
+ if (bootstrap) return bootstrap
43
+ throw new ServiceError('AUTH_FAILED', 'Invite token is invalid')
44
+ }
45
+ if (invite.expires_at <= now) throw new ServiceError('AUTH_FAILED', 'Invite token has expired')
46
+ if (invite.uses >= invite.max_uses) throw new ServiceError('AUTH_FAILED', 'Invite token has been used')
47
+ const handle = profile?.handle?.trim()
48
+ const displayName = profile?.display_name?.trim() || handle
49
+ if (!handle) throw new ServiceError('BAD_REQUEST', 'Handle is required')
50
+ if (!password) throw new ServiceError('BAD_REQUEST', 'Password is required')
51
+ if (this.authRepo.isHandleTaken({ handle })) throw new ServiceError('CONFLICT', 'Handle already taken')
52
+ const userId = newId('u')
53
+ const roles = invite.initial_roles_json ? JSON.parse(invite.initial_roles_json) : this.getDefaultRoles()
54
+ const passwordHash = await hashPassword(password)
55
+ const { sessionId, sessionToken, expiresAt } = this._makeSessionParts(now)
56
+ this.authRepo.registerUser({
57
+ inviteId: invite.invite_id,
58
+ userId, handle, displayName,
59
+ rolesJson: JSON.stringify(roles),
60
+ passwordHash, now,
61
+ sessionId, sessionTokenHash: hashToken(sessionToken), sessionExpiresAt: expiresAt
62
+ })
63
+ return {
64
+ sessionToken,
65
+ user: { user_id: userId, handle, display_name: displayName, roles }
66
+ }
67
+ }
68
+
69
+ async tryBootstrap({ inviteToken, profile, now, password }) {
70
+ if (!this.bootstrapToken || inviteToken !== this.bootstrapToken) return null
71
+ if (this.authRepo.getUserCount() > 0) return null
72
+ const handle = profile?.handle?.trim()
73
+ const displayName = profile?.display_name?.trim() || handle
74
+ if (!handle) throw new ServiceError('BAD_REQUEST', 'Handle is required')
75
+ if (!password) throw new ServiceError('BAD_REQUEST', 'Password is required')
76
+ if (this.authRepo.isHandleTaken({ handle })) throw new ServiceError('CONFLICT', 'Handle already taken')
77
+ const userId = newId('u')
78
+ const roles = ['admin']
79
+ const passwordHash = await hashPassword(password)
80
+ const { sessionId, sessionToken, expiresAt } = this._makeSessionParts(now)
81
+ this.authRepo.registerBootstrapUser({
82
+ userId, handle, displayName,
83
+ rolesJson: JSON.stringify(roles),
84
+ passwordHash, now,
85
+ sessionId, sessionTokenHash: hashToken(sessionToken), sessionExpiresAt: expiresAt
86
+ })
87
+ this.bootstrapToken = null
88
+ return {
89
+ sessionToken,
90
+ user: { user_id: userId, handle, display_name: displayName, roles }
91
+ }
92
+ }
93
+
94
+ async signInWithPassword({ handle, password }) {
95
+ if (!handle || !password) throw new ServiceError('BAD_REQUEST', 'Handle and password required')
96
+ const row = this.authRepo.findUserByHandle({ handle })
97
+ if (!row || !row.password_hash) throw new ServiceError('AUTH_FAILED', 'Invalid handle or password')
98
+ const isValid = await verifyPassword(password, row.password_hash)
99
+ if (!isValid) throw new ServiceError('AUTH_FAILED', 'Invalid handle or password')
100
+ const now = this.nowFn()
101
+ const { sessionId, sessionToken, expiresAt } = this._makeSessionParts(now)
102
+ this.authRepo.insertSession({ sessionId, userId: row.user_id, tokenHash: hashToken(sessionToken), now, expiresAt })
103
+ return {
104
+ sessionToken,
105
+ user: { user_id: row.user_id, handle: row.handle, display_name: row.display_name, roles: JSON.parse(row.roles_json) }
106
+ }
107
+ }
108
+
109
+ createSession(userId) {
110
+ const now = this.nowFn()
111
+ const { sessionId, sessionToken, expiresAt } = this._makeSessionParts(now)
112
+ this.authRepo.insertSession({ sessionId, userId, tokenHash: hashToken(sessionToken), now, expiresAt })
113
+ return { sessionId, sessionToken, expiresAt }
114
+ }
115
+
116
+ validateSession(sessionToken) {
117
+ if (!sessionToken) return null
118
+ const now = this.nowFn()
119
+ const row = this.authRepo.findSessionWithUser({ tokenHash: hashToken(sessionToken) })
120
+ if (!row || row.revoked_at || row.expires_at <= now) return null
121
+ const lastSeenAt = row.last_seen_at ?? null
122
+ this.authRepo.touchSession({ sessionId: row.session_id, now })
123
+ return {
124
+ session_id: row.session_id,
125
+ last_seen_at: lastSeenAt,
126
+ user: { user_id: row.user_id, handle: row.handle, display_name: row.display_name, roles: JSON.parse(row.roles_json) }
127
+ }
128
+ }
129
+
130
+ revokeSession(sessionId) {
131
+ this.authRepo.revokeSession({ sessionId, now: this.nowFn() })
132
+ }
133
+
134
+ // ── User management (admin only) ───────────────────────────────────────────
135
+
136
+ listUsers({ requestingUserId }) {
137
+ this.requireAdmin(requestingUserId)
138
+ return this.authRepo.listUsers().map(row => ({
139
+ user_id: row.user_id,
140
+ handle: row.handle,
141
+ display_name: row.display_name,
142
+ roles: JSON.parse(row.roles_json),
143
+ created_at: row.created_at,
144
+ }))
145
+ }
146
+
147
+ setUserRoles({ targetUserId, roles, requestingUserId }) {
148
+ this.requireAdmin(requestingUserId)
149
+ if (!Array.isArray(roles)) throw new ServiceError('BAD_REQUEST', 'roles must be an array')
150
+ this.authRepo.updateUserRoles({ userId: targetUserId, rolesJson: JSON.stringify(roles) })
151
+ }
152
+
153
+ async adminSetPassword({ targetUserId, newPassword, requestingUserId }) {
154
+ this.requireAdmin(requestingUserId)
155
+ if (!newPassword || newPassword.length < 8) throw new ServiceError('BAD_REQUEST', 'Password must be at least 8 characters')
156
+ const passwordHash = await hashPassword(newPassword)
157
+ this.authRepo.updateUserPassword({ userId: targetUserId, passwordHash })
158
+ this.authRepo.revokeAllUserSessions({ userId: targetUserId, now: this.nowFn() })
159
+ }
160
+
161
+ adminUpdateDisplayName({ targetUserId, displayName, requestingUserId }) {
162
+ this.requireAdmin(requestingUserId)
163
+ const name = displayName?.trim()
164
+ if (!name) throw new ServiceError('BAD_REQUEST', 'Display name is required')
165
+ this.authRepo.updateUserDisplayName({ userId: targetUserId, displayName: name })
166
+ }
167
+
168
+ requireAdmin(userId) {
169
+ const user = this.getUser(userId)
170
+ if (!user || !user.roles.includes('admin')) throw new ServiceError('FORBIDDEN', 'Admin role required')
171
+ }
172
+
173
+ getUser(userId) {
174
+ const row = this.authRepo.findUserById({ userId })
175
+ if (!row) return null
176
+ return { user_id: row.user_id, handle: row.handle, display_name: row.display_name, roles: JSON.parse(row.roles_json) }
177
+ }
178
+
179
+ findInvite(inviteToken) {
180
+ return this.authRepo.findInviteByTokenHash({ tokenHash: hashToken(inviteToken) })
181
+ }
182
+
183
+ listUsersBasic() {
184
+ return this.authRepo.listUsers().map(row => ({
185
+ user_id: row.user_id,
186
+ handle: row.handle,
187
+ display_name: row.display_name,
188
+ roles: JSON.parse(row.roles_json),
189
+ }))
190
+ }
191
+
192
+ getDefaultRoles() { return ['user'] }
193
+
194
+ getUserCount() {
195
+ return this.authRepo.getUserCount()
196
+ }
197
+
198
+ isHandleTaken(handle) {
199
+ return this.authRepo.isHandleTaken({ handle })
200
+ }
201
+
202
+ _makeSessionParts(now) {
203
+ const sessionId = newId('s')
204
+ const sessionToken = randomToken(32)
205
+ const expiresAt = now + this.sessionTtlMs
206
+ return { sessionId, sessionToken, expiresAt }
207
+ }
208
+ }