The Office's one polling call, glance, answers "has anything changed
since I last looked?" with counts; live core-api ignored the
parameters it needed, so glance could not ship.
GET /scribe/mailbox now takes since_message_id=N: only entries whose
message number is above N (strictly — not >=), combined with
view/unread/tag the same way they combine with each other today.
message_id, which already bypasses those filters, keeps bypassing
since_message_id too.
The response gains newest_message_id: the caller's newest message
number across its whole mailbox, every view, regardless of any filter
the call applied; null when the mailbox is empty. It exists because
the unread-first sort can return an older unread entry on a limit=1
page while a newer message is already read — the exact shape glance
hits. Computed from a scoped MAX(message_id) query (tenant and
address), never from the page itself, so it cannot read stale or leak
across the tenant/address boundary (D-73).
total's meaning is unchanged: it already counted every filter match
independent of limit (the count query runs against the pre-limit
subquery), so since_message_id just becomes one more filter it counts
against.
Three mutations run and reverted to confirm the new tests actually
cover the behavior: message_id > vs >= broke the boundary test;
computing newest_message_id from the sorted page instead of the whole
mailbox broke the older-unread test; dropping the address filter from
the MAX query broke the address-boundary test.
583 -> 592 tests passing; test database left at head (010).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
workspace D-70 ("`feedback` needs no address"): the failure channel must
not depend on the thing most likely to break. `scribe_messages.from_identity`
becomes nullable (migration 010); `POST /scribe/messages` accepts a missing
`X-Office-Identity` for exactly one shape — an office caller, `to` is
`["tower-control"]` alone, and `tags` contains `feedback` — stored with
`from_identity` null and `from_consumer` the caller's consumer. Every other
send without an identity is still 400 `identity_required`, enforced in
ScribeService.send_message rather than the caller dependency, since the
decision needs the request body. `MessageResponse.from_identity` is now
optional so mailbox/history/find/message_id answer such a message with a
null sender. Migration 010's downgrade refuses outright while any
sender-less row exists rather than inventing one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The user wants a way to get rid of a message that is no longer useful. A
message is stored once and shared by every recipient; `history` is how
agents rebuild context (D-66), so deleting the message itself would take
it from every other reader. Deletion is therefore per mailbox entry
(D-70): `archive` (kept, out of the default view) and `trash` (out of the
mailbox, restorable). True erasure of content stays `redact` — not this
round.
Migration 009 (down_revision="008") adds
scribe_mailbox_entries.archived_at and trashed_at — independent nullable
timestamps, mirroring book_records.archived_at/trashed_at (migration
005). No data step: every existing entry is neither.
PATCH /scribe/mailbox/{entry_id} takes `archived` and `trashed` booleans
beside `read` (true sets the timestamp if not already set, false clears
it); the response carries both. GET /scribe/mailbox takes `view`: `inbox`
(default, neither archived nor trashed), `archive` (archived and not
trashed), `trash` (trashed, regardless of archived_at — trashing hides an
entry from the archive view too), `all` (no filter on either column).
`find` now excludes trashed entries (archived ones still match).
T-9: GET /scribe/mailbox?message_id=N is a direct lookup, not a filtered
list — the unique constraint on (address, message_id) guarantees zero or
one, in any view, ignoring unread/tag/before_id entirely. That is the
point: a client that already knows the message number never pages the
unread-first/id-descending listing to find it.
Judgement call: the listing's own cursor (`before_id`, a single `id`
comparison) still does not match its two-key sort (unread-first, then id
descending) — the pre-existing gap the dossier flagged as optional to
fix. Left unchanged: T-9's message_id lookup is the actual answer to "a
client pages to find one," and reworking the general cursor is a larger,
separate concern (which field(s) a keyset cursor needs) that risks the
already-passing test_mailbox_lists_unread_first for no requirement this
round asked for.
Judgement call: archived_at and trashed_at are independent timestamps,
never cross-clearing each other. Trashing an already-archived entry does
not clear archived_at, so restoring it (trashed=false) returns it to the
archive view, not the inbox — the view filter's own trash-takes-priority
rule is what keeps it out of view=archive while trashed, not a write-time
side effect.
Tests: 12 new (6 archive/trash flow + 4 message_id lookup + 2 migration
round-trip). Full suite 554 -> 566 passed.
Mutation checks (Edit, reverted after each):
- view=inbox dropped its trashed_at filter ->
test_trash_hides_from_inbox_archive_and_find_shows_in_trash failed
(assert 1 == 0).
- message_id lookup dropped the caller-address filter ->
test_message_id_answers_nothing_for_a_message_not_in_their_mailbox
failed (assert 1 == 0).
- trash updated every mailbox entry for the message instead of the
caller's own row ->
test_trashing_one_recipients_entry_leaves_the_others_untouched failed
(assert 0 == 1).
All three reverted; full suite re-verified green (566 passed).
Migration 008 -> 009 -> 008 -> 009 round-tripped against core_api_test,
both manually (alembic CLI) and in
TestMigration009RoundTrip. Production stays at 008 — every alembic
invocation derived POSTGRES_* from TEST_DATABASE_URL.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
claude.ai's connector keeps no MCP session between tool calls, so the
Office cannot remember which address a conversation chose. Each consumer
grant now carries a fallback: the Office uses it when a call names none
and has no session binding (tatlock-office T-5, in a parallel round,
reads it).
Migration 008 (down_revision="007") adds
tenant_consumers.default_address — nullable varchar(63) — and a
composite foreign key (tenant_id, default_address) ->
scribe_addresses(owner_tenant, name), so a default always names a real
address in the same tenant. scribe_addresses already carries both a
primary key and a named unique constraint on (owner_tenant, name)
(migration 006), so the composite FK has something to bind to; Postgres's
default MATCH SIMPLE means a NULL default_address never triggers the
check, which is the state every grant but one is left in. The data step
sets jpmschweitzer/office-claude -> claude-ai only if that address exists
at upgrade time (true in production, false in core_api_test, where no
scribe_addresses are seeded) — an UPDATE ... WHERE EXISTS, not a bare
UPDATE, so it is a no-op rather than a failure when absent.
llm_tester/office-claude is untouched and stays null. Downgrade drops the
FK then the column.
TenantConsumer (models.py) gains the column and a matching
ForeignKeyConstraint. TenantsService.get_me now returns a third element,
the caller's own consumer's default_address (looked up only when
caller.consumer is not None — a user-kind caller carries no consumer, so
there is no grant row to hold a default for it) or None. GET /tenants/me
answers it; GET /tenants and its TenantSummary schema are unchanged.
Tests: TestMigration008RoundTrip/ForeignKey/DataStep added to
tests/test_tenants_migration.py (round-trip 007<->008, the FK existing
and refusing an unknown address while allowing null, the data step's
no-op and positive cases). tests/conftest.py gains a
default_address_grant fixture (registers a throwaway address, points a
grant at it, reverses both on teardown). tests/test_tenants.py gains
TestMeDefaultAddress (an office caller with/without a default, a user
caller always null).
Friction found and fixed in the same commit: the new composite FK made
Postgres refuse tests/test_scribe.py's existing clean_scribe_tables
TRUNCATE ("cannot truncate a table referenced in a foreign key
constraint") — the restriction is on the constraint's existence, not on
whether any row's default_address is actually non-null. tenant_consumers
joins that TRUNCATE; CASCADE was rejected because it would silently drop
every grant rather than only addresses, so the four grants
(jpmschweitzer/llm_tester x office-claude/office-gemini) are reinserted
in the same fixture, right after.
Mutation-checked: forcing default_address to null in the controller
fails exactly test_office_caller_with_a_default_sees_it, leaving the
without-a-default and user-caller tests green. Disabling the FK's
creation (and, symmetrically, its drop in downgrade — to keep the
round-trip self-consistent against the live core_api_test schema) fails
test_fk_exists and test_fk_refuses_a_default_naming_an_address_that_does_not_exist,
leaving test_fk_allows_a_null_default_regardless_of_tenant green. Both
mutations reverted; the full suite re-verified green (543 + 11 = 554
passed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A tenant has been an unchecked string since T-4/T-7: the Office's
X-Office-Tenant header or a user JWT's tatlock_user claim, with Book and
Scribe each running their own _require_tenant that checked only presence.
A typo'd claim opened a fresh, empty namespace rather than being refused.
src/domains/tenants/ (controller/models/schemas/service) adds tenants and
tenant_consumers (migration 007, down_revision="006"): tenants.id is the
slug a tatlock tenant carries, kind (household|person|test) and status
(active|suspended) CHECK-constrained; tenant_consumers is the grant pairing
a consumer to a tenant. Migration 007 seeds the two tenants in use today —
jpmschweitzer (household, "Jeroen") and llm_tester (test, "Playground") —
each granted to office-claude, the only Office consumer configured in
Authentik. Every existing owner_tenant column (book_records,
book_tenant_counters, scribe_addresses, scribe_messages,
scribe_mailbox_entries — five, grepped and confirmed) now carries a
foreign key to tenants.id, ON DELETE RESTRICT, at both the migration and
the ORM model.
TenantsService.require_tenant is the one enforcement point: no tenant is
400 tenant_required (unchanged); an unknown tenant is 403 tenant_unknown;
a suspended one is 403 tenant_suspended; an Office caller whose consumer
holds no grant in that tenant is 403 consumer_not_granted. One query — a
LEFT JOIN against the grant — so presence, status and the grant are all
read in a single round trip. Book's and Scribe's own _require_tenant now
delegate to it, translating TenantError to their own exception type so
every call site and each controller's single except clause keep working
unchanged.
Read-only this round: GET /tenants/me answers the caller's own tenant and
its granted consumers (the cheap "am I allowed here" probe); GET /tenants
answers every tenant for a tatlock/scheduler caller, or a list of one for
a tenant-bearing caller — another tenant's existence is never revealed.
No write routes; provisioning a tenant is workspace T-195.
Tests: tests/test_tenants.py and tests/test_tenants_migration.py (new);
a TestTenantRegistryEnforcement class added to both tests/test_book.py and
tests/test_scribe.py proving all four cases (unknown, suspended,
ungranted-consumer, and the control) on a live Book and Scribe route, not
only on /tenants itself. tests/conftest.py gains a tenant_registry schema
fixture and a suspended_tenant fixture (inserted and deleted per test; no
FK references it); book_schema and scribe_schema each independently grant
a second, test-environment-only consumer (office-gemini) in both seeded
tenants, since test_book.py and test_scribe.py have used it as a stand-in
second consumer since before this grant check existed — the migration
itself still seeds only office-claude, matching the dossier.
Mutation-checked: dropping the grant check fails the three
consumer_not_granted tests (200 instead of 403); dropping the status
check fails the three tenant_suspended tests; making Book bypass the
helper fails its unknown-tenant test (200 instead of 403). All three
restored and the full suite re-verified green (508 + 35 = 543 passed).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
005 has never run in production (still at 004), so it is amended in place
rather than patched later by a 007: book_records gains owner_tenant (the
tatlock tenant, Caller.tenant from T-4) and number, a short outward id
allocated per tenant via a new book_tenant_counters row locked FOR UPDATE
in the same transaction as the insert — INSERT ... ON CONFLICT DO NOTHING
bootstraps the counter row so a tenant's very first allocation doesn't
race its own creation. Unique on (owner_tenant, number); composite index
on (owner_tenant, kind, status). 006 applies on top unchanged.
Every Book route now requires a tenant (BookService._require_tenant,
mirroring the Scribe's own pattern) and filters on it: a record in
another tenant answers exactly like one that does not exist, in both
directions. link and add's parent convenience both route through
_get_or_404, so a link can never reach another tenant — which is also
what keeps show's links/backlinks free of their own tenant filter, since
every link touching a tenant's record is created tenant-safe already.
Responses include number; list accepts number= to find one.
Verified: core_api_test downgraded to 004 (with the original migration,
to match its already-applied pre-amendment schema), amended, upgraded
back to head — clean round trip. 508 tests pass (489 baseline + 19 new:
3 migration-shape, 16 tenant/numbering). Mutation checks confirmed and
reverted: dropping list's tenant filter, allocating numbers from a global
counter, and dropping link's tenant check on its target each fail the
test built to catch it. Raw REST against a dev server on :8777 against
core_api_test confirms the same behaviour over the wire: two tenants'
first records both land on number 1, and a cross-tenant read answers 404
not_found.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Office (workspace D-66, D-68, D-73) needs a message bus: an address is
a topic named for an identity, owned by a tenant and a consumer. D-68 puts
it here, in core-api's own database, behind its own auth, following
src/domains/book/.
Migration 006 lands the whole schema by hand (down_revision="005"):
scribe_addresses (owner_tenant + name primary key — no surrogate id, since
no route ever references an address except by name), scribe_messages
(retained; a generated tsvector search column + GIN index on subject/body,
and a GIN index on the topics array for history's lookup), and
scribe_mailbox_entries (one row per recipient address per message, unique
on address+message_id). No data: seeding `jeroen` and `tower-control` is a
deploy step through `register`, not this migration.
Routes, every one behind require_caller("office", "user"), tenant from the
caller (T-4):
- register/list addresses — register is idempotent for the same consumer,
409 address_taken for another consumer in the tenant; list marks which
addresses the caller's consumer owns.
- send — `from` is always the caller's own identity, stamped server-side,
never read from the body; 403 identity_not_owned if that identity is not
a registered address owned by the caller's consumer; 404 unknown_address
(naming the missing ones) if any recipient does not exist in the tenant;
writes one mailbox entry per recipient.
- mailbox / find — the caller's own identity's entries (mailbox: unread
first; find: full text via websearch_to_tsquery), both requiring the
identity be owned by the caller's consumer.
- history — the retained messages sent to an address, oldest first;
reading never consumes or marks, and reads scribe_messages directly
rather than joining through mailbox entries.
- mark / tag / untag — share one PATCH route; an entry outside the
caller's tenant, or addressed to an identity the caller isn't reading
as, answers 404 not_found — indistinguishable from a nonexistent entry.
Every query filters on the caller's tenant (D-73): an address or message
in another tenant answers exactly like one that does not exist, so no id
is ever confirmed across that boundary (test_scribe.py::TestTenantIsolation,
including two tests proving the tenant filter is load-bearing even when
the same address *name* legitimately exists, differently owned, in two
tenants — not just when the caller's own identity is unregistered there).
Messaging only this round: groups, subscriptions, the notification matrix
and delivery are explicitly not built (workspace T-176) — the schema
already carries what `topics`/`tags` need for them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Office (workspace D-73) now signs every visitor in through Authentik
with one OAuth client per consumer, and every token carries a signed
tatlock_user claim. Caller gains consumer (X-Office-Consumer, the
Authentik application slug) and tenant (X-Office-Tenant, the
tatlock_user claim). For kind "office" both are required on every call,
reads included — missing is 400 consumer_required/tenant_required,
malformed is 400 consumer_invalid/tenant_invalid. Every other kind
ignores both headers outright: tatlock and scheduler carry no tenant,
and a "user" caller's tenant comes only from their own JWT's
tatlock_user claim, never from a header — a header must not be able to
put a non-Office caller into a tenant.
X-Office-Client (informational) is retired in favour of
X-Office-Consumer. book_events.client keeps its name (no migration this
round) and now records the consumer instead.
Book routes do not start filtering by tenant this round — that is
core-api T-2.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Office (workspace D-64..D-72) needs a store for Notes, Tasks,
Decisions, Questions and Rejected options. D-68 puts it here, in
core-api's own database, behind its own auth.
Migration 005 lands the whole schema now, by hand (down_revision="004";
the chain has a hash-named revision between 003 and 004, confirmed
before trusting the numbering): book_records (kind, status validated
per kind in the service, scope work/personal, a generated tsvector
search column + GIN index), book_tags, book_record_links, book_attachments
(table only, no file handling yet), and book_events — append-only via a
database trigger that raises on UPDATE and DELETE for every role,
including the owning application.
Routes this round (D-70's core verbs), every one behind
require_caller("office", "user"), writes with identity_required=True,
every change writing a book_events row: add, show, list (the nine
views), find (full-text), change, close (task done/dropped; question
resolved via a `resolves` link into `settled`; decision superseded via
a `supersedes` link into `superseded`), link/unlink, tag/untag, history.
Append, remind, reschedule, undo, purge, redact, pin, check, snooze,
archive/trash/restore, pause/resume, due, next, batch calls and
idempotency keys are explicitly not this round — the schema already
carries what they need.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
api.schweitz.net is internet-facing with no forward-auth, and every
GET under /housekeeping/* (health, devices, areas, scenes, scripts,
automations, history) was unauthenticated. It answers 500 today only
because Home Assistant's token is dead; once that token is restored it
would serve the house's device list to anyone.
Reads now go through require_caller("office", "tatlock", "user").
tatlock is in the list because tatlock's Housekeeper
(tatlock/src/agents/housekeeper/client.py) is the only caller of these
routes today and already sends Bearer CORE_API_KEY when it is set.
Writes are untouched — control_device, activate_scene, run_script and
toggle_automation still depend on get_admin_user. Whether tatlock may
write is a separate decision, not this one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
core-api had no service-key auth: AuthService.validate_api_key was never
called, api_keys stayed empty, and CORE_API_KEY was read by nothing. This
is the first thing that reads a service key.
require_caller(*allowed, identity_required=False) resolves, in order:
office_service_key -> tatlock_service_key -> scheduler_service_key ->
the existing get_current_user() JWT path. Each static key is compared
with hmac.compare_digest, gated on the configured key being non-empty so
an unset key can never match. A disallowed kind is 403 forbidden.
The office caller's identity comes from X-Office-Identity: required on
writes (400 identity_required if missing), validated against
^[a-z0-9][a-z0-9-]{0,62}$ and against the reserved/group- set (400
identity_invalid). Other kinds carry a fixed identity and ignore the
header.
New settings (src/shared/config.py, default ""): office_service_key,
tatlock_service_key, scheduler_service_key; and book_timezone
("Europe/Amsterdam") for the Book's due/remind handling.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add NewsHeadline and NewsResponse schemas
- Add NewsService for parsing news from Qdrant volatile collection
- Add GET /tools/news endpoint to tools controller
- News fetched from 'news' namespace in volatile_{user} collection
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Forecast: Check 'daily' key first (scheduler stores count in 'days')
- Sun times: Use ISO fields, calculate daylight from various sources
- Air quality: Support aqi_us/aqi_european and nitrogen_dioxide fields
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
python-jose jwt.decode() requires audience as string or None, not list.
Now extract and validate audience from unverified claims first,
then use token's actual audience for JWT decode.
Fixes "audience must be a string or None" error.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Changed oidc_audience (string) to oidc_audiences (list)
- Now accepts tokens with audience: core-api, tatlock-ui, tatlock
- Fixes environment endpoint returning "default" user when using
tatlock-ui token (audience mismatch was causing JWT claims error)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove src/models/ollama_client.py, embeddings.py, embeddings_ollama.py
- Remove model aliases and AI config from settings (both config.py files)
- Update health endpoints to only check database connectivity
- Update tests to reflect database-only health checks
- Update README, .env.example, and OIDC docstrings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The domains.auth.oidc module had its own oidc_config instance that
wasn't being configured, causing environment endpoint to always use
hardcoded "local" user instead of authenticated user.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The environment endpoint was importing from non-existent path
`src.oidc.dependencies` instead of `src.auth.oidc`, causing
authentication to fail and queries to go to wrong Qdrant collection.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add GET /tools/environment endpoint that fetches weather, forecast, sun times,
and air quality data from user's volatile Qdrant collection.
- Add qdrant-client dependency
- Create QdrantReadClient wrapper for read-only queries
- Add environment schemas and service in tools domain
- Parse weather, forecast, sun times, and air quality from Qdrant payloads
- Support user-specific collections via preferred_username from OIDC
- Add comprehensive service tests (13 tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Initialize user.roles=[] and user.preferences on new user creation
to avoid MissingGreenlet error in async context.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Authentik JWT sub claim may not be a valid UUID.
Now derives a deterministic UUID from the sub string if parsing fails.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Added get_current_user_or_forward_auth() combined dependency that:
- First checks for X-authentik-* headers from NPM forward auth (web)
- Falls back to JWT Bearer token validation (mobile/native)
This fixes web authentication where browsers don't send Bearer tokens
but rely on NPM's forward auth proxy to pass user info via headers.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When allow_credentials=True, browsers reject wildcard (*) origins.
Added specific allowed origins for Tatlock domains.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add endpoint to get current user profile from NPM forward auth headers.
Enables web authentication flow where NPM handles Authentik login.
- Read X-authentik-* headers set by NPM forward auth
- Auto-create user if not in database (first login via web)
- Sync roles from current Authentik groups
- Add get_user_by_email and get_user_by_authentik_id helpers
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add user profile, preferences, and API key management endpoints:
- GET /auth/users/me - full user profile with roles and preferences
- GET/PATCH /auth/users/me/preferences - user preferences management
- GET/POST/DELETE /auth/users/me/api-keys - API key lifecycle
API keys use tak_ prefix, SHA-256 hashing, and are shown only once on creation.
Preferences support partial updates with JSON merge behavior.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Architecture changes:
- Permission format: domain.category:action (e.g., control-room.general:admin)
- Decoupled groups from roles via group_roles mapping table
- Groups are organizational (synced from Authentik)
- Roles are permissions (admin-managed via API)
New features:
- require_permission() and require_any_permission() dependency factories
- Action hierarchy: admin > editor > user > viewer
- Global admin override (admin.general:admin grants all)
- Group-role management endpoints (assign/remove roles)
- GET /auth/roles endpoint to list all roles
Database changes:
- Added category column to roles table (default: general)
- Removed authentik_group column (decoupled)
- Added group_roles association table
- Added user_groups association table
- Migration updates role names to domain.general:action format
Tests:
- 67 new tests for auth service and controller
- Covers token validation, user sync, role sync
- Covers group-role assignment/removal
- Covers schema conversions and permission system
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- GET /tools/system/stats - Real-time host system statistics
- CPU usage, memory, all mounted disks, network I/O
- GPU/VRAM stats via nvidia-smi (if available)
- Uses psutil for cross-platform host metrics
- Auto-discovers and filters real filesystems
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds link_type column to quick_links table for iframe vs new_tab behavior.
Includes Alembic migration and schema updates.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Dashboard domain with Quick Links CRUD + reorder endpoints
- Dashboard widgets management endpoints
- Database migrations for quick_links and dashboard_widgets tables
- Static file controller for Organizr widgets
- Default local user when OIDC is disabled
- Domain-based architecture refactor (src/domains/, src/shared/)
- Test suite updated for new structure (285 tests passing)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- GET /auth/groups - list groups with search/pagination
- POST /auth/groups/sync-from-authentik - bulk sync from Authentik
- Group model and database migration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove hardcoded default URLs (portainer, npm, ollama, etc.)
- All external service URLs now required via ENV vars
- Remove obsolete ai_client and ai_controller (core-ai proxy)
- Clean up health_controller obsolete core-ai references
- App fails fast at startup if required config missing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Simplify root endpoint to return minimal status info
- Remove obsolete endpoint listings (moved to /docs)
- Clean up verbose app description
- Disable redoc UI
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove _list_containers_via_socket method
- Remove _inspect_container_via_socket method
- Simplify list_containers and inspect_container to use Portainer API only
- Raise RuntimeError when no Portainer endpoints available
BREAKING: Portainer API configuration is now required for all container
and stack operations. Set PORTAINER_URL and PORTAINER_API_KEY env vars.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove credentials.py import, use pydantic-settings .env support
- Remove unused search API keys (Brave, Google)
- Update default search provider to SearXNG
- Add extra="ignore" to allow flexible env var usage
- Update .env.example with organized sections
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>