federation.md: new 'Approval Token Verification' subsection covering issuance (queue path generates token, returns in 202), storage on initiator (federation_peers.approval_token), forwarding from /approve, verification on receiver's awaiting_approval branch, single-use lifecycle, backward compatibility, and threat-model boundary (sender-side outbound gating tracked separately). database.md: approval_token column documented on both federation_peers and peer_approval_requests with cross-references to federation.md. api.md: /peer/accept request + 202 response now show optional approvalToken field with pointer to the federation spec.
445 lines
16 KiB
Markdown
445 lines
16 KiB
Markdown
# Database Schema Reference
|
|
|
|
Source of truth: `packages/server/src/db/schema.ts` (Drizzle ORM)
|
|
Migrations: drizzle-kit generates SQL from `schema.ts` (`pnpm db:generate` from `packages/server/`). On startup, `initDatabase()` runs `drizzle.migrate()` against `packages/server/drizzle/`, then `ensureDefaults()` (settings row, Snowflake worker ID, first-admin promotion). Migration history was squashed to a single baseline on 2026-04-24 (backlog #31 Phase 2).
|
|
Engine: SQLite via `better-sqlite3`
|
|
IDs: Snowflake text, permissions: bigint decimal strings
|
|
|
|
---
|
|
|
|
## Core Tables
|
|
|
|
### users
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | Snowflake |
|
|
| username | text UNIQUE NOT NULL | | Login name |
|
|
| displayName | text | | |
|
|
| passwordHash | text NOT NULL | | bcrypt; `'!federation-replicated'` for stubs |
|
|
| avatar | text | | Upload filename |
|
|
| status | text | `'offline'` | online/idle/dnd/offline |
|
|
| customStatus | text | | |
|
|
| isAdmin | integer | 0 | First registered user = 1 |
|
|
| homeInstance | text | | Federation origin URL (null = local) |
|
|
| homeUserId | text | | Canonical ID on home instance |
|
|
| replicatedInstances | text | `'[]'` | JSON array of instance URLs |
|
|
| banner | text | | Upload filename |
|
|
| accentColor | text | | Hex color |
|
|
| avatarColor | text | | Hex color |
|
|
| bio | text | | |
|
|
| isDeleted | integer | 0 | Soft-delete flag |
|
|
| discoverable | integer | 1 | Visible in user directory |
|
|
| profileUpdatedAt | integer | | Epoch ms |
|
|
| passwordChangedAt | integer | | Token revocation: tokens before this rejected |
|
|
| showActivity | integer NOT NULL | 1 | Rich presence visibility |
|
|
| federationRegistryUpdatedAt | integer | 0 | LWW timestamp for federation registry sync |
|
|
| createdAt | integer NOT NULL | | Epoch ms |
|
|
|
|
### spaces
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| name | text NOT NULL | | |
|
|
| icon | text | | Upload filename |
|
|
| banner | text | | Upload filename |
|
|
| avatarColor | text | | Hex color |
|
|
| ownerId | text NOT NULL | | FK → users.id |
|
|
| inviteCode | text UNIQUE | | |
|
|
| visibility | text | `'private'` | public/request/private |
|
|
| description | text | | |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### space_members
|
|
PK: (spaceId, userId)
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| spaceId | text NOT NULL | FK → spaces.id CASCADE |
|
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
|
| nickname | text | Per-space display name |
|
|
| joinedAt | integer NOT NULL | |
|
|
|
|
### channel_categories
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| spaceId | text NOT NULL | | FK → spaces.id CASCADE |
|
|
| name | text NOT NULL | | |
|
|
| position | integer | 0 | |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### channels
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| spaceId | text NOT NULL | | FK → spaces.id CASCADE |
|
|
| name | text NOT NULL | | |
|
|
| type | text NOT NULL | | text/voice |
|
|
| topic | text | | |
|
|
| position | integer | 0 | |
|
|
| categoryId | text | | Soft FK → channel_categories |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### messages
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| id | text PK | |
|
|
| channelId | text NOT NULL | FK → channels.id CASCADE |
|
|
| userId | text NOT NULL | FK → users.id |
|
|
| replyToId | text | FK → messages.id SET NULL |
|
|
| content | text | |
|
|
| editedAt | integer | |
|
|
| createdAt | integer NOT NULL | |
|
|
|
|
### attachments
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| messageId | text | | FK → messages.id CASCADE |
|
|
| dmMessageId | text | | FK → dm_messages.id CASCADE |
|
|
| uploaderId | text | | User who uploaded |
|
|
| filename | text NOT NULL | | Stored filename |
|
|
| originalName | text NOT NULL | | User-facing name |
|
|
| mimetype | text NOT NULL | | |
|
|
| size | integer NOT NULL | | Bytes |
|
|
| thumbnailFilename | text | | Generated thumbnail |
|
|
| width | integer | | Image/video pixel width |
|
|
| height | integer | | Image/video pixel height |
|
|
| duration | real | | Audio/video seconds |
|
|
| sourceUrl | text | | Remote URL (federation) |
|
|
| federationStatus | text | | local/remote/remote_partial |
|
|
| federationMeta | text | | JSON rejection info |
|
|
| createdAt | integer NOT NULL | | |
|
|
CHECK: exactly one of messageId/dmMessageId is set
|
|
|
|
### embeds
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| id | text PK | |
|
|
| messageId | text | FK → messages.id CASCADE |
|
|
| dmMessageId | text | FK → dm_messages.id CASCADE |
|
|
| url | text NOT NULL | |
|
|
| embedType | text NOT NULL | generic/video/image/audio/rich |
|
|
| provider | text | youtube/vimeo/spotify/null |
|
|
| title | text | |
|
|
| description | text | |
|
|
| image | text | Thumbnail/og:image URL |
|
|
| embedUrl | text | iframe-safe URL |
|
|
| width | integer | |
|
|
| height | integer | |
|
|
| color | text | |
|
|
| createdAt | integer NOT NULL | |
|
|
CHECK: exactly one of messageId/dmMessageId is set
|
|
|
|
### reactions
|
|
PK: id
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| id | text PK | |
|
|
| messageId | text NOT NULL | FK → messages.id CASCADE |
|
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
|
| emoji | text NOT NULL | |
|
|
| createdAt | integer NOT NULL | |
|
|
|
|
---
|
|
|
|
## DM Tables
|
|
|
|
### dm_channels
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| ownerId | text | | NULL for 1-on-1, set for group |
|
|
| federatedId | text | | Cross-instance identifier |
|
|
| ownerHomeUserId | text | | Owner's canonical home ID |
|
|
| ownerHomeInstance | text | | Owner's home instance URL |
|
|
| deletedAt | integer | | Soft-delete (GC after 24h if no local members) |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### dm_members
|
|
PK: (dmChannelId, userId)
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| dmChannelId | text NOT NULL | | FK → dm_channels.id CASCADE |
|
|
| userId | text NOT NULL | | FK → users.id CASCADE |
|
|
| closed | integer | 0 | Soft-close flag |
|
|
|
|
### dm_messages
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| dmChannelId | text NOT NULL | | FK → dm_channels.id CASCADE |
|
|
| userId | text NOT NULL | | FK → users.id |
|
|
| replyToId | text | | FK → dm_messages.id SET NULL |
|
|
| content | text | | |
|
|
| type | text NOT NULL | `'user'` | user/system |
|
|
| editedAt | integer | | |
|
|
| sourceInstance | text | | Federation source origin |
|
|
| sourceMessageId | text | | Original ID on source instance |
|
|
| encryptionVersion | integer | 0 | |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### dm_reactions
|
|
PK: id
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| id | text PK | |
|
|
| dmMessageId | text NOT NULL | FK → dm_messages.id CASCADE |
|
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
|
| emoji | text NOT NULL | |
|
|
| createdAt | integer NOT NULL | |
|
|
|
|
---
|
|
|
|
## Social Tables
|
|
|
|
### friends
|
|
PK: (userId, friendId)
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
|
| friendId | text NOT NULL | FK → users.id CASCADE |
|
|
| createdAt | integer NOT NULL | |
|
|
|
|
### friend_requests
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| fromId | text NOT NULL | | FK → users.id CASCADE |
|
|
| toId | text NOT NULL | | FK → users.id CASCADE |
|
|
| status | text | `'pending'` | pending/accepted/declined |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
---
|
|
|
|
## RBAC Tables
|
|
|
|
### roles
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| spaceId | text NOT NULL | | FK → spaces.id CASCADE |
|
|
| name | text NOT NULL | | |
|
|
| color | text | `'#b9bbbe'` | Hex |
|
|
| position | integer | 0 | Hierarchy position |
|
|
| permissions | text | | Bigint decimal string |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### member_roles
|
|
PK: (spaceId, userId, roleId)
|
|
All columns FK CASCADE to their respective tables.
|
|
|
|
### channel_overrides
|
|
PK: (channelId, targetType, targetId)
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| channelId | text NOT NULL | | FK → channels.id CASCADE |
|
|
| targetType | text NOT NULL | | role/member |
|
|
| targetId | text NOT NULL | | Role ID or user ID |
|
|
| allow | text NOT NULL | `'0'` | Bigint decimal string |
|
|
| deny | text NOT NULL | `'0'` | Bigint decimal string |
|
|
|
|
### category_overrides
|
|
PK: (categoryId, targetType, targetId)
|
|
Same structure as channel_overrides, with categoryId FK → channel_categories.id CASCADE.
|
|
|
|
---
|
|
|
|
## State Tables
|
|
|
|
### read_states
|
|
PK: (userId, channelId)
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
|
| channelId | text NOT NULL | Channel or DM channel ID |
|
|
| lastReadMessageId | text NOT NULL | |
|
|
| updatedAt | integer NOT NULL | |
|
|
|
|
### space_folders
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| userId | text NOT NULL | | FK → users.id CASCADE |
|
|
| name | text | | |
|
|
| color | text | | |
|
|
| position | integer | 0 | |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### space_folder_members
|
|
PK: (folderId, spaceId)
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| folderId | text NOT NULL | | FK → space_folders.id CASCADE |
|
|
| spaceId | text NOT NULL | | May be federated (no local FK) |
|
|
| position | integer | 0 | |
|
|
|
|
### user_space_layout
|
|
PK: userId
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| userId | text PK | | FK → users.id CASCADE |
|
|
| layout | text NOT NULL | `'[]'` | JSON array of {t:'s',id} | {t:'f',id} |
|
|
| updatedAt | integer NOT NULL | | |
|
|
|
|
---
|
|
|
|
## Moderation Tables
|
|
|
|
### bans
|
|
PK: (spaceId, userId)
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| spaceId | text NOT NULL | FK → spaces.id CASCADE |
|
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
|
| reason | text | |
|
|
| bannedBy | text | FK → users.id |
|
|
| createdAt | integer NOT NULL | |
|
|
|
|
### join_requests
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| spaceId | text NOT NULL | | FK → spaces.id CASCADE |
|
|
| userId | text NOT NULL | | FK → users.id CASCADE |
|
|
| message | text | | |
|
|
| status | text NOT NULL | `'pending'` | pending/accepted/declined |
|
|
| decidedBy | text | | FK → users.id |
|
|
| createdAt | integer NOT NULL | | |
|
|
| decidedAt | integer | | |
|
|
|
|
### voice_restrictions
|
|
PK: (spaceId, userId, restrictionType)
|
|
| Column | Type | Notes |
|
|
|--------|------|-------|
|
|
| spaceId | text NOT NULL | FK → spaces.id CASCADE |
|
|
| userId | text NOT NULL | FK → users.id CASCADE |
|
|
| restrictionType | text NOT NULL | mute/deafen |
|
|
| moderatorId | text | FK → users.id |
|
|
| createdAt | integer NOT NULL | |
|
|
|
|
---
|
|
|
|
## Instance Settings (singleton, id=1)
|
|
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | integer PK | 1 | |
|
|
| instanceName | text | `'Backspace'` | |
|
|
| workerId | integer | | Snowflake worker ID |
|
|
| discoveryEnabled | integer NOT NULL | 1 | |
|
|
| maxBitrateKbps | integer NOT NULL | 20000 | |
|
|
| minBitrateKbps | integer NOT NULL | 500 | |
|
|
| bitrateStepKbps | integer NOT NULL | 500 | |
|
|
| allowedResolutions | text NOT NULL | `'540,720,1080'` | CSV |
|
|
| allowedFramerates | text NOT NULL | `'30,45,60'` | CSV |
|
|
| maxResolution | integer NOT NULL | 1080 | |
|
|
| maxFramerate | integer NOT NULL | 60 | |
|
|
| registrationOpen | integer | | null = use env |
|
|
| gifApiKey | text | | Klipy API key |
|
|
| bitrateMatrixOverrides | text | | JSON sparse overrides |
|
|
| allowCustomBitrate | integer NOT NULL | 1 | |
|
|
| maxUploadSizeBytes | integer | | null = use env |
|
|
| federationRelayEnabled | integer NOT NULL | 1 | |
|
|
| federationRelayTtlDays | integer NOT NULL | 30 | |
|
|
| autoAcceptPeering | integer NOT NULL | 1 | When 0, `peer/accept` rejects unsolicited requests with 403 |
|
|
| updatedAt | integer NOT NULL | | |
|
|
|
|
---
|
|
|
|
## Federation Tables
|
|
|
|
### federation_peers
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| origin | text NOT NULL UNIQUE | | `https://domain.tld` |
|
|
| instanceName | text | | |
|
|
| hmacSecret | text NOT NULL | | 256-bit hex |
|
|
| status | text NOT NULL | `'active'` | active/pending/awaiting_approval/unreachable/revoked/rejected/needs_attention |
|
|
| lastSeenAt | integer | | |
|
|
| lastFailureAt | integer | | |
|
|
| consecutiveFailures | integer NOT NULL | 0 | >=10 → unreachable (network/5xx failures). Counter — never null. |
|
|
| consecutiveAuthFailures | integer NOT NULL | 0 | >=5 → needs_attention. Tracked separately from `consecutiveFailures` (network) because auth (401/403) and network failures have different resolution paths. |
|
|
| lastSyncedAt | integer | 0 | |
|
|
| remoteMaxUploadSize | integer | | Bytes, from peer |
|
|
| createdAt | integer NOT NULL | | |
|
|
| approvalToken | text | | Single-use 64-hex-char token stored when this row is in `awaiting_approval` (received from remote's 202 response). Verified against the inbound `/peer/accept` `approvalToken` field before promoting to `active`. Cleared (`NULL`) on promotion. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
|
|
|
### peer_approval_requests
|
|
Holds incoming peering requests queued for admin review when `autoAcceptPeering` is `false`. One row per requesting origin (UNIQUE constraint). Rows expire after 30 days via janitor cleanup.
|
|
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | Snowflake |
|
|
| origin | text NOT NULL UNIQUE | | Requesting instance's origin URL |
|
|
| instanceName | text | | Instance name sent by requester |
|
|
| hmacSecret | text NOT NULL | | Requester's HMAC secret; used to sign denial notification |
|
|
| requestedAt | integer NOT NULL | | Epoch ms |
|
|
| expiresAt | integer NOT NULL | | Epoch ms; requestedAt + 30 days |
|
|
| approvalToken | text | | Single-use 64-hex-char token issued in the 202 response when this row is created. Forwarded by `/approve` in its outbound `/peer/accept` so the remote initiator can verify mutual admin approval. Deleted along with this row when `/approve` runs. See [federation.md → Approval Token Verification](federation.md#approval-token-verification). |
|
|
|
|
### federation_outbox
|
|
UNIQUE: (peerId, entityId)
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| peerId | text NOT NULL | | FK → federation_peers.id CASCADE |
|
|
| contextId | text NOT NULL | | DM channel / friend context |
|
|
| entityId | text NOT NULL | | Message / reaction / request ID |
|
|
| contextType | text NOT NULL | `'dm'` | dm/friend |
|
|
| eventType | text NOT NULL | | create/update/delete/reaction_add/etc |
|
|
| payload | text NOT NULL | | JSON event data |
|
|
| encryptionVersion | integer | 0 | |
|
|
| attempts | integer | 0 | |
|
|
| nextRetryAt | integer NOT NULL | | |
|
|
| expiresAt | integer NOT NULL | | TTL-based |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### federation_file_queue
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| peerOrigin | text NOT NULL | | |
|
|
| dmMessageId | text NOT NULL | | |
|
|
| sourceUrl | text NOT NULL | | Remote download URL |
|
|
| targetFilename | text | | Local stored filename |
|
|
| originalName | text NOT NULL | | |
|
|
| mimetype | text NOT NULL | | |
|
|
| size | integer NOT NULL | | |
|
|
| status | text NOT NULL | `'pending'` | pending/completed/rejected/failed |
|
|
| rejectionReason | text | | |
|
|
| attempts | integer | 0 | Max 10 |
|
|
| nextRetryAt | integer NOT NULL | | |
|
|
| expiresAt | integer NOT NULL | | |
|
|
| createdAt | integer NOT NULL | | |
|
|
|
|
### federation_mutation_log
|
|
| Column | Type | Default | Notes |
|
|
|--------|------|---------|-------|
|
|
| id | text PK | | |
|
|
| entityId | text NOT NULL | | |
|
|
| contextId | text NOT NULL | | |
|
|
| contextType | text NOT NULL | `'dm'` | dm/friend |
|
|
| mutationType | text NOT NULL | | create/update/delete |
|
|
| mutatedAt | integer NOT NULL | | Checkpoint for sync |
|
|
| payload | text | | JSON |
|
|
Retention: 90 days (cleaned by federation janitor)
|
|
|
|
### user_federation_registry
|
|
Persistent registry of all instances a user has federated with. Tracks full lifecycle.
|
|
|
|
| Column | Type | Constraints | Purpose |
|
|
|--------|------|-------------|---------|
|
|
| user_id | TEXT | NOT NULL, FK→users(id) CASCADE | Owner |
|
|
| origin | TEXT | NOT NULL | Instance origin URL (e.g., `https://domain.com`) |
|
|
| label | TEXT | NOT NULL DEFAULT '' | Instance display name |
|
|
| username | TEXT | NOT NULL DEFAULT '' | Federated username on remote |
|
|
| remote_user_id | TEXT | NOT NULL DEFAULT '' | Snowflake ID on remote |
|
|
| status | TEXT | NOT NULL DEFAULT 'connected' | connected/disconnected/unreachable/auth_expired |
|
|
| added_at | INTEGER | NOT NULL | Epoch ms — when first federated |
|
|
| last_connected_at | INTEGER | | Epoch ms — last successful connection |
|
|
| disconnected_at | INTEGER | | Epoch ms — when user disconnected |
|
|
| error_message | TEXT | | Last error message |
|
|
|
|
**PK:** `(user_id, origin)`
|