diff --git a/.version b/.version index 724a7cb0..d30ee6c7 100644 --- a/.version +++ b/.version @@ -1,6 +1,6 @@ { "major": 1, - "minor": 5, + "minor": 6, "patch": 0, "prerelease": "" } diff --git a/CLAUDE.md b/CLAUDE.md index 181ab2c6..82a04110 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -469,16 +469,31 @@ TMI uses staticcheck for Go code quality analysis. The project has intentionally - `test(integration): add database connection pooling tests` - `deps: update Gin framework to v1.11.0` -### Automatic Versioning - -TMI uses automatic semantic versioning (0.MINOR.PATCH) based on conventional commits: - -- **Feature commits** (`feat:`): Post-commit hook increments MINOR version, resets PATCH to 0 (0.9.3 -> 0.10.0) -- **All other commits** (`fix:`, `refactor:`, etc.): Post-commit hook increments PATCH version (0.9.0 -> 0.9.1) -- **Version file**: `.version` (JSON) tracks current state -- **Script**: `scripts/update-version.sh --commit` (automatically called by post-commit hook) - -Version updates are fully automated. All feature development occurs in dev// branches or in feature/ branches that are children of dev/ branches; those branches are not auto-versioned so that new features don't bump the semantic version multiple times during development of a single feature or release. The main branch only gets direct commits for patching, security fixes, and merging of release branches. +### Versioning — currently MANUAL (see #627) + +**Automatic versioning is disabled.** `scripts/hooks/post-commit` is an +explanatory no-op. It could not fire under the PR-only workflow: it exited early +unless `HEAD` was on `main` (commits are authored on `fix/*`/`dev/*` branches), +and the commit that lands on `main` comes from GitHub's squash-merge, where no +local hook runs. It had been silently doing nothing — `main` sat at 1.5.0 across +several `feat:` merges. + +Until #627 lands, **bump versions by hand as part of the change**, and keep all +three in step (they are independent and have already drifted): + +- `.version` (JSON) — the source of the server build version +- `api-schema/tmi-openapi.json` → `info.version` +- verify with `make build-server`, which prints the version it embedded + +Intended scheme once automated: `feat:` → MINOR (reset PATCH), everything else → +PATCH. With squash-merge the PR title is the conventional-commit subject, so that +is the string to parse. `scripts/update-version.sh` still works when invoked +directly. + +All feature development occurs in dev// branches or in +feature/ branches that are children of dev/ branches. The +main branch only gets direct commits for patching, security fixes, and merging of +release branches. ## Custom Tools diff --git a/Makefile b/Makefile index dc962cf8..5d8e8266 100644 --- a/Makefile +++ b/Makefile @@ -22,8 +22,31 @@ SERVER_PORT ?= 8080 # Default database backend for dev environment (postgres|oracle) DB ?= postgres -# Default kube cluster target for dev environment (docker-desktop|k3s|kind) -CLUSTER ?= docker-desktop +# Kube cluster target for the dev environment (docker-desktop|k3s). +# +# DELIBERATELY NO DEFAULT. A wrong-cluster operation is silent and expensive: +# `make dev-up` used to default to docker-desktop, so running it while the +# server under test lived on k3s rebuilt and rolled a cluster nobody was +# looking at, leaving the real target stale — and `make dev-status` reported +# against a different cluster than the one just deployed. The destructive +# targets (dev-nuke, dev-down) make the same mistake unrecoverable. +# +# Every dev-* target guards on this via REQUIRE_CLUSTER. +CLUSTER ?= + +# Fail with an actionable hint when a cluster-targeting target is invoked +# without CLUSTER. $@ is the target being run, so the hint is copy-pasteable. +define REQUIRE_CLUSTER +if [ -z "$(CLUSTER)" ]; then \ + printf 'Error: CLUSTER is required for `make %s` — there is no default.\n\n' "$@" >&2; \ + printf ' make %s CLUSTER=docker-desktop # local Docker Desktop Kubernetes\n' "$@" >&2; \ + printf ' make %s CLUSTER=k3s # remote k3s cluster\n\n' "$@" >&2; \ + printf 'Picking for you is how a deploy lands on the wrong cluster: the target\n' >&2; \ + printf 'you are not watching gets rebuilt while the one under test stays stale.\n' >&2; \ + printf 'Current kube context is %s (informational only — it does NOT set the target).\n' "$$(kubectl config current-context 2>/dev/null || echo unknown)" >&2; \ + exit 2; \ +fi +endef # ============================================================================ # ATOMIC COMPONENTS - Infrastructure Management @@ -330,33 +353,43 @@ tilt-down: ## Stop Tilt and restore the prod-shaped server # ============================================================================ dev-up: ## Bring up the full dev environment (cluster + deploy). DB=postgres|oracle CLUSTER=docker-desktop|k3s + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --db $(DB) --cluster $(CLUSTER) up dev-down: ## Tear down the dev environment; KEEP db data + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --db $(DB) --cluster $(CLUSTER) down dev-restart: ## Rebuild the server image + roll the server pod (cluster + db untouched) + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --db $(DB) --cluster $(CLUSTER) restart dev-reset: ## Soft known-state: redeploy the stack with fresh images; KEEP db data + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --db $(DB) --cluster $(CLUSTER) reset dev-nuke: ## Hard known-state: destroy everything incl. db data + images, rebuild + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --db $(DB) --cluster $(CLUSTER) nuke dev-status: ## dev environment status dashboard + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --cluster $(CLUSTER) status dev-logs: ## Stream the tmi-server pod logs + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --cluster $(CLUSTER) logs dev-deploy: ## (Re)apply manifests + rollout without recreating cluster/db + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --db $(DB) --cluster $(CLUSTER) deploy dev-cluster-up: ## Switch to the cluster kube context (docker-desktop or k3s) + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --cluster $(CLUSTER) cluster up dev-cluster-down: ## No-op for docker-desktop and k3s (clusters are not owned) + @$(REQUIRE_CLUSTER) @uv run scripts/devenv.py --cluster $(CLUSTER) cluster down # --- deprecated aliases (removable next release) --- diff --git a/api-schema/tmi-openapi.json b/api-schema/tmi-openapi.json index 45112c8f..c736dcfa 100644 --- a/api-schema/tmi-openapi.json +++ b/api-schema/tmi-openapi.json @@ -3,7 +3,7 @@ "info": { "title": "TMI (Threat Modeling Improved) API", "description": "A RESTful API for collaborative threat modeling with full X6 graph library compatibility. This API provides schemas that align with AntV X6 cell object models for seamless integration with modern diagramming libraries. Supports OAuth 2.0 authentication with client callback integration for seamless single-page application authentication flows.\n\n## API Design v1.1.0\n\n### Authorization Model\nTMI uses hierarchical authorization: access control is defined at the ThreatModel level via the authorization field (readers, writers, owners). All child resources (Assets, Diagrams, Documents, Notes, Repositories, Threats) inherit permissions from their parent ThreatModel. This simplifies permission management and ensures consistent access control.\n\n### Bulk Operations\nNotes and Diagrams do not support bulk operations due to their unique creation workflows and lack of valid bulk use cases. All other resources (Threats, Assets, Documents, Repositories) support full bulk operations: POST (create), PUT (upsert), PATCH (partial update), DELETE (batch delete).\n\nAll resources support bulk metadata operations regardless of resource-level bulk support.\n\n### List Response Strategy\n- ThreatModels return summary information (TMListItem) because they contain many child objects that can be large.\n- Diagrams return summary information (DiagramListItem) because diagram data (cells, images) can be large.\n- Notes return summary information (NoteListItem) because the content field can be large.\n- Threats, Assets, Documents, Repositories return full schemas as they are relatively small and static.\n\n### PATCH Support\nAll resources support PATCH for partial updates using JSON Patch (RFC 6902). This is particularly useful for:\n- Assets: Array field updates (affected_assets, trust_boundaries) ensuring no duplicates\n- Notes: Updating name/description without changing content field\n- All resources: Efficient updates without full object replacement\n", - "version": "1.5.0", + "version": "1.6.0", "contact": { "name": "TMI Development Team", "url": "https://github.com/ericfitz/tmi", @@ -6330,9 +6330,11 @@ }, "code": { "type": "string", - "description": "Authorization code (required for authorization_code grant)", + "description": "Authorization code received from OAuth provider. Per RFC 6749, can contain any visible ASCII characters (VSCHAR: 0x20-0x7E). maxLength is deliberately generous: Microsoft Entra v2.0 codes routinely exceed 700 characters and can pass 2000 with additional scopes/claims. A 512 limit silently broke Microsoft sign-in with a 400 from request validation before the handler ran (Google codes are ~100 chars, so only Microsoft was affected). Do not tighten without checking real Entra code lengths.", "example": "0AX4XfWiXY2BZ_example_auth_code", - "nullable": true + "nullable": true, + "maxLength": 8192, + "pattern": "^[\\x20-\\x7E]+$" }, "client_id": { "type": "string", @@ -11456,17 +11458,17 @@ }, "developer_key": { "type": "string", - "description": "Google Picker developer key. Deprecated \u2014 prefer provider_config.developer_key. Populated only for provider_id=google_workspace.", + "description": "Google Picker developer key. Deprecated — prefer provider_config.developer_key. Populated only for provider_id=google_workspace.", "example": "AIzaSyB-1234example" }, "app_id": { "type": "string", - "description": "Google Cloud app id. Deprecated \u2014 prefer provider_config.app_id. Populated only for provider_id=google_workspace.", + "description": "Google Cloud app id. Deprecated — prefer provider_config.app_id. Populated only for provider_id=google_workspace.", "example": "123456789012" }, "provider_config": { "type": "object", - "description": "Provider-specific public configuration values for picker initialization. Keys vary by provider \u2014 see provider documentation. For google_workspace: developer_key, app_id. For microsoft: client_id, tenant_id, picker_origin.", + "description": "Provider-specific public configuration values for picker initialization. Keys vary by provider — see provider documentation. For google_workspace: developer_key, app_id. For microsoft: client_id, tenant_id, picker_origin.", "additionalProperties": { "type": "string" } @@ -11686,7 +11688,7 @@ "user_agent_data": { "type": "object", "additionalProperties": true, - "description": "Optional NavigatorUAData payload (\u2264 4 KB serialized)" + "description": "Optional NavigatorUAData payload (≤ 4 KB serialized)" }, "viewport": { "type": "string", @@ -12694,7 +12696,7 @@ } }, "Conflict": { - "description": "Conflict \u2014 the supplied version does not match the resource's current version. Refetch and retry.", + "description": "Conflict — the supplied version does not match the resource's current version. Refetch and retry.", "content": { "application/json": { "schema": { @@ -12727,7 +12729,7 @@ } }, "PreconditionRequired": { - "description": "Precondition Required \u2014 the request did not include an If-Match header. This response is returned only when the server has flipped the RequireIfMatch config flag (planned for a future release).", + "description": "Precondition Required — the request did not include an If-Match header. This response is returned only when the server has flipped the RequireIfMatch config flag (planned for a future release).", "content": { "application/json": { "schema": { @@ -13089,7 +13091,7 @@ "required": true, "schema": { "type": "string", - "description": "RFC 6749 \u00a7A.11 authorization-code = 1*VSCHAR; VSCHAR = %x20-7E. Microsoft codes can exceed 1500 chars and contain *, !, $.", + "description": "RFC 6749 §A.11 authorization-code = 1*VSCHAR; VSCHAR = %x20-7E. Microsoft codes can exceed 1500 chars and contain *, !, $.", "pattern": "^[ -~]+$", "minLength": 1, "maxLength": 4096 @@ -15459,10 +15461,10 @@ }, "code": { "type": "string", - "description": "Authorization code received from OAuth provider. Per RFC 6749, can contain any visible ASCII characters (VSCHAR: 0x20-0x7E).", + "description": "Authorization code received from OAuth provider. Per RFC 6749, can contain any visible ASCII characters (VSCHAR: 0x20-0x7E). maxLength is deliberately generous: Microsoft Entra v2.0 codes routinely exceed 700 characters and can pass 2000 with additional scopes/claims. A 512 limit silently broke Microsoft sign-in with a 400 from request validation before the handler ran (Google codes are ~100 chars, so only Microsoft was affected). Do not tighten without checking real Entra code lengths.", "example": "0AX4XfWiXY2BZ_example_auth_code_from_google", "pattern": "^[\\x20-\\x7E]+$", - "maxLength": 512, + "maxLength": 8192, "nullable": true }, "state": { @@ -40222,7 +40224,7 @@ "get": { "operationId": "getDiagramModel", "summary": "Get minimal diagram model for automated analysis", - "description": "Returns a minimal representation of the diagram optimized for automated threat modeling. Strips all visual styling, layout, and rendering properties. Includes threat model context, computed parent-child relationships, and flattened metadata.\n\nContent negotiation: use the Accept header to select the response format \u2014 application/json (default), application/yaml, or application/graphml+xml. An Accept header that matches none of these yields 406 Not Acceptable.", + "description": "Returns a minimal representation of the diagram optimized for automated threat modeling. Strips all visual styling, layout, and rendering properties. Includes threat model context, computed parent-child relationships, and flattened metadata.\n\nContent negotiation: use the Accept header to select the response format — application/json (default), application/yaml, or application/graphml+xml. An Accept header that matches none of these yields 406 Not Acceptable.", "tags": [ "Threat Model Sub-Resources" ], @@ -44987,7 +44989,7 @@ } }, "422": { - "description": "Unprocessable Entity. Returned when a status transition has unmet requirements \u2014 for example, transitioning to needs_revision requires revision_notes to be set on the survey response.", + "description": "Unprocessable Entity. Returned when a status transition has unmet requirements — for example, transitioning to needs_revision requires revision_notes to be set on the survey response.", "content": { "application/json": { "schema": { @@ -45692,7 +45694,7 @@ } }, "422": { - "description": "Unprocessable Entity. Returned when a status transition has unmet requirements \u2014 for example, transitioning to needs_revision requires revision_notes to be set on the survey response.", + "description": "Unprocessable Entity. Returned when a status transition has unmet requirements — for example, transitioning to needs_revision requires revision_notes to be set on the survey response.", "content": { "application/json": { "schema": { @@ -62506,7 +62508,7 @@ "Timmy Chat" ], "summary": "Send a message to Timmy", - "description": "Sends a user message to the Timmy AI assistant and returns an SSE stream of the assistant response.\n\n**SSE event types emitted by this endpoint:**\n\n- `status` (zero or more) \u2014 Phase-transition events fired BEFORE `message_start` so clients can show \"Timmy is \u2026\" affordances during the often-multi-second pre-token latency. Payload: `{\"phase\": \"\", \"entity_type\": \"\", \"entity_name\": \"\", \"detail\": \"\"}`. `phase` is a stable identifier the client can map to localized strings; the server is free to add or rename phases as the pipeline evolves, so unknown phases should be treated as opaque labels. Current phases include `building_context`, `loading_history`, `querying_embeddings`, `waiting_for_llm`. Once tokens begin streaming, no further `status` events are emitted.\n- `message_start` (exactly one, after any `status` events) \u2014 Signals that the server is about to (or has just begun) streaming tokens. Payload: `{\"status\": \"processing\"}`.\n- `token` (zero or more) \u2014 Individual tokens streamed from the LLM. Payload: token text.\n- `message_end` (exactly one, terminal) \u2014 The full persisted assistant message. Payload: `TimmyChatMessage` object.\n- `error` (terminal in the failure case) \u2014 Emitted instead of `message_end` if the request fails after the SSE stream has begun.\n\n**Auto-generated session title.** After the first user message in a fresh session is processed successfully, the server may asynchronously generate a short title (\u2264 60 characters) from that message and persist it to the session's `title` field. This only happens when the existing title is empty or matches the client's default placeholder (e.g. `Chat \u2014 ,