Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Sibling projects (client, wiki, etc.) are registered in `.local/repos.json`; che

### Storage

Use the generic `Store[T]` from `api/store.go`; each entity type has its own store instance (DiagramStore, ThreatModelStore) with CRUD and concurrency control. Validate entity fields before storing; use the `WithTimestamps` interface for entities with `created_at`/`modified_at`.
`api/store.go` declares the store interfaces (DiagramStore, ThreatModelStore, etc.); each entity type has a GORM-backed implementation (`api/*_store_gorm.go`) with CRUD and concurrency control. There is no generic `Store[T]`. Validate entity fields before storing; use the `WithTimestamps` interface for entities with `created_at`/`modified_at`.

PostgreSQL for persistence, Redis for caching and sessions, in-memory storage for tests. Schema is managed by GORM `AutoMigrate()` from struct tags in `api/models/*.go` (single source of truth). **Oracle ADB is a supported production target**; any DB-touching change must be reviewed by the `oracle-db-admin` subagent (see Policies).

Expand Down
90 changes: 15 additions & 75 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ For detailed setup instructions, see [Development Setup Guide](https://github.co

### Prerequisites

- Go 1.25+
- Go 1.26+
- Docker Desktop (for database & Redis containers)
- Make (for build automation)

Expand All @@ -31,10 +31,10 @@ For detailed setup instructions, see [Development Setup Guide](https://github.co
```bash
git clone https://github.com/ericfitz/tmi.git
cd tmi
make dev-up
make dev-up CLUSTER=docker-desktop
```

The complete development environment (a local kind cluster running the server, plus database and Redis) will start automatically, with the server reachable on port 8080.
The complete development environment (the server, database and Redis, deployed into a Kubernetes cluster) will start automatically, with the server reachable on port 8080. `CLUSTER` is required: `docker-desktop` (local) or `k3s` (remote).

## Project Structure

Expand All @@ -46,34 +46,7 @@ The complete development environment (a local kind cluster running the server, p

### Data Storage Pattern

The project uses strongly-typed concurrent maps for in-memory storage:

```go
// Store provides thread-safe storage for a specific entity type
type Store[T any] struct {
data map[string]T
mutex sync.RWMutex
}

// DiagramStore stores diagrams by UUID
var DiagramStore = NewStore[api.Diagram]()

// ThreatModelStore stores threat models by UUID
var ThreatModelStore = NewStore[api.ThreatModel]()
```

Benefits of this approach:

- Type safety with generics
- Concurrency protection with mutexes
- Clear separation between different entity stores
- Easy to replace with a database implementation later

This pattern is used for all entity types (diagrams, threat models, threats) and provides:

- CRUD operations
- Atomic updates
- Support for filtering and queries
Persistence goes through GORM. Entity models live in `api/models/` and are the single source of truth for the schema: tables are created and migrated from the model struct tags by `AutoMigrate()`. The database is PostgreSQL or Oracle Autonomous Database (a supported production target); the stores are accessed through the interfaces declared in `api/store.go`, with GORM-backed implementations in `api/*_store_gorm.go`. Redis is used for caching and sessions. Unit tests of the GORM stores run against SQLite.

## Documentation

Expand All @@ -98,67 +71,34 @@ See **[TMI Wiki](https://github.com/ericfitz/tmi/wiki)** for the complete docume
## Development Commands

```bash
make dev-up # Start complete dev environment (kind cluster)
make dev-status # Show dev environment status
make dev-down # Tear down dev environment (keep db data)
make dev-up CLUSTER=docker-desktop # Start complete dev environment
make dev-status CLUSTER=docker-desktop # Show dev environment status
make dev-down CLUSTER=docker-desktop # Tear down dev environment (keep db data)
make build-server # Build production binary
make test-unit # Run unit tests
make test-integration-new # Run integration tests (server must be running)
make test-integration # Run integration tests (needs a running stack)
make cats-fuzz # Run security fuzzing
make lint # Run code linting
```

## Configuration

Server configuration can be set via environment variables or using a `.env` file:

1. Copy the `.env.example` file to `.env`
2. Modify the values as needed
3. Start the server, which will automatically load the `.env` file

You can also specify a custom .env file with:
The server reads a YAML configuration file passed with `--config`, and every setting can be overridden with a `TMI_`-prefixed environment variable (for example `TMI_SERVER_PORT`, `TMI_JWT_EXPIRATION_SECONDS`, `TMI_REDIS_URL`). The YAML file holds the bootstrap keys needed to start the server; operational settings are stored in the database settings service and can be overridden with the environment or the YAML file.

```bash
./bin/tmiserver --env=/path/to/custom.env
./bin/tmiserver --config=/path/to/config.yml
./bin/tmiserver --generate-config # write an annotated example configuration
```

Available configuration options:

| Variable | Default | Description |
| -------------------- | --------------------------------- | ------------------------------------------- |
| SERVER_PORT | 8080 | HTTP/HTTPS server port |
| SERVER_INTERFACE | 0.0.0.0 | Network interface to listen on |
| SERVER_READ_TIMEOUT | 5s | HTTP read timeout |
| SERVER_WRITE_TIMEOUT | 10s | HTTP write timeout |
| SERVER_IDLE_TIMEOUT | 60s | HTTP idle timeout |
| LOG_LEVEL | info | Logging level (debug, info, warn, error) |
| TLS_ENABLED | false | Enable HTTPS/TLS |
| TLS_CERT_FILE | | Path to TLS certificate file |
| TLS_KEY_FILE | | Path to TLS private key file |
| TLS_SUBJECT_NAME | [hostname] | Subject name for certificate validation |
| TLS_HTTP_REDIRECT | true | Redirect HTTP to HTTPS when TLS is enabled |
| JWT_SECRET | secret | JWT signing secret (change for production!) |
| JWT_EXPIRES_IN | 24h | JWT expiration |
| OAUTH_URL | https://oauth-provider.com/oauth2 | OAuth provider URL |
| OAUTH_SECRET | | OAuth client secret |
| DB_URL | localhost | Database URL |
| DB_USERNAME | | Database username |
| DB_PASSWORD | | Database password |
| DB_NAME | tmi | Database name |
| ENV | development | Environment (development or production) |
See [config-example.yml](config-example.yml) for the bootstrap keys and their defaults, and the [TMI Wiki](https://github.com/ericfitz/tmi/wiki) for the full configuration reference. The environment variable for each key is declared with the key in `internal/config/config.go`.

### WebSocket URLs

When TLS is enabled (`TLS_ENABLED=true`), clients should connect using secure WebSocket URLs:

- Use `wss://` instead of `ws://` for WebSocket connections
- Example: `wss://your-server.com:8080/ws/diagrams/123`

When TLS is disabled, use standard WebSocket URLs:
The collaborative editing WebSocket for a diagram is `GET /threat_models/{threat_model_id}/diagrams/{diagram_id}/ws`, and server-wide notifications are on `GET /ws/notifications`. Both are specified in the AsyncAPI document (`api-schema/tmi-asyncapi.yml`) and authenticate with a short-lived ticket passed as the `ticket` query parameter.

- Example: `ws://your-server.com:8080/ws/diagrams/123`
When the client reaches the server over TLS, use `wss://` instead of `ws://`. Example: `wss://your-server.com/threat_models/{threat_model_id}/diagrams/{diagram_id}/ws?ticket=...`

You can use the `/api/server-info` endpoint to get the correct WebSocket base URL automatically.
Use `GET /api/server-info` to discover the correct WebSocket base URL, scheme and TLS settings automatically.

## License

Expand Down
183 changes: 182 additions & 1 deletion api-schema/tmi-asyncapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,55 @@ channels:
operationRejectedMessage:
$ref: "#/components/messages/OperationRejectedMessage"

"/ws/notifications":
address: "/ws/notifications"
description: |
WebSocket endpoint for server-wide notifications. Unlike the diagram channel it is not tied to a
threat model or collaboration session. Authentication uses the same ticket mechanism as the diagram channel.

## Delivery
- A new connection receives every notification until it sends an update_subscription message
- The server broadcasts each event to all connected notification clients (subject to the client's subscription filters)
- A heartbeat is sent every 30 seconds to every client, regardless of subscription
- Read timeout: 60 seconds with ping/pong keepalive

## Events emitted today
- threat_model_created, threat_model_updated, threat_model_deleted
- user_joined, user_left (fired when a user connects to or disconnects from this notification hub, not a diagram collaboration session)
- heartbeat
- subscription_updated (sent only to the requesting client, with user_id "system", to confirm an update_subscription message)

## Reserved message types (defined but never emitted)
threat_model_shared, collaboration_started, collaboration_ended, collaboration_invite,
system_announcement, system_maintenance, system_update
messages:
notificationMessage:
$ref: "#/components/messages/NotificationMessage"
updateSubscriptionMessage:
$ref: "#/components/messages/UpdateSubscriptionMessage"

operations:
receiveNotifications:
action: receive
channel:
$ref: "#/channels/~1ws~1notifications"
summary: Receive server notifications (server to client)
description: |
Receive threat model lifecycle events, notification-hub presence events and heartbeats.
messages:
- $ref: "#/components/messages/NotificationMessage"

updateNotificationSubscription:
action: send
channel:
$ref: "#/channels/~1ws~1notifications"
summary: Restrict which notifications are delivered (client to server)
description: |
Replace the connection's subscription. After this message only the listed message types are
delivered (heartbeats are always delivered), optionally narrowed to specific threat models or diagrams.
messages:
- $ref: "#/components/messages/UpdateSubscriptionMessage"

sendDiagramOperation:
action: send
channel:
Expand Down Expand Up @@ -606,6 +654,51 @@ components:
email: disruptive-user@example.com
display_name: Disruptive User

NotificationMessage:
name: NotificationMessage
title: Notification (Server → Client)
summary: Server-wide notification delivered on the notification channel
description: |
Envelope for every message sent on /ws/notifications. The data field depends on message_type:
threat model events carry threat_model_id, threat_model_name and action; user_joined and user_left
carry user_email and user_name; heartbeat carries no data; subscription_updated carries a message string.
payload:
$ref: "#/components/schemas/NotificationPayload"
examples:
- name: ThreatModelCreated
summary: A threat model was created
payload:
message_type: threat_model_created
user_id: alice@tmi.local
timestamp: "2024-01-15T14:30:00Z"
data:
threat_model_id: 6ba7b810-9dad-11d1-80b4-00c04fd430c8
threat_model_name: Payment Service
action: created
- name: Heartbeat
summary: Periodic keep-alive
payload:
message_type: heartbeat
user_id: system
timestamp: "2024-01-15T14:30:30Z"

UpdateSubscriptionMessage:
name: UpdateSubscriptionMessage
title: Update Subscription (Client → Server)
summary: Replace the connection's notification subscription
payload:
$ref: "#/components/schemas/UpdateSubscriptionPayload"
examples:
- name: ThreatModelEventsOnly
summary: Receive only threat model updates for one threat model
payload:
message_type: update_subscription
data:
subscribed_types:
- threat_model_updated
threat_model_filters:
- 6ba7b810-9dad-11d1-80b4-00c04fd430c8

ErrorMessage:
name: ErrorMessage
title: Error Message
Expand Down Expand Up @@ -1280,9 +1373,97 @@ components:
- removed_user
additionalProperties: false

NotificationPayload:
type: object
description: Notification envelope sent on /ws/notifications
properties:
message_type:
type: string
description: |
Notification type. Emitted today: threat_model_created, threat_model_updated, threat_model_deleted,
user_joined, user_left, heartbeat, subscription_updated. The remaining values are reserved and never emitted.
enum:
- threat_model_created
- threat_model_updated
- threat_model_deleted
- threat_model_shared
- collaboration_started
- collaboration_ended
- collaboration_invite
- system_announcement
- system_maintenance
- system_update
- user_joined
- user_left
- heartbeat
- subscription_updated
example: threat_model_created
user_id:
type: string
description: Identifier of the user who triggered the event (the user's email address), or "system" for heartbeats and subscription_updated
example: alice@tmi.local
timestamp:
type: string
format: date-time
description: UTC timestamp when the notification was generated
example: "2024-01-15T14:30:00Z"
data:
type: object
description: Type-specific data; omitted for heartbeat
additionalProperties: true
example:
threat_model_id: 6ba7b810-9dad-11d1-80b4-00c04fd430c8
threat_model_name: Payment Service
action: created
required:
- message_type
- user_id
- timestamp
additionalProperties: false

UpdateSubscriptionPayload:
type: object
description: Client request to replace its notification subscription
properties:
message_type:
type: string
const: update_subscription
data:
type: object
properties:
subscribed_types:
type: array
description: Notification message types to receive
items:
type: string
threat_model_filters:
type: array
description: Only deliver threat model events for these threat model IDs; empty or omitted means all
items:
type: string
format: uuid
diagram_filters:
type: array
description: Only deliver collaboration events for these diagram IDs; empty or omitted means all
items:
type: string
format: uuid
additionalProperties: false
required:
- message_type
- data
additionalProperties: false

ErrorPayload:
type: object
description: General error message payload
description: |
General error message payload.

Interim note: the error field is a free string and no canonical vocabulary has been chosen
(tracked in issue 1076). The values the server sends today are unauthorized, invalid_message,
invalid_message_type, unsupported_message_type, invalid_participant, invalid_request and
removed_from_session. insufficient_permissions is not an error value; it is a reason on
authorization_denied messages.
properties:
message_type:
type: string
Expand Down
Loading
Loading