@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.
- package/README.md +313 -0
- package/index.js +148 -0
- package/migrate/001-drop-channel-invites.js +3 -0
- package/migrate/002-invite-initial-roles.js +5 -0
- package/migrate/003-dm-channels.js +35 -0
- package/migrate/004-notifications.js +15 -0
- package/migrate/005-uploads.js +21 -0
- package/migrate/006-mention-priority.js +3 -0
- package/migrate/007-push-subscriptions.js +14 -0
- package/migrate/008-messages-channel-seq-index.js +3 -0
- package/migrate/009-message-reactions.js +15 -0
- package/migrate/010-edit-messages.js +7 -0
- package/package.json +51 -0
- package/pages/_error.html +12 -0
- package/pages/_layout.html +31 -0
- package/pages/_layout.js +13 -0
- package/pages/admin/_layout.html +52 -0
- package/pages/admin/_layout.js +8 -0
- package/pages/admin/bots/[userId].js +88 -0
- package/pages/admin/bots/[userId].phtml +89 -0
- package/pages/admin/bots/index.js +41 -0
- package/pages/admin/bots/index.phtml +58 -0
- package/pages/admin/index.js +8 -0
- package/pages/admin/invites/index.js +72 -0
- package/pages/admin/invites/index.phtml +88 -0
- package/pages/admin/users/[userId].js +60 -0
- package/pages/admin/users/[userId].phtml +57 -0
- package/pages/admin/users/index.js +20 -0
- package/pages/admin/users/index.phtml +37 -0
- package/pages/api/uploads/index.js +66 -0
- package/pages/api/user/settings.js +26 -0
- package/pages/auth/signout.js +14 -0
- package/pages/channels/[channelId].js +99 -0
- package/pages/channels/[channelId].phtml +173 -0
- package/pages/index.js +33 -0
- package/pages/invite/[token].js +10 -0
- package/pages/login/index.js +57 -0
- package/pages/login/index.phtml +29 -0
- package/pages/public/client/action-sheet.js +77 -0
- package/pages/public/client/app.js +38 -0
- package/pages/public/client/auth-tabs.js +13 -0
- package/pages/public/client/emoji-data.js +197 -0
- package/pages/public/client/islands/call.js +1770 -0
- package/pages/public/client/islands/sidebar.js +1197 -0
- package/pages/public/client/long-press.js +59 -0
- package/pages/public/client/modal.js +50 -0
- package/pages/public/client/router.js +87 -0
- package/pages/public/client/rtc-peer-manager.js +344 -0
- package/pages/public/client/settings-sync.js +76 -0
- package/pages/public/client/shared/messages.js +147 -0
- package/pages/public/client/swipe-nav.js +98 -0
- package/pages/public/client/theme.js +27 -0
- package/pages/public/client/ws.js +71 -0
- package/pages/public/favicon.ico +0 -0
- package/pages/public/favicon.png +0 -0
- package/pages/public/icon.png +0 -0
- package/pages/public/manifest.json +11 -0
- package/pages/public/sw.js +38 -0
- package/pages/public/themes/base.css +1786 -0
- package/pages/public/themes/dark.css +22 -0
- package/pages/public/themes/forest.css +22 -0
- package/pages/public/themes/light.css +23 -0
- package/pages/public/themes/ocean.css +22 -0
- package/pages/public/themes/rose.css +22 -0
- package/pages/registration/index.js +35 -0
- package/pages/registration/index.phtml +38 -0
- package/pages/uploads/[uploadId]/[filename].js +45 -0
- package/src/adapters/InMemoryAuthRepository.js +74 -0
- package/src/adapters/InMemoryChannelRepository.js +138 -0
- package/src/adapters/InMemoryDeliveryRepository.js +52 -0
- package/src/adapters/InMemoryFileStore.js +53 -0
- package/src/adapters/InMemoryHubRepository.js +85 -0
- package/src/adapters/InMemoryMessageRepository.js +35 -0
- package/src/adapters/InMemoryReactionRepository.js +45 -0
- package/src/adapters/InMemorySearchRepository.js +37 -0
- package/src/adapters/InMemorySignalingRepository.js +35 -0
- package/src/adapters/InMemoryUploadRepository.js +36 -0
- package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
- package/src/adapters/LocalFileStore.js +40 -0
- package/src/adapters/SqliteAuthRepository.js +184 -0
- package/src/adapters/SqliteChannelRepository.js +149 -0
- package/src/adapters/SqliteDeliveryRepository.js +53 -0
- package/src/adapters/SqliteHubRepository.js +99 -0
- package/src/adapters/SqliteMessageRepository.js +90 -0
- package/src/adapters/SqlitePushRepository.js +39 -0
- package/src/adapters/SqliteReactionRepository.js +50 -0
- package/src/adapters/SqliteSearchRepository.js +42 -0
- package/src/adapters/SqliteSignalingRepository.js +50 -0
- package/src/adapters/SqliteUploadRepository.js +34 -0
- package/src/adapters/SqliteUserSettingsRepository.js +23 -0
- package/src/adminAuth.js +25 -0
- package/src/config.js +11 -0
- package/src/context.js +77 -0
- package/src/core/dm.js +10 -0
- package/src/core/mentions.js +27 -0
- package/src/core/messages.js +21 -0
- package/src/core/reactions.js +6 -0
- package/src/core/roles.js +5 -0
- package/src/core/uploads.js +107 -0
- package/src/db/initDb.js +225 -0
- package/src/db/openDb.js +18 -0
- package/src/db/runMigrations.js +45 -0
- package/src/db/transaction.js +11 -0
- package/src/ports/IFileStore.js +34 -0
- package/src/services/AuthService.js +208 -0
- package/src/services/BotService.js +148 -0
- package/src/services/ChannelService.js +176 -0
- package/src/services/DeliveryService.js +28 -0
- package/src/services/HubService.js +133 -0
- package/src/services/MessageService.js +122 -0
- package/src/services/NotificationService.js +45 -0
- package/src/services/PresenceService.js +55 -0
- package/src/services/ReactionService.js +57 -0
- package/src/services/SearchService.js +21 -0
- package/src/services/SignalingService.js +177 -0
- package/src/services/UploadService.js +111 -0
- package/src/services/UserSettingsService.js +30 -0
- package/src/services/WebPushService.js +217 -0
- package/src/util/crypto.js +21 -0
- package/src/util/errors.js +14 -0
- package/src/util/ids.js +3 -0
- package/src/util/logger.js +21 -0
- package/src/ws/ChatServer.js +478 -0
- package/src/ws/handlers/authHandlers.js +152 -0
- package/src/ws/handlers/channelHandlers.js +166 -0
- package/src/ws/handlers/hubHandlers.js +82 -0
- package/src/ws/handlers/messageHandlers.js +88 -0
- package/src/ws/handlers/pushHandlers.js +25 -0
- package/src/ws/handlers/reactionHandlers.js +27 -0
- package/src/ws/handlers/rtcHandlers.js +126 -0
- 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
|
+
}
|