Skip to content
DigitalTolkPublic

About

No description, website, or topics provided.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Latest commit

 

History

262 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ex

CI Coverage Status

A team messaging application built with Go and React.

Tech Stack

  • Backend: Go (single binary with embedded frontend)
  • Frontend: React + TypeScript + Vite + shadcn/ui
  • Database: DynamoDB (single-table design)
  • Cache/PubSub: Redis
  • Real-time: WebSocket + Redis pub/sub
  • Auth: OIDC SSO + email invites (guests), JWT sessions

Prerequisites

  • Docker & Docker Compose

Quick Start

docker compose up --build

This builds the app using the production Dockerfile (frontend + Go binary) and starts it alongside DynamoDB Local and Redis.

The DynamoDB table is created automatically on first start. The first user to log in via SSO is automatically promoted to admin.

Signing in locally

The dev stack ships a lightweight OIDC provider (Dex), so no real SSO tenant is needed. Click Sign in with SSO and use one of the test accounts from dev/dex.yaml — the same accounts make seed creates, all with the password password123:

Email Seeded role
alice@example.com admin
bob@example.com guest
carol@example.com guest

Works with both make dev (http://localhost:8500) and make dev-watch (http://localhost:5173). On an unseeded stack the first user to sign in becomes admin, so sign in as Alice first. To use a real identity provider instead, set OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET in .env (see SSO Configuration) — those override the Dex defaults.

# Or use the Makefile shortcuts:
make dev          # foreground (same as docker compose up --build)
make dev-up       # background
make dev-down     # stop all
make dev-logs     # tail logs

Hot reload

make dev bakes the frontend into the Go binary (go:embed), so every change means an image rebuild. For day-to-day work use the hot-reload stack instead:

make dev-watch       # hot reload, no image rebuild per change
make dev-watch-down  # stop it
make dev-watch-logs  # tail logs (air rebuilds + Vite HMR)

Open http://localhost:5173 — the Vite dev server serves the SPA with HMR and proxies /api (including the WebSocket) and /auth to the Go app on :8500. The Go server runs under air and recompiles in-container on save.

This layers docker-compose.dev.yml over the base file, swapping the app to Dockerfile.dev and adding a vite service. docker compose watch syncs changed files into the running containers; only dependency manifests (go.mod/go.sum, package.json/package-lock.json) trigger a rebuild.

Keep using make dev to verify the real production path before shipping — it's the only one that exercises the embedded-frontend build.

SSO Configuration

The app uses OpenID Connect (OIDC) for authentication. The OIDC redirect URL is always {BASE_URL}/auth/oidc/callback (e.g. http://localhost:8080/auth/oidc/callback). Register this as the redirect URI in your identity provider.

Microsoft Entra ID (Azure AD / MS365)

  1. Go to Azure Portal > Microsoft Entra ID > App registrations > New registration
  2. Set the Redirect URI to http://localhost:8080/auth/oidc/callback (or your production URL)
  3. Under Certificates & secrets, create a new Client secret and copy the value
  4. Note the Application (client) ID and Directory (tenant) ID from the Overview page
  5. Set these environment variables:
OIDC_ISSUER=https://login.microsoftonline.com/{tenant-id}/v2.0
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
BASE_URL=http://localhost:8080

Workspaces on Entra ID can additionally enable the tighter Microsoft 365 Integration (directory profile sync + the /mstmeetings slash command).

Google Workspace

  1. Go to Google Cloud Console > APIs & Services > Credentials > Create OAuth client ID
  2. Set Authorized redirect URIs to http://localhost:8080/auth/oidc/callback
  3. Set these environment variables:
OIDC_ISSUER=https://accounts.google.com
OIDC_CLIENT_ID=your-client-id.apps.googleusercontent.com
OIDC_CLIENT_SECRET=your-client-secret
BASE_URL=http://localhost:8080

Any OIDC Provider

Any provider that supports OpenID Connect Discovery (.well-known/openid-configuration) will work. Set OIDC_ISSUER to the issuer URL, and register {BASE_URL}/auth/oidc/callback as the redirect URI.

Microsoft 365 Integration (optional)

When the workspace signs in through Microsoft Entra ID, setting MS_TENANT_ID enables a tighter Microsoft 365 integration — with no per-user account linking, because the app authenticates against Microsoft Graph app-only (client credentials) using the same app registration the workspace already signs in through:

  • Profile enrichment — each SSO login syncs the user's phone number and manager from the directory onto their Ex profile (hover card + directory page), broadcast live via user.updated. Values come from Entra ID's mobilePhone/businessPhones fields and the manager assignment, so they only appear for users whose directory profiles have them filled in. The OIDC email claim is the source of truth for identity; directory lookups resolve it against the mail attribute first with UPN as fallback, so tenants where logins differ from mailbox addresses (UPN first.last@… vs mail first@…) work throughout. A periodic re-sync (every MS_PROFILE_SYNC_INTERVAL, default 12h; Redis-lock elected so a cluster sweeps once) refreshes every user without waiting for their next login — the first sweep runs at boot and backfills existing users.
  • Offboarding sync — a separate, faster sweep (every MS_STATUS_SYNC_INTERVAL, default 1h; one tenant-wide GET /users?$select=id,accountEnabled listing per sweep, Redis-lock elected) mirrors directory standing onto account status: an SSO user disabled or deleted in Entra is deactivated (refresh tokens wiped, live sessions force-logged-out — SSO removal alone doesn't end an existing session) and automatically reactivated once the directory account can sign in again (never an account an admin deactivated). A deactivated account stays the author of its messages and keeps its channel memberships underneath, but is listed last and marked inactive in the directory, hover card and DM header, left out of channel member lists, @-mention suggestions and people pickers, can't be added to a channel, and gets nothing from the notification fan-out (no alert, Activity row, badge or push — @all included). Safety rails: only accounts with a stored AAD object id are judged (an email can't tell a departed colleague from mail ≠ UPN), an empty listing is never acted on, and a circuit breaker refuses to deactivate more than max(3, 10% of users) in one sweep — a tenant/app-registration misconfiguration makes every account look gone and must not read as company-wide offboarding.
  • /mstmeetings slash command — creates a Microsoft Teams online meeting organized by the invoking user and posts the join link into the chat. Everyone in the current chat (channel, group or DM) is placed on the meeting roster (by AAD object id, granting direct join/lobby-bypass), but Teams does not ring or send calendar invites for ad-hoc online meetings: the chat message is the invitation. In channels it leads with @all, so every member is alerted through the normal notification pipeline — desktop popup, and the deferred mobile push when they're away or offline — subject to their own mute/group-mention preferences. Teams-native ringing would require a registered calling bot (Calls.InitiateGroupCall.All + an Azure Bot resource with a public callback endpoint) and is deliberately not included. Join-link messages are excluded from search indexing (stale meeting URLs are noise, not content).

Without MS_TENANT_ID, everything below is disabled and the app behaves exactly as before.

There is no separate "enable Graph" switch in Azure — Microsoft Graph is always available. Setup is two things in Azure plus one Teams policy:

1. Add the application permissions (Azure Portal)

  1. Azure Portal > Microsoft Entra ID > App registrations > your app (the one whose client ID is in OIDC_CLIENT_ID)
  2. API permissions > Add a permission > Microsoft Graph > Application permissions (⚠️ not Delegated — the app calls Graph as itself, with no signed-in user)
  3. Add:
    • User.Read.All — read phone + manager from the directory
    • OnlineMeetings.ReadWrite.All — create Teams meetings
  4. Click Grant admin consent for <tenant> — the Status column must show a green check. Without this step the permissions sit there un-consented and every Graph call returns 403.

2. Grant the Teams application access policy (PowerShell)

Graph's rule for app-only meeting creation is that permissions alone are not sufficient: the tenant must explicitly authorize the app to act on behalf of the organizing users via an application access policy. Run once as a Teams administrator:

Install-Module MicrosoftTeams
Connect-MicrosoftTeams

New-CsApplicationAccessPolicy -Identity "ex-meetings" `
  -AppIds "<your-client-id>" `
  -Description "Allow Ex to create Teams meetings on behalf of users"

# Tenant-wide (simplest):
Grant-CsApplicationAccessPolicy -PolicyName "ex-meetings" -Global

To scope it instead, grant per user: Grant-CsApplicationAccessPolicy -PolicyName "ex-meetings" -Identity "user@yourdomain.com". Propagation can take up to ~30 minutes — a 403 from /mstmeetings right after granting usually just means "wait a bit."

Profile enrichment needs only step 1 — if you skip step 2, logins still sync phone/manager and only meeting creation fails.

3. Configure Ex

MS_TENANT_ID={tenant-id}          # from the app registration's Overview page — enables the integration
MS_CLIENT_ID=...                  # optional — defaults to OIDC_CLIENT_ID
MS_CLIENT_SECRET=...              # optional — defaults to OIDC_CLIENT_SECRET

Restart and check the boot log for Microsoft 365 integration enabled.

4. Verify

  • Profiles: log out and back in (the sync runs at SSO login, so existing sessions pick up data on their next login), then open your profile hover card — phone and manager should appear.
  • Meetings: type /mstmeetings in any channel or DM and send — the join link should post into the chat. If it errors, check the server log: a Graph 403 there means the access policy (step 2) hasn't propagated or wasn't granted to that user.

Transactional Email

Two flows send mail: guest invitations and password resets. Message construction and SMTP delivery use go-mail; Amazon SES is supported natively through the AWS SDK.

Pick a transport with EMAIL_PROVIDER (smtp, the default, or ses). An unknown value fails the boot rather than silently sending nothing.

SMTP — any relay:

EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587                      # 587/25 use STARTTLS; 465 uses implicit TLS
SMTP_USER=your-smtp-user           # omit for a relay that needs no auth
SMTP_PASS=your-smtp-password
EMAIL_FROM="Ex <noreply@yourcompany.com>"

STARTTLS is mandatory by default: a relay that does not offer it fails the send instead of receiving credentials and links in plaintext. Set SMTP_TLS_POLICY=opportunistic (the default when ENV=development, so Mailpit works unchanged) for a trusted local relay without TLS, or none to never upgrade. Port 465 is implicit TLS whatever the policy.

SES — the API, not the SMTP endpoint, so there are no SMTP credentials to mint or rotate:

EMAIL_PROVIDER=ses
AWS_REGION=eu-north-1              # SES is regional; the sender is verified per-region
EMAIL_FROM="Ex <noreply@yourcompany.com>"
SES_CONFIGURATION_SET=ex-transactional   # optional: bounce/complaint event publishing

Credentials come from the default AWS chain (env → task role → IRSA → IMDS) — the same identity DynamoDB and S3 use, so an IAM-role deploy needs no extra secrets. The S3 static keys are deliberately NOT reused: they point at MinIO in local setups. For local SES testing set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.

Links inside the mail are built from BASE_URL, so set that to the public origin or recipients get unreachable links.

Mail is optional and never load-bearing. With no transport configured (SMTP_HOST unset, or EMAIL_PROVIDER=ses with no AWS_REGION) the app boots normally (logging a warning) and every flow still works — the invite dialog and the admin reset dialog both show a copyable link for the admin to relay by hand. The reset dialog states explicitly whether the email was sent, so an admin is never left assuming delivery that did not happen. A malformed EMAIL_FROM, by contrast, fails the boot: that is a misconfiguration, not an opt-out.

Verifying the settings

Admin → Workspace settings → Email shows the effective transport and sender, and sends a real test message through it. Leave the recipient blank to mail yourself. On failure the transport's own error is shown verbatim (connection refused, 535 auth failed, email address is not verified) — that string is what tells you which setting is wrong. A success only means the server handed the message over without an error, so if nothing arrives, check the spam folder and the sending domain's SPF/DKIM records next.

Delivery is covered end-to-end against a real mail server: the integration suite (make check) starts Mailpit in a container, sends the real invite and reset templates through it, and reads them back through Mailpit's API to assert headers, both MIME alternatives, non-ASCII subjects, and long-body line limits. SES sends the same rendered MIME as a raw message, so it inherits that guarantee.

Password reset (guest accounts only)

Guests — accounts created by accepting an invite — hold a local bcrypt password and can recover it two ways:

  • Self-service: "Forgot your password?" on the sign-in page (/forgot-password) emails a one-time link.
  • Admin-initiated: Directory → Users → the guest's "..." menu → Reset password, which emails the link and shows it to the admin.

Either way the link is single-use, expires in one hour, and redeeming it revokes every existing session for that account.

SSO users can never have a password reset here. Their credential lives in the identity provider; the server refuses to mint or redeem a reset for an OIDC account (the check keys on the auth provider, so an SSO user demoted to the guest role is still excluded), and the UI does not offer the action. Self-service requests for an SSO address answer exactly like requests for an unknown address, so neither can be used to enumerate accounts.

Configuration

Variable Default Description
PORT 8080 HTTP server port
ENV production development or production (unset is production: fail closed)
BASE_URL http://localhost:8080 Application base URL (used to derive OIDC redirect URL)
OIDC_ISSUER - OIDC provider issuer URL
OIDC_CLIENT_ID - OIDC client ID
OIDC_CLIENT_SECRET - OIDC client secret
MS_TENANT_ID - Entra ID tenant — enables the Microsoft 365 integration (profile sync + /mstmeetings)
MS_CLIENT_ID OIDC_CLIENT_ID Graph app-registration client ID override
MS_CLIENT_SECRET OIDC_CLIENT_SECRET Graph app-registration client secret override
MS_PROFILE_SYNC_INTERVAL 12h Directory re-sync cadence (phone + manager for all users; first sweep at boot = backfill)
MS_STATUS_SYNC_INTERVAL 1h Account-standing check (disabled or deleted in Entra → deactivated; first check at boot)
SESSION_MAX_AGE 2160h (90 days) Absolute session lifetime: refresh stops working this long after sign-in
OIDC_ALLOWED_EMAIL_DOMAINS - Comma-separated email domains allowed to sign in via SSO (unset = any)
CONNECTOR_PROVIDER_URL - Connector catalogue provider; must resolve to a public address (or be under CONNECTOR_PRIVATE_DOMAINS) outside development
CONNECTOR_PRIVATE_DOMAINS - Domains whose hosts (the domain and every subdomain) connector calls may reach on a private 10.x address, e.g. digitaltolk.net (unset = none). Other private ranges, loopback and the cloud metadata address stay blocked. Unusable entries are logged at startup and ignored; it never stops the server
JWT_SECRET dev-secret-change-me (dev only) JWT signing secret (openssl rand -base64 48)
AWS_REGION us-east-1 AWS region
DYNAMODB_TABLE ex DynamoDB table name (single-table design — see below)
DYNAMODB_ENDPOINT - DynamoDB endpoint (set for local dev)
REDIS_URL redis://localhost:6379 Redis connection URL
ACCESS_LOG_ENABLED true Set false to silence per-request logs except 5xx responses
EMAIL_PROVIDER smtp Mail transport: smtp or ses. Unknown value = boot failure
EMAIL_FROM SMTP_FROM Sender address for invites and password resets; accepts Name <addr>. Malformed = boot failure
SES_CONFIGURATION_SET - SES only: configuration set for bounce/complaint event publishing
SMTP_HOST - SMTP server hostname. Unset = mail disabled (links are shown for manual relay)
SMTP_PORT 587 SMTP server port (587/25 STARTTLS, 465 implicit TLS)
SMTP_USER - SMTP username (omit for an unauthenticated relay)
SMTP_PASS - SMTP password
SMTP_FROM noreply@example.com Legacy sender address; EMAIL_FROM falls back to this
SMTP_TLS_POLICY mandatory (opportunistic in development) STARTTLS policy: mandatory, opportunistic or none. Unknown value = boot failure
SENTRY_FRONTEND_DSN - Enables Sentry in the SPA (served as a meta tag to browsers + both native shells)
SENTRY_FRONTEND_TRACES_SAMPLE_RATE 0 (off) Sentry browser performance tracing sample rate (0..1)
SENTRY_FRONTEND_REPLAY_SESSION_SAMPLE_RATE 0 (off) Sentry session replay: sample rate for all sessions (0..1)
SENTRY_FRONTEND_REPLAY_ERROR_SAMPLE_RATE 0 (off) Sentry session replay: sample rate for sessions with an error (0..1)

Datadog APM (optional)

The Docker image is compiled through Orchestrion, so APM instrumentation (HTTP server/client, Redis, AWS SDK v2 — the set lives in orchestrion.tool.go) is baked in at compile time. Tracing is off by default (DD_TRACE_ENABLED=false in the image); a deployment opts in purely via environment:

Variable Example Description
DD_TRACE_ENABLED true Turn the compiled-in tracer on
DD_AGENT_HOST datadog-agent Datadog agent host (default localhost)
DD_ENV production Environment tag on all traces
DD_SERVICE ex (default) Service name
DD_VERSION v0.0.57 Version tag on all traces
DD_TRACE_SAMPLE_RATE 1.0 Trace sampling rate

Plain go build / go test remain uninstrumented — Orchestrion only runs in the Docker build, and its version is pinned through go.mod.

Build

# Build production binary (includes embedded frontend)
make build

# Build Docker image
make docker

Object storage (S3)

Attachments upload straight from the browser to the bucket; the server only signs each S3 request. Files over 16 MiB go up as S3 multipart uploads (retried part by part, resumed after a dropped connection or a re-attach of the same file); smaller files take one PUT that S3 checks against the file's SHA-256. The production bucket needs:

CORS — the browser calls S3 directly, reads each part's ETag, and sends the signed checksum and If-None-Match headers (uploads are write-once):

[
  {
    "AllowedOrigins": ["https://ex.example.com"],
    "AllowedMethods": ["GET", "PUT", "POST", "DELETE"],
    "AllowedHeaders": ["content-type", "x-amz-checksum-sha256", "if-none-match"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

Lifecycle rule — abandoned multipart uploads keep their parts (and their storage cost) until aborted:

{ "Rules": [{ "ID": "abort-incomplete-uploads", "Status": "Enabled", "Filter": {}, "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 2 } }] }

IAM — besides s3:GetObject, s3:PutObject and s3:DeleteObject, the app role needs s3:AbortMultipartUpload and s3:ListMultipartUploadParts (browsers abort and resume through URLs it signs).

Local MinIO (docker compose) already allows every origin and header.

DynamoDB

Despite the name, DYNAMODB_TABLE configures a single table, not a prefix. The app follows the DynamoDB single-table design: every entity (users, channels, conversations, messages, memberships, invites, refresh tokens, settings, …) lives in one table, distinguished by composite PK/SK prefixes (USER#, CHAN#, MSG#, …) plus two GSIs.

Local development

The dev stack creates the table for you on first start — EnsureTable runs only when ENV=development and is a no-op when the table already exists.

Production — pre-create the table

In production the binary will not create or modify the table; that responsibility lives with your infrastructure tooling so the running app needs only dynamodb:GetItem, dynamodb:Query, dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:DeleteItem, dynamodb:TransactWriteItems, dynamodb:BatchWriteItem — never CreateTable.

Create the table once with the AWS CLI (replace ex with your DYNAMODB_TABLE value if different):

aws dynamodb create-table \
  --table-name ex \
  --billing-mode PAY_PER_REQUEST \
  --attribute-definitions \
      AttributeName=PK,AttributeType=S \
      AttributeName=SK,AttributeType=S \
      AttributeName=GSI1PK,AttributeType=S \
      AttributeName=GSI1SK,AttributeType=S \
      AttributeName=GSI2PK,AttributeType=S \
      AttributeName=GSI2SK,AttributeType=S \
  --key-schema \
      AttributeName=PK,KeyType=HASH \
      AttributeName=SK,KeyType=RANGE \
  --global-secondary-indexes '[
    {
      "IndexName": "GSI1",
      "KeySchema": [
        {"AttributeName": "GSI1PK", "KeyType": "HASH"},
        {"AttributeName": "GSI1SK", "KeyType": "RANGE"}
      ],
      "Projection": {"ProjectionType": "ALL"}
    },
    {
      "IndexName": "GSI2",
      "KeySchema": [
        {"AttributeName": "GSI2PK", "KeyType": "HASH"},
        {"AttributeName": "GSI2SK", "KeyType": "RANGE"}
      ],
      "Projection": {"ProjectionType": "ALL"}
    }
  ]'

# Enable TTL so expired refresh tokens / invites are auto-evicted.
aws dynamodb update-time-to-live \
  --table-name ex \
  --time-to-live-specification "Enabled=true, AttributeName=ttl"

If you prefer Terraform / CloudFormation / CDK, replicate the same shape: PK+SK primary key, two GSIs (GSI1/GSI2) each with *PK+*SK and ProjectionType=ALL, and a TTL on the ttl attribute.

Key prefixes (per parent partition)

A parent partition (PK = CHAN#<id> or PK = CONV#<id>) holds the parent's messages plus a few index rows that let the sidebar's "pinned" and "files" panes load in O(pinned) / O(files-shared) instead of scanning every message:

SK prefix Item Lifecycle
MSG# Message Created on send; soft-deleted on delete.
PIN# Pinned-message ref Written when a message is pinned; removed on unpin or message delete. Listed by Query SK begins_with.
FILE# Shared-file ref Upserted per (parent, attachmentID) on every send/edit that references the file; removed on delete only when the row's MessageID still points at the deleted message (re-shares survive).

PIN# and FILE# rows live alongside the MSG# rows in the same DDB partition, so listing them is a single Query against PK + begins_with(SK, "PIN#") (no GSI). The dedicated row also lets ListPinned / ListFiles skip the legacy 1000-message scan that previously bounded both queries' accuracy.

Existing deployments must run this migration once after upgrading — ListPinned and ListFiles read exclusively from PIN# / FILE# rows when the index is wired (always, in production), so pre-rollout messages whose pins and attachments never got indexed will show up as empty Files / Pinned panels until the backfill runs.

# Preview (default — no writes)
go run ./cmd/migrate parent-index

# Apply: writes one PIN# row per pinned message and one FILE# row
# per shared attachment (latest sharer wins). Idempotent; safe to
# re-run after a crash. Requires the same AWS_REGION /
# DYNAMODB_TABLE / DYNAMODB_ENDPOINT env vars as the server.
go run ./cmd/migrate parent-index --apply

Local dev (docker compose) — DynamoDB Local lives inside the dynamodb container; point the migrator at it from the host:

# Preview against the running stack:
AWS_REGION=us-east-1 \
AWS_ACCESS_KEY_ID=dummy \
AWS_SECRET_ACCESS_KEY=dummy \
DYNAMODB_TABLE=ex \
DYNAMODB_ENDPOINT=http://localhost:8000 \
  go run ./cmd/migrate parent-index

# Same shape with --apply when you're ready to write.
AWS_REGION=us-east-1 \
AWS_ACCESS_KEY_ID=dummy \
AWS_SECRET_ACCESS_KEY=dummy \
DYNAMODB_TABLE=ex \
DYNAMODB_ENDPOINT=http://localhost:8000 \
  go run ./cmd/migrate parent-index --apply

The AWS credentials are mandatory but their values don't matter — DynamoDB Local accepts anything; the SDK just refuses to start without a key pair set.

The script is read-mostly: it scans every parent's MSG# rows once and writes ~1 index row per pinned message + ~1 per shared attachment. Storage impact is bounded by parents × (pinned_messages + unique_attachments) — typically negligible vs. message volume.

Thread-delete backfill (migrate thread-delete)

Deleting a thread root now cascades: every reply is soft-deleted too, and the server rejects new replies to a deleted thread (409). Threads deleted before this shipped left their replies live — the root renders as a "(Message deleted)" tombstone while its replies are still visible. Run this one-off once after upgrading to tombstone those orphaned replies so historical data matches the new invariant.

# Preview (default — no writes)
go run ./cmd/migrate thread-delete

# Apply: tombstones every reply whose thread root is already deleted.
# Idempotent (already-deleted replies are skipped); safe to re-run.
# Same AWS_REGION / DYNAMODB_TABLE / DYNAMODB_ENDPOINT env vars as the server.
go run ./cmd/migrate thread-delete --apply

The same DynamoDB Local env-var shape shown above for migrate parent-index applies here. No events are published — these threads were closed long ago and no client is watching them.

Followed threads by latest reply (migrate thread-activity)

The Threads page lists a user's followed threads latest reply first, a page at a time, from their follow rows (GSI2PK = THREADACT#<user>). New follows and every reply keep that order; follow rows written before need their thread's latest reply stamped once. Run it after every instance runs the new build; until it has run, those older threads are missing from the read part of the list (unread ones still show).

go run ./cmd/migrate thread-activity            # preview
go run ./cmd/migrate thread-activity --apply

Participants of older threads (migrate thread-participants)

A thread is on its participants' Threads pages through their follow rows, and every reply writes them for its author and the root author. Threads rooted before that (before 2026-08-01) can miss rows for people who took part back then. This backfill scans the thread index once, gives the root author and every reply author of each such thread a follow row where they have none (a deliberate unfollow stays), dated by the thread's latest reply, and then flags the root. Replies to a flagged root find their audience in the follow rows instead of re-reading the whole thread. Threads written by the history importer are flagged as they are imported. Run it after every instance runs the new build; it is idempotent and can be re-run after a failure.

go run ./cmd/migrate thread-participants                       # preview
go run ./cmd/migrate thread-participants --apply [--workers=4]

go run ./cmd/migrate with no arguments lists every subcommand; all of them default to a dry run.

Architecture

  • Real-time: WebSocket for server-to-client push, REST for everything else
  • Stateless servers: Redis pub/sub enables horizontal scaling
  • DynamoDB single-table: All entities in one table with composite keys
  • Embedded SPA: Frontend built into the Go binary for single-artifact deployment
  • First user is admin: The first person to log in via SSO gets the admin role

About

No description, website, or topics provided.

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages