Skip to content

DigitalTolk/ex

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.

# 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

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

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.

Email Invites (SMTP)

To send invite links via email, configure the following environment variables:

SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-smtp-user
SMTP_PASS=your-smtp-password
SMTP_FROM=noreply@yourcompany.com

When SMTP is not configured, invite links are logged to the server console.

Configuration

Variable Default Description
PORT 8080 HTTP server port
ENV development development or production
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
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
SMTP_HOST - SMTP server hostname
SMTP_PORT 587 SMTP server port
SMTP_USER - SMTP username
SMTP_PASS - SMTP password
SMTP_FROM - Sender email address for invites

Build

# Build production binary (includes embedded frontend)
make build

# Build Docker image
make docker

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 upgradingListPinned 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 --dry-run

# 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 --dry-run

# 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.

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

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors