@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,22 @@
1
+ /* dark theme — default */
2
+ :root, [data-theme="dark"] {
3
+ --bg-base: #1a1b1e;
4
+ --bg-topbar: #141517;
5
+ --bg-sidebar: #1e1f22;
6
+ --bg-hover: rgba(255,255,255,0.06);
7
+ --bg-active: rgba(88,166,255,0.12);
8
+ --bg-input: #2b2d31;
9
+ --bg-call: #1e1f22;
10
+
11
+ --text-primary: #e3e5e8;
12
+ --text-secondary: #585c60;
13
+ --text-muted: #6d6f78;
14
+
15
+ --border: #2e3035;
16
+ --accent: #58a6ff;
17
+
18
+ --color-success: #3ba55c;
19
+ --color-danger: #ed4245;
20
+
21
+ --code-background: rgba(255,255,255,.1);
22
+ }
@@ -0,0 +1,22 @@
1
+ /* forest theme — earthy greens */
2
+ :root, [data-theme="forest"] {
3
+ --bg-base: #141f14;
4
+ --bg-topbar: #0e160e;
5
+ --bg-sidebar: #192419;
6
+ --bg-hover: rgba(80,180,80,0.08);
7
+ --bg-active: rgba(80,180,80,0.16);
8
+ --bg-input: #1f2e1f;
9
+ --bg-call: #192419;
10
+
11
+ --text-primary: #d6e8d0;
12
+ --text-secondary: #3f533d;
13
+ --text-muted: #4a6645;
14
+
15
+ --border: #243524;
16
+ --accent: #60c060;
17
+
18
+ --color-success: #4aad5a;
19
+ --color-danger: #d05a3a;
20
+ --code-background: rgba(255,255,255,.1);
21
+
22
+ }
@@ -0,0 +1,23 @@
1
+ /* light theme */
2
+ :root, [data-theme="light"] {
3
+ --bg-base: #f9fafb;
4
+ --bg-topbar: #ffffff;
5
+ --bg-sidebar: #f2f3f5;
6
+ --bg-hover: rgba(0,0,0,0.05);
7
+ --bg-active: rgba(0,100,220,0.1);
8
+ --bg-input: #ffffff;
9
+ --bg-call: #f2f3f5;
10
+
11
+ --text-primary: #060607;
12
+ --text-secondary: #a2a7bb;
13
+ --text-muted: #80848e;
14
+
15
+ --border: #e3e5e8;
16
+ --accent: #0068d6;
17
+
18
+ --color-success: #248046;
19
+ --color-danger: #da373c;
20
+
21
+ --code-background: rgba(0,0,0,.1);
22
+
23
+ }
@@ -0,0 +1,22 @@
1
+ /* ocean theme — deep teal */
2
+ :root, [data-theme="ocean"] {
3
+ --bg-base: #0d1b2a;
4
+ --bg-topbar: #091320;
5
+ --bg-sidebar: #0f2137;
6
+ --bg-hover: rgba(0,200,200,0.08);
7
+ --bg-active: rgba(0,200,200,0.15);
8
+ --bg-input: #152a3e;
9
+ --bg-call: #0f2137;
10
+
11
+ --text-primary: #cde8f0;
12
+ --text-secondary: #475e65;
13
+ --text-muted: #4a7080;
14
+
15
+ --border: #1a3448;
16
+ --accent: #00c8c8;
17
+
18
+ --color-success: #2fb57a;
19
+ --color-danger: #e05a5a;
20
+
21
+ --code-background: rgba(255,255,255,.1);
22
+ }
@@ -0,0 +1,22 @@
1
+ /* rose theme — warm pink */
2
+ :root, [data-theme="rose"] {
3
+ --bg-base: #1e1218;
4
+ --bg-topbar: #160c11;
5
+ --bg-sidebar: #241520;
6
+ --bg-hover: rgba(230,100,140,0.08);
7
+ --bg-active: rgba(230,100,140,0.16);
8
+ --bg-input: #2c1a26;
9
+ --bg-call: #241520;
10
+
11
+ --text-primary: #f0d8e4;
12
+ --text-secondary: #79596a;
13
+ --text-muted: #7a4860;
14
+
15
+ --border: #36202e;
16
+ --accent: #e8688a;
17
+
18
+ --color-success: #5ab87a;
19
+ --color-danger: #e05050;
20
+ --code-background: rgba(255,255,255,.1);
21
+
22
+ }
@@ -0,0 +1,35 @@
1
+ import { auth, sessionFromRequest, sessionCookie } from '../../src/context.js'
2
+ import { p } from '../../src/config.js'
3
+
4
+ export async function GET(req) {
5
+ const session = sessionFromRequest(req)
6
+ if (session) return Response.redirect(new URL(p('/'), req.url), 302)
7
+
8
+ const url = new URL(req.url)
9
+ const inviteToken = url.searchParams.get('invite') ?? ''
10
+ return { error: null, invite_token: inviteToken }
11
+ }
12
+
13
+ export async function POST(req) {
14
+ const form = await req.formData()
15
+ try {
16
+ const inviteToken = form.get('invite_token')?.trim()
17
+ const handle = form.get('handle')?.trim()
18
+ const display_name = form.get('display_name')?.trim() || handle
19
+ const password = form.get('password')
20
+ const result = await auth.redeemInvite({ inviteToken, profile: { handle, display_name }, password })
21
+ return new Response(null, {
22
+ status: 302,
23
+ headers: {
24
+ Location: p('/'),
25
+ 'Set-Cookie': sessionCookie(result.sessionToken),
26
+ }
27
+ })
28
+ } catch (err) {
29
+ const invite_token = form.get('invite_token') ?? ''
30
+ return {
31
+ error: err.message ?? 'Something went wrong',
32
+ invite_token,
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,38 @@
1
+ <section class="signin">
2
+ {{#if error}}
3
+ <div class="alert alert-error" role="alert">{{error}}</div>
4
+ {{/if}}
5
+
6
+ <!-- Sign up with invite -->
7
+ <section id="panel-signup" role="tabpanel">
8
+ <form method="POST" action="{{base}}/registration" class="auth-form" novalidate>
9
+ <input type="hidden" name="_action" value="signup">
10
+ <div class="field">
11
+ <label for="signup-token">Invite code</label>
12
+ <input id="signup-token" name="invite_token" type="text" autocomplete="off" required
13
+ placeholder="paste invite code" value="{{invite_token}}">
14
+ </div>
15
+ <div class="field">
16
+ <label for="signup-handle">Handle</label>
17
+ <input id="signup-handle" name="handle" type="text" autocomplete="username" autocapitalize="none" required
18
+ placeholder="choose-a-handle">
19
+ </div>
20
+ <div class="field">
21
+ <label for="signup-display">Display name</label>
22
+ <input id="signup-display" name="display_name" type="text" autocomplete="name" placeholder="Your Name">
23
+ </div>
24
+ <div class="field">
25
+ <label for="signup-password">Password</label>
26
+ <input id="signup-password" name="password" type="password" autocomplete="new-password" required
27
+ placeholder="••••••••">
28
+ </div>
29
+ <button type="submit" class="btn-primary">Create account</button>
30
+ </form>
31
+ </section>
32
+ <footer>
33
+ <div class="auth-tabs" role="tablist">
34
+ <a role="tab" aria-controls="panel-signin" aria-selected="false" href="{{base}}/login" title="Sign in">Sign in</a>
35
+ <a role="tab" aria-controls="panel-signup" aria-selected="true" href="{{base}}/registration" title="Sign up">Sign up</a>
36
+ </div>
37
+ </footer>
38
+ </section>
@@ -0,0 +1,45 @@
1
+ /**
2
+ * GET /uploads/:uploadId/:filename
3
+ *
4
+ * Authenticated file download handler.
5
+ * `:filename` is cosmetic — the actual file is located by uploadId + stored_name from DB.
6
+ * Path traversal is impossible because stored_name is opaque and never interpolated from URL.
7
+ */
8
+ import { sessionFromRequest, botUserFromRequest, uploadService } from '../../../src/context.js'
9
+ import { ServiceError, httpStatus } from '../../../src/util/errors.js'
10
+
11
+ export async function GET(req) {
12
+ const session = sessionFromRequest(req)
13
+ const botUser = session ? null : await botUserFromRequest(req)
14
+ const reqUser = session?.user ?? botUser
15
+ if (!reqUser) return new Response('Unauthorized', { status: 401 })
16
+
17
+ const parts = new URL(req.url).pathname.split('/')
18
+ // pathname: /uploads/<uploadId>/<filename>
19
+ const uploadId = parts[2]
20
+
21
+ if (!uploadId) return new Response('Not Found', { status: 404 })
22
+
23
+ try {
24
+ const { stream, mimeType, contentDisposition } = await uploadService.streamFile({
25
+ uploadId,
26
+ requestingUserId: reqUser.user_id,
27
+ userRoles: reqUser.roles ?? [],
28
+ })
29
+
30
+ return new Response(stream, {
31
+ status: 200,
32
+ headers: {
33
+ 'Content-Type': mimeType,
34
+ 'Content-Disposition': contentDisposition,
35
+ 'Cache-Control': 'private, max-age=31536000, immutable',
36
+ },
37
+ })
38
+ } catch (err) {
39
+ if (err instanceof ServiceError) {
40
+ const status = httpStatus(err)
41
+ return new Response(err.message, { status })
42
+ }
43
+ throw err
44
+ }
45
+ }
@@ -0,0 +1,74 @@
1
+ export class InMemoryAuthRepository {
2
+ constructor() {
3
+ this._users = new Map() // userId → user record (with password_hash, roles_json)
4
+ this._sessions = new Map() // sessionId → session record
5
+ this._invites = new Map() // tokenHash → invite record
6
+ }
7
+
8
+ // ── Invites ────────────────────────────────────────────────────────────────
9
+
10
+ insertInvite({ inviteId, tokenHash, createdByUserId, now, expiresAt, maxUses, note, initialRolesJson }) {
11
+ this._invites.set(tokenHash, { invite_id: inviteId, token_hash: tokenHash, created_by_user_id: createdByUserId, created_at: now, expires_at: expiresAt, max_uses: maxUses, uses: 0, note, initial_roles_json: initialRolesJson, redeemed_by_user_id: null })
12
+ }
13
+
14
+ findInviteByTokenHash({ tokenHash }) {
15
+ return this._invites.get(tokenHash) ?? null
16
+ }
17
+
18
+ registerUser({ inviteId, userId, handle, displayName, rolesJson, passwordHash, now, sessionId, sessionTokenHash, sessionExpiresAt }) {
19
+ this._users.set(userId, { user_id: userId, handle, display_name: displayName, roles_json: rolesJson, password_hash: passwordHash, created_at: now })
20
+ for (const invite of this._invites.values()) {
21
+ if (invite.invite_id === inviteId) { invite.uses += 1; invite.redeemed_by_user_id = userId; break }
22
+ }
23
+ this._sessions.set(sessionId, { session_id: sessionId, user_id: userId, token_hash: sessionTokenHash, created_at: now, expires_at: sessionExpiresAt, last_seen_at: now, revoked_at: null })
24
+ }
25
+
26
+ registerBootstrapUser({ userId, handle, displayName, rolesJson, passwordHash, now, sessionId, sessionTokenHash, sessionExpiresAt }) {
27
+ this._users.set(userId, { user_id: userId, handle, display_name: displayName, roles_json: rolesJson, password_hash: passwordHash, created_at: now })
28
+ this._sessions.set(sessionId, { session_id: sessionId, user_id: userId, token_hash: sessionTokenHash, created_at: now, expires_at: sessionExpiresAt, last_seen_at: now, revoked_at: null })
29
+ }
30
+
31
+ // ── Users ──────────────────────────────────────────────────────────────────
32
+
33
+ findUserByHandle({ handle }) {
34
+ return [...this._users.values()].find(u => u.handle === handle) ?? null
35
+ }
36
+
37
+ findUserById({ userId }) {
38
+ const u = this._users.get(userId)
39
+ if (!u) return null
40
+ return { user_id: u.user_id, handle: u.handle, display_name: u.display_name, roles_json: u.roles_json }
41
+ }
42
+
43
+ getUserCount() {
44
+ return this._users.size
45
+ }
46
+
47
+ isHandleTaken({ handle }) {
48
+ return [...this._users.values()].some(u => u.handle === handle)
49
+ }
50
+
51
+ // ── Sessions ───────────────────────────────────────────────────────────────
52
+
53
+ insertSession({ sessionId, userId, tokenHash, now, expiresAt }) {
54
+ this._sessions.set(sessionId, { session_id: sessionId, user_id: userId, token_hash: tokenHash, created_at: now, expires_at: expiresAt, last_seen_at: now, revoked_at: null })
55
+ }
56
+
57
+ findSessionWithUser({ tokenHash }) {
58
+ const session = [...this._sessions.values()].find(s => s.token_hash === tokenHash)
59
+ if (!session) return null
60
+ const user = this._users.get(session.user_id)
61
+ if (!user) return null
62
+ return { session_id: session.session_id, user_id: user.user_id, expires_at: session.expires_at, revoked_at: session.revoked_at, handle: user.handle, display_name: user.display_name, roles_json: user.roles_json }
63
+ }
64
+
65
+ touchSession({ sessionId, now }) {
66
+ const s = this._sessions.get(sessionId)
67
+ if (s) s.last_seen_at = now
68
+ }
69
+
70
+ revokeSession({ sessionId, now }) {
71
+ const s = this._sessions.get(sessionId)
72
+ if (s) s.revoked_at = now
73
+ }
74
+ }
@@ -0,0 +1,138 @@
1
+ export class InMemoryChannelRepository {
2
+ constructor() {
3
+ this._channels = new Map() // channelId → channel record
4
+ this._members = new Map() // `${channelId}:${userId}` → membership record
5
+ }
6
+
7
+ insertChannelWithOwner({ channelId, hubId, kind, name, topic, visibility, createdByUserId, now }) {
8
+ const nextOrder = [...this._channels.values()]
9
+ .filter(c => c.hub_id === hubId && !c.deleted_at)
10
+ .reduce((max, c) => Math.max(max, c.sort_order ?? 0), -1) + 1
11
+ this._channels.set(channelId, { channel_id: channelId, hub_id: hubId, kind, name, topic, visibility, sort_order: nextOrder, created_by_user_id: createdByUserId, created_at: now, deleted_at: null })
12
+ this._members.set(`${channelId}:${createdByUserId}`, { channel_id: channelId, user_id: createdByUserId, role: 'owner', joined_at: now, left_at: null, banned_at: null })
13
+ }
14
+
15
+ _sortOrder(a, b) {
16
+ const orderDiff = (a.sort_order ?? 0) - (b.sort_order ?? 0)
17
+ return orderDiff !== 0 ? orderDiff : (a.created_at ?? 0) - (b.created_at ?? 0)
18
+ }
19
+
20
+ _toPublic(c) {
21
+ return { channel_id: c.channel_id, hub_id: c.hub_id, name: c.name, kind: c.kind, visibility: c.visibility, topic: c.topic, sort_order: c.sort_order ?? 0 }
22
+ }
23
+
24
+ listInHub({ hubId }) {
25
+ return [...this._channels.values()]
26
+ .filter(c => c.hub_id === hubId && !c.deleted_at)
27
+ .sort((a, b) => this._sortOrder(a, b))
28
+ .map(c => this._toPublic(c))
29
+ }
30
+
31
+ listAccessibleInHub({ hubId, userId, isGuest = false }) {
32
+ return [...this._channels.values()]
33
+ .filter(c => {
34
+ if (c.hub_id !== hubId || c.deleted_at) return false
35
+ const m = this._members.get(`${c.channel_id}:${userId}`)
36
+ if (m && !m.left_at && !m.banned_at) return true
37
+ if (!isGuest && c.visibility === 'public') return true
38
+ return false
39
+ })
40
+ .sort((a, b) => this._sortOrder(a, b))
41
+ .map(c => this._toPublic(c))
42
+ }
43
+
44
+ listAll() {
45
+ return [...this._channels.values()]
46
+ .filter(c => !c.deleted_at)
47
+ .sort((a, b) => this._sortOrder(a, b))
48
+ .map(c => ({ ...this._toPublic(c), hub_name: c.hub_id }))
49
+ }
50
+
51
+ listAccessible({ userId, isGuest = false }) {
52
+ return [...this._channels.values()]
53
+ .filter(c => {
54
+ if (c.deleted_at) return false
55
+ const cm = this._members.get(`${c.channel_id}:${userId}`)
56
+ if (cm && !cm.left_at && !cm.banned_at) return true
57
+ if (!isGuest && c.visibility === 'public') return true
58
+ return false
59
+ })
60
+ .sort((a, b) => this._sortOrder(a, b))
61
+ .map(c => ({ ...this._toPublic(c), hub_name: c.hub_id }))
62
+ }
63
+
64
+ findById({ channelId }) {
65
+ return this._channels.get(channelId) ?? null
66
+ }
67
+
68
+ findMembership({ channelId, userId }) {
69
+ return this._members.get(`${channelId}:${userId}`) ?? null
70
+ }
71
+
72
+ findByHubAndName({ hubId, name }) {
73
+ return [...this._channels.values()].find(c => c.hub_id === hubId && c.name === name && !c.deleted_at) ?? null
74
+ }
75
+
76
+ findDmByName({ name }) {
77
+ return [...this._channels.values()].find(c => c.kind === 'dm' && c.name === name && !c.deleted_at) ?? null
78
+ }
79
+
80
+ insertDmChannel({ channelId, name, userIdA, userIdB, now }) {
81
+ this._channels.set(channelId, { channel_id: channelId, hub_id: null, kind: 'dm', name, topic: null, visibility: 'private', sort_order: 0, created_by_user_id: userIdA, created_at: now, deleted_at: null })
82
+ this._members.set(`${channelId}:${userIdA}`, { channel_id: channelId, user_id: userIdA, role: 'member', joined_at: now, left_at: null, banned_at: null })
83
+ this._members.set(`${channelId}:${userIdB}`, { channel_id: channelId, user_id: userIdB, role: 'member', joined_at: now, left_at: null, banned_at: null })
84
+ }
85
+
86
+ listDmsByUser({ userId }) {
87
+ return [...this._channels.values()]
88
+ .filter(c => c.kind === 'dm' && !c.deleted_at)
89
+ .filter(c => {
90
+ const m = this._members.get(`${c.channel_id}:${userId}`)
91
+ return m && !m.left_at
92
+ })
93
+ .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
94
+ .map(c => {
95
+ const otherMember = [...this._members.values()].find(m => m.channel_id === c.channel_id && m.user_id !== userId && !m.left_at)
96
+ return { channel_id: c.channel_id, name: c.name, kind: c.kind, other_user_id: otherMember?.user_id ?? null }
97
+ })
98
+ }
99
+
100
+ upsertMembership({ channelId, userId, role, now }) {
101
+ const key = `${channelId}:${userId}`
102
+ const existing = this._members.get(key)
103
+ if (existing) { existing.left_at = null; existing.banned_at = null }
104
+ else { this._members.set(key, { channel_id: channelId, user_id: userId, role, joined_at: now, left_at: null, banned_at: null }) }
105
+ }
106
+
107
+ setMemberLeft({ channelId, userId, now }) {
108
+ const m = this._members.get(`${channelId}:${userId}`)
109
+ if (m) m.left_at = now
110
+ }
111
+
112
+ listActiveMembers({ channelId }) {
113
+ return [...this._members.values()]
114
+ .filter(m => m.channel_id === channelId && !m.left_at && !m.banned_at)
115
+ .map(m => ({ user_id: m.user_id, role: m.role }))
116
+ }
117
+
118
+ patchChannel({ channelId, name, topic, visibility }) {
119
+ const c = this._channels.get(channelId)
120
+ if (!c) return
121
+ if (name !== undefined) c.name = name
122
+ if (topic !== undefined) c.topic = topic
123
+ if (visibility !== undefined) c.visibility = visibility
124
+ }
125
+
126
+ softDeleteChannel({ channelId, now }) {
127
+ const c = this._channels.get(channelId)
128
+ if (c) c.deleted_at = now
129
+ }
130
+
131
+ reorderChannels({ hubId, channelIds }) {
132
+ channelIds.forEach((channelId, index) => {
133
+ const c = this._channels.get(channelId)
134
+ if (c && c.hub_id === hubId && !c.deleted_at) c.sort_order = index
135
+ })
136
+ return this.listInHub({ hubId })
137
+ }
138
+ }
@@ -0,0 +1,52 @@
1
+ import { newId } from '../util/ids.js'
2
+
3
+ export class InMemoryDeliveryRepository {
4
+ constructor() {
5
+ this._store = new Map() // deliveryId → record
6
+ }
7
+
8
+ _key(channelId, userId) { return `${channelId}:${userId}` }
9
+
10
+ findByChannelAndUser({ channelId, userId }) {
11
+ for (const record of this._store.values()) {
12
+ if (record.channel_id === channelId && record.user_id === userId) return record
13
+ }
14
+ return null
15
+ }
16
+
17
+ create({ channelId, userId, now }) {
18
+ const deliveryId = newId('del')
19
+ const record = { delivery_id: deliveryId, user_id: userId, channel_id: channelId, after_seq: 0, mention_seq: 0, mention_priority: 'normal', last_delivered_at: now, status: 'active' }
20
+ this._store.set(deliveryId, record)
21
+ return { ...record }
22
+ }
23
+
24
+ advance({ channelId, userId, afterSeq, now }) {
25
+ for (const record of this._store.values()) {
26
+ if (record.channel_id === channelId && record.user_id === userId) {
27
+ record.after_seq = afterSeq
28
+ record.last_delivered_at = now
29
+ if (record.mention_seq > 0 && record.mention_seq <= afterSeq) record.mention_seq = 0
30
+ return
31
+ }
32
+ }
33
+ }
34
+
35
+ advanceMention({ channelId, userId, mentionSeq, priority = 'normal' }) {
36
+ for (const record of this._store.values()) {
37
+ if (record.channel_id === channelId && record.user_id === userId) {
38
+ if ((record.mention_seq ?? 0) < mentionSeq) {
39
+ record.mention_seq = mentionSeq
40
+ record.mention_priority = priority
41
+ }
42
+ return
43
+ }
44
+ }
45
+ }
46
+
47
+ buildDigestData({ userId }) {
48
+ return [...this._store.values()]
49
+ .filter(d => d.user_id === userId)
50
+ .map(d => ({ channel_id: d.channel_id, name: d.channel_id, kind: 'text', after_seq: d.after_seq, mention_seq: d.mention_seq ?? 0, mention_priority: d.mention_priority ?? 'normal', max_seq: 0, other_user_id: null }))
51
+ }
52
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * InMemoryFileStore — test double for IFileStore.
3
+ * Stores file contents as Uint8Array values in a Map keyed by "uploadId/storedName".
4
+ */
5
+
6
+ import { ServiceError } from '../util/errors'
7
+ export class InMemoryFileStore {
8
+ constructor() {
9
+ this._store = new Map()
10
+ }
11
+
12
+ #key(uploadId, storedName) {
13
+ return `${uploadId}/${storedName}`
14
+ }
15
+
16
+ async write({ uploadId, storedName, stream }) {
17
+ // Accept ReadableStream, Uint8Array, Buffer, or string
18
+ if (stream && typeof stream.getReader === 'function') {
19
+ const chunks = []
20
+ const reader = stream.getReader()
21
+ while (true) {
22
+ const { done, value } = await reader.read()
23
+ if (done) break
24
+ chunks.push(value)
25
+ }
26
+ const total = chunks.reduce((n, c) => n + c.length, 0)
27
+ const buf = new Uint8Array(total)
28
+ let offset = 0
29
+ for (const c of chunks) { buf.set(c, offset); offset += c.length }
30
+ this._store.set(this.#key(uploadId, storedName), buf)
31
+ } else {
32
+ const buf = stream instanceof Uint8Array ? stream : new Uint8Array(Buffer.from(stream))
33
+ this._store.set(this.#key(uploadId, storedName), buf)
34
+ }
35
+ }
36
+
37
+ async read({ uploadId, storedName }) {
38
+ const buf = this._store.get(this.#key(uploadId, storedName))
39
+ if (!buf) {
40
+ throw new ServiceError('NOT_FOUND', `File not found: ${uploadId}/${storedName}`)
41
+ }
42
+ return new ReadableStream({
43
+ start(controller) {
44
+ controller.enqueue(buf)
45
+ controller.close()
46
+ }
47
+ })
48
+ }
49
+
50
+ async delete({ uploadId, storedName }) {
51
+ this._store.delete(this.#key(uploadId, storedName))
52
+ }
53
+ }
@@ -0,0 +1,85 @@
1
+ export class InMemoryHubRepository {
2
+ constructor() {
3
+ this._hubs = new Map() // hubId → hub record
4
+ this._members = new Map() // `${hubId}:${userId}` → membership record
5
+ }
6
+
7
+ insertHubWithOwner({ hubId, name, description, visibility, createdByUserId, now }) {
8
+ this._hubs.set(hubId, { hub_id: hubId, name, description, visibility, created_by_user_id: createdByUserId, created_at: now, deleted_at: null })
9
+ const key = `${hubId}:${createdByUserId}`
10
+ this._members.set(key, { hub_id: hubId, user_id: createdByUserId, joined_at: now, left_at: null })
11
+ }
12
+
13
+ _channelCount(hubId) {
14
+ // Channels are tracked externally — return 0 in isolation
15
+ return 0
16
+ }
17
+
18
+ _toPublic(hub) {
19
+ return { hub_id: hub.hub_id, name: hub.name, description: hub.description, visibility: hub.visibility, channel_count: 0 }
20
+ }
21
+
22
+ listAllHubs() {
23
+ return [...this._hubs.values()].filter(h => !h.deleted_at).sort((a, b) => a.name.localeCompare(b.name)).map(h => this._toPublic(h))
24
+ }
25
+
26
+ listAccessibleHubs({ userId }) {
27
+ return [...this._hubs.values()]
28
+ .filter(h => {
29
+ if (h.deleted_at) return false
30
+ if (h.visibility === 'public') return true
31
+ const m = this._members.get(`${h.hub_id}:${userId}`)
32
+ return m && !m.left_at
33
+ })
34
+ .sort((a, b) => a.name.localeCompare(b.name))
35
+ .map(h => this._toPublic(h))
36
+ }
37
+
38
+ findById({ hubId }) {
39
+ return this._hubs.get(hubId) ?? null
40
+ }
41
+
42
+ findByName({ name }) {
43
+ return [...this._hubs.values()].find(h => h.name === name && !h.deleted_at) ?? null
44
+ }
45
+
46
+ findMembership({ hubId, userId }) {
47
+ return this._members.get(`${hubId}:${userId}`) ?? null
48
+ }
49
+
50
+ upsertMembership({ hubId, userId, now }) {
51
+ const key = `${hubId}:${userId}`
52
+ const existing = this._members.get(key)
53
+ if (existing) { existing.left_at = null }
54
+ else { this._members.set(key, { hub_id: hubId, user_id: userId, joined_at: now, left_at: null }) }
55
+ }
56
+
57
+ setMemberLeft({ hubId, userId, now }) {
58
+ const m = this._members.get(`${hubId}:${userId}`)
59
+ if (m) m.left_at = now
60
+ }
61
+
62
+ listMembers({ hubId }) {
63
+ return [...this._members.values()]
64
+ .filter(m => m.hub_id === hubId && !m.left_at)
65
+ .sort((a, b) => (a.joined_at ?? 0) - (b.joined_at ?? 0))
66
+ .map(m => ({ user_id: m.user_id, handle: null, display_name: null, joined_at: m.joined_at }))
67
+ }
68
+
69
+ patchHub({ hubId, name, description, visibility }) {
70
+ const hub = this._hubs.get(hubId)
71
+ if (!hub) return
72
+ if (name !== undefined) hub.name = name
73
+ if (description !== undefined) hub.description = description
74
+ if (visibility !== undefined) hub.visibility = visibility
75
+ }
76
+
77
+ listActiveChannelIds({ hubId }) {
78
+ return [] // channels not tracked in HubRepo; test via integration if needed
79
+ }
80
+
81
+ softDeleteHub({ hubId, now }) {
82
+ const hub = this._hubs.get(hubId)
83
+ if (hub) hub.deleted_at = now
84
+ }
85
+ }
@@ -0,0 +1,35 @@
1
+ export class InMemoryMessageRepository {
2
+ constructor() {
3
+ this._messages = [] // { msg_id, channel_id, seq, user_id, user_handle, ts, text, client_msg_id, deleted_at, edited_at }
4
+ this._seqs = new Map() // channelId → current max seq
5
+ }
6
+
7
+ insertMessage({ msgId, channelId, userId, now, text, clientMsgId }) {
8
+ const seq = (this._seqs.get(channelId) ?? 0) + 1
9
+ this._seqs.set(channelId, seq)
10
+ this._messages.push({ msg_id: msgId, channel_id: channelId, seq, user_id: userId, user_handle: userId, ts: now, text, client_msg_id: clientMsgId, deleted_at: null, edited_at: null })
11
+ return { seq }
12
+ }
13
+
14
+ getById(msgId) {
15
+ return this._messages.find(m => m.msg_id === msgId) ?? null
16
+ }
17
+
18
+ updateMessage({ msgId, text, editedAt }) {
19
+ const msg = this._messages.find(m => m.msg_id === msgId)
20
+ if (msg) { msg.text = text; msg.edited_at = editedAt }
21
+ }
22
+
23
+ deleteMessage({ msgId, deletedAt }) {
24
+ const msg = this._messages.find(m => m.msg_id === msgId)
25
+ if (msg) msg.deleted_at = deletedAt
26
+ }
27
+
28
+ listMessages({ channelId, afterSeq, limit }) {
29
+ return this._messages
30
+ .filter(m => m.channel_id === channelId && m.seq > afterSeq && m.deleted_at == null)
31
+ .sort((a, b) => a.seq - b.seq)
32
+ .slice(0, limit)
33
+ .map(m => ({ msg_id: m.msg_id, seq: m.seq, user_id: m.user_id, user_handle: m.user_handle, ts: m.ts, text: m.text, edited_at: m.edited_at ?? null }))
34
+ }
35
+ }