Compare commits
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"env": {
|
||||
"PQL_VAULT": "/mnt/media/Projects/core-api"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pql)",
|
||||
"Bash(pql *)",
|
||||
"Bash(/home/jpmschweitzer/.local/bin/pql:*)",
|
||||
"Bash(git status:*)",
|
||||
"Bash(git log:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git branch:*)",
|
||||
"Bash(.venv/bin/python -m pytest:*)",
|
||||
"Bash(.venv/bin/pytest:*)",
|
||||
"Bash(pytest:*)",
|
||||
"Bash(alembic *)",
|
||||
"Bash(docker logs core-api:*)",
|
||||
"Bash(curl -s http://localhost:8083/*)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj)",
|
||||
"Bash(/mnt/media/Projects/cladmin/ops/bin/toj:*)",
|
||||
"Bash(alembic downgrade base*)",
|
||||
"Bash(chmod -R 777 *)",
|
||||
"Bash(chmod 777 *)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(find * -delete*)",
|
||||
"Bash(find * -exec*)",
|
||||
"Bash(git * add --all*)",
|
||||
"Bash(git * add -A*)",
|
||||
"Bash(git * add .)",
|
||||
"Bash(git * branch -D *)",
|
||||
"Bash(git * checkout -- *)",
|
||||
"Bash(git * clean -fd*)",
|
||||
"Bash(git * clean -fdx*)",
|
||||
"Bash(git * commit --no-verify*)",
|
||||
"Bash(git * merge --no-ff*)",
|
||||
"Bash(git * push --force*)",
|
||||
"Bash(git * push -f*)",
|
||||
"Bash(git * reset --hard*)",
|
||||
"Bash(git * restore .*)",
|
||||
"Bash(git add --all*)",
|
||||
"Bash(git add -A*)",
|
||||
"Bash(git add .)",
|
||||
"Bash(git branch -D *)",
|
||||
"Bash(git checkout -- *)",
|
||||
"Bash(git clean -fd*)",
|
||||
"Bash(git clean -fdx*)",
|
||||
"Bash(git commit --no-verify*)",
|
||||
"Bash(git merge --no-ff*)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(git push -f*)",
|
||||
"Bash(git reset --hard*)",
|
||||
"Bash(git restore .*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(psql * -c DROP*)",
|
||||
"Bash(psql * DROP DATABASE*)",
|
||||
"Bash(psql * TRUNCATE*)",
|
||||
"Bash(rm -rf $HOME)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(su *)",
|
||||
"Bash(sudo *)",
|
||||
"Bash(toj)",
|
||||
"Bash(toj:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.pql/changelog/*.sql merge=union
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.schweitz.internal
|
||||
registry: git.schweitz.net
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
@@ -36,8 +36,8 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
git.schweitz.internal/jpmschweitzer/core-api:latest
|
||||
git.schweitz.internal/jpmschweitzer/core-api:${{ github.ref_name }}
|
||||
git.schweitz.net/jpmschweitzer/core-api:latest
|
||||
git.schweitz.net/jpmschweitzer/core-api:${{ github.ref_name }}
|
||||
|
||||
- name: Trigger Watchtower update
|
||||
if: success()
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Trigger only. The checks live in the Makefile, where they can be read, run by
|
||||
# hand (`make pre-push`), and changed under review.
|
||||
#
|
||||
# This file is identical in every repo in this workspace, deliberately: the call
|
||||
# surface is the same everywhere even though what each gate runs is not, so
|
||||
# nobody has to read a repo to find out how to check it (D-27).
|
||||
#
|
||||
# Enable per clone with: git config core.hooksPath .githooks
|
||||
# Never bypass with --no-verify. Suppress a specific finding deliberately
|
||||
# instead, with a reason — see `make pre-push`.
|
||||
set -euo pipefail
|
||||
exec make -C "$(git rev-parse --show-toplevel)" pre-push
|
||||
+17
@@ -136,3 +136,20 @@ Thumbs.db
|
||||
|
||||
# profiling data
|
||||
.prof
|
||||
|
||||
# Claude Code local settings (machine-specific, may contain credentials)
|
||||
.claude/settings.local.json
|
||||
|
||||
# pql — ignore everything except the changelog, which is the replication log of
|
||||
# record and must be committed for tickets to travel with the repo.
|
||||
.pql/*
|
||||
!.pql/changelog/
|
||||
|
||||
|
||||
# pql shims planted by `pql init` into the dir core.hooksPath points at.
|
||||
# Per-clone: each embeds the absolute path of the pql binary that planted it.
|
||||
# Only .githooks/pre-push is shared.
|
||||
.githooks/pre-commit
|
||||
.githooks/post-merge
|
||||
.githooks/post-checkout
|
||||
.githooks/post-rewrite
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Changelog format marker, written by pql. Comments only: this file
|
||||
-- is never executed — Import descends into the per-table directories
|
||||
-- and does not read the changelog root.
|
||||
--
|
||||
-- A changelog carrying no marker is format 1, the shape that existed
|
||||
-- before formats were versioned. An older format is migrated forward
|
||||
-- by `pql plan upgrade` (and automatically from the post-merge hook);
|
||||
-- a newer one is refused rather than replayed under rules this binary
|
||||
-- does not know. See D-28 and docs/versions.md.
|
||||
-- pql:changelog_format: 2.0.0
|
||||
-- pql:written_by: 2.2.0
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,93 @@
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'description', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||
|
||||
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||
|
||||
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.
|
||||
|
||||
**Code done 2026-10-19**, commits `04ec54b`…`63feb88` (round core-api-T-1-001): `make test` 381 → 430 passed; migration 005 round-trips on `core_api_test`; production `core_api` still at 004; mutation-checked (key compare, the housekeeping lock, the append-only trigger). Follow-ups: T-2 (remaining verbs, short ids, scope tags — amends 005 before its first deploy), T-3 (alembic target). Held for the milestone.', NULL, '2026-10-19 09:20:45', '2026-10-19 09:20:45.376', '2026-10-19 09:20:45.376', NULL, 'f74c7130a534e2a97136986fb545eedd', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'description', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.', NULL, '2026-10-19 10:36:41', '2026-10-19 10:36:41.675', '2026-10-19 10:36:41.675', NULL, '2f858098d03ce3b28a64f4dcbdbea26a', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'description', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||
|
||||
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.', NULL, '2026-10-19 10:44:56', '2026-10-19 10:44:56.770', '2026-10-19 10:44:56.770', NULL, '5a9e8eb56b5634bdb34d36f9b5a6cdbf', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'description', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.
|
||||
|
||||
**Code done 2026-10-19** (round core-api-T-4-001): `0453d08`, `ce2ec61` — `Caller` carries consumer and tenant; the Office must send `X-Office-Consumer` and `X-Office-Tenant` on every call (400 `consumer_required`/`_invalid`, `tenant_required`/`_invalid`); other kinds ignore both headers; a JWT user''s tenant comes only from its own `tatlock_user` claim; the consumer lands in `book_events.client`. `make test` 430 → 451; mutation-checked (tenant requirement, header spoofing). Held — deploys with core-api T-1 at the milestone.', NULL, '2026-10-19 11:46:47', '2026-10-19 11:46:47.566', '2026-10-19 11:46:47.566', NULL, '1c452fa8a2ac5f5c326e9bf08e7981b6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'status', 'backlog', 'done', NULL, '2026-10-19 11:46:47', '2026-10-19 11:46:47.688', '2026-10-19 11:46:47.688', NULL, '877161504860711bfc61a86a6ed44e46', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'description', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||
|
||||
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||
|
||||
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||
|
||||
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.', NULL, '2026-10-19 11:46:47', '2026-10-19 11:46:47.827', '2026-10-19 11:46:47.827', NULL, '1cf3bed3f8970e8f70a0b78845c75c35', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'description', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.
|
||||
|
||||
**Code done 2026-10-19** (round core-api-T-5-001): `ccdbc44`, `93b00b6` — migration 006 (`scribe_addresses` keyed on tenant + name, retained `scribe_messages` with full text, `scribe_mailbox_entries`), routes register, list, send, mailbox, history, find and one PATCH for mark/tag/untag; `from` stamped from the caller; every query filtered by tenant, another tenant''s ids answered as missing; an entry not addressed to the caller''s identity answers 404 even inside the tenant. `make test` 451 → 489; mutation-checked (tenant filter, from-stamping, ownership). Held for release. **The round printed the `core_api_test` password into its output; the lead rotated it** (role and `.env`, never printed; tests pass on the new one).', NULL, '2026-10-19 13:24:10', '2026-10-19 13:24:10.811', '2026-10-19 13:24:10.811', NULL, 'a43a09f6533c7433c348d6a15f669920', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'status', 'backlog', 'done', NULL, '2026-10-19 13:24:10', '2026-10-19 13:24:10.937', '2026-10-19 13:24:10.937', NULL, '07a38b83b95b5a7d62f1ef4704408375', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'description', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||
|
||||
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||
|
||||
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||
|
||||
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||
|
||||
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||
|
||||
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.', NULL, '2026-10-19 13:24:11', '2026-10-19 13:24:11.249', '2026-10-19 13:24:11.249', NULL, 'fe81a166cb0353f3a1e0747758c04e44', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'description', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||
|
||||
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||
|
||||
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
|
||||
|
||||
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', NULL, '2026-10-19 14:06:27', '2026-10-19 14:06:27.325', '2026-10-19 14:06:27.325', NULL, '0e233f93ee1e85162d05109749d33032', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'status', 'backlog', 'done', NULL, '2026-10-19 14:51:47', '2026-10-19 14:51:47.496', '2026-10-19 14:51:47.496', NULL, '625ca10cfc9bcaf576a2fd52a3fe2792', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'T-1', '2026-10-19 08:37:53.567', '2026-10-19 08:37:53.567', NULL, '624cc203712b63205f39eecd40c86d95', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'T-2', '2026-10-19 09:20:45.104', '2026-10-19 09:20:45.104', NULL, '6d58bd9debd724db63e6067a82013b5f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'T-3', '2026-10-19 09:20:45.230', '2026-10-19 09:20:45.230', NULL, 'fdd572c5cfad3a68c1477ecede1db7f4', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'T-4', '2026-10-19 11:25:10.777', '2026-10-19 11:25:10.777', NULL, 'e30571204475f25d624a8d593829cda9', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'T-5', '2026-10-19 12:57:09.632', '2026-10-19 12:57:09.632', NULL, 'a8774787d37deb533e701914864fd560', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8NBWVQ53ZX7WMZNE5GJJP4', 'T-6', '2026-10-19 13:24:11.102', '2026-10-19 13:24:11.102', NULL, 'f6351f2d6190ae5fbd9e55e6eadc04e5', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'T-7', '2026-10-19 14:06:27.193', '2026-10-19 14:06:27.193', NULL, '09267e8234ec21c6d75767d7cd21bb51', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'T-8', '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, 'd8c7f6122652cb1c3e448f1adedd54ab', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,139 @@
|
||||
-- Auto-generated by pql init. CREATE TABLE statements
|
||||
-- for the planning schema; per-table dir keeps the changelog
|
||||
-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is
|
||||
-- idempotent so running schema files from each directory in
|
||||
-- replay order is harmless.
|
||||
--
|
||||
-- Importer parses the markers below to detect schema drift
|
||||
-- between the producing pql version and the local one — a
|
||||
-- bumped canonical_version means projection rules changed
|
||||
-- and replay must refuse rather than silently corrupt state.
|
||||
-- pql:created_by: 2.2.0
|
||||
-- pql:canonical_version: 2
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')),
|
||||
domain TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK(status IN ('active','superseded','resolved','open')),
|
||||
date TEXT,
|
||||
file_path TEXT NOT NULL,
|
||||
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_refs (
|
||||
source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE,
|
||||
ref_type TEXT NOT NULL
|
||||
CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')),
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (source_id, target_id, ref_type)
|
||||
);
|
||||
|
||||
-- Identity split (D-26): a ticket's stable, collision-proof identity is its
|
||||
-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly
|
||||
-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural
|
||||
-- reference (parent, deps, history, labels) targets record_id, so a label
|
||||
-- clash never corrupts the graph — only ticket_idmap needs a relabel.
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
record_id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')),
|
||||
parent_record_id TEXT REFERENCES tickets(record_id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- No CHECK enumeration: the ticket status vocabulary is per-vault
|
||||
-- configurable (ticket_statuses in .pql/config.yaml). Validation lives
|
||||
-- in Go (planning.StatusSet), so adding/renaming statuses needs no
|
||||
-- schema change. The DEFAULT is a harmless fallback — CreateTicket
|
||||
-- always inserts the configured default explicitly.
|
||||
status TEXT NOT NULL DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium'
|
||||
CHECK(priority IN ('critical','high','medium','low')),
|
||||
assigned_to TEXT,
|
||||
team TEXT,
|
||||
decision_ref TEXT REFERENCES decisions(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
-- ticket_idmap maps a record_id to its current friendly label (T-NNN).
|
||||
-- ticket_id is intentionally NOT globally unique: two uncoordinated clones
|
||||
-- can mint the same label, which surfaces as a duplicate-label collision
|
||||
-- (detected at replay) and is fixed with "pql ticket relabel".
|
||||
CREATE TABLE IF NOT EXISTS ticket_idmap (
|
||||
record_id TEXT PRIMARY KEY REFERENCES tickets(record_id),
|
||||
ticket_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_deps (
|
||||
blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (blocker_record_id, blocked_record_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_history (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_by TEXT,
|
||||
changed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT UNIQUE,
|
||||
canonical_version INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_labels (
|
||||
ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id),
|
||||
label TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
hash TEXT,
|
||||
canonical_version INTEGER,
|
||||
PRIMARY KEY (ticket_record_id, label)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id);
|
||||
@@ -0,0 +1,96 @@
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'task', NULL, 'Caller auth, locked housekeeping routes, and the Book domain', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||
|
||||
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 08:37:53.566', '2026-10-19 08:37:53.566', NULL, '2caf93d2159440ddba20ebe336115a3c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 09:20:45.104', NULL, '9034c13962b3a44d6572e1e2d92e3ab3', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||
|
||||
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 09:20:45.229', NULL, 'b096eb96b790b241405fe4703d4911af', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6KV13SSS2KZP10SKTT903C', 'task', NULL, 'Caller auth, locked housekeeping routes, and the Book domain', 'Caller authentication for service callers, the housekeeping routes locked behind it, and the `book` domain with migration 005 — the first half of the household''s Office storage. Driven by workspace T-166; the design is workspace D-64…D-72 (`cladmin/governance/decisions/office.md`), above all D-68 (the Book lives here), D-70 (the verbs) and D-71.
|
||||
|
||||
- `require_caller()`: the Office''s service key with a forwarded identity, the Scheduler''s key (dispatch only), else an Authentik JWT. On every new route and every housekeeping route — core-api is internet-facing at api.schweitz.net with no forward-auth, and `/housekeeping/devices` answers unauthenticated today.
|
||||
- `src/domains/book/` and migration 005: records (note, task, decision, question, rejected), tags with system tags `work`/`personal`, links, attachments, append-only events.
|
||||
|
||||
**Code done 2026-10-19**, commits `04ec54b`…`63feb88` (round core-api-T-1-001): `make test` 381 → 430 passed; migration 005 round-trips on `core_api_test`; production `core_api` still at 004; mutation-checked (key compare, the housekeeping lock, the append-only trigger). Follow-ups: T-2 (remaining verbs, short ids, scope tags — amends 005 before its first deploy), T-3 (alembic target). Held for the milestone.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 08:37:53.566', '2026-10-19 09:20:45.375', NULL, '27ad92892863649997bb7a0ca147cbed', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 10:36:41.674', NULL, '39efc332e98d1f72eff1763b33125b19', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||
|
||||
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 10:44:56.770', NULL, 'd930f0dbc1197dff3600619b31a6b0c6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'task', NULL, 'The caller carries consumer and tenant from the Office', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 11:25:10.777', '2026-10-19 11:25:10.777', NULL, 'e73c46c5e77917c049e92ff1f627f447', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'task', NULL, 'The caller carries consumer and tenant from the Office', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.
|
||||
|
||||
**Code done 2026-10-19** (round core-api-T-4-001): `0453d08`, `ce2ec61` — `Caller` carries consumer and tenant; the Office must send `X-Office-Consumer` and `X-Office-Tenant` on every call (400 `consumer_required`/`_invalid`, `tenant_required`/`_invalid`); other kinds ignore both headers; a JWT user''s tenant comes only from its own `tatlock_user` claim; the consumer lands in `book_events.client`. `make test` 430 → 451; mutation-checked (tenant requirement, header spoofing). Held — deploys with core-api T-1 at the milestone.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 11:25:10.777', '2026-10-19 11:46:47.566', NULL, 'ed07dd9dcd17801b51cbbf8cb3145451', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN7T48Z610867H891MZCVGA4', 'task', NULL, 'The caller carries consumer and tenant from the Office', '`require_caller()` (T-1) learns who the Office''s caller is beyond a declared identity: the **consumer** (the Authentik client the request was signed in through, e.g. `office-claude`) and the **tenant** (the signed `tatlock_user` claim, e.g. `jpmschweitzer`, `llm_tester`). The Office has already verified the token; core-api trusts these only alongside the Office''s service key, as it does the identity. Driven by workspace T-185; design in workspace D-73.
|
||||
|
||||
**Code done 2026-10-19** (round core-api-T-4-001): `0453d08`, `ce2ec61` — `Caller` carries consumer and tenant; the Office must send `X-Office-Consumer` and `X-Office-Tenant` on every call (400 `consumer_required`/`_invalid`, `tenant_required`/`_invalid`); other kinds ignore both headers; a JWT user''s tenant comes only from its own `tatlock_user` claim; the consumer lands in `book_events.client`. `make test` 430 → 451; mutation-checked (tenant requirement, header spoofing). Held — deploys with core-api T-1 at the milestone.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 11:25:10.777', '2026-10-19 11:46:47.688', NULL, '4fbb627f0d1f48350ebd4d97fc64fd94', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||
|
||||
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||
|
||||
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 11:46:47.827', NULL, '3d53cf5d73b33f32d80232456e23f5ad', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'task', NULL, 'The Scribe''s messaging: addresses, send, mailbox, history, find, mark, tag', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 12:57:09.632', '2026-10-19 12:57:09.632', NULL, 'ae66ca9641212daeb46527917c324175', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'task', NULL, 'The Scribe''s messaging: addresses, send, mailbox, history, find, mark, tag', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.
|
||||
|
||||
**Code done 2026-10-19** (round core-api-T-5-001): `ccdbc44`, `93b00b6` — migration 006 (`scribe_addresses` keyed on tenant + name, retained `scribe_messages` with full text, `scribe_mailbox_entries`), routes register, list, send, mailbox, history, find and one PATCH for mark/tag/untag; `from` stamped from the caller; every query filtered by tenant, another tenant''s ids answered as missing; an entry not addressed to the caller''s identity answers 404 even inside the tenant. `make test` 451 → 489; mutation-checked (tenant filter, from-stamping, ownership). Held for release. **The round printed the `core_api_test` password into its output; the lead rotated it** (role and `.env`, never printed; tests pass on the new one).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 12:57:09.632', '2026-10-19 13:24:10.810', NULL, '5351a56b3c5e6fb9594f0b2d87717774', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8F5Z00KKNGTADK9BHG99N8', 'task', NULL, 'The Scribe''s messaging: addresses, send, mailbox, history, find, mark, tag', 'The messaging half of the Scribe, the household''s message bus: addresses, sending, mailboxes, history, search, read state and tags — enough for the first end-to-end flow through the Office. Driven by workspace T-186; design in workspace D-66 and D-73. Builds on T-1''s caller and T-4''s consumer and tenant.
|
||||
|
||||
**Code done 2026-10-19** (round core-api-T-5-001): `ccdbc44`, `93b00b6` — migration 006 (`scribe_addresses` keyed on tenant + name, retained `scribe_messages` with full text, `scribe_mailbox_entries`), routes register, list, send, mailbox, history, find and one PATCH for mark/tag/untag; `from` stamped from the caller; every query filtered by tenant, another tenant''s ids answered as missing; an entry not addressed to the caller''s identity answers 404 even inside the tenant. `make test` 451 → 489; mutation-checked (tenant filter, from-stamping, ownership). Held for release. **The round printed the `core_api_test` password into its output; the lead rotated it** (role and `.env`, never printed; tests pass on the new one).', 'done', 'high', NULL, NULL, NULL, '2026-10-19 12:57:09.632', '2026-10-19 13:24:10.937', NULL, 'a75c4791f0cf9c91a56afc65e474bd43', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8NBWVQ53ZX7WMZNE5GJJP4', 'bug', NULL, 'User-kind callers have no consumer, so their address ownership matches any other''s', 'Callers of kind `user` (an Authentik user straight to core-api, e.g. tatlock-ui later) carry no consumer, so `scribe_addresses.owner_consumer` is `NULL` for anything they register — and ownership checks compare consumers, so two different user-kind callers in one tenant would each pass as owner of the other''s addresses (`None == None`). `book_events.client` has the same nullable shape. Not reachable through the Office (every Office call carries a real consumer since T-4), so not on the messaging slice''s path — but it must be closed before any user-kind caller registers or sends. Fix: give a user-kind caller a consumer of its own (e.g. `user:<preferred_username>`), or refuse address ownership to callers without one. Found in the T-5 round''s report.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 13:24:11.102', '2026-10-19 13:24:11.102', NULL, 'd7e0fab068383f77f9a0dd0b7b421a92', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMYNQF0VP25QMY7YTB41W', 'task', NULL, 'Alembic takes its target from production settings; name it instead', '`alembic/env.py` always takes its target from `get_settings().database_url`, composed from `.env`''s `POSTGRES_*` — which point at **production** `core_api`. `get_settings()` is `lru_cache`d and is warmed by anything that imports `src.main`, so a test that needs to migrate `core_api_test` has to run alembic in a subprocess with every `POSTGRES_*` overridden. T-1''s tests do exactly that, correctly — but one missed override migrates production.
|
||||
|
||||
Fix: honour `-x db_url=…` (or a `TEST_DATABASE_URL`-style variable) in `env.py` before falling back to settings, and have the migration tests use it, so the target is named rather than inherited. Found by the T-1 round''s friction report.
|
||||
|
||||
Related, from the T-4 round: there is no make target to start a dev server against `core_api_test` — the round built the `POSTGRES_*` overrides from `TEST_DATABASE_URL` by hand, carefully unprinted. A `make dev-test-server` that does it would remove a recurring hand-built step that one slip turns into a run against production.
|
||||
|
||||
Raised again by the T-5 round: every round rebuilds the `:8777` dev server against `core_api_test` by hand, and the T-5 round leaked the test password doing it. Make this target the next thing core-api does.', 'backlog', 'medium', NULL, NULL, NULL, '2026-10-19 09:20:45.229', '2026-10-19 13:24:11.248', NULL, '54942cb45b6635f1d21a053e8a8e25c6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'task', NULL, 'The Book''s final schema and tenant boundary, before 005''s first release', 'The Book''s final schema and its tenant boundary, before core-api''s first release applies migration 005 to production. Split from T-2 (whose remaining verbs stay there). Amend 005 — never run in production — rather than add a migration: per-tenant short outward numbers ("Task 12", workspace D-67), an owner tenant on every record (D-73), and every Book route filtered by it, so the routes are safe to expose the moment they deploy.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 14:06:27.192', '2026-10-19 14:06:27.192', NULL, 'cd22ba2ca9a28b7690305eda1f8153b4', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN6XMY61H4P86X0TKGD3D37W', 'task', NULL, 'The Book''s remaining verbs, short outward ids, and work/personal as tags', 'The rest of the Book''s verbs (workspace D-70), and two corrections to what T-1 built — all before the Book''s first deploy. Driven by workspace T-166/T-167.
|
||||
|
||||
**Corrections, found reviewing T-1 (2026-10-19):**
|
||||
- **Outward ids.** Records use UUIDs, but the Office names items in words — "Task 12", "Note 40" (workspace D-67). Add a short sequential `number` per record (unique, never reused) and accept it wherever a UUID is accepted. Migration 005 has never run against production `core_api` (still at 004), so **amend 005** rather than adding 006 — and re-prove its round trip on `core_api_test`.
|
||||
- **`work` and `personal` as tags.** `tag`/`untag` treat them as free text; they must map onto the `scope` column (tagging `work` sets scope work; untagging the only scope is refused with a code that names the fix), so the outward "baked-in tags" of D-69 hold.
|
||||
|
||||
**The remaining verbs:** `append`, `remind`, `reschedule` (batch), `undo` (this identity''s last change, from `book_events`), `purge` (from trash only), `redact` (scrubs a value from the record and its events — the one audited exception to the append-only trigger), `pin`/`unpin`, `check`/`uncheck`, `snooze`, `archive`/`unarchive`, `trash`/`restore`, `pause`/`resume` (a project and its descendants through `parent`; statuses saved in `paused_statuses` and restored exactly), `due`, `next` (filters energy, estimate, context tags, scope); batch forms for every note- and write-class verb; an idempotency key on `add`.
|
||||
|
||||
**Add an owner (D-73).** Book records need an `owner` — the tatlock tenant id from the token''s signed `tatlock_user` claim (production `jpmschweitzer`), forwarded by the Office — and every query scoped by it. Not in migration 005 as written; add it while amending 005. The Scribe''s tables (workspace T-176) carry the same owner on addresses.
|
||||
|
||||
**Isolation (D-73).** Every Book query and write filters on the owner tenant; a test proves an `llm_tester` caller cannot read, change, link to or close a `jpmschweitzer` record (and the reverse) — the error is `not_found`, so another tenant''s ids are not even confirmed to exist.
|
||||
|
||||
**Split 2026-10-19:** the short outward ids and the owner tenant (amending 005), and the Book''s tenant filter, moved to **T-7**, so core-api can release safely. T-2 keeps the remaining verbs and work/personal as tags.', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 09:20:45.104', '2026-10-19 14:06:27.324', NULL, '58063dda134a948a461cd97325286e48', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN99CB552PSF82EE2P8PJZP0', 'story', NULL, 'The tenant registry: tenants, consumer grants, and one enforcement point', 'The tenant registry: one table that says which tenants exist, which consumers may act in each, and whether a tenant is active — the anchor every service''s tenancy conforms to (workspace D-73).
|
||||
|
||||
Today a tenant is an unchecked string: the Office header or a JWT''s `tatlock_user` claim is trusted as-is, and the Book and the Scribe file rows under whatever value arrives. A typo''d claim opens a fresh, empty namespace instead of being refused.
|
||||
|
||||
**Tables (migration 007):**
|
||||
- `tenants` — `id` (the slug, the value of `tatlock_user`; same pattern `_require_office_tenant` accepts), `display_name`, `kind` (`household` | `person` | `test`), `status` (`active` | `suspended`), timestamps. Seeded: `jpmschweitzer` (household), `llm_tester` (test).
|
||||
- `tenant_consumers` — a grant: consumer X may act in tenant Y. PK `(tenant_id, consumer)`. Seeded: `office-claude` in both seeded tenants.
|
||||
- Foreign keys from every `owner_tenant` column in the Book and the Scribe to `tenants.id`.
|
||||
|
||||
**Enforcement:** every tenant-scoped operation goes through one helper. No tenant → 400 `tenant_required`. Tenant unknown → 403 `tenant_unknown`. Tenant suspended → 403 `tenant_suspended`. Office caller whose consumer holds no grant in that tenant → 403 `consumer_not_granted`.
|
||||
|
||||
**Read API, for other services to conform to:** `GET /tenants/me` (the caller''s own tenant and its consumer''s grant); `GET /tenants` (service callers without a tenant see all; a tenant-bearing caller sees only its own — the hard boundary).
|
||||
|
||||
**Not in v1:** write routes for provisioning (T-195 on the workspace board adds them, or an operator verb); per-consumer verb classes (workspace T-184 — a column on `tenant_consumers` when it lands).', 'backlog', 'high', NULL, NULL, NULL, '2026-10-19 14:51:37.641', '2026-10-19 14:51:37.641', NULL, '756a467f5dc4a84c7ee9080ef21ca5fb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GN8Z1FF1QDGR4QE4405AP8GC', 'task', NULL, 'The Book''s final schema and tenant boundary, before 005''s first release', 'The Book''s final schema and its tenant boundary, before core-api''s first release applies migration 005 to production. Split from T-2 (whose remaining verbs stay there). Amend 005 — never run in production — rather than add a migration: per-tenant short outward numbers ("Task 12", workspace D-67), an owner tenant on every record (D-73), and every Book route filtered by it, so the routes are safe to expose the moment they deploy.', 'done', 'high', NULL, NULL, NULL, '2026-10-19 14:06:27.192', '2026-10-19 14:51:47.496', NULL, '8797eb16319aa33c8317b0a1f0defc3d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
@@ -1,72 +0,0 @@
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
> **Start every session by reading this file.**
|
||||
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
|
||||
|
||||
## 1. Agent Operational Protocols
|
||||
|
||||
### 🧠 Work Patterns (Plan-Act-Reflect)
|
||||
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
|
||||
* **Act:** Execute the changes in small, atomic steps.
|
||||
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
|
||||
|
||||
### 🛡️ Git Discipline
|
||||
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
|
||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||
* `feat: add user login endpoint`
|
||||
* `fix: resolve database connection timeout`
|
||||
* `refactor: split monolith dependency file`
|
||||
* **Atomic Commits:** Keep commits small. One logical change = one commit.
|
||||
|
||||
### 📝 Changelog Maintenance
|
||||
* **Update `CHANGELOG.md`** with every user-facing change.
|
||||
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
|
||||
|
||||
### 🚀 Release Flow
|
||||
When changes are ready for deployment:
|
||||
|
||||
1. **Ask user if deploy cycle is desired **
|
||||
|
||||
2. **Update version** in `pyproject.toml`:
|
||||
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
|
||||
- New features: bump minor version (1.8.4 → 1.9.0)
|
||||
|
||||
3. **Update CHANGELOG.md**:
|
||||
- Move items from `[Unreleased]` to new version section
|
||||
- Add release date: `## [1.8.4] - 2025-12-16`
|
||||
|
||||
4. **Commit and tag**:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: description of changes"
|
||||
git tag v1.8.4
|
||||
git push origin main --tags
|
||||
```
|
||||
|
||||
5. **CI/CD triggers automatically**:
|
||||
- Gitea CI builds Docker image on new tag
|
||||
- Watchtower pulls and deploys to production
|
||||
- Verify deployment: `curl http://192.168.86.149:8083/health`
|
||||
|
||||
---
|
||||
|
||||
## 2. FastAPI Architecture & Best Practices
|
||||
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
|
||||
|
||||
### 📂 Project Structure (Directory-based, NOT File-type based)
|
||||
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
|
||||
|
||||
**Correct Structure:**
|
||||
```text
|
||||
src/
|
||||
├── auth/
|
||||
│ ├── router.py # Endpoints
|
||||
│ ├── schemas.py # Pydantic models
|
||||
│ ├── service.py # Business logic (CRUD, etc.)
|
||||
│ ├── dependencies.py# Module-specific dependencies
|
||||
│ └── config.py # Module-specific settings
|
||||
├── posts/
|
||||
│ ├── router.py
|
||||
│ └── ...
|
||||
└── main.py # App entry point
|
||||
+175
@@ -5,6 +5,181 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.12.0] - 2026-10-19
|
||||
|
||||
### Added
|
||||
|
||||
- **The tenant registry** (`src/domains/tenants/`, core-api T-8 / workspace
|
||||
D-73) - `tenants` and `tenant_consumers` (migration 007), and
|
||||
`TenantsService.require_tenant`, the one enforcement point Book's and
|
||||
Scribe's own `_require_tenant` now delegate to instead of checking only
|
||||
presence: a tenant that is not in the table is 403 `tenant_unknown`, one
|
||||
marked `suspended` is 403 `tenant_suspended`, and an Office caller whose
|
||||
consumer holds no grant in that tenant is 403 `consumer_not_granted` -
|
||||
closing the gap where a typo'd `tatlock_user` claim opened a fresh, empty
|
||||
namespace rather than being refused. Every existing `owner_tenant` column
|
||||
(`book_records`, `book_tenant_counters`, `scribe_addresses`,
|
||||
`scribe_messages`, `scribe_mailbox_entries`) now carries a foreign key to
|
||||
`tenants.id` (`ON DELETE RESTRICT`). Migration 007 seeds the two tenants
|
||||
in use today - `jpmschweitzer` (household) and `llm_tester` (test) - both
|
||||
granted to `office-claude`, the only Office consumer configured in
|
||||
Authentik. 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, never revealing
|
||||
another tenant's existence. No write routes - provisioning a tenant is
|
||||
later (workspace T-195).
|
||||
- **The Scribe** (`src/domains/scribe/`) - the household's message bus
|
||||
(dossier core-api T-5 / workspace D-66, D-68, D-73): `register`/`list`
|
||||
addresses, `send`, `mailbox`, `history`, `find`, `mark`, `tag`/`untag`, all
|
||||
under `/scribe/*`, behind `require_caller("office", "user")`. An address is
|
||||
a topic owned by a tenant and a consumer; `send`'s `from` is always the
|
||||
caller's own identity, never the request body. 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. Messaging only this round: groups, subscriptions, the
|
||||
notification matrix and delivery are later work (workspace T-176).
|
||||
Migration 006 creates `scribe_addresses`, `scribe_messages` (retained,
|
||||
full-text indexed), `scribe_mailbox_entries`.
|
||||
- **Caller carries consumer and tenant from the Office** (`src/domains/auth/
|
||||
callers.py`, core-api T-4 / workspace D-73) - `Caller` gains `consumer` and
|
||||
`tenant`. For an `office` caller both `X-Office-Consumer` and
|
||||
`X-Office-Tenant` 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.
|
||||
- **Caller auth** (`src/domains/auth/callers.py`) - the first thing in this repo
|
||||
that reads a service key. `require_caller()` resolves a static office/tatlock/
|
||||
scheduler key (constant-time compare, checked before any JWT parsing) or falls
|
||||
through to the existing OIDC path. New settings: `office_service_key`,
|
||||
`tatlock_service_key`, `scheduler_service_key`, `book_timezone`.
|
||||
- **The Book** (`src/domains/book/`) - Notes, Tasks, Decisions, Questions and
|
||||
Rejected options (workspace D-68/D-69/D-70). Routes: `add`, `show`, `list`,
|
||||
`find`, `change`, `close`, `link`/`unlink`, `tag`/`untag`, `history`, all under
|
||||
`/book/*`, behind `require_caller("office", "user")`. Migration 005 creates the
|
||||
whole schema: `book_records`, `book_tags`, `book_record_links`,
|
||||
`book_attachments`, `book_events` (append-only, DB trigger-enforced),
|
||||
`book_tenant_counters`.
|
||||
- **The Book is behind the tenant boundary** (core-api T-7 / workspace D-73) -
|
||||
every route now requires a tenant on the caller (400 `tenant_required` for
|
||||
a `user`-kind caller with no `tatlock_user` claim; an `office` caller
|
||||
already has one from every call per T-4) and filters on it: a record in
|
||||
another tenant answers exactly like one that does not exist (404
|
||||
`not_found`), in both directions, and `link`/`add`'s `parent` convenience
|
||||
refuse a target in another tenant the same way. `add` sets `owner_tenant`
|
||||
from the caller and allocates `number` - a short outward id numbered per
|
||||
tenant (each tenant's Book starts at 1, unique on `(owner_tenant,
|
||||
number)`, never reused), allocated via a per-tenant counter row
|
||||
(`book_tenant_counters`) locked `FOR UPDATE` in the same transaction as
|
||||
the insert. Responses include `number`; `list` accepts `number=` to find
|
||||
one. Migration 005 (unreleased - production was still at 004) is amended
|
||||
in place to add `book_records.owner_tenant`/`number` and
|
||||
`book_tenant_counters`, rather than patching them in with a later
|
||||
migration.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`X-Office-Client` is retired in favour of `X-Office-Consumer`** (core-api
|
||||
T-4) - the informational header is no longer read; `book_events.client`
|
||||
(no migration this round) now records the consumer instead.
|
||||
- **Housekeeping reads are locked** - `GET /housekeeping/*` (health, devices,
|
||||
areas, scenes, scripts, automations, history) now require a caller
|
||||
(`office`, `tatlock`, or `user`) instead of being unauthenticated. Writes are
|
||||
unchanged (`get_admin_user`). `api.schweitz.net` carries no forward-auth, so
|
||||
this was an open read of the house's device list once Home Assistant's token
|
||||
is restored (T-1 / D-68).
|
||||
|
||||
## [1.11.0] - 2026-01-08
|
||||
|
||||
### Added
|
||||
|
||||
- **News Headlines API** - New endpoint for news ticker integration
|
||||
- `GET /tools/news` - Fetch news headlines from user's volatile collection
|
||||
- Returns headlines with title, description, source, and URL
|
||||
- Data sourced from `volatile_{user}` Qdrant collection (news namespace)
|
||||
- Uses `preferred_username` from OIDC, falls back to `default`
|
||||
- News subdomain under tools (`src/domains/tools/news/`)
|
||||
- `NewsHeadline` and `NewsResponse` Pydantic schemas
|
||||
- `NewsService` for parsing news data from Qdrant
|
||||
|
||||
## [1.10.12] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Forecast data retrieval** - Pass raw_data to service instead of extracting wrong field
|
||||
- Qdrant client was extracting `days` (integer 7) instead of `daily` (list)
|
||||
- Now passes full raw_data for service to parse correctly
|
||||
|
||||
## [1.10.11] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Weather wind direction** - Convert integer degrees to cardinal direction string
|
||||
- Scheduler stores wind_direction as degrees (e.g., 135)
|
||||
- Schema expects string (e.g., "SE")
|
||||
- Added `_degrees_to_cardinal()` conversion
|
||||
|
||||
## [1.10.10] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Environment data parsing** - Fix parsing of scheduler-generated Qdrant data
|
||||
- Forecast: Check `daily` key first (scheduler stores day count in `days`, list in `daily`)
|
||||
- Sun times: Use `sunrise_iso`/`sunset_iso` fields, handle time-only format fallback
|
||||
- Sun times: Calculate daylight from `daylight_duration_seconds` or `daylight_hours`
|
||||
- Air quality: Support `aqi_us`/`aqi_european` and `nitrogen_dioxide` field names
|
||||
|
||||
## [1.10.9] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Environment user ID cleanup** - Strip email domain from user identifier
|
||||
- If `preferred_username` is an email, extract just the username part
|
||||
- Ensures Qdrant collection name matches (e.g., `volatile_jpmschweitzer` not `volatile_jpmschweitzer@gmail.com`)
|
||||
|
||||
## [1.10.8] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OIDC audience validation** - python-jose requires string audience, not list
|
||||
- Extract and validate audience from unverified claims first
|
||||
- Use token's actual audience for JWT decode (after validating it's allowed)
|
||||
- Fixes "audience must be a string or None" error
|
||||
|
||||
## [1.10.7] - 2026-01-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-issuer OIDC support** - Accept tokens from multiple OAuth providers
|
||||
- Changed `oidc_issuer` (string) to `oidc_issuers` (list)
|
||||
- Each issuer has its own JWKS endpoint, now cached per-issuer
|
||||
- Validates token issuer against allowed list before fetching JWKS
|
||||
- Supports tokens from: `core-api`, `tatlock-ui`, `tatlock` OAuth applications
|
||||
- Completes fix for environment endpoint user resolution
|
||||
|
||||
### Removed
|
||||
|
||||
- Deprecated `src/config.py` - consolidated to `src/shared/config.py`
|
||||
- Deprecated `src/security.py` - consolidated to `src/shared/security.py`
|
||||
|
||||
## [1.10.6] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OIDC audience mismatch** - Accept tokens from multiple clients
|
||||
- Changed `oidc_audience` (string) to `oidc_audiences` (list)
|
||||
- Now accepts tokens with audience: `core-api`, `tatlock-ui`, or `tatlock`
|
||||
- Fixes environment endpoint returning "default" user instead of authenticated username
|
||||
- Fixed main.py to use `oidc_audiences[0]` for Swagger UI OAuth client
|
||||
|
||||
### Changed
|
||||
|
||||
- Documentation cleanup in README
|
||||
|
||||
## [1.10.4] - 2026-01-07
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# CLAUDE.md — core-api
|
||||
|
||||
FastAPI service providing infrastructure management, home automation, and utility
|
||||
endpoints for the homelab. Talks to Portainer, Nginx Proxy Manager, Home Assistant,
|
||||
Postgres (via SQLAlchemy async + Alembic), Qdrant, and Authentik (OIDC). Deployed on
|
||||
tower-of-joy at **:8083**.
|
||||
|
||||
## Ports — these differ, deliberately
|
||||
|
||||
| | Port | How |
|
||||
|---|---|---|
|
||||
| Local dev | **8788** | `./wakeup.sh`, uvicorn `--reload`, logs to `logs/server.log` |
|
||||
| Production | **8083** | container; health at `http://192.168.86.149:8083/health` |
|
||||
|
||||
Testing `localhost:8083` on the dev box hits the *container*, not your reload server.
|
||||
|
||||
## Live contract
|
||||
|
||||
The live contract is always `http://localhost:8083/openapi.json` (62 paths, verified
|
||||
2026-08-09) and human docs at `http://localhost:8083/docs` / `/redoc` — generated from
|
||||
running code, so query it rather than inferring routes from source or from the README's
|
||||
endpoint list, which can drift.
|
||||
|
||||
## Architecture
|
||||
|
||||
Domain-first layout under `src/domains/<name>/{controller,models,schemas,service}.py`
|
||||
(auth, dashboard, health, housekeeping, infrastructure, static, tools). `src/main.py`
|
||||
wires only `src.domains.*` — verify by reading its imports.
|
||||
|
||||
**Legacy top-level packages — "not in `main.py`" does not mean dead.** Routes are wired
|
||||
only from `src.domains.*`, so grepping `main.py`'s imports looks like it settles which
|
||||
packages are live. It does not. `main.py:55` calls `initialize_oidc()`, and that function
|
||||
(`src/shared/security.py:21`) deliberately imports and configures **both** `src.auth.oidc`
|
||||
and `src.domains.auth.oidc` — a function-body import, invisible to a grep of `main.py`.
|
||||
That one call drags in `src/auth/`, `src/controllers/`, `src/db/`, `src/logging_config.py`
|
||||
and `src/base_schema.py` at startup.
|
||||
|
||||
Three tiers, established by importing the app inside the container and reading
|
||||
`sys.modules` (verified 2026-08-09):
|
||||
|
||||
| Tier | Packages |
|
||||
|---|---|
|
||||
| Serving routes | `src/domains/`, `src/shared/`, `src/service_groups/` |
|
||||
| **Loaded and configured**, but serving no routes | `src/auth/`, `src/db/`, `src/controllers/`, `src/logging_config.py`, `src/base_schema.py` |
|
||||
| Genuinely unreferenced | `src/agent/`, `src/api/`, `src/clients/`, `src/dns/`, `src/memory/`, `src/models/` |
|
||||
|
||||
`src/auth/` is the trap. Its `oidc_config` singleton is configured at every startup with
|
||||
the real Authentik issuers — the log line `src.auth.oidc:configure` proves it — so a test
|
||||
importing `src.auth.oidc` is exercising live, configured code, not a fossil. No `src/domains/*`
|
||||
module depends on it, so it is configured defensively rather than used; that makes it a
|
||||
deletion candidate, but a considered one, not obvious cleanup.
|
||||
|
||||
**Before deleting anything from `src/`, import the app and read `sys.modules`** rather than
|
||||
grepping `main.py`. Function-body imports exist here specifically to dodge circular imports,
|
||||
and they are exactly what a grep misses.
|
||||
|
||||
**That check has its own blind spot, so do not read the third tier as a delete list.** The
|
||||
table above is a snapshot taken after a cold `import src.main` — it shows what *startup*
|
||||
loads. A module imported inside a request handler would be absent from it while being
|
||||
entirely live, and absence would then be a timing artifact rather than evidence of death.
|
||||
This bit on webber, where a tool package imported from inside an agent method looked
|
||||
unloaded and was serving every request. Nothing in core-api is currently known to work that
|
||||
way, but that is the weaker claim — it means nobody has exercised the routes and re-checked,
|
||||
not that nobody does it. Before deleting a third-tier package, drive the endpoints that
|
||||
would plausibly load it and take the snapshot again.
|
||||
|
||||
Some tests (`test_auth_controller.py`, `test_oidc.py`, `test_npm_client.py`,
|
||||
`test_portainer_client.py`, `test_static_controller.py`, `test_tools_controller.py`) import
|
||||
from the top-level paths rather than `src.domains.*`. Which of those cover live code follows
|
||||
the table above — `test_oidc.py` does; the client tests target the unreferenced tier. Not
|
||||
cleaned up in this pass; flagged, not fixed.
|
||||
|
||||
Shared infra (config, database, logging, security/OIDC, external API clients) lives in
|
||||
`src/shared/`.
|
||||
|
||||
Group new work by **domain, not by file type** — a single large `routers/` folder is
|
||||
the thing to avoid. Reference: [FastAPI best practices](https://github.com/zhanymkanov/fastapi-best-practices).
|
||||
|
||||
## Database
|
||||
|
||||
SQLAlchemy 2.0 async + asyncpg, migrations via Alembic (`alembic/versions/`). Models
|
||||
live under `src/domains/<name>/models.py` and must be imported in `alembic/env.py` to
|
||||
register with `Base.metadata` — check that file when adding a new model or `alembic
|
||||
revision --autogenerate` will silently miss it.
|
||||
|
||||
## Working here
|
||||
|
||||
**Plan, act, reflect.** Outline which files you will touch and the side effects before
|
||||
writing. Change in small atomic steps. Afterwards, verify: did existing tests break, and
|
||||
does the new behaviour have a test?
|
||||
|
||||
**Test locally first — the build-deploy loop is slow.** `./wakeup.sh` auto-reloads on
|
||||
code changes (not on `requirements.txt` changes; restart the container/script after
|
||||
adding a dependency). Deploy only when a feature is complete and tested.
|
||||
|
||||
Run tests through the venv explicitly, to avoid environment mismatch:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest tests/
|
||||
# or, with coverage:
|
||||
.venv/bin/python -m pytest --cov=src --cov-report=term-missing
|
||||
```
|
||||
|
||||
Copy `.env.example` to `.env` and configure Portainer, NPM, Home Assistant, SearXNG,
|
||||
Postgres, and Qdrant hosts/credentials.
|
||||
|
||||
No linter is configured in this repo (no ruff/flake8 config, none in `requirements.txt`
|
||||
or `dev-requirements.txt`) — unlike some sibling repos, don't assume `ruff check` exists
|
||||
here.
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/build.yml` is the only workflow: triggered on `v*` tag push, it
|
||||
creates a Gitea release, builds and pushes the image, then pings Watchtower. There is
|
||||
**no CI test/lint gate** — pytest only runs locally or on request. Verify tests pass
|
||||
before tagging a release.
|
||||
|
||||
## Work tracking
|
||||
|
||||
Work lives in **pql**, not a markdown TODO. **This repo's vault is standalone** — its tickets
|
||||
and its internal decisions live here in `.pql/` and `governance/`, and travel with a clone,
|
||||
because `.pql/changelog/` is committed and replayed by the git hooks (workspace D-15). The databases are
|
||||
gitignored and rebuildable with `pql plan rebuild`.
|
||||
|
||||
`pql` is **not** on the non-interactive `PATH` — invoke it as
|
||||
`/home/jpmschweitzer/.local/bin/pql`. From inside this repo no `--vault` is needed: pql anchors
|
||||
at the nearest `.git/` ancestor, which is this repo.
|
||||
|
||||
```bash
|
||||
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
|
||||
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
|
||||
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
|
||||
```
|
||||
|
||||
Stack-level decisions that constrain this service live in the **workspace** vault and need the
|
||||
flag:
|
||||
|
||||
```bash
|
||||
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain core-api
|
||||
```
|
||||
|
||||
Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link
|
||||
to a workspace decision. Cite the id in the ticket body instead.
|
||||
|
||||
Do not add a TODO section to a markdown file.
|
||||
|
||||
## Git
|
||||
|
||||
- **History is linear — no merge commits.** Work on `main`, or a short-lived branch
|
||||
that is fast-forwarded and deleted. (This repo's AGENTS.md previously mandated a
|
||||
feature branch for every change; that rule was retired workspace-wide on 2026-08-08
|
||||
and does not apply here anymore.)
|
||||
- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`.
|
||||
- **Atomic commits** — one logical change each.
|
||||
- **Stage explicitly. Never `git add -A`** — it is denied by policy, and it sweeps in
|
||||
whatever else is dirty, including secrets.
|
||||
- Update `CHANGELOG.md` with every user-facing change, under `[Unreleased]` in `Added` /
|
||||
`Changed` / `Fixed`.
|
||||
|
||||
## Releasing
|
||||
|
||||
Ask whether a deploy is wanted first — it is not automatic.
|
||||
|
||||
1. Bump the version in `pyproject.toml` (patch for fixes, minor for features).
|
||||
2. Move `[Unreleased]` entries into a dated version section in `CHANGELOG.md`.
|
||||
3. Stage the changed files by name, commit, tag `vX.Y.Z`, `git push origin main --tags`.
|
||||
4. Gitea CI (`build.yml`) builds and pushes the image on the tag; Watchtower deploys it.
|
||||
5. Verify: `curl http://192.168.86.149:8083/health`.
|
||||
|
||||
## Security
|
||||
|
||||
- OIDC authentication via Authentik, multi-issuer/multi-audience support.
|
||||
- Admin endpoints require authentication when `OIDC_ENABLED=true`.
|
||||
- README claims the container "runs as non-root user (uid 1000)" — **checked and
|
||||
false**: the Dockerfile has no `USER` directive, so the container runs as root.
|
||||
Not fixed here (out of scope for a docs normalization pass); flagging so it isn't
|
||||
restated as fact.
|
||||
@@ -0,0 +1,67 @@
|
||||
# core-api — the repo's command surface (D-27).
|
||||
#
|
||||
# Paths resolve here rather than in callers (D-10). `python3` on this host is
|
||||
# 3.8 and cannot parse these sources, and a bare `pytest` resolves only in a
|
||||
# login shell — so both are named explicitly through the venv.
|
||||
|
||||
VENV := $(CURDIR)/.venv
|
||||
PYTHON ?= python3.12
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## Show this help
|
||||
@grep -hE '^[a-z][a-z0-9_-]*:.*?## ' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: setup
|
||||
setup: ## Create the venv, install dependencies, and prove the result is usable
|
||||
$(PYTHON) -m venv .venv
|
||||
$(VENV)/bin/pip install -r requirements.txt -r dev-requirements.txt
|
||||
# Exit 0 from pip install is not evidence (D-24) — it is the step's job not
|
||||
# to fail, so a broken result and a working one look identical from here.
|
||||
# On 2026-08-09 the venv existed and pip had exited 0, but sqlalchemy was
|
||||
# declared in requirements.txt and not installed; that surfaced as 11
|
||||
# pytest collection errors that read as broken imports, not as a setup
|
||||
# problem. `--collect-only` exercises every import the suite touches
|
||||
# without running a single test, so it catches exactly that class of
|
||||
# drift and stays cheap. `pip check` was considered too, but it only
|
||||
# verifies the *installed* set's internal consistency against itself —
|
||||
# it would not have caught this case, because sqlalchemy was still
|
||||
# present as another package's transitive dependency even when dropped
|
||||
# from requirements.txt. collect-only checks declared-vs-actually-usable
|
||||
# directly, which is the axis that broke.
|
||||
$(VENV)/bin/python -m pytest --collect-only tests/
|
||||
|
||||
.PHONY: test
|
||||
test: ## Run the test suite
|
||||
@test -x $(VENV)/bin/python || { echo "FAIL — no venv; run: make setup"; exit 69; }
|
||||
$(VENV)/bin/python -m pytest tests/
|
||||
|
||||
# No `lint` target, deliberately. This repo configures no linter — no ruff or
|
||||
# flake8 config, and neither in requirements. Per D-27 the name is reserved for
|
||||
# repos that lint; an empty target here would report clean for something never
|
||||
# run. Add the target when a linter is added, not before.
|
||||
|
||||
# git hands a hook a non-login shell, which never sees ~/.local/bin — where
|
||||
# gitleaks lands. Without this the scan reports "not installed" on every push,
|
||||
# which is a check that fails open (D-24).
|
||||
export PATH := $(HOME)/.local/bin:/usr/local/bin:$(PATH)
|
||||
|
||||
.PHONY: secrets
|
||||
secrets: ## Scan the commits about to be pushed for credentials
|
||||
@ci/secrets.sh
|
||||
|
||||
# The call surface is identical in every repo; what it runs is not.
|
||||
#
|
||||
# `secrets` runs first, deliberately: it is the only failure here that cannot be
|
||||
# undone by fixing it afterwards. A failed lint costs another commit; a pushed
|
||||
# credential is cached and indexed whether or not it is later deleted.
|
||||
#
|
||||
# Some of these fail today, and are left wired anyway. The state was measured
|
||||
# once and written down in T-56 rather than being worked around here — a gate
|
||||
# quietly narrowed to what already passes is a gate that reports success for
|
||||
# doing nothing, which is the failure this workspace keeps rediscovering.
|
||||
.PHONY: pre-push
|
||||
pre-push: secrets ## Everything the pre-push hook runs
|
||||
@echo " -- not gated here yet: lint (no linter configured) and test (T-56)"
|
||||
@@ -97,10 +97,6 @@ src/
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy credentials template
|
||||
cp src/credentials.example.py src/credentials.py
|
||||
# Edit src/credentials.py with your values
|
||||
|
||||
# Run locally
|
||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
||||
```
|
||||
@@ -164,8 +160,8 @@ docker run -p 8083:8083 core-code:latest
|
||||
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
|
||||
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
|
||||
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
|
||||
| `OIDC_ISSUER` | OIDC issuer URL | - |
|
||||
| `OIDC_AUDIENCE` | OIDC audience | - |
|
||||
| `OIDC_ISSUERS` | OIDC issuer URLs (comma-separated) | See config.py |
|
||||
| `OIDC_AUDIENCES` | OIDC audiences (comma-separated) | See config.py |
|
||||
|
||||
## API Documentation
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ from src.shared.database import Base
|
||||
# Import all models to ensure they're registered with Base.metadata
|
||||
from src.domains.auth.models import User, Role, UserRole, UserPreferences, ApiKey # noqa: F401
|
||||
from src.domains.dashboard.models import QuickLink, DashboardWidget # noqa: F401
|
||||
from src.domains.book.models import ( # noqa: F401
|
||||
BookRecord, BookTag, BookRecordLink, BookAttachment, BookEvent, BookTenantCounter,
|
||||
)
|
||||
from src.domains.scribe.models import ( # noqa: F401
|
||||
ScribeAddress, ScribeMessage, ScribeMailboxEntry,
|
||||
)
|
||||
from src.domains.tenants.models import Tenant, TenantConsumer # noqa: F401
|
||||
|
||||
# Alembic Config object
|
||||
config = context.config
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Create Book tables (T-1, D-68/D-69/D-70; tenant/number amended by T-7)
|
||||
|
||||
Revision ID: 005
|
||||
Revises: 004
|
||||
Create Date: 2026-10-19
|
||||
|
||||
The whole Book schema lands in this one migration — book_records,
|
||||
book_tags, book_record_links, book_attachments, book_events,
|
||||
book_tenant_counters — so later rounds (append, remind, reschedule,
|
||||
pause/resume, redact, ...) need no migration of their own for these tables.
|
||||
|
||||
book_events is append-only: a trigger raises on UPDATE and DELETE, for
|
||||
every role including the owning application. `redact` (D-70 rule 4) is the
|
||||
one sanctioned exception and is a later round; this migration does not
|
||||
soften the trigger.
|
||||
|
||||
core-api T-7: amended before 005's first release (production was still at
|
||||
004) to add book_records.owner_tenant/number and book_tenant_counters,
|
||||
rather than patching them in with a later migration — see T-7's dossier.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "005"
|
||||
down_revision: Union[str, None] = "004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the Book tables, its search column/index, and the append-only trigger."""
|
||||
|
||||
op.create_table(
|
||||
"book_records",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("number", sa.Integer(), nullable=False),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("title", sa.String(length=500), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("checklist", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("scope", sa.String(length=10), nullable=False, server_default="personal"),
|
||||
sa.Column("priority", sa.Integer(), nullable=True),
|
||||
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("energy", sa.String(length=10), nullable=True),
|
||||
sa.Column("estimate_minutes", sa.Integer(), nullable=True),
|
||||
sa.Column("next_step", sa.Text(), nullable=True),
|
||||
sa.Column("start_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("all_day", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("remind_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("recurrence", sa.String(length=255), nullable=True),
|
||||
sa.Column("recurrence_mode", sa.String(length=20), nullable=True),
|
||||
sa.Column("remind_to", sa.String(length=63), nullable=True),
|
||||
sa.Column("last_touched_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("trashed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("paused_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("paused_statuses", JSONB(), nullable=True),
|
||||
sa.Column("source", sa.String(length=50), nullable=True),
|
||||
sa.Column("external_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("colour", sa.String(length=20), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
||||
sa.UniqueConstraint("owner_tenant", "number", name="uq_book_records_tenant_number"),
|
||||
)
|
||||
op.create_index("ix_book_records_kind", "book_records", ["kind"])
|
||||
op.create_index("ix_book_records_status", "book_records", ["status"])
|
||||
op.create_index("ix_book_records_due_at", "book_records", ["due_at"])
|
||||
op.create_index(
|
||||
"ix_book_records_tenant_kind_status",
|
||||
"book_records",
|
||||
["owner_tenant", "kind", "status"],
|
||||
)
|
||||
|
||||
# Generated column — 'simple' because the household writes mixed Dutch
|
||||
# and English, and a language-specific config would stem one of the two
|
||||
# badly. Added via raw SQL: Alembic's op.add_column has no first-class
|
||||
# GENERATED ALWAYS AS support.
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE book_records
|
||||
ADD COLUMN search tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title, '') || ' ' || body)) STORED
|
||||
"""
|
||||
)
|
||||
op.create_index(
|
||||
"ix_book_records_search", "book_records", ["search"], postgresql_using="gin"
|
||||
)
|
||||
|
||||
# One row per tenant: the next `number` to allocate on book_records.
|
||||
# Locked FOR UPDATE in the same transaction as the record insert it
|
||||
# numbers (BookService._allocate_number) — see that method's docstring
|
||||
# for why this shape is safe under concurrency.
|
||||
op.create_table(
|
||||
"book_tenant_counters",
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("next_number", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.PrimaryKeyConstraint("owner_tenant"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"book_tags",
|
||||
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("tag", sa.String(length=63), nullable=False),
|
||||
sa.PrimaryKeyConstraint("record_id", "tag"),
|
||||
)
|
||||
op.create_index("ix_book_tags_tag", "book_tags", ["tag"])
|
||||
|
||||
op.create_table(
|
||||
"book_record_links",
|
||||
sa.Column("from_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("relation", sa.String(length=20), nullable=False),
|
||||
sa.Column("to_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("from_id", "relation", "to_id"),
|
||||
)
|
||||
op.create_index("ix_book_record_links_to_id", "book_record_links", ["to_id"])
|
||||
|
||||
op.create_table(
|
||||
"book_attachments",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("path", sa.String(length=1000), nullable=False),
|
||||
sa.Column("mime", sa.String(length=255), nullable=True),
|
||||
sa.Column("size", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_book_attachments_record_id", "book_attachments", ["record_id"])
|
||||
|
||||
op.create_table(
|
||||
"book_events",
|
||||
sa.Column("id", UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("record_id", UUID(as_uuid=True), sa.ForeignKey("book_records.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("identity", sa.String(length=63), nullable=False),
|
||||
sa.Column("client", sa.String(length=63), nullable=True),
|
||||
sa.Column("action", sa.String(length=20), nullable=False),
|
||||
sa.Column("diff", JSONB(), nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_book_events_record_id", "book_events", ["record_id"])
|
||||
|
||||
# Append-only, for every role including the owning application.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION book_events_append_only() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'book_events is append-only: % not allowed', TG_OP;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TRIGGER book_events_no_update_delete
|
||||
BEFORE UPDATE OR DELETE ON book_events
|
||||
FOR EACH ROW EXECUTE FUNCTION book_events_append_only()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the Book tables, in dependency order."""
|
||||
op.execute("DROP TRIGGER IF EXISTS book_events_no_update_delete ON book_events")
|
||||
op.execute("DROP FUNCTION IF EXISTS book_events_append_only()")
|
||||
|
||||
op.drop_index("ix_book_events_record_id", table_name="book_events")
|
||||
op.drop_table("book_events")
|
||||
|
||||
op.drop_index("ix_book_attachments_record_id", table_name="book_attachments")
|
||||
op.drop_table("book_attachments")
|
||||
|
||||
op.drop_index("ix_book_record_links_to_id", table_name="book_record_links")
|
||||
op.drop_table("book_record_links")
|
||||
|
||||
op.drop_index("ix_book_tags_tag", table_name="book_tags")
|
||||
op.drop_table("book_tags")
|
||||
|
||||
op.drop_table("book_tenant_counters")
|
||||
|
||||
op.drop_index("ix_book_records_search", table_name="book_records")
|
||||
op.drop_index("ix_book_records_tenant_kind_status", table_name="book_records")
|
||||
op.drop_index("ix_book_records_due_at", table_name="book_records")
|
||||
op.drop_index("ix_book_records_status", table_name="book_records")
|
||||
op.drop_index("ix_book_records_kind", table_name="book_records")
|
||||
op.drop_table("book_records")
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Create Scribe tables (dossier core-api T-5, D-66/D-68/D-73)
|
||||
|
||||
Revision ID: 006
|
||||
Revises: 005
|
||||
Create Date: 2026-10-19
|
||||
|
||||
scribe_addresses, scribe_messages, scribe_mailbox_entries — messaging only
|
||||
(no groups, subscriptions, notification matrix or delivery this round;
|
||||
workspace T-176). No data: seeding `jeroen` and `tower-control` is a
|
||||
deploy step through `register`, not this migration.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "006"
|
||||
down_revision: Union[str, None] = "005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the Scribe tables, their search/topics GIN indexes."""
|
||||
|
||||
op.create_table(
|
||||
"scribe_addresses",
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("name", sa.String(length=63), nullable=False),
|
||||
sa.Column("owner_consumer", sa.String(length=63), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=63), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("owner_tenant", "name"),
|
||||
sa.UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"scribe_messages",
|
||||
sa.Column("id", sa.Integer(), sa.Identity(always=False), nullable=False),
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("topics", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||
sa.Column("from_identity", sa.String(length=63), nullable=False),
|
||||
sa.Column("from_consumer", sa.String(length=63), nullable=True),
|
||||
sa.Column("subject", sa.String(length=500), nullable=True),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||
sa.Column("reply_to", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["reply_to"], ["scribe_messages.id"], name="fk_scribe_messages_reply_to"),
|
||||
)
|
||||
op.create_index("ix_scribe_messages_owner_tenant", "scribe_messages", ["owner_tenant"])
|
||||
op.create_index(
|
||||
"ix_scribe_messages_topics", "scribe_messages", ["topics"], postgresql_using="gin"
|
||||
)
|
||||
|
||||
# Generated column — 'simple' because the household writes mixed Dutch
|
||||
# and English, matching book_records.search (migration 005). Added via
|
||||
# raw SQL: Alembic's op.add_column has no first-class GENERATED ALWAYS
|
||||
# AS support.
|
||||
op.execute(
|
||||
"""
|
||||
ALTER TABLE scribe_messages
|
||||
ADD COLUMN search tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('simple', coalesce(subject, '') || ' ' || body)) STORED
|
||||
"""
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scribe_messages_search", "scribe_messages", ["search"], postgresql_using="gin"
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"scribe_mailbox_entries",
|
||||
sa.Column("id", sa.Integer(), sa.Identity(always=False), nullable=False),
|
||||
sa.Column("address", sa.String(length=63), nullable=False),
|
||||
sa.Column("owner_tenant", sa.String(length=63), nullable=False),
|
||||
sa.Column("message_id", sa.Integer(), nullable=False),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default=sa.text("'{}'::text[]")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["message_id"], ["scribe_messages.id"], name="fk_scribe_mailbox_entries_message_id"
|
||||
),
|
||||
sa.UniqueConstraint("address", "message_id", name="uq_scribe_mailbox_entries_address_message"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scribe_mailbox_entries_owner_tenant_address",
|
||||
"scribe_mailbox_entries",
|
||||
["owner_tenant", "address"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_scribe_mailbox_entries_message_id", "scribe_mailbox_entries", ["message_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the Scribe tables, in dependency order."""
|
||||
op.drop_index("ix_scribe_mailbox_entries_message_id", table_name="scribe_mailbox_entries")
|
||||
op.drop_index(
|
||||
"ix_scribe_mailbox_entries_owner_tenant_address", table_name="scribe_mailbox_entries"
|
||||
)
|
||||
op.drop_table("scribe_mailbox_entries")
|
||||
|
||||
op.drop_index("ix_scribe_messages_search", table_name="scribe_messages")
|
||||
op.drop_index("ix_scribe_messages_topics", table_name="scribe_messages")
|
||||
op.drop_index("ix_scribe_messages_owner_tenant", table_name="scribe_messages")
|
||||
op.drop_table("scribe_messages")
|
||||
|
||||
op.drop_table("scribe_addresses")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Create the tenant registry (core-api T-8, workspace D-73)
|
||||
|
||||
Revision ID: 007
|
||||
Revises: 006
|
||||
Create Date: 2026-10-19
|
||||
|
||||
`tenants` and `tenant_consumers` — the registry every Book and Scribe
|
||||
`owner_tenant` column now conforms to via a foreign key (`ON DELETE
|
||||
RESTRICT`: a tenant with any Book or Scribe row can never be dropped out
|
||||
from under it). 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
|
||||
(dossier core-api T-8). No write routes ship this round; provisioning a
|
||||
third tenant is later (workspace T-195) and happens by hand until then.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "007"
|
||||
down_revision: Union[str, None] = "006"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# Every existing owner_tenant column (core-api T-8's dossier: "five
|
||||
# owner_tenant columns exist ... find them by grep rather than trusting
|
||||
# this count" — grepped and confirmed at five, here).
|
||||
OWNER_TENANT_FKS = (
|
||||
("book_records", "fk_book_records_owner_tenant"),
|
||||
("book_tenant_counters", "fk_book_tenant_counters_owner_tenant"),
|
||||
("scribe_addresses", "fk_scribe_addresses_owner_tenant"),
|
||||
("scribe_messages", "fk_scribe_messages_owner_tenant"),
|
||||
("scribe_mailbox_entries", "fk_scribe_mailbox_entries_owner_tenant"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the tenant registry, seed it, then bind every existing
|
||||
owner_tenant column to it."""
|
||||
|
||||
op.create_table(
|
||||
"tenants",
|
||||
sa.Column("id", sa.String(length=63), nullable=False),
|
||||
sa.Column("display_name", sa.Text(), nullable=False),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.CheckConstraint("kind IN ('household', 'person', 'test')", name="ck_tenants_kind"),
|
||||
sa.CheckConstraint("status IN ('active', 'suspended')", name="ck_tenants_status"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"tenant_consumers",
|
||||
sa.Column("tenant_id", sa.String(length=63), nullable=False),
|
||||
sa.Column("consumer", sa.String(length=63), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("tenant_id", "consumer"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], name="fk_tenant_consumers_tenant_id"),
|
||||
)
|
||||
|
||||
# Seed: the two tenants in use today, both granted to office-claude —
|
||||
# the only Office consumer configured in Authentik (dossier T-8).
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenants (id, display_name, kind, status) VALUES
|
||||
('jpmschweitzer', 'Jeroen', 'household', 'active'),
|
||||
('llm_tester', 'Playground', 'test', 'active')
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO tenant_consumers (tenant_id, consumer) VALUES
|
||||
('jpmschweitzer', 'office-claude'),
|
||||
('llm_tester', 'office-claude')
|
||||
"""
|
||||
)
|
||||
|
||||
# Every existing owner_tenant column now conforms to the registry.
|
||||
for table, fk_name in OWNER_TENANT_FKS:
|
||||
op.create_foreign_key(
|
||||
fk_name, table, "tenants", ["owner_tenant"], ["id"], ondelete="RESTRICT"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Unbind every owner_tenant column, then drop the registry."""
|
||||
for table, fk_name in OWNER_TENANT_FKS:
|
||||
op.drop_constraint(fk_name, table, type_="foreignkey")
|
||||
|
||||
op.drop_table("tenant_consumers")
|
||||
op.drop_table("tenants")
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Secret scan over the commits about to be pushed.
|
||||
#
|
||||
# Lives here rather than inside .githooks/pre-push so it can be read, run by
|
||||
# hand (`make secrets`), and changed under review. A hook is a trigger; it is
|
||||
# not a home for logic. Identical in every repo in this workspace (D-27).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
# A non-login shell — which is what git gives a hook — skips /etc/profile.d
|
||||
# and never sees ~/.local/bin, where the gitleaks release tarball lands.
|
||||
# Without this the scan reports "not installed" on every push.
|
||||
[ -d "$HOME/.local/bin" ] && PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "FAIL secrets — gitleaks not installed, so this check would be a no-op pretending to pass." >&2
|
||||
echo " https://github.com/gitleaks/gitleaks/releases → ~/.local/bin/gitleaks" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Scan the outgoing range, not full history. History here carries findings
|
||||
# that are settled — test fixtures and vendored third-party code — and a gate
|
||||
# that fails on something unfixable gets bypassed within a week. What matters
|
||||
# is what is about to leave this machine.
|
||||
if upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null); then
|
||||
range="$upstream..HEAD"
|
||||
elif git rev-parse --verify --quiet origin/main >/dev/null; then
|
||||
range="origin/main..HEAD"
|
||||
else
|
||||
range=""
|
||||
fi
|
||||
|
||||
if [ -z "$range" ]; then
|
||||
gitleaks dir . --redact --no-banner --exit-code 1 || {
|
||||
echo "FAIL secrets — gitleaks found a credential in the working tree." >&2; exit 1; }
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -n "$(git log --oneline "$range" 2>/dev/null)" ] || exit 0
|
||||
|
||||
gitleaks git . --log-opts="$range" --redact --no-banner --exit-code 1 >/dev/null 2>&1 || {
|
||||
echo "FAIL secrets — gitleaks found a credential in the commits being pushed." >&2
|
||||
echo " inspect (values redacted): gitleaks git . --log-opts=\"$range\" --redact" >&2
|
||||
echo " then remove and rotate it, or suppress deliberately:" >&2
|
||||
echo " inline '# gitleaks:allow <reason>'" >&2
|
||||
echo " or add the fingerprint to .gitleaksignore WITH a reason" >&2
|
||||
exit 1
|
||||
}
|
||||
echo " ok secrets"
|
||||
@@ -0,0 +1,54 @@
|
||||
# Decisions, Questions, Rejected
|
||||
|
||||
This directory holds structured planning records that pql parses
|
||||
into pql.db. Each record is a `### [DQR]-N: Title` heading inside
|
||||
a markdown file. Files live in three per-type subdirectories:
|
||||
|
||||
- `decisions/<domain>.md` — confirmed design decisions
|
||||
- `questions/<domain>.md` — open questions that may resolve into
|
||||
decisions or rejected proposals
|
||||
- `rejected/<domain>.md` — rejected proposals (kept for the audit
|
||||
trail)
|
||||
|
||||
The parser infers domain from the filename stem and record type
|
||||
from the parent subdirectory.
|
||||
|
||||
D-records that propose implementation work link to `initiative`-type
|
||||
tickets via `decision_ref`. Run `pql decisions show <id>
|
||||
--with-tickets` to inspect implementation status.
|
||||
|
||||
## Recommended domains
|
||||
|
||||
Start with this canonical set; create files as records land in
|
||||
each domain:
|
||||
|
||||
- **architecture** — structural commitments (storage, layering,
|
||||
languages, libraries)
|
||||
- **process** — team workflow (commits, branches, releases, reviews)
|
||||
- **design** — user-facing surface (UX, UI, public APIs)
|
||||
- **coding-conventions** — team-internal code shape (style, lint,
|
||||
file layout)
|
||||
- **testing** — quality strategy (coverage, layers, gates)
|
||||
|
||||
You might also want, project-permitting:
|
||||
|
||||
- `accessibility` — if you ship user-facing software
|
||||
- `security` — if you handle user data or network surfaces
|
||||
- `licensing` — if you release open-source or commercial
|
||||
- `documentation` — if user-docs are non-trivial
|
||||
- `deployment` — if shipping is non-trivial
|
||||
- `performance` — if you have perf budgets / SLOs
|
||||
|
||||
<!-- pql:records (auto-generated; do not edit manually) -->
|
||||
|
||||
## Decisions
|
||||
|
||||
- _(none)_
|
||||
|
||||
## Open questions
|
||||
|
||||
- _(none)_
|
||||
|
||||
## Rejected
|
||||
|
||||
- _(none)_
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "core-api"
|
||||
version = "1.10.4"
|
||||
version = "1.12.0"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+60
-23
@@ -22,29 +22,42 @@ class OIDCConfig:
|
||||
def __init__(self):
|
||||
# These will be set from environment variables in config.py
|
||||
self.enabled = False
|
||||
self.issuer = ""
|
||||
self.audience = ""
|
||||
self.jwks_uri = ""
|
||||
self.issuers: list[str] = []
|
||||
self.audiences: list[str] = []
|
||||
|
||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
||||
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||
"""Configure OIDC settings"""
|
||||
self.enabled = enabled
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
||||
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||
self.audiences = audiences
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||
|
||||
def get_jwks_uri(self, issuer: str) -> str:
|
||||
"""Get JWKS URI for a specific issuer"""
|
||||
return f"{issuer.rstrip('/')}/jwks/"
|
||||
|
||||
def is_valid_issuer(self, issuer: str) -> bool:
|
||||
"""Check if issuer is in the allowed list"""
|
||||
normalized = issuer.rstrip('/')
|
||||
return normalized in self.issuers
|
||||
|
||||
|
||||
# Global OIDC config instance
|
||||
oidc_config = OIDCConfig()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jwks() -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
||||
# Per-issuer JWKS cache
|
||||
_jwks_cache: Dict[str, Dict] = {}
|
||||
|
||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||
|
||||
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
Args:
|
||||
issuer: The token issuer URL
|
||||
|
||||
Returns:
|
||||
JWKS dictionary containing public keys for token verification
|
||||
@@ -55,15 +68,24 @@ def get_jwks() -> Dict:
|
||||
if not oidc_config.enabled:
|
||||
return {}
|
||||
|
||||
normalized_issuer = issuer.rstrip('/')
|
||||
|
||||
# Return cached JWKS if available
|
||||
if normalized_issuer in _jwks_cache:
|
||||
return _jwks_cache[normalized_issuer]
|
||||
|
||||
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
||||
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||
response = httpx.get(jwks_uri, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
||||
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||
_jwks_cache[normalized_issuer] = jwks
|
||||
return jwks
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch JWKS: {e}")
|
||||
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable"
|
||||
@@ -109,6 +131,21 @@ async def get_current_user(
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
# First, extract issuer and audience from unverified claims
|
||||
unverified_claims = jwt.get_unverified_claims(token)
|
||||
token_issuer = unverified_claims.get("iss", "")
|
||||
token_audience = unverified_claims.get("aud", "")
|
||||
|
||||
# Validate issuer is in our allowed list
|
||||
if not oidc_config.is_valid_issuer(token_issuer):
|
||||
logger.warning(f"Invalid token issuer: {token_issuer}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||
|
||||
# Validate audience is in our allowed list
|
||||
if token_audience not in oidc_config.audiences:
|
||||
logger.warning(f"Invalid token audience: {token_audience}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||
|
||||
# Decode token header to get key ID
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
@@ -116,8 +153,8 @@ async def get_current_user(
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
# Find matching key in JWKS
|
||||
jwks = get_jwks()
|
||||
# Find matching key in JWKS for this specific issuer
|
||||
jwks = get_jwks_for_issuer(token_issuer)
|
||||
rsa_key = None
|
||||
|
||||
for key in jwks.get("keys", []):
|
||||
@@ -129,17 +166,17 @@ async def get_current_user(
|
||||
logger.warning(f"No matching key found for kid: {kid}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||
|
||||
# Verify and decode token
|
||||
# Verify and decode token using the token's actual issuer and audience
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=oidc_config.audience,
|
||||
issuer=oidc_config.issuer,
|
||||
audience=token_audience, # Use the token's audience (already validated)
|
||||
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||
)
|
||||
|
||||
user_email = payload.get("email", "unknown")
|
||||
logger.info(f"Authenticated user: {user_email}")
|
||||
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.db.models import User, Role, UserPreferences, Group
|
||||
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
|
||||
|
||||
@@ -10,7 +10,7 @@ import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -7,7 +7,7 @@ import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -8,7 +8,7 @@ import httpx
|
||||
import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
"""
|
||||
Global configuration for Core Code API
|
||||
|
||||
All configuration is loaded from environment variables or .env file.
|
||||
See .env.example for available settings.
|
||||
"""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
def _get_version_from_pyproject() -> str:
|
||||
"""Load version from pyproject.toml"""
|
||||
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return data.get("project", {}).get("version", "0.0.0")
|
||||
except FileNotFoundError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
__version__ = _get_version_from_pyproject()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Global application settings"""
|
||||
|
||||
# Application
|
||||
app_name: str = "Core Code API"
|
||||
app_version: str = __version__
|
||||
debug: bool = False
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8083
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["*"]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
# Logging
|
||||
log_level: str = "DEBUG"
|
||||
|
||||
# Qdrant Configuration
|
||||
qdrant_host: str = "qdrant"
|
||||
qdrant_port: int = 6333
|
||||
qdrant_collection_conversations: str = "core_api_conversations"
|
||||
qdrant_collection_documents: str = "core_api_documents"
|
||||
qdrant_collection_user_facts: str = "core_api_user_facts"
|
||||
|
||||
# Search Configuration
|
||||
search_provider: str = "searxng"
|
||||
searxng_url: str # Required - set SEARXNG_URL in .env
|
||||
|
||||
# Infrastructure Management (Portainer)
|
||||
portainer_url: str # Required - set PORTAINER_URL in .env
|
||||
portainer_api_key: str # Required - set PORTAINER_API_KEY in .env
|
||||
|
||||
# Infrastructure Management (Nginx Proxy Manager)
|
||||
npm_url: str # Required - set NPM_URL in .env
|
||||
npm_email: str # Required - set NPM_EMAIL in .env
|
||||
npm_password: str # Required - set NPM_PASSWORD in .env
|
||||
|
||||
# Home Assistant Configuration
|
||||
homeassistant_url: str # Required - set HOMEASSISTANT_URL in .env
|
||||
homeassistant_token: str # Required - set HOMEASSISTANT_TOKEN in .env
|
||||
homeassistant_timeout: int = 30
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres_host: str # Required - set POSTGRES_HOST in .env (e.g., localhost:5432)
|
||||
postgres_user: str = "core_api"
|
||||
postgres_password: str # Required - set POSTGRES_PASSWORD in .env
|
||||
postgres_database: str = "core_api"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Construct database URL from components"""
|
||||
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}/{self.postgres_database}"
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
oidc_enabled: bool = False # Set to True to require authentication
|
||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
||||
oidc_audience: str = "core-api"
|
||||
|
||||
# Authentik API (for token validation and user management)
|
||||
# Must use domain name (not IP) when AUTHENTIK_COOKIE_DOMAIN is set
|
||||
authentik_url: str = "https://auth.schweitz.net" # Authentik base URL
|
||||
authentik_username: str = "" # Admin username for API access (AUTHENTIK_USERNAME env var)
|
||||
authentik_password: str = "" # Admin password for API access (AUTHENTIK_PASSWORD env var)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
extra = "ignore" # Ignore extra env vars not defined in Settings
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings instance"""
|
||||
return Settings()
|
||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.controllers.base import BaseController
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.db import get_database
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -20,6 +20,7 @@ from src.domains.auth.oidc import (
|
||||
)
|
||||
from src.domains.auth.service import AuthService, get_auth_service
|
||||
from src.domains.auth.controller import auth_controller
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
from src.domains.auth.models import (
|
||||
User,
|
||||
Role,
|
||||
@@ -50,6 +51,9 @@ __all__ = [
|
||||
"get_auth_service",
|
||||
# Controller
|
||||
"auth_controller",
|
||||
# Callers (T-1)
|
||||
"Caller",
|
||||
"require_caller",
|
||||
# Models
|
||||
"User",
|
||||
"Role",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Caller Authentication
|
||||
|
||||
core-api has no service-key auth today: AuthService.validate_api_key is
|
||||
never called in any route, api_keys stays empty, and `CORE_API_KEY` is read
|
||||
by nothing. This module is the first thing that reads it.
|
||||
|
||||
Resolution order, static keys before any JWT parsing:
|
||||
1. Bearer == office_service_key -> kind "office"
|
||||
2. Bearer == tatlock_service_key -> kind "tatlock"
|
||||
3. Bearer == scheduler_service_key -> kind "scheduler"
|
||||
4. Otherwise -> get_current_user() -> kind "user"
|
||||
|
||||
Each static key is matched with hmac.compare_digest, and an unconfigured
|
||||
(empty-string) key never matches — checked explicitly rather than trusting
|
||||
compare_digest("", "") to be the right thing to compare, which would make an
|
||||
empty configured key match an empty bearer.
|
||||
|
||||
core-api T-4 (workspace D-73): the Office now signs every visitor in through
|
||||
Authentik with one OAuth client per consumer, and every token carries a
|
||||
signed `tatlock_user` claim. The Office verifies the token and forwards the
|
||||
result in two headers, trusted only alongside the office service key:
|
||||
|
||||
- `X-Office-Consumer` — the Authentik application slug the token came from
|
||||
(`office-claude`, `office-gemini`, ...).
|
||||
- `X-Office-Tenant` — the token's `tatlock_user` claim (the tatlock tenant).
|
||||
|
||||
For kind "office" both are required on **every** call, reads included — the
|
||||
tenant scopes reads too. For every other kind they are ignored outright:
|
||||
"tatlock" and "scheduler" carry no tenant (`None`); a "user" caller gets its
|
||||
tenant from its own JWT's `tatlock_user` claim if it has one, else `None` —
|
||||
never from a header, so a header can never put a non-Office caller into a
|
||||
tenant.
|
||||
|
||||
`X-Office-Client` (informational, pre-T-4) is retired in favour of
|
||||
`X-Office-Consumer`; nothing reads it any more.
|
||||
"""
|
||||
import hmac
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Literal, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Security
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
from src.domains.auth.oidc import get_current_user, security
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CallerKind = Literal["office", "tatlock", "scheduler", "user"]
|
||||
|
||||
# Reserved identities: the system nouns, never a caller's own identity.
|
||||
RESERVED_IDENTITIES = {"book", "office", "scheduler", "tatlock"}
|
||||
|
||||
# ^[a-z0-9][a-z0-9-]{0,62}$, and additionally not a "group-*" shape (checked
|
||||
# separately below, since the character class alone can't exclude a prefix).
|
||||
_IDENTITY_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
# X-Office-Consumer: the Authentik application slug, same shape as an identity.
|
||||
_CONSUMER_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
# X-Office-Tenant: tatlock's own tenant ids contain underscores (e.g.
|
||||
# `llm_tester`), so the identity/consumer shape doesn't fit.
|
||||
_TENANT_RE = re.compile(r"^[a-z0-9_][a-z0-9_.-]{0,62}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Caller:
|
||||
"""Who is calling, resolved once per request."""
|
||||
|
||||
kind: CallerKind
|
||||
identity: str # stamped on every write and event
|
||||
consumer: Optional[str] # X-Office-Consumer; office only, else None
|
||||
tenant: Optional[str] # the tatlock tenant; office header or a user's own JWT claim
|
||||
|
||||
|
||||
def _bad_request(code: str) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail={"code": code})
|
||||
|
||||
|
||||
def _validate_identity(identity: str) -> None:
|
||||
"""Raise 400 identity_invalid for a malformed or reserved identity."""
|
||||
if not _IDENTITY_RE.match(identity):
|
||||
raise _bad_request("identity_invalid")
|
||||
if identity.startswith("group-") or identity in RESERVED_IDENTITIES:
|
||||
raise _bad_request("identity_invalid")
|
||||
|
||||
|
||||
def _require_office_consumer(request: Request) -> str:
|
||||
"""X-Office-Consumer is required on every office call; raise if absent/malformed."""
|
||||
value = request.headers.get("X-Office-Consumer")
|
||||
if not value:
|
||||
raise _bad_request("consumer_required")
|
||||
if not _CONSUMER_RE.match(value):
|
||||
raise _bad_request("consumer_invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _require_office_tenant(request: Request) -> str:
|
||||
"""X-Office-Tenant is required on every office call, reads included."""
|
||||
value = request.headers.get("X-Office-Tenant")
|
||||
if not value:
|
||||
raise _bad_request("tenant_required")
|
||||
if not _TENANT_RE.match(value):
|
||||
raise _bad_request("tenant_invalid")
|
||||
return value
|
||||
|
||||
|
||||
def require_caller(*allowed: CallerKind, identity_required: bool = False) -> Callable:
|
||||
"""
|
||||
Build a FastAPI dependency that resolves the caller and enforces it is
|
||||
one of `allowed`.
|
||||
|
||||
Args:
|
||||
allowed: caller kinds permitted on this route.
|
||||
identity_required: for an "office" caller, whether X-Office-Identity
|
||||
must be present (true on writes). Other kinds carry a fixed
|
||||
identity and ignore this. Unrelated to consumer/tenant, which an
|
||||
"office" caller must carry on every call regardless of this flag.
|
||||
"""
|
||||
|
||||
async def dependency(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
|
||||
) -> Caller:
|
||||
settings = get_settings()
|
||||
token = credentials.credentials if credentials else None
|
||||
|
||||
caller: Optional[Caller] = None
|
||||
|
||||
if (
|
||||
token
|
||||
and settings.office_service_key
|
||||
and hmac.compare_digest(token, settings.office_service_key)
|
||||
):
|
||||
consumer = _require_office_consumer(request)
|
||||
tenant = _require_office_tenant(request)
|
||||
|
||||
identity_header = request.headers.get("X-Office-Identity")
|
||||
if not identity_header:
|
||||
if identity_required:
|
||||
raise _bad_request("identity_required")
|
||||
identity = ""
|
||||
else:
|
||||
_validate_identity(identity_header)
|
||||
identity = identity_header
|
||||
caller = Caller(kind="office", identity=identity, consumer=consumer, tenant=tenant)
|
||||
|
||||
elif (
|
||||
token
|
||||
and settings.tatlock_service_key
|
||||
and hmac.compare_digest(token, settings.tatlock_service_key)
|
||||
):
|
||||
caller = Caller(kind="tatlock", identity="tatlock", consumer=None, tenant=None)
|
||||
|
||||
elif (
|
||||
token
|
||||
and settings.scheduler_service_key
|
||||
and hmac.compare_digest(token, settings.scheduler_service_key)
|
||||
):
|
||||
caller = Caller(kind="scheduler", identity="scheduler", consumer=None, tenant=None)
|
||||
|
||||
else:
|
||||
# No static key matched (or none configured) — fall through to
|
||||
# the existing OIDC/JWT path. A static key never reaches here.
|
||||
user = await get_current_user(credentials)
|
||||
identity = (user or {}).get("preferred_username") or "user"
|
||||
# The tenant comes only from the JWT's own claim — never from a
|
||||
# header, which would let any caller spoof another tenant.
|
||||
tenant = (user or {}).get("tatlock_user") or None
|
||||
caller = Caller(kind="user", identity=identity, consumer=None, tenant=tenant)
|
||||
|
||||
if caller.kind not in allowed:
|
||||
logger.warning(f"Caller kind '{caller.kind}' forbidden on this route (allowed: {allowed})")
|
||||
raise HTTPException(status_code=403, detail={"code": "forbidden"})
|
||||
|
||||
return caller
|
||||
|
||||
return dependency
|
||||
+65
-26
@@ -63,29 +63,42 @@ class OIDCConfig:
|
||||
def __init__(self):
|
||||
# These will be set from environment variables in config.py
|
||||
self.enabled = False
|
||||
self.issuer = ""
|
||||
self.audience = ""
|
||||
self.jwks_uri = ""
|
||||
self.issuers: list[str] = []
|
||||
self.audiences: list[str] = []
|
||||
|
||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
||||
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||
"""Configure OIDC settings"""
|
||||
self.enabled = enabled
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
||||
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||
self.audiences = audiences
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||
|
||||
def get_jwks_uri(self, issuer: str) -> str:
|
||||
"""Get JWKS URI for a specific issuer"""
|
||||
return f"{issuer.rstrip('/')}/jwks/"
|
||||
|
||||
def is_valid_issuer(self, issuer: str) -> bool:
|
||||
"""Check if issuer is in the allowed list"""
|
||||
normalized = issuer.rstrip('/')
|
||||
return normalized in self.issuers
|
||||
|
||||
|
||||
# Global OIDC config instance
|
||||
oidc_config = OIDCConfig()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jwks() -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
||||
# Per-issuer JWKS cache
|
||||
_jwks_cache: Dict[str, Dict] = {}
|
||||
|
||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||
|
||||
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
Args:
|
||||
issuer: The token issuer URL
|
||||
|
||||
Returns:
|
||||
JWKS dictionary containing public keys for token verification
|
||||
@@ -96,15 +109,24 @@ def get_jwks() -> Dict:
|
||||
if not oidc_config.enabled:
|
||||
return {}
|
||||
|
||||
normalized_issuer = issuer.rstrip('/')
|
||||
|
||||
# Return cached JWKS if available
|
||||
if normalized_issuer in _jwks_cache:
|
||||
return _jwks_cache[normalized_issuer]
|
||||
|
||||
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
||||
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||
response = httpx.get(jwks_uri, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
||||
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||
_jwks_cache[normalized_issuer] = jwks
|
||||
return jwks
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch JWKS: {e}")
|
||||
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable"
|
||||
@@ -157,15 +179,30 @@ async def get_current_user(
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
# Decode token header to get key ID
|
||||
# First, decode token without verification to get issuer and key ID
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
unverified_claims = jwt.get_unverified_claims(token)
|
||||
|
||||
kid = unverified_header.get("kid")
|
||||
token_issuer = unverified_claims.get("iss", "")
|
||||
token_audience = unverified_claims.get("aud", "")
|
||||
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
# Find matching key in JWKS
|
||||
jwks = get_jwks()
|
||||
# Validate issuer is in allowed list
|
||||
logger.debug(f"Token issuer: {token_issuer}, allowed issuers: {oidc_config.issuers}")
|
||||
if not oidc_config.is_valid_issuer(token_issuer):
|
||||
logger.warning(f"Invalid token issuer: {token_issuer} (allowed: {oidc_config.issuers})")
|
||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||
|
||||
# Validate audience is in allowed list
|
||||
if token_audience not in oidc_config.audiences:
|
||||
logger.warning(f"Invalid token audience: {token_audience} (allowed: {oidc_config.audiences})")
|
||||
raise HTTPException(status_code=401, detail="Invalid token audience")
|
||||
|
||||
# Get JWKS for this specific issuer
|
||||
jwks = get_jwks_for_issuer(token_issuer)
|
||||
rsa_key = None
|
||||
|
||||
for key in jwks.get("keys", []):
|
||||
@@ -177,17 +214,17 @@ async def get_current_user(
|
||||
logger.warning(f"No matching key found for kid: {kid}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||
|
||||
# Verify and decode token
|
||||
# Verify and decode token using the token's actual issuer and audience
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=oidc_config.audience,
|
||||
issuer=oidc_config.issuer,
|
||||
audience=token_audience, # Use the token's audience (already validated)
|
||||
issuer=token_issuer, # Use the token's issuer (already validated)
|
||||
)
|
||||
|
||||
user_email = payload.get("email", "unknown")
|
||||
logger.info(f"Authenticated user: {user_email}")
|
||||
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
||||
|
||||
return payload
|
||||
|
||||
@@ -285,12 +322,14 @@ async def get_optional_user(
|
||||
}
|
||||
|
||||
if not credentials:
|
||||
logger.debug("No credentials provided for optional auth")
|
||||
return None
|
||||
|
||||
try:
|
||||
return await get_current_user(credentials)
|
||||
except HTTPException:
|
||||
# Invalid token - return None instead of raising
|
||||
except HTTPException as e:
|
||||
# Invalid token - log and return None instead of raising
|
||||
logger.warning(f"Optional auth failed: {e.detail}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Book Domain
|
||||
|
||||
The household's Notes, Tasks, Decisions, Questions and Rejected options
|
||||
(workspace D-68, D-69, D-70). Served under /book/*.
|
||||
"""
|
||||
from src.domains.book.controller import book_controller
|
||||
|
||||
__all__ = ["book_controller"]
|
||||
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Book Controller
|
||||
|
||||
The core Book verbs (D-70): add, show, list, find, change, close,
|
||||
link/unlink, tag/untag, history. Every route requires a caller (office or
|
||||
user); writes additionally require an identity (T-1). Every route also
|
||||
requires a tenant on the caller — enforced in the service layer
|
||||
(`BookService._require_tenant`) rather than here, so reads and writes share
|
||||
one check (core-api T-7).
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
from src.domains.book.schemas import (
|
||||
BookCloseRequest,
|
||||
BookEventResponse,
|
||||
BookHistoryResponse,
|
||||
BookLinkRequest,
|
||||
BookLinkResponse,
|
||||
BookRecordCreate,
|
||||
BookRecordDetailResponse,
|
||||
BookRecordListResponse,
|
||||
BookRecordResponse,
|
||||
BookRecordUpdate,
|
||||
BookTagRequest,
|
||||
ListView,
|
||||
)
|
||||
from src.domains.book.service import BookError, get_book_service
|
||||
from src.shared.base import BaseController
|
||||
from src.shared.database import get_async_session
|
||||
from src.shared.logging import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
require_book_reader = require_caller("office", "user")
|
||||
require_book_writer = require_caller("office", "user", identity_required=True)
|
||||
|
||||
|
||||
def _record_response(record, tags) -> BookRecordResponse:
|
||||
schema = BookRecordResponse.model_validate(record, from_attributes=True)
|
||||
return schema.model_copy(update={"tags": tags})
|
||||
|
||||
|
||||
def _link_response(link) -> BookLinkResponse:
|
||||
return BookLinkResponse.model_validate(link, from_attributes=True)
|
||||
|
||||
|
||||
def _detail_response(record, tags, links, backlinks) -> BookRecordDetailResponse:
|
||||
base = BookRecordResponse.model_validate(record, from_attributes=True).model_dump()
|
||||
base["tags"] = tags
|
||||
return BookRecordDetailResponse(
|
||||
**base,
|
||||
links=[_link_response(link) for link in links],
|
||||
backlinks=[_link_response(link) for link in backlinks],
|
||||
)
|
||||
|
||||
|
||||
def _event_response(event) -> BookEventResponse:
|
||||
return BookEventResponse.model_validate(event, from_attributes=True)
|
||||
|
||||
|
||||
class BookController(BaseController):
|
||||
"""Controller for the Book domain."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/book", tags=["Book"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
service = get_book_service()
|
||||
|
||||
# =====================================================================
|
||||
# add
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records",
|
||||
response_model=BookRecordResponse,
|
||||
status_code=201,
|
||||
summary="Add a record to the Book",
|
||||
)
|
||||
async def add_record(
|
||||
data: BookRecordCreate,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.add_record(session, data, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# list
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records",
|
||||
response_model=BookRecordListResponse,
|
||||
summary="List records",
|
||||
)
|
||||
async def list_records(
|
||||
view: Optional[ListView] = Query(None, description="inbox, today, upcoming, waiting, someday, pinned, archive, trash, done"),
|
||||
tag: Optional[str] = Query(None),
|
||||
scope: Optional[str] = Query(None),
|
||||
parent: Optional[uuid.UUID] = Query(None),
|
||||
number: Optional[int] = Query(None, description="find the one record with this outward id"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
results, total = await service.list_records(
|
||||
session, caller, view=view, tag=tag, scope=scope, parent=parent,
|
||||
number=number, limit=limit, offset=offset,
|
||||
)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookRecordListResponse(
|
||||
records=[_record_response(record, tags) for record, tags in results],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# find — declared before /{record_id} so "find" is never parsed as an id
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records/find",
|
||||
response_model=BookRecordListResponse,
|
||||
summary="Full-text search the Book",
|
||||
)
|
||||
async def find_records(
|
||||
q: str = Query(..., min_length=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
results, total = await service.find_records(session, caller, q=q, limit=limit, offset=offset)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookRecordListResponse(
|
||||
records=[_record_response(record, tags) for record, tags in results],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# show
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records/{record_id}",
|
||||
response_model=BookRecordDetailResponse,
|
||||
summary="Show a record, its links and its tags",
|
||||
)
|
||||
async def show_record(
|
||||
record_id: uuid.UUID,
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags, links, backlinks = await service.show_record(session, record_id, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _detail_response(record, tags, links, backlinks)
|
||||
|
||||
# =====================================================================
|
||||
# change
|
||||
# =====================================================================
|
||||
|
||||
@router.patch(
|
||||
"/records/{record_id}",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Change a record",
|
||||
)
|
||||
async def change_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookRecordUpdate,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.change_record(session, record_id, data, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# close
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/close",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Close a record (task done/dropped, question resolved, decision superseded)",
|
||||
)
|
||||
async def close_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookCloseRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.close_record(session, record_id, data, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# link / unlink
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/link",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Link a record to another",
|
||||
)
|
||||
async def link_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookLinkRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.link_records(
|
||||
session, record_id, data.to_id, data.relation, caller
|
||||
)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/unlink",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Remove a link between two records",
|
||||
)
|
||||
async def unlink_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookLinkRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.unlink_records(
|
||||
session, record_id, data.to_id, data.relation, caller
|
||||
)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# tag / untag
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/tag",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Tag a record",
|
||||
)
|
||||
async def tag_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookTagRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.tag_record(session, record_id, data.tag, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
@router.post(
|
||||
"/records/{record_id}/untag",
|
||||
response_model=BookRecordResponse,
|
||||
summary="Remove a tag from a record",
|
||||
)
|
||||
async def untag_record(
|
||||
record_id: uuid.UUID,
|
||||
data: BookTagRequest,
|
||||
caller: Caller = Depends(require_book_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
record, tags = await service.untag_record(session, record_id, data.tag, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return _record_response(record, tags)
|
||||
|
||||
# =====================================================================
|
||||
# history
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/records/{record_id}/history",
|
||||
response_model=BookHistoryResponse,
|
||||
summary="The append-only event history for a record",
|
||||
)
|
||||
async def record_history(
|
||||
record_id: uuid.UUID,
|
||||
caller: Caller = Depends(require_book_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
events = await service.get_history(session, record_id, caller)
|
||||
except BookError as error:
|
||||
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
|
||||
return BookHistoryResponse(
|
||||
record_id=record_id,
|
||||
events=[_event_response(event) for event in events],
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
book_controller = BookController()
|
||||
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Book Domain Models
|
||||
|
||||
The Book holds Notes, Tasks, Decisions, Questions and Rejected options for
|
||||
the Office (workspace D-68, D-69, D-70). SQLAlchemy 2.0 Mapped/mapped_column
|
||||
style, timestamptz, following src/domains/auth/models.py.
|
||||
|
||||
Table prefix is `book_` (D-68). `book_events` is append-only — migration 005
|
||||
adds a trigger that raises on UPDATE and DELETE, enforced at the database,
|
||||
not here.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Computed,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.shared.database import Base
|
||||
|
||||
# Kinds and their valid statuses. Enforced in the service layer (per kind),
|
||||
# not as a database CHECK — the valid status set depends on the kind, which
|
||||
# a single-column CHECK cannot express without a trigger.
|
||||
RECORD_KINDS = ("note", "task", "decision", "question", "rejected")
|
||||
|
||||
STATUSES_BY_KIND = {
|
||||
"note": {"active"},
|
||||
"task": {"inbox", "open", "waiting", "someday", "done", "dropped"},
|
||||
"decision": {"proposed", "accepted", "superseded"},
|
||||
"question": {"open", "settled", "dropped"},
|
||||
"rejected": {"rejected"},
|
||||
}
|
||||
|
||||
DEFAULT_STATUS_BY_KIND = {
|
||||
"note": "active",
|
||||
"task": "inbox",
|
||||
"decision": "proposed",
|
||||
"question": "open",
|
||||
"rejected": "rejected",
|
||||
}
|
||||
|
||||
SCOPES = ("work", "personal")
|
||||
DEFAULT_SCOPE = "personal"
|
||||
|
||||
LINK_RELATIONS = ("parent", "blocks", "resolves", "supersedes", "related")
|
||||
|
||||
|
||||
class BookRecord(Base):
|
||||
"""A Note, Task, Decision, Question or Rejected option."""
|
||||
|
||||
__tablename__ = "book_records"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
|
||||
UniqueConstraint("owner_tenant", "number", name="uq_book_records_tenant_number"),
|
||||
Index("ix_book_records_tenant_kind_status", "owner_tenant", "kind", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
# The tatlock tenant the record belongs to (Caller.tenant, T-4/D-73).
|
||||
# Every query in the service layer filters on it — there is no
|
||||
# database-level RLS here, so that filter is load-bearing in Python,
|
||||
# following src/domains/scribe/models.py's same note. FK to
|
||||
# tenants.id added by migration 007 (core-api T-8).
|
||||
owner_tenant: Mapped[str] = mapped_column(
|
||||
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
# A short outward id, numbered per tenant (never globally) so a tenant
|
||||
# can never infer another's activity from its numbers. Allocated by
|
||||
# BookService._allocate_number, which locks a per-tenant counter row
|
||||
# FOR UPDATE in the same transaction as this row's insert.
|
||||
number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
checklist: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
scope: Mapped[str] = mapped_column(String(10), nullable=False, default=DEFAULT_SCOPE)
|
||||
priority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
energy: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
estimate_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
next_step: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
start_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
all_day: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
remind_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
recurrence: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
recurrence_mode: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
remind_to: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||
last_touched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
trashed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
paused_statuses: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
source: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
colour: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
# Generated by migration 005 (`to_tsvector('simple', ...)`, GIN-indexed).
|
||||
# 'simple' because the household writes mixed Dutch and English — a
|
||||
# language-specific config would stem one of the two badly. `Computed`
|
||||
# mirrors the migration's SQL so SQLAlchemy knows this column is
|
||||
# database-generated and excludes it from INSERT/UPDATE — without it,
|
||||
# the ORM tries to write NULL into a GENERATED ALWAYS column and
|
||||
# Postgres rejects the insert outright.
|
||||
search: Mapped[Optional[str]] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed("to_tsvector('simple', coalesce(title, '') || ' ' || body)", persisted=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# Deliberately no `tags` relationship here. The service always queries
|
||||
# book_tags explicitly (`BookService._tags_for`) rather than traversing
|
||||
# an ORM relationship — partly because async SQLAlchemy can't lazy-load
|
||||
# on an unloaded attribute without a greenlet error, but mainly because
|
||||
# a relationship named `tags` collides with the response schema's
|
||||
# `tags: List[str]` field: `BookRecordResponse.model_validate(record,
|
||||
# from_attributes=True)` would read the relationship (a list of BookTag
|
||||
# ORM objects) instead of the list of strings the controller builds
|
||||
# separately, and fail validation. Cascade delete is handled by the
|
||||
# column's own `ondelete="CASCADE"`, so the relationship bought nothing.
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BookRecord {self.kind} {self.id}>"
|
||||
|
||||
|
||||
class BookTag(Base):
|
||||
"""A free tag on a record. `work`/`personal` live on `scope`, not here."""
|
||||
|
||||
__tablename__ = "book_tags"
|
||||
|
||||
record_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
tag: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BookTag {self.tag} on {self.record_id}>"
|
||||
|
||||
|
||||
class BookRecordLink(Base):
|
||||
"""A directed relation between two records."""
|
||||
|
||||
__tablename__ = "book_record_links"
|
||||
|
||||
from_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
relation: Mapped[str] = mapped_column(String(20), primary_key=True)
|
||||
to_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BookRecordLink {self.from_id} {self.relation} {self.to_id}>"
|
||||
|
||||
|
||||
class BookAttachment(Base):
|
||||
"""A file attached to a record. Table only — no file handling yet."""
|
||||
|
||||
__tablename__ = "book_attachments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
record_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
path: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
mime: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BookAttachment {self.path} on {self.record_id}>"
|
||||
|
||||
|
||||
class BookEvent(Base):
|
||||
"""
|
||||
An append-only audit row for every change to a record.
|
||||
|
||||
Migration 005 adds a trigger that raises on UPDATE and DELETE against
|
||||
this table — enforced for every role, the owning application included.
|
||||
`redact` (D-70 rule 4) is the one sanctioned exception, and it is a
|
||||
later round; nothing here weakens the trigger.
|
||||
"""
|
||||
|
||||
__tablename__ = "book_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
record_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("book_records.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
identity: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
client: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
diff: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BookEvent {self.action} on {self.record_id} by {self.identity}>"
|
||||
|
||||
|
||||
class BookTenantCounter(Base):
|
||||
"""
|
||||
One row per tenant: the next `number` to allocate on `book_records`.
|
||||
|
||||
`BookService._allocate_number` upserts this row (`INSERT ... ON
|
||||
CONFLICT DO NOTHING`, so a tenant's first `add` never races its own
|
||||
counter row into existence) and then locks it `FOR UPDATE` in the same
|
||||
transaction as the record insert it numbers — a second concurrent `add`
|
||||
in the same tenant blocks on that lock until the first commits, so two
|
||||
concurrent creates never read the same `next_number`. A different
|
||||
tenant's counter row is untouched, so tenants never contend with each
|
||||
other.
|
||||
"""
|
||||
|
||||
__tablename__ = "book_tenant_counters"
|
||||
|
||||
# FK to tenants.id added by migration 007 (core-api T-8).
|
||||
owner_tenant: Mapped[str] = mapped_column(
|
||||
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), primary_key=True
|
||||
)
|
||||
next_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BookTenantCounter {self.owner_tenant} next={self.next_number}>"
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Book Domain Schemas
|
||||
|
||||
Pydantic request/response payloads for the Book (D-68, D-70). This round
|
||||
ships `add`, `show`, `list`, `find`, `change`, `close`, `link`/`unlink`,
|
||||
`tag`/`untag`, `history` — the verbs the Office needs next round are not
|
||||
schematized here (dossier: append, remind, reschedule, undo, purge, redact,
|
||||
pin, check, snooze, archive/trash/restore, pause/resume, due, next, batch,
|
||||
idempotency keys).
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Literal, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
RecordKind = Literal["note", "task", "decision", "question", "rejected"]
|
||||
RecordScope = Literal["work", "personal"]
|
||||
RecordEnergy = Literal["low", "medium", "high"]
|
||||
RecurrenceMode = Literal["schedule", "after_completion"]
|
||||
LinkRelation = Literal["parent", "blocks", "resolves", "supersedes", "related"]
|
||||
ListView = Literal[
|
||||
"inbox", "today", "upcoming", "waiting", "someday",
|
||||
"pinned", "archive", "trash", "done",
|
||||
]
|
||||
|
||||
|
||||
class BookChecklistItem(BaseSchema):
|
||||
"""One line of a record's checklist."""
|
||||
|
||||
text: str
|
||||
checked: bool = False
|
||||
position: int = 0
|
||||
|
||||
|
||||
class BookRecordCreate(BaseSchema):
|
||||
"""`add` — kind defaults to a Note so text-only capture costs nothing (D-72)."""
|
||||
|
||||
kind: RecordKind = "note"
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
body: str = ""
|
||||
checklist: List[BookChecklistItem] = Field(default_factory=list)
|
||||
status: Optional[str] = None
|
||||
scope: RecordScope = "personal"
|
||||
priority: Optional[int] = None
|
||||
pinned: bool = False
|
||||
energy: Optional[RecordEnergy] = None
|
||||
estimate_minutes: Optional[int] = None
|
||||
next_step: Optional[str] = None
|
||||
start_at: Optional[datetime] = None
|
||||
due_at: Optional[datetime] = None
|
||||
all_day: bool = False
|
||||
remind_at: Optional[datetime] = None
|
||||
recurrence: Optional[str] = None
|
||||
recurrence_mode: Optional[RecurrenceMode] = None
|
||||
remind_to: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
colour: Optional[str] = None
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
parent: Optional[uuid.UUID] = Field(
|
||||
None, description="Convenience: also create a `parent` link to this record"
|
||||
)
|
||||
|
||||
|
||||
class BookRecordUpdate(BaseSchema):
|
||||
"""`change` — every field optional; only what is set is applied."""
|
||||
|
||||
kind: Optional[RecordKind] = None
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
body: Optional[str] = None
|
||||
checklist: Optional[List[BookChecklistItem]] = None
|
||||
status: Optional[str] = None
|
||||
scope: Optional[RecordScope] = None
|
||||
priority: Optional[int] = None
|
||||
pinned: Optional[bool] = None
|
||||
energy: Optional[RecordEnergy] = None
|
||||
estimate_minutes: Optional[int] = None
|
||||
next_step: Optional[str] = None
|
||||
start_at: Optional[datetime] = None
|
||||
due_at: Optional[datetime] = None
|
||||
all_day: Optional[bool] = None
|
||||
remind_at: Optional[datetime] = None
|
||||
recurrence: Optional[str] = None
|
||||
recurrence_mode: Optional[RecurrenceMode] = None
|
||||
remind_to: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
external_id: Optional[str] = None
|
||||
colour: Optional[str] = None
|
||||
|
||||
|
||||
class BookCloseRequest(BaseSchema):
|
||||
"""
|
||||
`close` — the field that applies depends on the record's kind:
|
||||
- task: `outcome` ("done" or "dropped")
|
||||
- question: `resolved_by` (the Decision id that resolves it); sets `settled`
|
||||
- decision: `superseded_by` (the new Decision id); the old one becomes `superseded`
|
||||
"""
|
||||
|
||||
outcome: Optional[Literal["done", "dropped"]] = None
|
||||
resolved_by: Optional[uuid.UUID] = None
|
||||
superseded_by: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class BookLinkRequest(BaseSchema):
|
||||
"""`link` / `unlink` — direction is from the record in the path to `to_id`."""
|
||||
|
||||
to_id: uuid.UUID
|
||||
relation: LinkRelation
|
||||
|
||||
|
||||
class BookTagRequest(BaseSchema):
|
||||
"""`tag` / `untag`."""
|
||||
|
||||
tag: str = Field(..., min_length=1, max_length=63)
|
||||
|
||||
|
||||
class BookLinkResponse(BaseSchema):
|
||||
from_id: uuid.UUID
|
||||
relation: str
|
||||
to_id: uuid.UUID
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BookRecordResponse(BaseSchema):
|
||||
"""Every write returns the stored record (D-70 rule 7). `number` is the
|
||||
outward id, numbered per tenant (core-api T-7); `owner_tenant` is not
|
||||
echoed back — it is implicit in the caller, following
|
||||
src/domains/scribe/schemas.py's AddressResponse."""
|
||||
|
||||
id: uuid.UUID
|
||||
number: int
|
||||
kind: str
|
||||
title: Optional[str]
|
||||
body: str
|
||||
checklist: List[dict] = Field(default_factory=list)
|
||||
status: str
|
||||
scope: str
|
||||
priority: Optional[int]
|
||||
pinned: bool
|
||||
energy: Optional[str]
|
||||
estimate_minutes: Optional[int]
|
||||
next_step: Optional[str]
|
||||
start_at: Optional[datetime]
|
||||
due_at: Optional[datetime]
|
||||
all_day: bool
|
||||
remind_at: Optional[datetime]
|
||||
recurrence: Optional[str]
|
||||
recurrence_mode: Optional[str]
|
||||
remind_to: Optional[str]
|
||||
last_touched_at: datetime
|
||||
archived_at: Optional[datetime]
|
||||
trashed_at: Optional[datetime]
|
||||
paused_at: Optional[datetime]
|
||||
paused_statuses: Optional[dict]
|
||||
source: Optional[str]
|
||||
external_id: Optional[str]
|
||||
colour: Optional[str]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookRecordDetailResponse(BookRecordResponse):
|
||||
"""`show` — the record plus its outgoing links and backlinks."""
|
||||
|
||||
links: List[BookLinkResponse] = Field(default_factory=list)
|
||||
backlinks: List[BookLinkResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookRecordListResponse(BaseSchema):
|
||||
records: List[BookRecordResponse]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class BookEventResponse(BaseSchema):
|
||||
id: uuid.UUID
|
||||
record_id: uuid.UUID
|
||||
at: datetime
|
||||
identity: str
|
||||
client: Optional[str]
|
||||
action: str
|
||||
diff: dict
|
||||
|
||||
|
||||
class BookHistoryResponse(BaseSchema):
|
||||
record_id: uuid.UUID
|
||||
events: List[BookEventResponse]
|
||||
@@ -0,0 +1,609 @@
|
||||
"""
|
||||
Book Domain Service
|
||||
|
||||
Business logic for the Book — Notes, Tasks, Decisions, Questions and
|
||||
Rejected options (D-68, D-70). Every mutating method stamps a `book_events`
|
||||
row via `_write_event`; the table itself is append-only (migration 005's
|
||||
trigger), so this module never updates or deletes an event.
|
||||
|
||||
Every method starts by requiring a tenant on the caller (`_require_tenant`,
|
||||
same pattern as `src/domains/scribe/service.py`) and ends by filtering its
|
||||
query on it: **a record in another tenant answers exactly like one that
|
||||
does not exist** (core-api T-7 / workspace D-73), so no method here ever
|
||||
confirms an id across that boundary. `link` and `add`'s `parent` convenience
|
||||
both route through `_get_or_404`, so neither can create a link into another
|
||||
tenant — which is also what keeps `show`'s links/backlinks free of a
|
||||
separate tenant filter: every link touching a tenant's record is created
|
||||
tenant-safe in the first place.
|
||||
|
||||
Methods take `session: AsyncSession` per call rather than storing it on
|
||||
`self`, following `src/domains/dashboard/service.py`.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.callers import Caller
|
||||
from src.domains.book.models import (
|
||||
DEFAULT_STATUS_BY_KIND,
|
||||
STATUSES_BY_KIND,
|
||||
BookEvent,
|
||||
BookRecord,
|
||||
BookRecordLink,
|
||||
BookTag,
|
||||
BookTenantCounter,
|
||||
)
|
||||
from src.domains.book.schemas import BookCloseRequest, BookRecordCreate, BookRecordUpdate
|
||||
from src.domains.tenants.service import TenantError, get_tenants_service
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BookError(Exception):
|
||||
"""Raised for a Book-domain failure; the controller maps it to a response."""
|
||||
|
||||
def __init__(self, code: str, status_code: int = 400):
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class BookService:
|
||||
"""Service for Book operations."""
|
||||
|
||||
# =========================================================================
|
||||
# Internal helpers
|
||||
# =========================================================================
|
||||
|
||||
async def _require_tenant(self, session: AsyncSession, caller: Caller) -> str:
|
||||
"""Every Book operation needs a tenant; a caller without one gets a
|
||||
clear error rather than a query that silently matches nothing. An
|
||||
Office caller always has one (callers.py enforces it on every call);
|
||||
a user-kind caller without a `tatlock_user` claim does not.
|
||||
|
||||
Delegates to the tenant registry's one enforcement point (core-api
|
||||
T-8 / workspace D-73), which additionally refuses an unknown
|
||||
tenant, a suspended one, or an Office consumer with no grant in it.
|
||||
`TenantError` is translated to `BookError` so every call site and
|
||||
the controller's single `except BookError` keep working unchanged.
|
||||
"""
|
||||
try:
|
||||
return await get_tenants_service().require_tenant(session, caller)
|
||||
except TenantError as error:
|
||||
raise BookError(error.code, error.status_code) from error
|
||||
|
||||
async def _get_or_404(self, session: AsyncSession, record_id, tenant: str) -> BookRecord:
|
||||
"""A record outside `tenant` answers exactly like one that does not
|
||||
exist — `session.get()` alone can't express that filter, since the
|
||||
primary key is `id` only, so this is a `select` rather than a get."""
|
||||
stmt = select(BookRecord).where(BookRecord.id == record_id, BookRecord.owner_tenant == tenant)
|
||||
record = (await session.execute(stmt)).scalar_one_or_none()
|
||||
if record is None:
|
||||
raise BookError("not_found", 404)
|
||||
return record
|
||||
|
||||
async def _allocate_number(self, session: AsyncSession, tenant: str) -> int:
|
||||
"""
|
||||
Allocate the next outward `number` for `tenant`, safe under
|
||||
concurrency.
|
||||
|
||||
`INSERT ... ON CONFLICT DO NOTHING` guarantees the tenant's counter
|
||||
row exists without racing its own creation (two simultaneous first
|
||||
`add`s in a brand-new tenant can't both try to INSERT the same row
|
||||
and fail), then `SELECT ... FOR UPDATE` locks that row for the rest
|
||||
of this transaction — a second concurrent `add` in the same tenant
|
||||
blocks on the lock until this one commits or rolls back, so two
|
||||
concurrent creates in one tenant can never read the same
|
||||
`next_number`. A different tenant's row is untouched, so tenants
|
||||
never contend with each other. This is the dossier's suggested
|
||||
shape (a per-tenant counter row taken FOR UPDATE in the same
|
||||
transaction as the insert); the ON CONFLICT DO NOTHING bootstrap is
|
||||
the one addition, needed because the plain shape alone still races
|
||||
on a tenant's very first allocation.
|
||||
"""
|
||||
upsert = (
|
||||
pg_insert(BookTenantCounter)
|
||||
.values(owner_tenant=tenant, next_number=1)
|
||||
.on_conflict_do_nothing(index_elements=["owner_tenant"])
|
||||
)
|
||||
await session.execute(upsert)
|
||||
|
||||
locked = (
|
||||
select(BookTenantCounter)
|
||||
.where(BookTenantCounter.owner_tenant == tenant)
|
||||
.with_for_update()
|
||||
)
|
||||
counter = (await session.execute(locked)).scalar_one()
|
||||
number = counter.next_number
|
||||
counter.next_number = number + 1
|
||||
return number
|
||||
|
||||
async def _tags_for(self, session: AsyncSession, record_id) -> List[str]:
|
||||
stmt = select(BookTag.tag).where(BookTag.record_id == record_id).order_by(BookTag.tag)
|
||||
result = await session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _write_event(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
caller: Caller,
|
||||
action: str,
|
||||
diff: dict,
|
||||
) -> None:
|
||||
event = BookEvent(
|
||||
record_id=record_id,
|
||||
identity=caller.identity,
|
||||
client=caller.consumer, # column name unchanged (no migration this round); T-4 retires X-Office-Client and records the consumer here instead
|
||||
action=action,
|
||||
diff=diff,
|
||||
)
|
||||
session.add(event)
|
||||
await session.flush()
|
||||
|
||||
def _validate_status(self, kind: str, status: Optional[str]) -> str:
|
||||
"""Resolve and validate a status for `kind`; defaults when unset."""
|
||||
resolved = status or DEFAULT_STATUS_BY_KIND[kind]
|
||||
if resolved not in STATUSES_BY_KIND[kind]:
|
||||
raise BookError("invalid_status", 400)
|
||||
return resolved
|
||||
|
||||
def _touch(self, record: BookRecord) -> None:
|
||||
record.last_touched_at = datetime.now(timezone.utc)
|
||||
|
||||
# =========================================================================
|
||||
# add / show / list / find
|
||||
# =========================================================================
|
||||
|
||||
async def add_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
data: BookRecordCreate,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`add` — text-only input lands an unfiled Note (D-72). Sets
|
||||
`owner_tenant` from the caller and allocates `number` (core-api T-7)."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
status = self._validate_status(data.kind, data.status)
|
||||
number = await self._allocate_number(session, tenant)
|
||||
|
||||
record = BookRecord(
|
||||
owner_tenant=tenant,
|
||||
number=number,
|
||||
kind=data.kind,
|
||||
title=data.title,
|
||||
body=data.body,
|
||||
checklist=[item.model_dump() for item in data.checklist],
|
||||
status=status,
|
||||
scope=data.scope,
|
||||
priority=data.priority,
|
||||
pinned=data.pinned,
|
||||
energy=data.energy,
|
||||
estimate_minutes=data.estimate_minutes,
|
||||
next_step=data.next_step,
|
||||
start_at=data.start_at,
|
||||
due_at=data.due_at,
|
||||
all_day=data.all_day,
|
||||
remind_at=data.remind_at,
|
||||
recurrence=data.recurrence,
|
||||
recurrence_mode=data.recurrence_mode,
|
||||
remind_to=data.remind_to,
|
||||
source=data.source,
|
||||
external_id=data.external_id,
|
||||
colour=data.colour,
|
||||
created_by=caller.identity,
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush() # assign record.id
|
||||
|
||||
tag_names = list(dict.fromkeys(data.tags)) # de-dup, keep order
|
||||
for tag in tag_names:
|
||||
session.add(BookTag(record_id=record.id, tag=tag))
|
||||
|
||||
if data.parent is not None:
|
||||
# The parent is a link target (`relation="parent"`), so it goes
|
||||
# through the same tenant-scoped lookup `link` uses — a parent
|
||||
# in another tenant (or no such record at all) answers 404
|
||||
# not_found, the same as an explicit `link` call would.
|
||||
await self._get_or_404(session, data.parent, tenant)
|
||||
session.add(BookRecordLink(
|
||||
from_id=record.id,
|
||||
relation="parent",
|
||||
to_id=data.parent,
|
||||
created_by=caller.identity,
|
||||
))
|
||||
|
||||
await self._write_event(session, record.id, caller, "add", {"kind": data.kind, "status": status})
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
logger.info(f"Book record added: kind={data.kind} id={record.id} number={number} tenant={tenant} by={caller.identity}")
|
||||
return record, tag_names
|
||||
|
||||
async def show_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str], List[BookRecordLink], List[BookRecordLink]]:
|
||||
"""`show` — the record, its tags, outgoing links and backlinks."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
tags = await self._tags_for(session, record_id)
|
||||
|
||||
links_stmt = select(BookRecordLink).where(BookRecordLink.from_id == record_id)
|
||||
links = list((await session.execute(links_stmt)).scalars().all())
|
||||
|
||||
backlinks_stmt = select(BookRecordLink).where(BookRecordLink.to_id == record_id)
|
||||
backlinks = list((await session.execute(backlinks_stmt)).scalars().all())
|
||||
|
||||
return record, tags, links, backlinks
|
||||
|
||||
async def list_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
caller: Caller,
|
||||
view: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
scope: Optional[str] = None,
|
||||
parent: Optional[object] = None,
|
||||
number: Optional[int] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||
"""
|
||||
`list` — the nine views of D-70, scoped to the caller's tenant.
|
||||
Status-named views (`inbox`, `waiting`, `someday`, `done`) filter on
|
||||
the matching task status and simply return nothing for kinds that
|
||||
never carry it. `today` and `upcoming` bucket on `due_at` in
|
||||
`book_timezone`; richer due/overdue handling is `due`/`next`, both
|
||||
next round. `number=` finds the one record with that outward id in
|
||||
the caller's tenant (core-api T-7).
|
||||
"""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
conditions = [BookRecord.owner_tenant == tenant]
|
||||
|
||||
if view in ("inbox", "waiting", "someday", "done"):
|
||||
conditions.append(BookRecord.status == view)
|
||||
elif view == "pinned":
|
||||
conditions.append(BookRecord.pinned.is_(True))
|
||||
elif view == "archive":
|
||||
conditions.append(BookRecord.archived_at.isnot(None))
|
||||
elif view == "trash":
|
||||
conditions.append(BookRecord.trashed_at.isnot(None))
|
||||
elif view in ("today", "upcoming"):
|
||||
tz = ZoneInfo(get_settings().book_timezone)
|
||||
local_now = datetime.now(tz)
|
||||
day_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
day_end = day_start.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
conditions.append(BookRecord.due_at.isnot(None))
|
||||
if view == "today":
|
||||
conditions.append(BookRecord.due_at >= day_start)
|
||||
conditions.append(BookRecord.due_at <= day_end)
|
||||
else:
|
||||
conditions.append(BookRecord.due_at > day_end)
|
||||
|
||||
if view not in ("archive", "trash"):
|
||||
conditions.append(BookRecord.archived_at.is_(None))
|
||||
conditions.append(BookRecord.trashed_at.is_(None))
|
||||
|
||||
if scope is not None:
|
||||
conditions.append(BookRecord.scope == scope)
|
||||
|
||||
if parent is not None:
|
||||
parent_subquery = (
|
||||
select(BookRecordLink.from_id)
|
||||
.where(BookRecordLink.relation == "parent", BookRecordLink.to_id == parent)
|
||||
)
|
||||
conditions.append(BookRecord.id.in_(parent_subquery))
|
||||
|
||||
if tag is not None:
|
||||
tag_subquery = select(BookTag.record_id).where(BookTag.tag == tag)
|
||||
conditions.append(BookRecord.id.in_(tag_subquery))
|
||||
|
||||
if number is not None:
|
||||
conditions.append(BookRecord.number == number)
|
||||
|
||||
base_query = select(BookRecord).where(*conditions)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = (
|
||||
base_query
|
||||
.order_by(BookRecord.last_touched_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
records = list((await session.execute(query)).scalars().all())
|
||||
|
||||
results = []
|
||||
for record in records:
|
||||
tags = await self._tags_for(session, record.id)
|
||||
results.append((record, tags))
|
||||
|
||||
return results, total
|
||||
|
||||
async def find_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
caller: Caller,
|
||||
q: str,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
|
||||
"""`find` — full-text search over title and body, scoped to the
|
||||
caller's tenant."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
tsquery = func.websearch_to_tsquery("simple", q)
|
||||
base_query = select(BookRecord).where(
|
||||
BookRecord.owner_tenant == tenant, BookRecord.search.op("@@")(tsquery)
|
||||
)
|
||||
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = base_query.order_by(BookRecord.updated_at.desc()).offset(offset).limit(limit)
|
||||
records = list((await session.execute(query)).scalars().all())
|
||||
|
||||
results = []
|
||||
for record in records:
|
||||
tags = await self._tags_for(session, record.id)
|
||||
results.append((record, tags))
|
||||
|
||||
return results, total
|
||||
|
||||
# =========================================================================
|
||||
# change / close
|
||||
# =========================================================================
|
||||
|
||||
async def change_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
data: BookRecordUpdate,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`change` — a partial update; only fields set on the request move."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
diff: dict = {}
|
||||
|
||||
new_kind = update_data.get("kind", record.kind)
|
||||
if "status" in update_data or "kind" in update_data:
|
||||
resolved_status = self._validate_status(new_kind, update_data.get("status"))
|
||||
update_data["status"] = resolved_status
|
||||
|
||||
if "checklist" in update_data:
|
||||
update_data["checklist"] = [
|
||||
item if isinstance(item, dict) else item.model_dump()
|
||||
for item in update_data["checklist"]
|
||||
]
|
||||
|
||||
for field, value in update_data.items():
|
||||
before = getattr(record, field)
|
||||
if before != value:
|
||||
diff[field] = {"from": str(before), "to": str(value)}
|
||||
setattr(record, field, value)
|
||||
|
||||
self._touch(record)
|
||||
|
||||
if diff:
|
||||
await self._write_event(session, record_id, caller, "change", diff)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
logger.info(f"Book record changed: id={record_id} by={caller.identity} fields={list(diff)}")
|
||||
return record, tags
|
||||
|
||||
async def close_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
data: BookCloseRequest,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""
|
||||
`close` — a Task is done or dropped; a Question is resolved into a
|
||||
Decision (`resolves` link, status `settled`); a Decision is
|
||||
superseded by a newer one (`supersedes` link, status `superseded`).
|
||||
`resolved_by`/`superseded_by` are link targets, so each is looked up
|
||||
in the caller's tenant the same as `link` — a target in another
|
||||
tenant answers 404 not_found.
|
||||
"""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
diff: dict = {}
|
||||
|
||||
if record.kind == "task":
|
||||
if data.outcome is None:
|
||||
raise BookError("invalid_status", 400)
|
||||
record.status = data.outcome
|
||||
diff = {"status": data.outcome}
|
||||
|
||||
elif record.kind == "question":
|
||||
if data.resolved_by is None:
|
||||
raise BookError("invalid_status", 400)
|
||||
decision = await self._get_or_404(session, data.resolved_by, tenant)
|
||||
if decision.kind != "decision":
|
||||
raise BookError("invalid_status", 400)
|
||||
session.add(BookRecordLink(
|
||||
from_id=decision.id,
|
||||
relation="resolves",
|
||||
to_id=record.id,
|
||||
created_by=caller.identity,
|
||||
))
|
||||
record.status = "settled"
|
||||
diff = {"status": "settled", "resolved_by": str(decision.id)}
|
||||
|
||||
elif record.kind == "decision":
|
||||
if data.superseded_by is None:
|
||||
raise BookError("invalid_status", 400)
|
||||
new_decision = await self._get_or_404(session, data.superseded_by, tenant)
|
||||
if new_decision.kind != "decision":
|
||||
raise BookError("invalid_status", 400)
|
||||
session.add(BookRecordLink(
|
||||
from_id=new_decision.id,
|
||||
relation="supersedes",
|
||||
to_id=record.id,
|
||||
created_by=caller.identity,
|
||||
))
|
||||
record.status = "superseded"
|
||||
diff = {"status": "superseded", "superseded_by": str(new_decision.id)}
|
||||
|
||||
else:
|
||||
# Notes and Rejected options have no `close` transition.
|
||||
raise BookError("invalid_status", 400)
|
||||
|
||||
self._touch(record)
|
||||
await self._write_event(session, record_id, caller, "close", diff)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
logger.info(f"Book record closed: id={record_id} by={caller.identity} diff={diff}")
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# link / unlink
|
||||
# =========================================================================
|
||||
|
||||
async def link_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
to_id,
|
||||
relation: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
"""`link` refuses a target in another tenant the same way a missing
|
||||
one is refused — both are 404 not_found (core-api T-7)."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
await self._get_or_404(session, to_id, tenant) # the target must exist in the same tenant
|
||||
|
||||
session.add(BookRecordLink(
|
||||
from_id=record_id,
|
||||
relation=relation,
|
||||
to_id=to_id,
|
||||
created_by=caller.identity,
|
||||
))
|
||||
await self._write_event(
|
||||
session, record_id, caller, "link", {"relation": relation, "to_id": str(to_id)}
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
async def unlink_records(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
to_id,
|
||||
relation: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
stmt = delete(BookRecordLink).where(
|
||||
BookRecordLink.from_id == record_id,
|
||||
BookRecordLink.relation == relation,
|
||||
BookRecordLink.to_id == to_id,
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount == 0:
|
||||
raise BookError("not_found", 404)
|
||||
|
||||
await self._write_event(
|
||||
session, record_id, caller, "unlink", {"relation": relation, "to_id": str(to_id)}
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(record)
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# tag / untag
|
||||
# =========================================================================
|
||||
|
||||
async def tag_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
tag: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
existing = await session.get(BookTag, (record_id, tag))
|
||||
if existing is None:
|
||||
session.add(BookTag(record_id=record_id, tag=tag))
|
||||
await self._write_event(session, record_id, caller, "tag", {"tag": tag})
|
||||
await session.commit()
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
async def untag_record(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
record_id,
|
||||
tag: str,
|
||||
caller: Caller,
|
||||
) -> Tuple[BookRecord, List[str]]:
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
record = await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
stmt = delete(BookTag).where(BookTag.record_id == record_id, BookTag.tag == tag)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
await self._write_event(session, record_id, caller, "untag", {"tag": tag})
|
||||
await session.commit()
|
||||
|
||||
tags = await self._tags_for(session, record_id)
|
||||
return record, tags
|
||||
|
||||
# =========================================================================
|
||||
# history
|
||||
# =========================================================================
|
||||
|
||||
async def get_history(
|
||||
self, session: AsyncSession, record_id, caller: Caller, limit: int = 100
|
||||
) -> List[BookEvent]:
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
await self._get_or_404(session, record_id, tenant)
|
||||
|
||||
stmt = (
|
||||
select(BookEvent)
|
||||
.where(BookEvent.record_id == record_id)
|
||||
.order_by(BookEvent.at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_book_service: Optional[BookService] = None
|
||||
|
||||
|
||||
def get_book_service() -> BookService:
|
||||
"""Get singleton Book service instance"""
|
||||
global _book_service
|
||||
if _book_service is None:
|
||||
_book_service = BookService()
|
||||
return _book_service
|
||||
@@ -13,9 +13,15 @@ from src.shared.base import BaseController
|
||||
from src.shared.clients import get_homeassistant_client
|
||||
from src.shared.logging import get_logger
|
||||
from src.domains.auth.oidc import get_admin_user
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# api.schweitz.net carries no forward-auth, so these reads were otherwise
|
||||
# open to the internet (T-1 / D-68). Writes are untouched — see controller
|
||||
# comments at each `get_admin_user` dependency below.
|
||||
require_housekeeping_reader = require_caller("office", "tatlock", "user")
|
||||
|
||||
|
||||
# Pydantic Schemas
|
||||
class Device(BaseModel):
|
||||
@@ -188,7 +194,7 @@ class HousekeepingController(BaseController):
|
||||
response_model=HealthResponse,
|
||||
summary="Home automation health check"
|
||||
)
|
||||
async def get_health():
|
||||
async def get_health(caller: Caller = Depends(require_housekeeping_reader)):
|
||||
"""Check Home Assistant connection health"""
|
||||
ha = get_homeassistant_client()
|
||||
return await ha.health_check()
|
||||
@@ -200,7 +206,8 @@ class HousekeepingController(BaseController):
|
||||
)
|
||||
async def list_devices(
|
||||
domain: Optional[str] = Query(None, description="Filter by domain (light, switch, climate, etc.)"),
|
||||
area: Optional[str] = Query(None, description="Filter by area/room name")
|
||||
area: Optional[str] = Query(None, description="Filter by area/room name"),
|
||||
caller: Caller = Depends(require_housekeeping_reader),
|
||||
):
|
||||
"""List all available devices with optional filtering"""
|
||||
ha = get_homeassistant_client()
|
||||
@@ -256,7 +263,10 @@ class HousekeepingController(BaseController):
|
||||
response_model=DeviceDetailResponse,
|
||||
responses={404: {"model": ErrorResponse}}
|
||||
)
|
||||
async def get_device(entity_id: str):
|
||||
async def get_device(
|
||||
entity_id: str,
|
||||
caller: Caller = Depends(require_housekeeping_reader),
|
||||
):
|
||||
"""Get detailed state of a specific device"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
@@ -296,7 +306,7 @@ class HousekeepingController(BaseController):
|
||||
response_model=AreaListResponse,
|
||||
summary="List areas/rooms"
|
||||
)
|
||||
async def list_areas():
|
||||
async def list_areas(caller: Caller = Depends(require_housekeeping_reader)):
|
||||
"""List all configured areas/rooms in Home Assistant"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
@@ -389,7 +399,7 @@ class HousekeepingController(BaseController):
|
||||
response_model=SceneListResponse,
|
||||
summary="List available scenes"
|
||||
)
|
||||
async def list_scenes():
|
||||
async def list_scenes(caller: Caller = Depends(require_housekeeping_reader)):
|
||||
"""List all available scenes in Home Assistant"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
@@ -459,7 +469,7 @@ class HousekeepingController(BaseController):
|
||||
response_model=ScriptListResponse,
|
||||
summary="List available scripts"
|
||||
)
|
||||
async def list_scripts():
|
||||
async def list_scripts(caller: Caller = Depends(require_housekeeping_reader)):
|
||||
"""List all available scripts/sequences in Home Assistant"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
@@ -531,7 +541,7 @@ class HousekeepingController(BaseController):
|
||||
response_model=AutomationListResponse,
|
||||
summary="List automations"
|
||||
)
|
||||
async def list_automations():
|
||||
async def list_automations(caller: Caller = Depends(require_housekeeping_reader)):
|
||||
"""List all automations with their enabled/disabled status"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
@@ -609,7 +619,8 @@ class HousekeepingController(BaseController):
|
||||
)
|
||||
async def get_history(
|
||||
entity_id: str = Query(..., description="Entity ID to get history for"),
|
||||
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)")
|
||||
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)"),
|
||||
caller: Caller = Depends(require_housekeeping_reader),
|
||||
):
|
||||
"""Get state history for a device"""
|
||||
ha = get_homeassistant_client()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Scribe Domain
|
||||
|
||||
The household's message bus (workspace D-66, D-68, D-73): addresses, send,
|
||||
mailbox, history, find, mark, tag. Served under /scribe/*.
|
||||
"""
|
||||
from src.domains.scribe.controller import scribe_controller
|
||||
|
||||
__all__ = ["scribe_controller"]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Scribe Controller
|
||||
|
||||
The Scribe's messaging verbs (dossier core-api T-5): register, list
|
||||
(addresses), send, mailbox, history, find, mark, tag/untag. Every route is
|
||||
behind an Office or user caller (T-1/T-4); 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.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
from src.domains.scribe.schemas import (
|
||||
AddressListResponse,
|
||||
AddressRegisterRequest,
|
||||
AddressResponse,
|
||||
HistoryResponse,
|
||||
MailboxEntryResponse,
|
||||
MailboxListResponse,
|
||||
MailboxPatchRequest,
|
||||
MessageResponse,
|
||||
MessageSendRequest,
|
||||
)
|
||||
from src.domains.scribe.service import ScribeError, get_scribe_service
|
||||
from src.shared.base import BaseController
|
||||
from src.shared.database import get_async_session
|
||||
from src.shared.logging import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
require_scribe_reader = require_caller("office", "user")
|
||||
require_scribe_writer = require_caller("office", "user", identity_required=True)
|
||||
|
||||
|
||||
def _address_response(address, owned: bool) -> AddressResponse:
|
||||
return AddressResponse(
|
||||
name=address.name,
|
||||
owner_consumer=address.owner_consumer,
|
||||
created_by=address.created_by,
|
||||
created_at=address.created_at,
|
||||
owned=owned,
|
||||
)
|
||||
|
||||
|
||||
def _message_response(message) -> MessageResponse:
|
||||
return MessageResponse.model_validate(message, from_attributes=True)
|
||||
|
||||
|
||||
def _entry_response(entry, message) -> MailboxEntryResponse:
|
||||
return MailboxEntryResponse(
|
||||
id=entry.id,
|
||||
address=entry.address,
|
||||
read_at=entry.read_at,
|
||||
tags=entry.tags,
|
||||
created_at=entry.created_at,
|
||||
message=_message_response(message),
|
||||
)
|
||||
|
||||
|
||||
class ScribeController(BaseController):
|
||||
"""Controller for the Scribe domain."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/scribe", tags=["Scribe"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
service = get_scribe_service()
|
||||
|
||||
# =====================================================================
|
||||
# register / list addresses
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/addresses",
|
||||
response_model=AddressResponse,
|
||||
summary="Register an address (idempotent for the same consumer)",
|
||||
)
|
||||
async def register_address(
|
||||
data: AddressRegisterRequest,
|
||||
caller: Caller = Depends(require_scribe_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
address, owned = await service.register_address(session, data.name, caller)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return _address_response(address, owned)
|
||||
|
||||
@router.get(
|
||||
"/addresses",
|
||||
response_model=AddressListResponse,
|
||||
summary="List addresses in the caller's tenant",
|
||||
)
|
||||
async def list_addresses(
|
||||
caller: Caller = Depends(require_scribe_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
results = await service.list_addresses(session, caller)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return AddressListResponse(
|
||||
addresses=[_address_response(address, owned) for address, owned in results]
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# send
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/messages",
|
||||
response_model=MessageResponse,
|
||||
status_code=201,
|
||||
summary="Send a message to one or more addresses",
|
||||
)
|
||||
async def send_message(
|
||||
data: MessageSendRequest,
|
||||
caller: Caller = Depends(require_scribe_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
message = await service.send_message(session, data, caller)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return _message_response(message)
|
||||
|
||||
# =====================================================================
|
||||
# find
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/messages",
|
||||
response_model=MailboxListResponse,
|
||||
summary="Full text search over messages in the caller identity's mailbox",
|
||||
)
|
||||
async def find_messages(
|
||||
q: str = Query(..., min_length=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
caller: Caller = Depends(require_scribe_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
results, total = await service.find_messages(session, caller, q, limit)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return MailboxListResponse(
|
||||
entries=[_entry_response(entry, message) for entry, message in results],
|
||||
total=total,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# mailbox
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/mailbox",
|
||||
response_model=MailboxListResponse,
|
||||
summary="The caller identity's mailbox, unread first",
|
||||
)
|
||||
async def get_mailbox(
|
||||
unread: bool = Query(False),
|
||||
tag: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
before_id: Optional[int] = Query(None),
|
||||
caller: Caller = Depends(require_scribe_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
results, total = await service.get_mailbox(
|
||||
session, caller, unread=unread, tag=tag, limit=limit, before_id=before_id
|
||||
)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return MailboxListResponse(
|
||||
entries=[_entry_response(entry, message) for entry, message in results],
|
||||
total=total,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@router.patch(
|
||||
"/mailbox/{entry_id}",
|
||||
response_model=MailboxEntryResponse,
|
||||
summary="Mark read/unread, or tag/untag a mailbox entry",
|
||||
)
|
||||
async def patch_mailbox_entry(
|
||||
entry_id: int,
|
||||
data: MailboxPatchRequest,
|
||||
caller: Caller = Depends(require_scribe_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
entry, message = await service.patch_mailbox_entry(session, entry_id, data, caller)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return _entry_response(entry, message)
|
||||
|
||||
# =====================================================================
|
||||
# history
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/addresses/{name}/history",
|
||||
response_model=HistoryResponse,
|
||||
summary="The retained messages sent to an address, oldest first",
|
||||
)
|
||||
async def address_history(
|
||||
name: str,
|
||||
since_id: Optional[int] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
caller: Caller = Depends(require_scribe_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
messages = await service.get_history(session, name, caller, since_id, limit)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return HistoryResponse(
|
||||
address=name,
|
||||
messages=[_message_response(message) for message in messages],
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
scribe_controller = ScribeController()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Scribe Domain Models
|
||||
|
||||
The Scribe is the household's message bus (workspace D-66, D-68, D-73): a
|
||||
retained pub/sub log where an address is a topic. This round ships the
|
||||
primitives migration 006 creates — addresses, messages, mailbox entries —
|
||||
for direct messaging only; groups, subscriptions, the notification matrix
|
||||
and delivery are later work (workspace T-176).
|
||||
|
||||
SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
||||
src/domains/book/models.py. Every table carries `owner_tenant` and every
|
||||
query in the service layer filters on it (D-73's hard tenant boundary) —
|
||||
there is no database-level RLS here, so that filter is load-bearing in
|
||||
Python, not just in SQL.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Computed,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Identity,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.shared.database import Base
|
||||
|
||||
# Address name shape, identical to src/domains/auth/callers.py's identity
|
||||
# rule (an address is named for an identity, D-66) — not imported from there
|
||||
# because that module's constants are private to it; kept here so a drift
|
||||
# between the two never happens silently, the regex is asserted equal in
|
||||
# tests/test_scribe.py instead.
|
||||
ADDRESS_NAME_PATTERN = r"^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
RESERVED_ADDRESS_NAMES = {"book", "office", "scheduler", "tatlock"}
|
||||
|
||||
|
||||
class ScribeAddress(Base):
|
||||
"""
|
||||
An address: a topic named for an identity. `register` creates it once
|
||||
(D-66) — the pair (owner_tenant, name) is the natural key, so no
|
||||
surrogate id: no route ever references an address except by name.
|
||||
"""
|
||||
|
||||
__tablename__ = "scribe_addresses"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"),
|
||||
)
|
||||
|
||||
# FK to tenants.id added by migration 007 (core-api T-8).
|
||||
owner_tenant: Mapped[str] = mapped_column(
|
||||
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), primary_key=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||
# Nullable: a "user"-kind caller (direct OIDC, not through the Office)
|
||||
# carries no consumer (src/domains/auth/callers.py), so an address it
|
||||
# registers has none either.
|
||||
owner_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ScribeAddress {self.owner_tenant}/{self.name}>"
|
||||
|
||||
|
||||
class ScribeMessage(Base):
|
||||
"""
|
||||
A retained message. `topics` is the set of addresses (and, later,
|
||||
groups) it was published to — an integer array is denormalized from
|
||||
the mailbox entries so `history` can read the retained log directly
|
||||
without joining through them (D-66: "topics are retained and
|
||||
re-readable"). Nothing deletes a message.
|
||||
"""
|
||||
|
||||
__tablename__ = "scribe_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
|
||||
# FK to tenants.id added by migration 007 (core-api T-8).
|
||||
owner_tenant: Mapped[str] = mapped_column(
|
||||
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||
from_identity: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||
subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||
reply_to: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("scribe_messages.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
# Generated by migration 006 (`to_tsvector('simple', ...)`, GIN-indexed),
|
||||
# 'simple' for the same reason as book_records.search: mixed Dutch/
|
||||
# English content. `Computed` mirrors the migration's SQL.
|
||||
search: Mapped[Optional[str]] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed("to_tsvector('simple', coalesce(subject, '') || ' ' || body)", persisted=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ScribeMessage {self.id} from={self.from_identity}>"
|
||||
|
||||
|
||||
class ScribeMailboxEntry(Base):
|
||||
"""One row per recipient address per message — what lands in a mailbox."""
|
||||
|
||||
__tablename__ = "scribe_mailbox_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("address", "message_id", name="uq_scribe_mailbox_entries_address_message"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
|
||||
address: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
# FK to tenants.id added by migration 007 (core-api T-8).
|
||||
owner_tenant: Mapped[str] = mapped_column(
|
||||
String(63), ForeignKey("tenants.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
message_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("scribe_messages.id"), nullable=False
|
||||
)
|
||||
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ScribeMailboxEntry {self.id} address={self.address} message={self.message_id}>"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Scribe Domain Schemas
|
||||
|
||||
Pydantic request/response payloads for the Scribe (D-66, D-68, D-73). This
|
||||
round ships `register`, `list` (addresses), `send`, `mailbox`, `history`,
|
||||
`find`, `mark`, `tag`/`untag` — groups, subscriptions, the notification
|
||||
matrix and delivery are later work (dossier core-api T-5).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from src.domains.scribe.models import ADDRESS_NAME_PATTERN
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
class AddressRegisterRequest(BaseSchema):
|
||||
"""`register` — the name only; owner (tenant, consumer) comes from the caller."""
|
||||
|
||||
name: str = Field(..., pattern=ADDRESS_NAME_PATTERN)
|
||||
|
||||
|
||||
class AddressResponse(BaseSchema):
|
||||
name: str
|
||||
owner_consumer: Optional[str]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
owned: bool = Field(..., description="True when the caller's consumer registered this address")
|
||||
|
||||
|
||||
class AddressListResponse(BaseSchema):
|
||||
addresses: List[AddressResponse]
|
||||
|
||||
|
||||
class MessageSendRequest(BaseSchema):
|
||||
"""`send` — `from` is never in the body; it is the caller's own identity."""
|
||||
|
||||
to: List[str] = Field(..., min_length=1)
|
||||
subject: Optional[str] = Field(None, max_length=500)
|
||||
body: str = Field(..., min_length=1)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
reply_to: Optional[int] = None
|
||||
|
||||
|
||||
class MessageResponse(BaseSchema):
|
||||
id: int
|
||||
topics: List[str]
|
||||
from_identity: str
|
||||
from_consumer: Optional[str]
|
||||
subject: Optional[str]
|
||||
body: str
|
||||
tags: List[str]
|
||||
reply_to: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MailboxEntryResponse(BaseSchema):
|
||||
"""`mailbox` / `find` rows, and the result of `mark`/`tag`/`untag`."""
|
||||
|
||||
id: int
|
||||
address: str
|
||||
read_at: Optional[datetime]
|
||||
tags: List[str]
|
||||
created_at: datetime
|
||||
message: MessageResponse
|
||||
|
||||
|
||||
class MailboxListResponse(BaseSchema):
|
||||
entries: List[MailboxEntryResponse]
|
||||
total: int
|
||||
limit: int
|
||||
|
||||
|
||||
class HistoryResponse(BaseSchema):
|
||||
address: str
|
||||
messages: List[MessageResponse]
|
||||
|
||||
|
||||
class MailboxPatchRequest(BaseSchema):
|
||||
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share one route."""
|
||||
|
||||
read: Optional[bool] = None
|
||||
add_tags: Optional[List[str]] = None
|
||||
remove_tags: Optional[List[str]] = None
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
Scribe Domain Service
|
||||
|
||||
Business logic for the Scribe — the household's message bus (D-66, D-68,
|
||||
D-73). Every method starts by requiring a tenant on the caller and ends by
|
||||
filtering its query on it: **an address or message in another tenant
|
||||
answers exactly like one that does not exist** (dossier core-api T-5), so
|
||||
no method here ever confirms an id across that boundary.
|
||||
|
||||
Methods take `session: AsyncSession` per call rather than storing it on
|
||||
`self`, following src/domains/book/service.py.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.callers import Caller
|
||||
from src.domains.scribe.models import (
|
||||
RESERVED_ADDRESS_NAMES,
|
||||
ScribeAddress,
|
||||
ScribeMailboxEntry,
|
||||
ScribeMessage,
|
||||
)
|
||||
from src.domains.scribe.schemas import MailboxPatchRequest, MessageSendRequest
|
||||
from src.domains.tenants.service import TenantError, get_tenants_service
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ScribeError(Exception):
|
||||
"""Raised for a Scribe-domain failure; the controller maps it to a response."""
|
||||
|
||||
def __init__(self, code: str, status_code: int = 400, **extra):
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.extra = extra
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class ScribeService:
|
||||
"""Service for Scribe operations."""
|
||||
|
||||
# =========================================================================
|
||||
# Internal helpers
|
||||
# =========================================================================
|
||||
|
||||
async def _require_tenant(self, session: AsyncSession, caller: Caller) -> str:
|
||||
"""Every Scribe operation needs a tenant; a caller without one gets
|
||||
a clear error rather than a query that silently matches nothing.
|
||||
|
||||
Delegates to the tenant registry's one enforcement point (core-api
|
||||
T-8 / workspace D-73), which additionally refuses an unknown
|
||||
tenant, a suspended one, or an Office consumer with no grant in it.
|
||||
`TenantError` is translated to `ScribeError` so every call site and
|
||||
the controller's single `except ScribeError` keep working
|
||||
unchanged."""
|
||||
try:
|
||||
return await get_tenants_service().require_tenant(session, caller)
|
||||
except TenantError as error:
|
||||
raise ScribeError(error.code, error.status_code) from error
|
||||
|
||||
def _validate_address_name(self, name: str) -> None:
|
||||
"""Beyond the schema's regex (shape only): not `group-*`, not reserved.
|
||||
Mirrors src/domains/auth/callers.py's `_validate_identity` rule —
|
||||
an address is named for an identity (D-66)."""
|
||||
if name.startswith("group-") or name in RESERVED_ADDRESS_NAMES:
|
||||
raise ScribeError("address_invalid", 400)
|
||||
|
||||
async def _get_address(self, session: AsyncSession, tenant: str, name: str) -> Optional[ScribeAddress]:
|
||||
return await session.get(ScribeAddress, (tenant, name))
|
||||
|
||||
async def _require_owned_identity(self, session: AsyncSession, caller: Caller, tenant: str) -> None:
|
||||
"""mailbox/find read the caller's own identity's mailbox: the identity
|
||||
must be a registered address owned by the caller's consumer, else
|
||||
403 identity_not_owned — the same refusal `send` uses for `from`."""
|
||||
address = await self._get_address(session, tenant, caller.identity)
|
||||
if address is None or address.owner_consumer != caller.consumer:
|
||||
raise ScribeError("identity_not_owned", 403)
|
||||
|
||||
async def _messages_for_entries(
|
||||
self, session: AsyncSession, entries: List[ScribeMailboxEntry]
|
||||
) -> dict:
|
||||
if not entries:
|
||||
return {}
|
||||
ids = [entry.message_id for entry in entries]
|
||||
stmt = select(ScribeMessage).where(ScribeMessage.id.in_(ids))
|
||||
messages = (await session.execute(stmt)).scalars().all()
|
||||
return {message.id: message for message in messages}
|
||||
|
||||
# =========================================================================
|
||||
# register / list addresses
|
||||
# =========================================================================
|
||||
|
||||
async def register_address(
|
||||
self, session: AsyncSession, name: str, caller: Caller
|
||||
) -> Tuple[ScribeAddress, bool]:
|
||||
"""`register` — idempotent for the same consumer; 409 address_taken
|
||||
for another consumer in the tenant (D-66)."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
self._validate_address_name(name)
|
||||
|
||||
existing = await self._get_address(session, tenant, name)
|
||||
if existing is not None:
|
||||
if existing.owner_consumer == caller.consumer:
|
||||
return existing, True
|
||||
raise ScribeError("address_taken", 409)
|
||||
|
||||
address = ScribeAddress(
|
||||
owner_tenant=tenant,
|
||||
name=name,
|
||||
owner_consumer=caller.consumer,
|
||||
created_by=caller.identity,
|
||||
)
|
||||
session.add(address)
|
||||
await session.commit()
|
||||
await session.refresh(address)
|
||||
|
||||
logger.info(f"Scribe address registered: {tenant}/{name} by={caller.identity}")
|
||||
return address, True
|
||||
|
||||
async def list_addresses(
|
||||
self, session: AsyncSession, caller: Caller
|
||||
) -> List[Tuple[ScribeAddress, bool]]:
|
||||
"""`list` — the caller's tenant; mark which the caller's consumer owns."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
|
||||
stmt = (
|
||||
select(ScribeAddress)
|
||||
.where(ScribeAddress.owner_tenant == tenant)
|
||||
.order_by(ScribeAddress.name)
|
||||
)
|
||||
addresses = (await session.execute(stmt)).scalars().all()
|
||||
return [(a, a.owner_consumer == caller.consumer) for a in addresses]
|
||||
|
||||
# =========================================================================
|
||||
# send
|
||||
# =========================================================================
|
||||
|
||||
async def send_message(
|
||||
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
|
||||
) -> ScribeMessage:
|
||||
"""`send` — `from` is the caller's own identity, never the body."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
|
||||
from_address = await self._get_address(session, tenant, caller.identity)
|
||||
if from_address is None or from_address.owner_consumer != caller.consumer:
|
||||
raise ScribeError("identity_not_owned", 403)
|
||||
|
||||
recipients = list(dict.fromkeys(data.to)) # de-dup, keep order
|
||||
found_stmt = select(ScribeAddress.name).where(
|
||||
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
|
||||
)
|
||||
found = set((await session.execute(found_stmt)).scalars().all())
|
||||
missing = [name for name in recipients if name not in found]
|
||||
if missing:
|
||||
raise ScribeError("unknown_address", 404, names=missing)
|
||||
|
||||
if data.reply_to is not None:
|
||||
reply_target = await session.get(ScribeMessage, data.reply_to)
|
||||
if reply_target is None or reply_target.owner_tenant != tenant:
|
||||
raise ScribeError("not_found", 404)
|
||||
|
||||
tags = list(dict.fromkeys(data.tags))
|
||||
|
||||
message = ScribeMessage(
|
||||
owner_tenant=tenant,
|
||||
topics=recipients,
|
||||
from_identity=caller.identity,
|
||||
from_consumer=caller.consumer,
|
||||
subject=data.subject,
|
||||
body=data.body,
|
||||
tags=tags,
|
||||
reply_to=data.reply_to,
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush() # assign message.id
|
||||
|
||||
for recipient in recipients:
|
||||
session.add(
|
||||
ScribeMailboxEntry(address=recipient, owner_tenant=tenant, message_id=message.id)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
|
||||
logger.info(
|
||||
f"Scribe message sent: id={message.id} from={caller.identity} to={recipients}"
|
||||
)
|
||||
return message
|
||||
|
||||
# =========================================================================
|
||||
# mailbox / find
|
||||
# =========================================================================
|
||||
|
||||
async def get_mailbox(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
caller: Caller,
|
||||
unread: Optional[bool] = None,
|
||||
tag: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
before_id: Optional[int] = None,
|
||||
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
||||
"""`mailbox` — the caller's identity's entries, unread first."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
await self._require_owned_identity(session, caller, tenant)
|
||||
|
||||
conditions = [
|
||||
ScribeMailboxEntry.owner_tenant == tenant,
|
||||
ScribeMailboxEntry.address == caller.identity,
|
||||
]
|
||||
if unread:
|
||||
conditions.append(ScribeMailboxEntry.read_at.is_(None))
|
||||
if tag is not None:
|
||||
conditions.append(ScribeMailboxEntry.tags.contains([tag]))
|
||||
if before_id is not None:
|
||||
conditions.append(ScribeMailboxEntry.id < before_id)
|
||||
|
||||
base_query = select(ScribeMailboxEntry).where(*conditions)
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = (
|
||||
base_query
|
||||
.order_by(ScribeMailboxEntry.read_at.is_(None).desc(), ScribeMailboxEntry.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
entries = list((await session.execute(query)).scalars().all())
|
||||
messages_by_id = await self._messages_for_entries(session, entries)
|
||||
|
||||
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
|
||||
|
||||
async def find_messages(
|
||||
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
|
||||
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
||||
"""`find` — full text over messages in the caller identity's mailbox."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
await self._require_owned_identity(session, caller, tenant)
|
||||
|
||||
tsquery = func.websearch_to_tsquery("simple", q)
|
||||
base_query = (
|
||||
select(ScribeMailboxEntry)
|
||||
.join(ScribeMessage, ScribeMailboxEntry.message_id == ScribeMessage.id)
|
||||
.where(
|
||||
ScribeMailboxEntry.owner_tenant == tenant,
|
||||
ScribeMailboxEntry.address == caller.identity,
|
||||
ScribeMessage.search.op("@@")(tsquery),
|
||||
)
|
||||
)
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = base_query.order_by(ScribeMailboxEntry.id.desc()).limit(limit)
|
||||
entries = list((await session.execute(query)).scalars().all())
|
||||
messages_by_id = await self._messages_for_entries(session, entries)
|
||||
|
||||
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
|
||||
|
||||
# =========================================================================
|
||||
# history
|
||||
# =========================================================================
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
caller: Caller,
|
||||
since_id: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
) -> List[ScribeMessage]:
|
||||
"""`history` — the retained messages sent to that address, oldest
|
||||
first. Reading never consumes or marks: this reads scribe_messages
|
||||
directly, never scribe_mailbox_entries."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
|
||||
address = await self._get_address(session, tenant, name)
|
||||
if address is None:
|
||||
raise ScribeError("unknown_address", 404)
|
||||
|
||||
conditions = [
|
||||
ScribeMessage.owner_tenant == tenant,
|
||||
ScribeMessage.topics.contains([name]),
|
||||
]
|
||||
if since_id is not None:
|
||||
conditions.append(ScribeMessage.id > since_id)
|
||||
|
||||
stmt = (
|
||||
select(ScribeMessage)
|
||||
.where(*conditions)
|
||||
.order_by(ScribeMessage.id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
# =========================================================================
|
||||
# mark / tag / untag
|
||||
# =========================================================================
|
||||
|
||||
async def patch_mailbox_entry(
|
||||
self, session: AsyncSession, entry_id: int, data: MailboxPatchRequest, caller: Caller
|
||||
) -> Tuple[ScribeMailboxEntry, ScribeMessage]:
|
||||
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share
|
||||
this route. An entry outside the caller's tenant, or addressed to
|
||||
an identity the caller is not reading as, answers 404 not_found —
|
||||
indistinguishable from a nonexistent entry, so an id never confirms
|
||||
someone else's mail exists."""
|
||||
tenant = await self._require_tenant(session, caller)
|
||||
|
||||
entry = await session.get(ScribeMailboxEntry, entry_id)
|
||||
if entry is None or entry.owner_tenant != tenant or entry.address != caller.identity:
|
||||
raise ScribeError("not_found", 404)
|
||||
|
||||
if data.read is True and entry.read_at is None:
|
||||
entry.read_at = datetime.now(timezone.utc)
|
||||
elif data.read is False:
|
||||
entry.read_at = None
|
||||
|
||||
if data.add_tags:
|
||||
current = set(entry.tags)
|
||||
entry.tags = list(entry.tags) + [t for t in data.add_tags if t not in current]
|
||||
if data.remove_tags:
|
||||
remove = set(data.remove_tags)
|
||||
entry.tags = [t for t in entry.tags if t not in remove]
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
|
||||
message = await session.get(ScribeMessage, entry.message_id)
|
||||
return entry, message
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_scribe_service: Optional[ScribeService] = None
|
||||
|
||||
|
||||
def get_scribe_service() -> ScribeService:
|
||||
"""Get singleton Scribe service instance"""
|
||||
global _scribe_service
|
||||
if _scribe_service is None:
|
||||
_scribe_service = ScribeService()
|
||||
return _scribe_service
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Tenants Domain
|
||||
|
||||
The tenant registry (core-api T-8 / workspace D-73): tenants, consumer
|
||||
grants, and the one enforcement point every tenant-scoped domain's own
|
||||
`_require_tenant` calls through to (Book, Scribe). Served under /tenants/*.
|
||||
"""
|
||||
from src.domains.tenants.controller import tenants_controller
|
||||
|
||||
__all__ = ["tenants_controller"]
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Tenants Controller
|
||||
|
||||
The tenant registry's read API (core-api T-8 / workspace D-73): `GET
|
||||
/tenants/me` and `GET /tenants`. No write routes this round — provisioning
|
||||
is later (workspace T-195).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
from src.domains.tenants.schemas import TenantListResponse, TenantMeResponse, TenantSummary
|
||||
from src.domains.tenants.service import TenantError, get_tenants_service
|
||||
from src.shared.base import BaseController
|
||||
from src.shared.database import get_async_session
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
require_tenants_reader = require_caller("office", "user")
|
||||
require_tenants_lister = require_caller("office", "user", "tatlock", "scheduler")
|
||||
|
||||
|
||||
def _summary(tenant) -> TenantSummary:
|
||||
return TenantSummary(
|
||||
id=tenant.id, display_name=tenant.display_name, kind=tenant.kind, status=tenant.status
|
||||
)
|
||||
|
||||
|
||||
class TenantsController(BaseController):
|
||||
"""Controller for the tenant registry's read API."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/tenants", tags=["Tenants"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
service = get_tenants_service()
|
||||
|
||||
# =====================================================================
|
||||
# me
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=TenantMeResponse,
|
||||
summary="The caller's own tenant and the consumers granted in it",
|
||||
)
|
||||
async def get_me(
|
||||
caller: Caller = Depends(require_tenants_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
tenant, consumers = await service.get_me(session, caller)
|
||||
except TenantError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code}
|
||||
) from error
|
||||
return TenantMeResponse(
|
||||
id=tenant.id,
|
||||
display_name=tenant.display_name,
|
||||
kind=tenant.kind,
|
||||
status=tenant.status,
|
||||
consumers=consumers,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# list
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=TenantListResponse,
|
||||
summary="Every tenant (tatlock/scheduler) or the caller's own, as a list of one",
|
||||
)
|
||||
async def list_tenants(
|
||||
caller: Caller = Depends(require_tenants_lister),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
tenants = await service.list_tenants(session, caller)
|
||||
except TenantError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code}
|
||||
) from error
|
||||
return TenantListResponse(tenants=[_summary(tenant) for tenant in tenants])
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
tenants_controller = TenantsController()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Tenants Domain Models
|
||||
|
||||
The tenant registry (core-api T-8 / workspace D-73): every Book and Scribe
|
||||
`owner_tenant` column carries a foreign key to `tenants.id` (migration 007),
|
||||
and `TenantsService.require_tenant` is the one place a caller's tenant is
|
||||
checked known, active, and — for an Office caller — granted to the calling
|
||||
consumer. SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
||||
src/domains/book/models.py.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.shared.database import Base
|
||||
|
||||
TENANT_KINDS = ("household", "person", "test")
|
||||
TENANT_STATUSES = ("active", "suspended")
|
||||
|
||||
# The slug shape a tenant id carries, identical to
|
||||
# src/domains/auth/callers.py's `_TENANT_RE` (tatlock's own tenant ids carry
|
||||
# underscores, e.g. `llm_tester`) — not imported from there because that
|
||||
# module's constants are private to it, following
|
||||
# src/domains/scribe/models.py's same note about ADDRESS_NAME_PATTERN.
|
||||
# Asserted equal in tests/test_tenants.py.
|
||||
TENANT_ID_PATTERN = r"^[a-z0-9_][a-z0-9_.-]{0,62}$"
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""A tenant: tatlock's own per-household/person/test namespace."""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN ('household', 'person', 'test')", name="ck_tenants_kind"),
|
||||
CheckConstraint("status IN ('active', 'suspended')", name="ck_tenants_status"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||
display_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant {self.id} kind={self.kind} status={self.status}>"
|
||||
|
||||
|
||||
class TenantConsumer(Base):
|
||||
"""A grant: consumer `consumer` may act in tenant `tenant_id`."""
|
||||
|
||||
__tablename__ = "tenant_consumers"
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(63), ForeignKey("tenants.id"), primary_key=True
|
||||
)
|
||||
consumer: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TenantConsumer {self.tenant_id}/{self.consumer}>"
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Tenants Domain Schemas
|
||||
|
||||
Pydantic request/response payloads for the tenant registry (core-api T-8 /
|
||||
workspace D-73). No write routes this round — provisioning is later
|
||||
(workspace T-195).
|
||||
"""
|
||||
from typing import List
|
||||
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
class TenantSummary(BaseSchema):
|
||||
"""The shape `GET /tenants` answers with, per tenant."""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
kind: str
|
||||
status: str
|
||||
|
||||
|
||||
class TenantMeResponse(TenantSummary):
|
||||
"""`GET /tenants/me` — the caller's own tenant plus the consumers
|
||||
granted in it, the cheap "am I allowed here" probe (D-73)."""
|
||||
|
||||
consumers: List[str]
|
||||
|
||||
|
||||
class TenantListResponse(BaseSchema):
|
||||
tenants: List[TenantSummary]
|
||||
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
Tenants Domain Service
|
||||
|
||||
The tenant registry (core-api T-8 / workspace D-73): `require_tenant` is
|
||||
the one enforcement point Book's and Scribe's own `_require_tenant` now
|
||||
delegate to, rather than each checking only presence (core-api T-1/T-5's
|
||||
original shape, which let a typo'd claim open a fresh, empty namespace
|
||||
instead of being refused).
|
||||
|
||||
It takes the session, so it is one query: a LEFT JOIN against the grant
|
||||
table, so presence, status and (for an Office caller) the grant are all
|
||||
read in a single round trip. When `caller.consumer` is `None` (tatlock,
|
||||
scheduler, or a user-kind caller — core-api T-6 carries no consumer for
|
||||
those), the join condition `TenantConsumer.consumer == None` compiles to
|
||||
`IS NULL`, which never matches a grant row (consumer is NOT NULL there),
|
||||
so the grant side of the row is always empty for them — correct, since the
|
||||
grant check below is skipped outright when `caller.consumer` is `None`.
|
||||
"""
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.callers import Caller
|
||||
from src.domains.tenants.models import Tenant, TenantConsumer
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TenantError(Exception):
|
||||
"""Raised for a tenant-registry failure; the controller maps it to a response."""
|
||||
|
||||
def __init__(self, code: str, status_code: int = 400):
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class TenantsService:
|
||||
"""The tenant registry's one enforcement point, and its read API."""
|
||||
|
||||
async def require_tenant(self, session: AsyncSession, caller: Caller) -> str:
|
||||
"""
|
||||
The one enforcement point (core-api T-8 / workspace D-73):
|
||||
|
||||
- no tenant on the caller -> 400 `tenant_required` (unchanged)
|
||||
- tenant not in the table -> 403 `tenant_unknown`
|
||||
- tenant `suspended` -> 403 `tenant_suspended`
|
||||
- an Office caller (carries a consumer) whose consumer holds no
|
||||
grant in that tenant -> 403 `consumer_not_granted`
|
||||
- a user-kind caller carries no consumer (core-api T-6, not this
|
||||
round): tenant checks only.
|
||||
|
||||
Returns the tenant id on success.
|
||||
"""
|
||||
if not caller.tenant:
|
||||
raise TenantError("tenant_required", 400)
|
||||
tenant = caller.tenant
|
||||
|
||||
stmt = (
|
||||
select(Tenant.status, TenantConsumer.consumer)
|
||||
.outerjoin(
|
||||
TenantConsumer,
|
||||
and_(
|
||||
TenantConsumer.tenant_id == Tenant.id,
|
||||
TenantConsumer.consumer == caller.consumer,
|
||||
),
|
||||
)
|
||||
.where(Tenant.id == tenant)
|
||||
)
|
||||
row = (await session.execute(stmt)).first()
|
||||
if row is None:
|
||||
raise TenantError("tenant_unknown", 403)
|
||||
|
||||
status, granted_consumer = row
|
||||
if status == "suspended":
|
||||
raise TenantError("tenant_suspended", 403)
|
||||
if caller.consumer is not None and granted_consumer is None:
|
||||
raise TenantError("consumer_not_granted", 403)
|
||||
|
||||
return tenant
|
||||
|
||||
async def get_me(self, session: AsyncSession, caller: Caller) -> Tuple[Tenant, List[str]]:
|
||||
"""`GET /tenants/me` — through the same helper, so "am I allowed
|
||||
here" shares its answer with every other tenant-scoped call."""
|
||||
tenant_id = await self.require_tenant(session, caller)
|
||||
|
||||
tenant = await session.get(Tenant, tenant_id)
|
||||
consumers_stmt = (
|
||||
select(TenantConsumer.consumer)
|
||||
.where(TenantConsumer.tenant_id == tenant_id)
|
||||
.order_by(TenantConsumer.consumer)
|
||||
)
|
||||
consumers = list((await session.execute(consumers_stmt)).scalars().all())
|
||||
return tenant, consumers
|
||||
|
||||
async def list_tenants(self, session: AsyncSession, caller: Caller) -> List[Tenant]:
|
||||
"""
|
||||
`GET /tenants` — `tatlock` and `scheduler` callers (no tenant) see
|
||||
every tenant; a tenant-bearing caller sees only its own, as a list
|
||||
of one (the hard boundary: another tenant's existence is never
|
||||
revealed, so this still runs through `require_tenant`); a user
|
||||
caller with no tenant is 400 `tenant_required`.
|
||||
"""
|
||||
if caller.tenant is None:
|
||||
if caller.kind in ("tatlock", "scheduler"):
|
||||
stmt = select(Tenant).order_by(Tenant.id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
raise TenantError("tenant_required", 400)
|
||||
|
||||
tenant_id = await self.require_tenant(session, caller)
|
||||
tenant = await session.get(Tenant, tenant_id)
|
||||
return [tenant]
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_tenants_service: Optional[TenantsService] = None
|
||||
|
||||
|
||||
def get_tenants_service() -> TenantsService:
|
||||
"""Get singleton Tenants service instance"""
|
||||
global _tenants_service
|
||||
if _tenants_service is None:
|
||||
_tenants_service = TenantsService()
|
||||
return _tenants_service
|
||||
@@ -18,6 +18,8 @@ from src.domains.tools.system.schemas import SystemStatsResponse
|
||||
from src.domains.tools.system.service import SystemStatsService
|
||||
from src.domains.tools.environment.schemas import EnvironmentResponse
|
||||
from src.domains.tools.environment.service import EnvironmentService
|
||||
from src.domains.tools.news.schemas import NewsResponse
|
||||
from src.domains.tools.news.service import NewsService
|
||||
from src.domains.auth.oidc import get_optional_user
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -38,6 +40,7 @@ class ToolsController(BaseController):
|
||||
self.dns_service = DNSService()
|
||||
self.system_stats_service = SystemStatsService()
|
||||
self.environment_service = EnvironmentService()
|
||||
self.news_service = NewsService()
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
"""Create and configure the router"""
|
||||
@@ -198,6 +201,9 @@ class ToolsController(BaseController):
|
||||
user_id = "default"
|
||||
if user:
|
||||
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||
# Strip email domain if present (e.g., "user@example.com" -> "user")
|
||||
if "@" in user_id:
|
||||
user_id = user_id.split("@")[0]
|
||||
|
||||
logger.info(f"Fetching environment data for user: {user_id}")
|
||||
result = await self.environment_service.get_current(user_id)
|
||||
@@ -210,6 +216,65 @@ class ToolsController(BaseController):
|
||||
detail=f"Failed to fetch environment data: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/news",
|
||||
response_model=NewsResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Get news headlines",
|
||||
description="""
|
||||
Get news headlines for the authenticated user.
|
||||
|
||||
Fetches news data from the Qdrant volatile collection for the authenticated user.
|
||||
Falls back to 'default' user if not authenticated.
|
||||
|
||||
**Data Returned:**
|
||||
- **Headlines:** List of news headlines with title, description, source, url
|
||||
- **Category:** News category (general, technology, etc.)
|
||||
- **Sources:** List of news sources
|
||||
|
||||
**Data Source:** Qdrant volatile_{user} collection (news namespace)
|
||||
|
||||
**Use Cases:**
|
||||
- Dashboard news ticker
|
||||
- News feed widgets
|
||||
- Information display
|
||||
"""
|
||||
)
|
||||
async def get_news(
|
||||
user: Optional[Dict] = Depends(get_optional_user),
|
||||
) -> NewsResponse:
|
||||
"""
|
||||
Get news headlines
|
||||
|
||||
Args:
|
||||
user: Optional authenticated user from OIDC
|
||||
|
||||
Returns:
|
||||
News headlines response
|
||||
|
||||
Raises:
|
||||
HTTPException: 500 for processing errors
|
||||
"""
|
||||
try:
|
||||
# Get user identifier from OIDC claims, fallback to 'default'
|
||||
user_id = "default"
|
||||
if user:
|
||||
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||
# Strip email domain if present (e.g., "user@example.com" -> "user")
|
||||
if "@" in user_id:
|
||||
user_id = user_id.split("@")[0]
|
||||
|
||||
logger.info(f"Fetching news data for user: {user_id}")
|
||||
result = await self.news_service.get_news(user_id)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get news data: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to fetch news data: {str(e)}"
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -43,13 +43,18 @@ class EnvironmentService:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Handle wind direction - convert degrees to cardinal if integer
|
||||
wind_dir = raw_data.get("wind_direction") or raw_data.get("wind_dir")
|
||||
if isinstance(wind_dir, (int, float)):
|
||||
wind_dir = self._degrees_to_cardinal(wind_dir)
|
||||
|
||||
return WeatherData(
|
||||
temperature=raw_data.get("temperature") or raw_data.get("temp"),
|
||||
feels_like=raw_data.get("feels_like") or raw_data.get("feelslike"),
|
||||
conditions=raw_data.get("conditions") or raw_data.get("weather") or raw_data.get("description"),
|
||||
humidity=raw_data.get("humidity"),
|
||||
wind_speed=raw_data.get("wind_speed") or raw_data.get("windspeed") or raw_data.get("wind"),
|
||||
wind_direction=raw_data.get("wind_direction") or raw_data.get("wind_dir"),
|
||||
wind_direction=wind_dir,
|
||||
pressure=raw_data.get("pressure"),
|
||||
visibility=raw_data.get("visibility"),
|
||||
uv_index=raw_data.get("uv_index") or raw_data.get("uv"),
|
||||
@@ -60,6 +65,13 @@ class EnvironmentService:
|
||||
logger.warning(f"Failed to parse weather data: {e}")
|
||||
return None
|
||||
|
||||
def _degrees_to_cardinal(self, degrees: float) -> str:
|
||||
"""Convert wind direction degrees to cardinal direction."""
|
||||
directions = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
||||
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
|
||||
index = round(degrees / 22.5) % 16
|
||||
return directions[index]
|
||||
|
||||
def _parse_forecast(self, raw_data: Any) -> Optional[List[ForecastDay]]:
|
||||
"""
|
||||
Parse raw forecast data into list of ForecastDay schemas.
|
||||
@@ -73,7 +85,15 @@ class EnvironmentService:
|
||||
# Normalize to list
|
||||
forecast_list = raw_data
|
||||
if isinstance(raw_data, dict):
|
||||
forecast_list = raw_data.get("days") or raw_data.get("forecast") or []
|
||||
# Check 'daily' first (scheduler format), then 'forecast', then 'days'
|
||||
# Note: 'days' might be an integer count, so check 'daily' first
|
||||
forecast_list = raw_data.get("daily") or raw_data.get("forecast")
|
||||
if forecast_list is None:
|
||||
days_value = raw_data.get("days")
|
||||
if isinstance(days_value, list):
|
||||
forecast_list = days_value
|
||||
else:
|
||||
forecast_list = []
|
||||
|
||||
if not isinstance(forecast_list, list):
|
||||
return None
|
||||
@@ -83,8 +103,8 @@ class EnvironmentService:
|
||||
if isinstance(day, dict):
|
||||
days.append(ForecastDay(
|
||||
date=day.get("date", ""),
|
||||
high=day.get("high") or day.get("maxtemp") or day.get("temp_max"),
|
||||
low=day.get("low") or day.get("mintemp") or day.get("temp_min"),
|
||||
high=day.get("high") or day.get("temp_high") or day.get("maxtemp") or day.get("temp_max"),
|
||||
low=day.get("low") or day.get("temp_low") or day.get("mintemp") or day.get("temp_min"),
|
||||
conditions=day.get("conditions") or day.get("weather") or day.get("description"),
|
||||
precipitation_chance=day.get("precipitation_chance") or day.get("pop") or day.get("precip"),
|
||||
icon=day.get("icon"),
|
||||
@@ -106,18 +126,38 @@ class EnvironmentService:
|
||||
return None
|
||||
|
||||
try:
|
||||
sunrise = raw_data.get("sunrise")
|
||||
sunset = raw_data.get("sunset")
|
||||
# Prefer ISO format fields (sunrise_iso, sunset_iso) over time-only fields
|
||||
sunrise = raw_data.get("sunrise_iso") or raw_data.get("sunrise")
|
||||
sunset = raw_data.get("sunset_iso") or raw_data.get("sunset")
|
||||
|
||||
# Parse datetime strings if needed
|
||||
if isinstance(sunrise, str):
|
||||
# Handle time-only format (HH:MM) by combining with today's date
|
||||
if len(sunrise) <= 5 and ":" in sunrise:
|
||||
today = datetime.now().date()
|
||||
sunrise = datetime.strptime(f"{today} {sunrise}", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
sunrise = datetime.fromisoformat(sunrise.replace("Z", "+00:00"))
|
||||
if isinstance(sunset, str):
|
||||
# Handle time-only format (HH:MM) by combining with today's date
|
||||
if len(sunset) <= 5 and ":" in sunset:
|
||||
today = datetime.now().date()
|
||||
sunset = datetime.strptime(f"{today} {sunset}", "%Y-%m-%d %H:%M")
|
||||
else:
|
||||
sunset = datetime.fromisoformat(sunset.replace("Z", "+00:00"))
|
||||
|
||||
# Calculate daylight minutes if not provided
|
||||
# Get daylight from various field names
|
||||
daylight_minutes = raw_data.get("daylight_minutes") or raw_data.get("daylight")
|
||||
if daylight_minutes is None and sunrise and sunset:
|
||||
if daylight_minutes is None:
|
||||
# Try to calculate from daylight_duration_seconds or daylight_hours
|
||||
daylight_seconds = raw_data.get("daylight_duration_seconds")
|
||||
if daylight_seconds:
|
||||
daylight_minutes = int(daylight_seconds / 60)
|
||||
else:
|
||||
daylight_hours = raw_data.get("daylight_hours")
|
||||
if daylight_hours:
|
||||
daylight_minutes = int(daylight_hours * 60)
|
||||
elif sunrise and sunset:
|
||||
daylight_minutes = int((sunset - sunrise).total_seconds() / 60)
|
||||
|
||||
# Parse optional fields
|
||||
@@ -167,7 +207,8 @@ class EnvironmentService:
|
||||
if not isinstance(raw_data, dict):
|
||||
return None
|
||||
|
||||
aqi = raw_data.get("aqi") or raw_data.get("index")
|
||||
# Try various AQI field names - prefer US AQI, then European, then generic
|
||||
aqi = raw_data.get("aqi") or raw_data.get("aqi_us") or raw_data.get("aqi_european") or raw_data.get("index")
|
||||
if isinstance(aqi, (int, float)):
|
||||
aqi = int(aqi)
|
||||
|
||||
@@ -177,7 +218,7 @@ class EnvironmentService:
|
||||
pm25=raw_data.get("pm25") or raw_data.get("pm2_5"),
|
||||
pm10=raw_data.get("pm10"),
|
||||
o3=raw_data.get("o3") or raw_data.get("ozone"),
|
||||
no2=raw_data.get("no2"),
|
||||
no2=raw_data.get("no2") or raw_data.get("nitrogen_dioxide"),
|
||||
location=raw_data.get("location"),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
News subdomain for Tools.
|
||||
|
||||
Provides news headlines from Qdrant volatile collection.
|
||||
"""
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
News data schemas for Tools domain.
|
||||
|
||||
Provides Pydantic models for news headlines retrieved from the Qdrant volatile collection.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
from pydantic import Field
|
||||
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
class NewsHeadline(BaseSchema):
|
||||
"""Single news headline."""
|
||||
|
||||
title: str = Field(
|
||||
...,
|
||||
description="Headline title"
|
||||
)
|
||||
description: Optional[str] = Field(
|
||||
None,
|
||||
description="Brief description or summary"
|
||||
)
|
||||
source: Optional[str] = Field(
|
||||
None,
|
||||
description="News source name"
|
||||
)
|
||||
url: Optional[str] = Field(
|
||||
None,
|
||||
description="Link to full article"
|
||||
)
|
||||
|
||||
|
||||
class NewsResponse(BaseSchema):
|
||||
"""News headlines response."""
|
||||
|
||||
headlines: List[NewsHeadline] = Field(
|
||||
default_factory=list,
|
||||
description="List of news headlines"
|
||||
)
|
||||
category: Optional[str] = Field(
|
||||
None,
|
||||
description="News category (e.g., 'general', 'technology')"
|
||||
)
|
||||
sources: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="List of source names"
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=datetime.utcnow,
|
||||
description="Timestamp when data was fetched"
|
||||
)
|
||||
user: Optional[str] = Field(
|
||||
None,
|
||||
description="User identifier used for data lookup"
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
News data service for Tools domain.
|
||||
|
||||
Fetches news headlines from the Qdrant volatile collection.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from src.shared.logging import get_logger
|
||||
from src.shared.clients.qdrant_client import get_qdrant_client
|
||||
from src.domains.tools.news.schemas import (
|
||||
NewsHeadline,
|
||||
NewsResponse,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class NewsService:
|
||||
"""
|
||||
Service for fetching news data from Qdrant volatile collection.
|
||||
|
||||
Retrieves news headlines for a specific user.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize news service with Qdrant client."""
|
||||
self.qdrant = get_qdrant_client()
|
||||
|
||||
def _parse_headlines(self, raw_data: Any) -> List[NewsHeadline]:
|
||||
"""
|
||||
Parse raw news data into list of NewsHeadline schemas.
|
||||
|
||||
Handles various formats from different news sources.
|
||||
"""
|
||||
if not raw_data:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Handle dict with nested headlines list
|
||||
headlines_list = raw_data
|
||||
if isinstance(raw_data, dict):
|
||||
headlines_list = raw_data.get("headlines") or raw_data.get("articles") or []
|
||||
|
||||
if not isinstance(headlines_list, list):
|
||||
return []
|
||||
|
||||
headlines = []
|
||||
for item in headlines_list:
|
||||
if isinstance(item, dict):
|
||||
headlines.append(NewsHeadline(
|
||||
title=item.get("title", ""),
|
||||
description=item.get("description") or item.get("summary"),
|
||||
source=item.get("source") or item.get("provider"),
|
||||
url=item.get("url") or item.get("link"),
|
||||
))
|
||||
elif isinstance(item, str):
|
||||
# Simple string headlines
|
||||
headlines.append(NewsHeadline(title=item))
|
||||
|
||||
return headlines
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse news headlines: {e}")
|
||||
return []
|
||||
|
||||
async def get_news(self, user: str = "default") -> NewsResponse:
|
||||
"""
|
||||
Get news headlines for a user.
|
||||
|
||||
Fetches news from the user's volatile collection.
|
||||
|
||||
Args:
|
||||
user: User identifier (default: 'default')
|
||||
|
||||
Returns:
|
||||
NewsResponse with headlines
|
||||
"""
|
||||
logger.info(f"Fetching news data for user: {user}")
|
||||
|
||||
# Get raw data from Qdrant
|
||||
news_records = await self.qdrant.get_by_namespace(user, "news")
|
||||
|
||||
headlines = []
|
||||
category = None
|
||||
sources = None
|
||||
|
||||
if news_records:
|
||||
raw_data = news_records[0].get("raw_data", {})
|
||||
headlines = self._parse_headlines(raw_data)
|
||||
if isinstance(raw_data, dict):
|
||||
category = raw_data.get("category")
|
||||
sources = raw_data.get("sources")
|
||||
|
||||
return NewsResponse(
|
||||
headlines=headlines,
|
||||
category=category,
|
||||
sources=sources,
|
||||
updated_at=datetime.utcnow(),
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_news_service: Optional[NewsService] = None
|
||||
|
||||
|
||||
def get_news_service() -> NewsService:
|
||||
"""Get or create singleton news service instance."""
|
||||
global _news_service
|
||||
if _news_service is None:
|
||||
_news_service = NewsService()
|
||||
return _news_service
|
||||
+7
-1
@@ -19,6 +19,9 @@ from src.domains.infrastructure import infrastructure_controller
|
||||
from src.domains.housekeeping import housekeeping_controller
|
||||
from src.domains.static import static_controller
|
||||
from src.domains.dashboard import dashboard_controller
|
||||
from src.domains.book import book_controller
|
||||
from src.domains.scribe import scribe_controller
|
||||
from src.domains.tenants import tenants_controller
|
||||
|
||||
# Initialize settings
|
||||
settings = get_settings()
|
||||
@@ -83,7 +86,7 @@ See `/docs` for the full API reference.
|
||||
lifespan=lifespan,
|
||||
debug=settings.debug,
|
||||
swagger_ui_init_oauth={
|
||||
"clientId": settings.oidc_audience,
|
||||
"clientId": settings.oidc_audiences[0] if settings.oidc_audiences else "core-api",
|
||||
"usePkceWithAuthorizationCodeGrant": True,
|
||||
} if settings.oidc_enabled else None
|
||||
)
|
||||
@@ -106,6 +109,9 @@ app.include_router(infrastructure_controller.router) # /infrastructure
|
||||
app.include_router(housekeeping_controller.router) # /housekeeping/*
|
||||
app.include_router(static_controller.router) # /static/*
|
||||
app.include_router(dashboard_controller.router) # /dashboard/*
|
||||
app.include_router(book_controller.router) # /book/*
|
||||
app.include_router(scribe_controller.router) # /scribe/*
|
||||
app.include_router(tenants_controller.router) # /tenants/*
|
||||
|
||||
|
||||
# Global exception handler
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Security initialization module
|
||||
|
||||
Handles OIDC configuration and authentication setup
|
||||
"""
|
||||
from src.config import Settings
|
||||
from src.auth.oidc import oidc_config
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def initialize_oidc(settings: Settings) -> None:
|
||||
"""
|
||||
Initialize OIDC authentication configuration
|
||||
|
||||
Configures the global oidc_config instance with settings from environment.
|
||||
If OIDC is enabled, logs the issuer URL for verification.
|
||||
|
||||
Args:
|
||||
settings: Application settings containing OIDC configuration
|
||||
"""
|
||||
oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuer=settings.oidc_issuer,
|
||||
audience=settings.oidc_audience
|
||||
)
|
||||
|
||||
if settings.oidc_enabled:
|
||||
logger.info(f"✓ OIDC authentication enabled (issuer: {settings.oidc_issuer})")
|
||||
else:
|
||||
logger.info("○ OIDC authentication disabled - API is publicly accessible")
|
||||
@@ -190,19 +190,10 @@ class QdrantReadClient:
|
||||
if aqi:
|
||||
result["air_quality"] = aqi if isinstance(aqi, dict) else {"aqi": aqi}
|
||||
|
||||
# Fetch forecast data
|
||||
# Fetch forecast data - pass raw_data to service for parsing
|
||||
forecast_records = await self.get_by_namespace(user, "forecast")
|
||||
if forecast_records:
|
||||
# Forecast might be a single record with list or multiple records
|
||||
first_record = forecast_records[0].get("raw_data")
|
||||
if isinstance(first_record, list):
|
||||
result["forecast"] = first_record
|
||||
elif isinstance(first_record, dict):
|
||||
# Could be a dict with 'days' or 'forecast' key
|
||||
result["forecast"] = first_record.get(
|
||||
"days",
|
||||
first_record.get("forecast", [first_record])
|
||||
)
|
||||
result["forecast"] = forecast_records[0].get("raw_data")
|
||||
|
||||
# Fetch sun times data
|
||||
sun_records = await self.get_by_namespace(user, "sun")
|
||||
|
||||
+17
-2
@@ -90,14 +90,29 @@ class Settings(BaseSettings):
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
oidc_enabled: bool = False
|
||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
||||
oidc_audience: str = "core-api"
|
||||
# Accept tokens from multiple OAuth providers (each has its own issuer/JWKS)
|
||||
oidc_issuers: list[str] = [
|
||||
"https://auth.schweitz.net/application/o/core-api/",
|
||||
"https://auth.schweitz.net/application/o/tatlock-ui/",
|
||||
"https://auth.schweitz.net/application/o/tatlock/",
|
||||
]
|
||||
# Accept tokens from multiple clients
|
||||
oidc_audiences: list[str] = ["core-api", "tatlock-ui", "tatlock"]
|
||||
|
||||
# Authentik API (for token validation and user management)
|
||||
authentik_url: str = "https://auth.schweitz.net"
|
||||
authentik_username: str = ""
|
||||
authentik_password: str = ""
|
||||
|
||||
# Caller auth — static service keys (T-1). Empty by default so an unset
|
||||
# key can never match an empty Bearer token (see src.domains.auth.callers).
|
||||
office_service_key: str = ""
|
||||
tatlock_service_key: str = ""
|
||||
scheduler_service_key: str = ""
|
||||
|
||||
# The Book (T-1) — local time for due/remind/recurrence handling.
|
||||
book_timezone: str = "Europe/Amsterdam"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
@@ -23,17 +23,17 @@ def initialize_oidc(settings: Settings) -> None:
|
||||
|
||||
auth_oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuer=settings.oidc_issuer,
|
||||
audience=settings.oidc_audience
|
||||
issuers=settings.oidc_issuers,
|
||||
audiences=settings.oidc_audiences
|
||||
)
|
||||
|
||||
domains_oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuer=settings.oidc_issuer,
|
||||
audience=settings.oidc_audience
|
||||
issuers=settings.oidc_issuers,
|
||||
audiences=settings.oidc_audiences
|
||||
)
|
||||
|
||||
if settings.oidc_enabled:
|
||||
logger.info(f"OIDC authentication enabled (issuer: {settings.oidc_issuer})")
|
||||
logger.info(f"OIDC authentication enabled (issuers: {settings.oidc_issuers})")
|
||||
else:
|
||||
logger.info("OIDC authentication disabled - API is publicly accessible")
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Shared test fixtures.
|
||||
|
||||
Database-backed tests need `TEST_DATABASE_URL` (core-api's gitignored .env).
|
||||
They **skip** when it is unset and **refuse** (hard failure, not a skip)
|
||||
when it points at `core_api` — that is production, and the dev `.env`'s
|
||||
other `POSTGRES_*` values point there too, so this is the one guard that
|
||||
stands between a test run and the production database.
|
||||
|
||||
Running the actual migration against the test database is done through the
|
||||
`alembic` CLI in a subprocess rather than in-process, because
|
||||
`alembic/env.py` always builds its connection URL from `get_settings()`
|
||||
(composed from `POSTGRES_*`), with no override hook of its own — and
|
||||
`get_settings()` is `lru_cache`d, so by the time a test runs, other test
|
||||
modules have almost certainly already imported `src.main` and cached a
|
||||
Settings instance pointed at production. A subprocess gets a clean
|
||||
environment and a clean cache; overriding `POSTGRES_HOST`/`USER`/
|
||||
`PASSWORD`/`DATABASE` in that child's environment, derived from
|
||||
`TEST_DATABASE_URL`, is what actually redirects the migration.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
ALEMBIC_BIN = REPO_ROOT / ".venv" / "bin" / "alembic"
|
||||
|
||||
load_dotenv(REPO_ROOT / ".env")
|
||||
|
||||
|
||||
def _components(url: str) -> dict:
|
||||
"""POSTGRES_* overrides for a subprocess, derived from a database URL."""
|
||||
parts = urlsplit(url)
|
||||
host = parts.hostname or ""
|
||||
if parts.port:
|
||||
host = f"{host}:{parts.port}"
|
||||
return {
|
||||
"POSTGRES_HOST": host,
|
||||
"POSTGRES_USER": parts.username or "",
|
||||
"POSTGRES_PASSWORD": parts.password or "",
|
||||
"POSTGRES_DATABASE": parts.path.lstrip("/"),
|
||||
}
|
||||
|
||||
|
||||
def run_alembic(url: str, *args: str) -> subprocess.CompletedProcess:
|
||||
"""Run `alembic <args>` against `url`, never against the repo's own .env database."""
|
||||
env = os.environ.copy()
|
||||
env.update(_components(url))
|
||||
result = subprocess.run(
|
||||
[str(ALEMBIC_BIN), *args],
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_database_url() -> str:
|
||||
"""The core_api_test database URL, or skip/refuse per the rules above."""
|
||||
url = os.environ.get("TEST_DATABASE_URL")
|
||||
if not url:
|
||||
pytest.skip("TEST_DATABASE_URL is not set — database tests skipped")
|
||||
|
||||
db_name = urlsplit(url).path.lstrip("/")
|
||||
if db_name == "core_api":
|
||||
pytest.fail(
|
||||
"TEST_DATABASE_URL resolves to 'core_api' (production) — refusing to use it for tests"
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def _grant_test_office_gemini(url: str) -> None:
|
||||
"""
|
||||
Grant a second, test-environment-only consumer, `office-gemini`, in
|
||||
both seeded tenants: test_book.py and test_scribe.py have used
|
||||
`office-gemini` as a stand-in second Office consumer since before
|
||||
core-api T-8's grant check existed, to prove behaviour that depends on
|
||||
two *distinct* consumers (address ownership across consumers,
|
||||
`book_events.client` recording the actual caller) rather than on any
|
||||
one consumer being granted. Migration 007 itself seeds only
|
||||
`office-claude` — the only consumer actually configured in Authentik
|
||||
today (T-8's dossier) — so this grant is inserted directly here,
|
||||
test-only, rather than through the migration; production's seed stays
|
||||
exactly what the dossier specifies. A consumer this never grants
|
||||
(e.g. `office-ungranted` in a test) is what proves the
|
||||
`consumer_not_granted` refusal — this must never widen to cover one of
|
||||
those.
|
||||
|
||||
A plain function, not a fixture: called from each of `book_schema`,
|
||||
`scribe_schema` and `tenant_registry` right after their own `run_alembic
|
||||
upgrade head`, so the grant is restored whenever any one of them is the
|
||||
first to re-establish the schema in a session — the same
|
||||
self-healing-on-first-use property those fixtures already rely on to
|
||||
survive a migration test downgrading the schema mid-suite. A session-
|
||||
scoped fixture shared between them would cache the *string* across that
|
||||
downgrade while the real schema moved on underneath it — which is what
|
||||
a first version of this did, and `tests/test_scribe.py` failing with
|
||||
"relation tenants does not exist" is what caught it.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
async def _grant() -> None:
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO tenant_consumers (tenant_id, consumer) VALUES "
|
||||
"('jpmschweitzer', 'office-gemini'), ('llm_tester', 'office-gemini') "
|
||||
"ON CONFLICT DO NOTHING"
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_grant())
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def tenant_registry(test_database_url: str) -> str:
|
||||
"""Ensure migration 007 (and everything before it) is applied to
|
||||
core_api_test, with its seed tenants/grants plus the test-only
|
||||
office-gemini grant (see `_grant_test_office_gemini`). Used directly by
|
||||
tests/test_tenants.py; book_schema and scribe_schema do the same thing
|
||||
independently rather than depending on this, so each keeps its own
|
||||
self-healing-on-first-use property (see that function's docstring)."""
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
||||
_grant_test_office_gemini(test_database_url)
|
||||
return test_database_url
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def book_schema(test_database_url: str) -> str:
|
||||
"""Ensure migration 005 (and everything before it, now including the
|
||||
tenant registry) is applied to core_api_test."""
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
||||
_grant_test_office_gemini(test_database_url)
|
||||
return test_database_url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def book_session(book_schema: str):
|
||||
"""An AsyncSession bound to core_api_test, rolled back after each test."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
asyncpg_url = book_schema.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def clean_book_tables(book_session):
|
||||
"""
|
||||
Book service methods commit internally (same pattern as the Dashboard
|
||||
service), so `book_session`'s post-test rollback never runs against
|
||||
anything — the data is already durable. TRUNCATE before each test
|
||||
instead, which the append-only trigger does not block: it is a
|
||||
row-level BEFORE UPDATE OR DELETE trigger, and TRUNCATE fires neither.
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
|
||||
await book_session.execute(
|
||||
text(
|
||||
"TRUNCATE book_events, book_attachments, book_record_links, book_tags, "
|
||||
"book_records, book_tenant_counters"
|
||||
)
|
||||
)
|
||||
await book_session.commit()
|
||||
yield book_session
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def scribe_schema(test_database_url: str) -> str:
|
||||
"""Ensure migration 006 (and everything before it, now including the
|
||||
tenant registry) is applied to core_api_test."""
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
||||
_grant_test_office_gemini(test_database_url)
|
||||
return test_database_url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def scribe_session(scribe_schema: str):
|
||||
"""An AsyncSession bound to core_api_test — same per-test engine pattern
|
||||
as book_session (see its docstring for why: TestClient drives the app
|
||||
on a different event loop than pytest-asyncio's fixture setup)."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
asyncpg_url = scribe_schema.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def clean_scribe_tables(scribe_session):
|
||||
"""Scribe service methods commit internally (same reason as Book's
|
||||
clean_book_tables). TRUNCATE all three tables together so Postgres
|
||||
resolves the FK order (mailbox_entries -> messages, messages'
|
||||
self-referencing reply_to) across the one statement."""
|
||||
from sqlalchemy import text
|
||||
|
||||
await scribe_session.execute(
|
||||
text("TRUNCATE scribe_mailbox_entries, scribe_messages, scribe_addresses")
|
||||
)
|
||||
await scribe_session.commit()
|
||||
yield scribe_session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def suspended_tenant(test_database_url: str):
|
||||
"""
|
||||
A third tenant, inserted with `status='suspended'`, for proving the
|
||||
`tenant_suspended` refusal (core-api T-8) without touching either
|
||||
seeded tenant that the rest of the suite depends on being active.
|
||||
Nothing else ever references this id, so inserting and deleting it
|
||||
per test is safe — no FK points at it.
|
||||
|
||||
Depends on `test_database_url` and runs its own `alembic upgrade head`
|
||||
rather than on `tenant_registry`/`book_schema`/`scribe_schema`: this
|
||||
fixture is reused from test_book.py, test_scribe.py and test_tenants.py,
|
||||
so whichever of those files runs first would permanently cache
|
||||
*any* session-scoped schema fixture it depended on — and a migration
|
||||
test downgrading the schema afterwards (test_book_migration.py,
|
||||
test_scribe_migration.py) would leave that cache stale for the other
|
||||
two files. Self-contained and function-scoped avoids it outright, at
|
||||
the cost of a redundant `alembic upgrade head` call (cheap relative to
|
||||
the many such calls the migration test files already make).
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
if result.returncode != 0:
|
||||
pytest.fail(f"alembic upgrade head failed:\n{result.stdout}\n{result.stderr}")
|
||||
|
||||
tenant_id = "suspended-tenant"
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
async with session_factory() as session:
|
||||
await session.execute(
|
||||
text(
|
||||
"INSERT INTO tenants (id, display_name, kind, status) "
|
||||
"VALUES (:id, 'Suspended Test Tenant', 'test', 'suspended') "
|
||||
"ON CONFLICT (id) DO UPDATE SET status = 'suspended'"
|
||||
),
|
||||
{"id": tenant_id},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
yield tenant_id
|
||||
|
||||
async with session_factory() as session:
|
||||
await session.execute(text("DELETE FROM tenants WHERE id = :id"), {"id": tenant_id})
|
||||
await session.commit()
|
||||
await engine.dispose()
|
||||
@@ -815,22 +815,32 @@ class TestPhase4Schemas:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /auth/me Endpoint Tests (NPM Forward Auth)
|
||||
# GET /auth/users/me Endpoint Tests (NPM Forward Auth)
|
||||
# =============================================================================
|
||||
|
||||
class TestAuthMeEndpoint:
|
||||
"""Test GET /auth/me endpoint with NPM forward auth."""
|
||||
"""
|
||||
Test GET /auth/users/me with NPM forward auth.
|
||||
|
||||
The path is /auth/users/me, not /auth/me — the route is declared as "/me"
|
||||
inside AuthController.create_router(), which mounts under a users prefix.
|
||||
These tests asserted /auth/me and had never passed; the generated spec is
|
||||
authoritative and both the local app and the deployed service agree on 62
|
||||
paths including this one.
|
||||
"""
|
||||
|
||||
PATH = "/auth/users/me"
|
||||
|
||||
def test_auth_me_in_openapi(self, client):
|
||||
"""Auth me endpoint should be in OpenAPI spec."""
|
||||
response = client.get("/openapi.json")
|
||||
spec = response.json()
|
||||
assert "/auth/me" in spec["paths"]
|
||||
assert "get" in spec["paths"]["/auth/me"]
|
||||
assert self.PATH in spec["paths"]
|
||||
assert "get" in spec["paths"][self.PATH]
|
||||
|
||||
def test_auth_me_returns_401_without_forward_auth(self, client):
|
||||
"""Should return 401 when accessed without forward auth headers."""
|
||||
response = client.get("/auth/me")
|
||||
response = client.get(self.PATH)
|
||||
# Without NPM forward auth headers, should return 401
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -840,7 +850,7 @@ class TestAuthMeEndpoint:
|
||||
spec = response.json()
|
||||
|
||||
# Check response schema references AuthSyncResponse
|
||||
me_endpoint = spec["paths"]["/auth/me"]["get"]
|
||||
me_endpoint = spec["paths"][self.PATH]["get"]
|
||||
assert "responses" in me_endpoint
|
||||
assert "200" in me_endpoint["responses"]
|
||||
|
||||
@@ -920,7 +930,11 @@ class TestForwardAuthParsing:
|
||||
|
||||
result = await get_forward_auth_user(mock_request)
|
||||
|
||||
assert result["groups"] == [""]
|
||||
# An empty groups header means no groups, not one group named "".
|
||||
# oidc.py has guarded this since the initial commit — this assertion
|
||||
# expected [""] and had never passed. [""] would also be unsafe: any
|
||||
# authorization check doing `"" in groups` would match.
|
||||
assert result["groups"] == []
|
||||
assert result["name"] == "jdoe" # Falls back to username
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
"""
|
||||
Tests for the Book domain (T-1 / D-68, D-70, T-7) against `core_api_test`.
|
||||
|
||||
`get_async_session` is overridden to the test database for every test here
|
||||
— the Book routes list `caller` before `session` in their dependencies, but
|
||||
overriding is what actually guarantees the suite never opens a connection
|
||||
to production regardless of FastAPI's dependency resolution order.
|
||||
"""
|
||||
import asyncio
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.domains.auth.oidc import oidc_config
|
||||
from src.main import app
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.database import get_async_session
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
OTHER_TENANT = "llm_tester"
|
||||
|
||||
OFFICE_KEY = "test-office-key"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def office_key():
|
||||
"""Every Book test authenticates as the office caller unless it says otherwise."""
|
||||
original = settings.office_service_key
|
||||
settings.office_service_key = OFFICE_KEY
|
||||
yield
|
||||
settings.office_service_key = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(clean_book_tables, test_database_url):
|
||||
"""
|
||||
A TestClient whose DB dependency is overridden to core_api_test.
|
||||
|
||||
The override must build its own engine/session per call rather than
|
||||
reuse `clean_book_tables`'s already-open one: TestClient drives the
|
||||
ASGI app from a different event loop than the one pytest-asyncio ran
|
||||
the fixture's setup in, and an asyncpg connection is not portable
|
||||
across loops — reusing it here raised "Future ... attached to a
|
||||
different loop" and then hung. Building fresh per call keeps
|
||||
everything inside whichever loop is actually running the request.
|
||||
"""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
async def _override():
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
app.dependency_overrides[get_async_session] = _override
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.pop(get_async_session, None)
|
||||
|
||||
|
||||
VALID_CONSUMER = "office-claude"
|
||||
VALID_TENANT = "jpmschweitzer"
|
||||
|
||||
|
||||
def office_headers(
|
||||
identity: str = "jeroen",
|
||||
consumer: str = VALID_CONSUMER,
|
||||
tenant: str = VALID_TENANT,
|
||||
) -> dict:
|
||||
return {
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Identity": identity,
|
||||
"X-Office-Consumer": consumer,
|
||||
"X-Office-Tenant": tenant,
|
||||
}
|
||||
|
||||
|
||||
class TestCallerMatrixOnBookRoutes:
|
||||
"""Dossier verification #3, the Book-specific half."""
|
||||
|
||||
def test_no_auth_is_401_on_book_routes(self, client):
|
||||
oidc_config.configure(enabled=True, issuers=[], audiences=[])
|
||||
try:
|
||||
response = client.post("/book/records", json={"body": "hi"})
|
||||
finally:
|
||||
oidc_config.configure(enabled=False, issuers=[], audiences=[])
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_office_write_without_identity_is_400(self, client):
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "hi"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_required"
|
||||
|
||||
def test_office_write_without_consumer_is_400(self, client):
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "hi"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "consumer_required"
|
||||
|
||||
def test_office_write_without_tenant_is_400(self, client):
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "hi"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_required"
|
||||
|
||||
def test_office_read_without_tenant_is_400(self, client):
|
||||
"""The tenant scopes reads too — a list call with no tenant is 400,
|
||||
even though list carries no identity_required."""
|
||||
response = client.get(
|
||||
"/book/records",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_required"
|
||||
|
||||
def test_office_malformed_consumer_is_400(self, client):
|
||||
response = client.get(
|
||||
"/book/records",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Consumer": "Not Valid",
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "consumer_invalid"
|
||||
|
||||
def test_office_malformed_tenant_is_400(self, client):
|
||||
response = client.get(
|
||||
"/book/records",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": "Not Valid",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_invalid"
|
||||
|
||||
def test_scheduler_key_is_403_on_book_records(self, client):
|
||||
settings.scheduler_service_key = "test-scheduler-key"
|
||||
try:
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "hi"},
|
||||
headers={"Authorization": "Bearer test-scheduler-key"},
|
||||
)
|
||||
finally:
|
||||
settings.scheduler_service_key = ""
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "forbidden"
|
||||
|
||||
def test_malformed_identity_is_400(self, client):
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "hi"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Identity": "Not Valid",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_invalid"
|
||||
|
||||
|
||||
class TestAddShowListFind:
|
||||
def test_add_note_defaults_to_personal_scope_and_inbox_free(self, client):
|
||||
response = client.post(
|
||||
"/book/records", json={"body": "buy milk"}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["kind"] == "note"
|
||||
assert data["status"] == "active"
|
||||
assert data["scope"] == "personal"
|
||||
assert data["created_by"] == "jeroen"
|
||||
|
||||
def test_add_rejects_bad_status_for_kind(self, client):
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "x", "status": "settled"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "invalid_status"
|
||||
|
||||
def test_every_record_carries_exactly_one_of_work_or_personal(self, client):
|
||||
default_scope = client.post(
|
||||
"/book/records", json={"body": "default scope"}, headers=office_headers()
|
||||
).json()["scope"]
|
||||
assert default_scope in ("work", "personal")
|
||||
|
||||
work_scope = client.post(
|
||||
"/book/records",
|
||||
json={"body": "work item", "scope": "work"},
|
||||
headers=office_headers(),
|
||||
).json()["scope"]
|
||||
assert work_scope == "work"
|
||||
|
||||
rejected = client.post(
|
||||
"/book/records",
|
||||
json={"body": "bad scope", "scope": "both"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert rejected.status_code == 422 # pydantic Literal rejects a third value
|
||||
|
||||
def test_show_returns_record_tags_links_and_backlinks(self, client):
|
||||
parent = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "project"}, headers=office_headers()
|
||||
).json()
|
||||
child = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "subtask", "parent": parent["id"], "tags": ["chore"]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
|
||||
child_shown = client.get(f"/book/records/{child['id']}", headers=office_headers()).json()
|
||||
assert child_shown["tags"] == ["chore"]
|
||||
assert len(child_shown["links"]) == 1
|
||||
assert child_shown["links"][0]["relation"] == "parent"
|
||||
assert child_shown["links"][0]["to_id"] == parent["id"]
|
||||
|
||||
parent_shown = client.get(f"/book/records/{parent['id']}", headers=office_headers()).json()
|
||||
assert len(parent_shown["backlinks"]) == 1
|
||||
assert parent_shown["backlinks"][0]["from_id"] == child["id"]
|
||||
|
||||
def test_show_missing_record_is_404(self, client):
|
||||
response = client.get(f"/book/records/{uuid.uuid4()}", headers=office_headers())
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_list_filters_by_scope(self, client):
|
||||
client.post("/book/records", json={"body": "a", "scope": "work"}, headers=office_headers())
|
||||
client.post("/book/records", json={"body": "b", "scope": "personal"}, headers=office_headers())
|
||||
|
||||
work_only = client.get("/book/records", params={"scope": "work"}, headers=office_headers()).json()
|
||||
assert work_only["total"] == 1
|
||||
assert work_only["records"][0]["scope"] == "work"
|
||||
|
||||
def test_find_full_text_matches_a_note_and_a_task(self, client):
|
||||
"""Dossier verification #4: a note and a task, both found by full-text."""
|
||||
note = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "note", "title": "Weekend plans", "body": "visit the garden centre"},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
task = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "renew the car insurance"},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
|
||||
found_note_by_body = client.get(
|
||||
"/book/records/find", params={"q": "garden"}, headers=office_headers()
|
||||
).json()
|
||||
assert found_note_by_body["total"] == 1
|
||||
assert found_note_by_body["records"][0]["id"] == note["id"]
|
||||
|
||||
found_note_by_title = client.get(
|
||||
"/book/records/find", params={"q": "weekend"}, headers=office_headers()
|
||||
).json()
|
||||
assert found_note_by_title["total"] == 1
|
||||
assert found_note_by_title["records"][0]["id"] == note["id"]
|
||||
|
||||
found_task = client.get(
|
||||
"/book/records/find", params={"q": "insurance"}, headers=office_headers()
|
||||
).json()
|
||||
assert found_task["total"] == 1
|
||||
assert found_task["records"][0]["id"] == task["id"]
|
||||
assert found_task["records"][0]["kind"] == "task"
|
||||
|
||||
|
||||
class TestChange:
|
||||
def test_change_updates_only_given_fields(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "water the plants"}, headers=office_headers()
|
||||
).json()
|
||||
|
||||
changed = client.patch(
|
||||
f"/book/records/{record['id']}",
|
||||
json={"priority": 1},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
assert changed["priority"] == 1
|
||||
assert changed["body"] == "water the plants"
|
||||
|
||||
def test_change_without_identity_is_400(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "x"}, headers=office_headers()
|
||||
).json()
|
||||
response = client.patch(
|
||||
f"/book/records/{record['id']}",
|
||||
json={"priority": 1},
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_required"
|
||||
|
||||
|
||||
class TestClose:
|
||||
def test_close_task_done(self, client):
|
||||
task = client.post(
|
||||
"/book/records", json={"kind": "task", "body": "mow the lawn"}, headers=office_headers()
|
||||
).json()
|
||||
closed = client.post(
|
||||
f"/book/records/{task['id']}/close", json={"outcome": "done"}, headers=office_headers()
|
||||
).json()
|
||||
assert closed["status"] == "done"
|
||||
|
||||
def test_close_question_via_resolves_sets_settled(self, client):
|
||||
question = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "question", "body": "what colour for the fence?"},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
decision = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "decision", "body": "fence will be green"},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
|
||||
closed = client.post(
|
||||
f"/book/records/{question['id']}/close",
|
||||
json={"resolved_by": decision["id"]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
assert closed["status"] == "settled"
|
||||
|
||||
shown = client.get(f"/book/records/{question['id']}", headers=office_headers()).json()
|
||||
assert any(b["relation"] == "resolves" and b["from_id"] == decision["id"] for b in shown["backlinks"])
|
||||
|
||||
def test_close_decision_via_supersedes_sets_superseded(self, client):
|
||||
old_decision = client.post(
|
||||
"/book/records", json={"kind": "decision", "body": "fence will be green"}, headers=office_headers()
|
||||
).json()
|
||||
new_decision = client.post(
|
||||
"/book/records", json={"kind": "decision", "body": "fence will be red"}, headers=office_headers()
|
||||
).json()
|
||||
|
||||
closed = client.post(
|
||||
f"/book/records/{old_decision['id']}/close",
|
||||
json={"superseded_by": new_decision["id"]},
|
||||
headers=office_headers(),
|
||||
).json()
|
||||
assert closed["status"] == "superseded"
|
||||
|
||||
def test_close_note_has_no_transition(self, client):
|
||||
note = client.post("/book/records", json={"body": "just a note"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{note['id']}/close", json={}, headers=office_headers()
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "invalid_status"
|
||||
|
||||
|
||||
class TestLinkUnlink:
|
||||
def test_link_then_unlink(self, client):
|
||||
a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json()
|
||||
b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json()
|
||||
|
||||
client.post(
|
||||
f"/book/records/{a['id']}/link",
|
||||
json={"to_id": b["id"], "relation": "related"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
shown = client.get(f"/book/records/{a['id']}", headers=office_headers()).json()
|
||||
assert len(shown["links"]) == 1
|
||||
|
||||
unlinked = client.post(
|
||||
f"/book/records/{a['id']}/unlink",
|
||||
json={"to_id": b["id"], "relation": "related"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert unlinked.status_code == 200
|
||||
shown_after = client.get(f"/book/records/{a['id']}", headers=office_headers()).json()
|
||||
assert len(shown_after["links"]) == 0
|
||||
|
||||
def test_unlink_nonexistent_link_is_404(self, client):
|
||||
a = client.post("/book/records", json={"body": "a"}, headers=office_headers()).json()
|
||||
b = client.post("/book/records", json={"body": "b"}, headers=office_headers()).json()
|
||||
response = client.post(
|
||||
f"/book/records/{a['id']}/unlink",
|
||||
json={"to_id": b["id"], "relation": "blocks"},
|
||||
headers=office_headers(),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestTagUntag:
|
||||
def test_tag_then_untag(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
|
||||
tagged = client.post(
|
||||
f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()
|
||||
).json()
|
||||
assert tagged["tags"] == ["chore"]
|
||||
|
||||
untagged = client.post(
|
||||
f"/book/records/{record['id']}/untag", json={"tag": "chore"}, headers=office_headers()
|
||||
).json()
|
||||
assert untagged["tags"] == []
|
||||
|
||||
def test_tagging_twice_is_idempotent(self, client):
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
client.post(f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers())
|
||||
second = client.post(
|
||||
f"/book/records/{record['id']}/tag", json={"tag": "chore"}, headers=office_headers()
|
||||
)
|
||||
assert second.status_code == 200
|
||||
assert second.json()["tags"] == ["chore"]
|
||||
|
||||
|
||||
class TestHistory:
|
||||
def test_every_write_appends_an_event_carrying_identity(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "x"}, headers=office_headers(identity="jeroen")
|
||||
).json()
|
||||
client.patch(
|
||||
f"/book/records/{record['id']}",
|
||||
json={"priority": 2},
|
||||
headers=office_headers(identity="tower-control"),
|
||||
)
|
||||
|
||||
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
||||
actions = [e["action"] for e in history["events"]]
|
||||
identities = {e["identity"] for e in history["events"]}
|
||||
|
||||
assert "add" in actions
|
||||
assert "change" in actions
|
||||
assert identities == {"jeroen", "tower-control"}
|
||||
|
||||
def test_history_events_are_immutable_at_the_api(self, client):
|
||||
"""There is no update/delete verb for events — only `add` and `change`
|
||||
write them, and the append-only trigger (tested in
|
||||
test_book_migration.py) is what actually enforces it."""
|
||||
record = client.post("/book/records", json={"body": "x"}, headers=office_headers()).json()
|
||||
history = client.get(f"/book/records/{record['id']}/history", headers=office_headers()).json()
|
||||
assert len(history["events"]) == 1
|
||||
assert history["events"][0]["action"] == "add"
|
||||
|
||||
def test_a_write_s_event_records_the_consumer(self, client):
|
||||
"""T-4: X-Office-Client is retired; book_events.client (no migration
|
||||
this round) now carries the consumer instead."""
|
||||
record = client.post(
|
||||
"/book/records",
|
||||
json={"body": "x"},
|
||||
headers=office_headers(consumer="office-gemini"),
|
||||
).json()
|
||||
|
||||
history = client.get(
|
||||
f"/book/records/{record['id']}/history", headers=office_headers()
|
||||
).json()
|
||||
assert history["events"][0]["action"] == "add"
|
||||
assert history["events"][0]["client"] == "office-gemini"
|
||||
|
||||
|
||||
class TestNumbering:
|
||||
"""core-api T-7: `number` is allocated per tenant, never globally."""
|
||||
|
||||
def test_numbering_starts_at_one_per_tenant_and_increments(self, client):
|
||||
first = client.post(
|
||||
"/book/records", json={"body": "a"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
).json()
|
||||
second = client.post(
|
||||
"/book/records", json={"body": "b"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
).json()
|
||||
other_tenant_first = client.post(
|
||||
"/book/records", json={"body": "c"}, headers=office_headers(tenant=OTHER_TENANT)
|
||||
).json()
|
||||
|
||||
assert first["number"] == 1
|
||||
assert second["number"] == 2
|
||||
# A different tenant starts its own sequence at 1 — it can never
|
||||
# infer VALID_TENANT's activity from its own numbers.
|
||||
assert other_tenant_first["number"] == 1
|
||||
|
||||
def test_list_number_finds_the_record(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "find me"}, headers=office_headers()
|
||||
).json()
|
||||
client.post("/book/records", json={"body": "noise"}, headers=office_headers())
|
||||
|
||||
found = client.get(
|
||||
"/book/records", params={"number": record["number"]}, headers=office_headers()
|
||||
).json()
|
||||
assert found["total"] == 1
|
||||
assert found["records"][0]["id"] == record["id"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_concurrent_creates_in_one_tenant_get_distinct_numbers(
|
||||
self, clean_book_tables, test_database_url
|
||||
):
|
||||
"""Direct service calls on two independent sessions, run
|
||||
concurrently via asyncio.gather, rather than through TestClient
|
||||
(whose calls are issued one at a time and so would never actually
|
||||
overlap at the database). Exercises BookService._allocate_number's
|
||||
FOR UPDATE lock for real."""
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.domains.auth.callers import Caller
|
||||
from src.domains.book.schemas import BookRecordCreate
|
||||
from src.domains.book.service import get_book_service
|
||||
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
service = get_book_service()
|
||||
caller = Caller(kind="office", identity="jeroen", consumer="office-claude", tenant=VALID_TENANT)
|
||||
|
||||
async def _create(body: str) -> int:
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
record, _ = await service.add_record(session, BookRecordCreate(body=body), caller)
|
||||
return record.number
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
numbers = await asyncio.gather(_create("one"), _create("two"))
|
||||
assert sorted(numbers) == [1, 2]
|
||||
|
||||
|
||||
class TestTenantIsolation:
|
||||
"""core-api T-7 / workspace D-73: a record in another tenant answers
|
||||
exactly like one that does not exist, checked in both directions."""
|
||||
|
||||
def test_llm_tester_cannot_list_jpmschweitzer_records(self, client):
|
||||
client.post(
|
||||
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
)
|
||||
as_other = client.get("/book/records", headers=office_headers(tenant=OTHER_TENANT)).json()
|
||||
assert as_other["total"] == 0
|
||||
|
||||
def test_llm_tester_cannot_show_a_jpmschweitzer_record(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
).json()
|
||||
response = client.get(
|
||||
f"/book/records/{record['id']}", headers=office_headers(tenant=OTHER_TENANT)
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_jpmschweitzer_cannot_show_an_llm_tester_record(self, client):
|
||||
"""The reverse direction of the previous test — the boundary is
|
||||
symmetric, not a one-way allowlist for the production tenant."""
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=OTHER_TENANT)
|
||||
).json()
|
||||
response = client.get(
|
||||
f"/book/records/{record['id']}", headers=office_headers(tenant=VALID_TENANT)
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_llm_tester_cannot_change_a_jpmschweitzer_record(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
).json()
|
||||
response = client.patch(
|
||||
f"/book/records/{record['id']}",
|
||||
json={"priority": 1},
|
||||
headers=office_headers(tenant=OTHER_TENANT),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_llm_tester_cannot_close_a_jpmschweitzer_record(self, client):
|
||||
task = client.post(
|
||||
"/book/records",
|
||||
json={"kind": "task", "body": "secret"},
|
||||
headers=office_headers(tenant=VALID_TENANT),
|
||||
).json()
|
||||
response = client.post(
|
||||
f"/book/records/{task['id']}/close",
|
||||
json={"outcome": "done"},
|
||||
headers=office_headers(tenant=OTHER_TENANT),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_llm_tester_cannot_tag_a_jpmschweitzer_record(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
).json()
|
||||
response = client.post(
|
||||
f"/book/records/{record['id']}/tag",
|
||||
json={"tag": "chore"},
|
||||
headers=office_headers(tenant=OTHER_TENANT),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_llm_tester_cannot_link_to_a_jpmschweitzer_record(self, client):
|
||||
"""`link` refuses a target in another tenant the same way (dossier)."""
|
||||
a = client.post(
|
||||
"/book/records", json={"body": "a"}, headers=office_headers(tenant=OTHER_TENANT)
|
||||
).json()
|
||||
b = client.post(
|
||||
"/book/records", json={"body": "b"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
).json()
|
||||
response = client.post(
|
||||
f"/book/records/{a['id']}/link",
|
||||
json={"to_id": b["id"], "relation": "related"},
|
||||
headers=office_headers(tenant=OTHER_TENANT),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_llm_tester_cannot_read_history_of_a_jpmschweitzer_record(self, client):
|
||||
record = client.post(
|
||||
"/book/records", json={"body": "secret"}, headers=office_headers(tenant=VALID_TENANT)
|
||||
).json()
|
||||
response = client.get(
|
||||
f"/book/records/{record['id']}/history", headers=office_headers(tenant=OTHER_TENANT)
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_add_with_a_parent_in_another_tenant_is_404_and_nothing_persists(self, client):
|
||||
"""The `parent` convenience link in `add` is a link target too, so
|
||||
it is refused the same way `link` is. The attempted child must not
|
||||
persist either — the whole add is one transaction."""
|
||||
parent = client.post(
|
||||
"/book/records", json={"body": "parent"}, headers=office_headers(tenant=OTHER_TENANT)
|
||||
).json()
|
||||
response = client.post(
|
||||
"/book/records",
|
||||
json={"body": "child", "parent": parent["id"]},
|
||||
headers=office_headers(tenant=VALID_TENANT),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
listing = client.get("/book/records", headers=office_headers(tenant=VALID_TENANT)).json()
|
||||
assert listing["total"] == 0
|
||||
|
||||
def test_find_does_not_leak_a_jpmschweitzer_record_to_llm_tester(self, client):
|
||||
client.post(
|
||||
"/book/records",
|
||||
json={"body": "unique-secret-garden-term"},
|
||||
headers=office_headers(tenant=VALID_TENANT),
|
||||
)
|
||||
found = client.get(
|
||||
"/book/records/find",
|
||||
params={"q": "unique-secret-garden-term"},
|
||||
headers=office_headers(tenant=OTHER_TENANT),
|
||||
).json()
|
||||
assert found["total"] == 0
|
||||
|
||||
|
||||
class TestUserCallerTenantRequired:
|
||||
"""A user-kind caller without a `tatlock_user` claim gets 400
|
||||
tenant_required — the Office caller gets this for free from
|
||||
callers.py, but a "user"-kind caller's tenant is optional there, so
|
||||
Book's own `_require_tenant` is what actually enforces it (dossier
|
||||
verification #2)."""
|
||||
|
||||
def test_user_without_tenant_claim_is_400_on_read(self, client):
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
|
||||
response = client.get("/book/records", headers={"Authorization": "Bearer some-jwt"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_required"
|
||||
|
||||
def test_user_without_tenant_claim_is_400_on_write(self, client):
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
|
||||
response = client.post(
|
||||
"/book/records", json={"body": "hi"}, headers={"Authorization": "Bearer some-jwt"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_required"
|
||||
|
||||
def test_user_with_tenant_claim_succeeds(self, client):
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {
|
||||
"preferred_username": "jeroen",
|
||||
"tatlock_user": VALID_TENANT,
|
||||
}
|
||||
response = client.post(
|
||||
"/book/records", json={"body": "hi"}, headers={"Authorization": "Bearer some-jwt"}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["created_by"] == "jeroen"
|
||||
assert response.json()["number"] == 1
|
||||
|
||||
|
||||
class TestTenantRegistryEnforcement:
|
||||
"""core-api T-8 / workspace D-73: Book's own `_require_tenant` now
|
||||
delegates to the tenant registry's one enforcement point, so a Book
|
||||
route gains three refusal paths it did not have before, alongside the
|
||||
control that a properly granted, active tenant still succeeds."""
|
||||
|
||||
def test_unknown_tenant_is_403(self, client):
|
||||
response = client.get(
|
||||
"/book/records", headers=office_headers(tenant="no-such-tenant")
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "tenant_unknown"
|
||||
|
||||
def test_suspended_tenant_is_403(self, client, suspended_tenant):
|
||||
response = client.get(
|
||||
"/book/records", headers=office_headers(tenant=suspended_tenant)
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "tenant_suspended"
|
||||
|
||||
def test_consumer_without_a_grant_is_403(self, client):
|
||||
response = client.get(
|
||||
"/book/records", headers=office_headers(consumer="office-ungranted")
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "consumer_not_granted"
|
||||
|
||||
def test_granted_consumer_in_an_active_tenant_succeeds(self, client):
|
||||
"""The control: office-claude, granted in jpmschweitzer (active),
|
||||
still reaches the route."""
|
||||
response = client.get("/book/records", headers=office_headers())
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Tests for Book migration 005.
|
||||
|
||||
Runs against `core_api_test` only (see conftest.py — skip when
|
||||
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
||||
Self-contained: starts and ends at head regardless of what ran before it,
|
||||
so test order never matters for the schema the rest of the suite needs.
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.conftest import run_alembic
|
||||
|
||||
|
||||
async def _table_exists(url: str, table: str) -> bool:
|
||||
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT to_regclass(:name) IS NOT NULL AS present"),
|
||||
{"name": table},
|
||||
)
|
||||
return bool(result.scalar())
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _scalar(url: str, query: str, **params) -> object:
|
||||
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text(query), params)
|
||||
return result.scalar()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestMigration005RoundTrip:
|
||||
"""Migration 005 must upgrade and downgrade cleanly (dossier verification #2)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_creates_book_tables(self, test_database_url):
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
for table in (
|
||||
"book_records", "book_tags", "book_record_links", "book_attachments",
|
||||
"book_events", "book_tenant_counters",
|
||||
):
|
||||
assert await _table_exists(test_database_url, table), f"{table} missing after upgrade"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
|
||||
# Baseline: make sure we start at head.
|
||||
baseline = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert baseline.returncode == 0, baseline.stderr
|
||||
|
||||
down = run_alembic(test_database_url, "downgrade", "004")
|
||||
assert down.returncode == 0, down.stderr
|
||||
assert not await _table_exists(test_database_url, "book_records")
|
||||
assert not await _table_exists(test_database_url, "book_events")
|
||||
assert not await _table_exists(test_database_url, "book_tenant_counters")
|
||||
|
||||
# Leave the schema at head for the rest of the suite.
|
||||
up = run_alembic(test_database_url, "upgrade", "005")
|
||||
assert up.returncode == 0, up.stderr
|
||||
assert await _table_exists(test_database_url, "book_records")
|
||||
assert await _table_exists(test_database_url, "book_events")
|
||||
assert await _table_exists(test_database_url, "book_tenant_counters")
|
||||
|
||||
|
||||
class TestMigration005TenantAndNumber:
|
||||
"""core-api T-7: owner_tenant/number amended onto 005 before its first release."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_book_records_owner_tenant_and_number_are_not_null(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
for column in ("owner_tenant", "number"):
|
||||
nullable = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT is_nullable FROM information_schema.columns "
|
||||
"WHERE table_name = 'book_records' AND column_name = :column",
|
||||
column=column,
|
||||
)
|
||||
assert nullable == "NO", f"book_records.{column} must be NOT NULL, got {nullable!r}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_book_records_has_tenant_number_unique_constraint(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT conname FROM pg_constraint WHERE conname = 'uq_book_records_tenant_number'",
|
||||
)
|
||||
assert found == "uq_book_records_tenant_number"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_book_records_has_tenant_kind_status_index(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT indexname FROM pg_indexes WHERE indexname = 'ix_book_records_tenant_kind_status'",
|
||||
)
|
||||
assert found == "ix_book_records_tenant_kind_status"
|
||||
|
||||
|
||||
class TestBookEventsAppendOnly:
|
||||
"""The trigger must block UPDATE and DELETE on book_events, no exceptions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_on_book_events_raises(self, clean_book_tables):
|
||||
from src.domains.book.models import BookEvent, BookRecord
|
||||
|
||||
session = clean_book_tables
|
||||
record = BookRecord(
|
||||
owner_tenant="jpmschweitzer", number=1,
|
||||
kind="note", body="trigger test", status="active", created_by="test-harness",
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
event = BookEvent(record_id=record.id, identity="test-harness", client=None, action="add", diff={})
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await session.execute(
|
||||
text("UPDATE book_events SET action = 'tampered' WHERE id = :id").bindparams(id=event.id)
|
||||
)
|
||||
await session.commit()
|
||||
assert "append-only" in str(exc_info.value).lower() or "book_events" in str(exc_info.value)
|
||||
await session.rollback()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_on_book_events_raises(self, clean_book_tables):
|
||||
from src.domains.book.models import BookEvent, BookRecord
|
||||
|
||||
session = clean_book_tables
|
||||
record = BookRecord(
|
||||
owner_tenant="jpmschweitzer", number=1,
|
||||
kind="note", body="trigger test", status="active", created_by="test-harness",
|
||||
)
|
||||
session.add(record)
|
||||
await session.flush()
|
||||
event = BookEvent(record_id=record.id, identity="test-harness", client=None, action="add", diff={})
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await session.execute(
|
||||
text("DELETE FROM book_events WHERE id = :id").bindparams(id=event.id)
|
||||
)
|
||||
await session.commit()
|
||||
assert "append-only" in str(exc_info.value).lower() or "book_events" in str(exc_info.value)
|
||||
await session.rollback()
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Tests for require_caller (T-1, extended by T-4).
|
||||
|
||||
Builds a tiny standalone FastAPI app rather than reusing src.main's real
|
||||
routes, so these are pure caller-resolution tests with no database and no
|
||||
coupling to a particular domain's routing.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
from src.domains.auth.oidc import oidc_config
|
||||
from src.shared.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
VALID_CONSUMER = "office-claude"
|
||||
VALID_TENANT = "jpmschweitzer"
|
||||
|
||||
|
||||
def _reset_service_keys():
|
||||
settings.office_service_key = ""
|
||||
settings.tatlock_service_key = ""
|
||||
settings.scheduler_service_key = ""
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_caller_state():
|
||||
"""Service keys and OIDC state are process-global; isolate every test."""
|
||||
original_keys = (
|
||||
settings.office_service_key,
|
||||
settings.tatlock_service_key,
|
||||
settings.scheduler_service_key,
|
||||
)
|
||||
original_oidc = (oidc_config.enabled, oidc_config.issuers, oidc_config.audiences)
|
||||
_reset_service_keys()
|
||||
yield
|
||||
(
|
||||
settings.office_service_key,
|
||||
settings.tatlock_service_key,
|
||||
settings.scheduler_service_key,
|
||||
) = original_keys
|
||||
oidc_config.enabled, oidc_config.issuers, oidc_config.audiences = original_oidc
|
||||
|
||||
|
||||
# Named with a leading underscore so pytest does not try to collect it as
|
||||
# a test module attribute (it previously warned: "cannot collect 'test_app'
|
||||
# because it is not a function").
|
||||
_caller_app = FastAPI()
|
||||
|
||||
|
||||
@_caller_app.get("/only-office")
|
||||
async def only_office(caller: Caller = Depends(require_caller("office"))):
|
||||
return {
|
||||
"kind": caller.kind,
|
||||
"identity": caller.identity,
|
||||
"consumer": caller.consumer,
|
||||
"tenant": caller.tenant,
|
||||
}
|
||||
|
||||
|
||||
@_caller_app.get("/office-tatlock-user")
|
||||
async def office_tatlock_user(
|
||||
caller: Caller = Depends(require_caller("office", "tatlock", "user")),
|
||||
):
|
||||
return {"kind": caller.kind, "identity": caller.identity, "tenant": caller.tenant}
|
||||
|
||||
|
||||
@_caller_app.post("/office-user-write")
|
||||
async def office_user_write(
|
||||
caller: Caller = Depends(require_caller("office", "user", identity_required=True)),
|
||||
):
|
||||
return {"kind": caller.kind, "identity": caller.identity}
|
||||
|
||||
|
||||
@_caller_app.get("/all-kinds")
|
||||
async def all_kinds(
|
||||
caller: Caller = Depends(require_caller("office", "tatlock", "scheduler", "user")),
|
||||
):
|
||||
return {"kind": caller.kind, "identity": caller.identity, "tenant": caller.tenant}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(_caller_app)
|
||||
|
||||
|
||||
class TestNoAuth:
|
||||
def test_no_token_defaults_to_local_user_when_oidc_disabled(self, client):
|
||||
"""Matches existing housekeeping/dashboard tests: OIDC off -> local user."""
|
||||
response = client.get("/office-tatlock-user")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "user", "identity": "local", "tenant": None}
|
||||
|
||||
def test_no_token_401_when_oidc_enabled(self, client):
|
||||
oidc_config.configure(enabled=True, issuers=[], audiences=[])
|
||||
response = client.get("/office-tatlock-user")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestOfficeKey:
|
||||
def test_office_key_with_identity_resolves(self, client):
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "office", "identity": "jeroen", "tenant": VALID_TENANT}
|
||||
|
||||
def test_office_key_consumer_and_tenant_are_set_on_caller(self, client):
|
||||
"""Dossier verification: both present -> Caller.consumer and .tenant set."""
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/only-office",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"kind": "office",
|
||||
"identity": "jeroen",
|
||||
"consumer": VALID_CONSUMER,
|
||||
"tenant": VALID_TENANT,
|
||||
}
|
||||
|
||||
def test_office_key_x_office_client_header_is_ignored(self, client):
|
||||
"""X-Office-Client is retired (T-4) in favour of X-Office-Consumer."""
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/only-office",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
"X-Office-Client": "claude-desktop",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["consumer"] == VALID_CONSUMER # not "claude-desktop"
|
||||
assert "client" not in body
|
||||
|
||||
def test_office_key_missing_identity_on_read_defaults_empty(self, client):
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "office", "identity": "", "tenant": VALID_TENANT}
|
||||
|
||||
def test_office_key_missing_identity_on_write_is_400(self, client):
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.post(
|
||||
"/office-user-write",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_required"
|
||||
|
||||
@pytest.mark.parametrize("identity", ["Jeroen", "group-home", "tatlock", "office", "book", "scheduler", "-abc", "a_b"])
|
||||
def test_office_key_malformed_or_reserved_identity_is_400(self, client, identity):
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.post(
|
||||
"/office-user-write",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Identity": identity,
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_invalid"
|
||||
|
||||
def test_office_key_valid_identity_on_write_succeeds(self, client):
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.post(
|
||||
"/office-user-write",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "office", "identity": "jeroen"}
|
||||
|
||||
|
||||
class TestOfficeConsumerAndTenant:
|
||||
"""T-4: both are required on every office call, reads included — the
|
||||
tenant scopes reads too. Missing -> 400 */_required; malformed -> 400
|
||||
*/_invalid."""
|
||||
|
||||
def test_missing_consumer_is_400(self, client):
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "consumer_required"
|
||||
|
||||
@pytest.mark.parametrize("consumer", ["Office-Claude", "office_claude", "-office"])
|
||||
def test_malformed_consumer_is_400(self, client, consumer):
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Consumer": consumer,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "consumer_invalid"
|
||||
|
||||
def test_missing_tenant_is_400_even_on_a_read(self, client):
|
||||
"""Dossier verification: an Office read without a tenant is 400 —
|
||||
/office-tatlock-user carries no identity_required, and this still
|
||||
fails before reaching the handler."""
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_required"
|
||||
|
||||
@pytest.mark.parametrize("tenant", ["Jeroen", "-abc", "a b"])
|
||||
def test_malformed_tenant_is_400(self, client, tenant):
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": tenant,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_invalid"
|
||||
|
||||
def test_tenant_allows_underscores(self, client):
|
||||
"""tatlock's own tenant ids contain underscores (e.g. llm_tester)."""
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": "llm_tester",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["tenant"] == "llm_tester"
|
||||
|
||||
def test_missing_both_reports_consumer_first(self, client):
|
||||
"""Lock down the check order: consumer before tenant when both are absent."""
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user", headers={"Authorization": "Bearer office-secret"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "consumer_required"
|
||||
|
||||
|
||||
class TestTatlockAndSchedulerKeys:
|
||||
def test_tatlock_key_resolves_and_ignores_identity_header(self, client):
|
||||
settings.tatlock_service_key = "tatlock-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer tatlock-secret",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "tatlock", "identity": "tatlock", "tenant": None}
|
||||
|
||||
def test_scheduler_key_forbidden_on_a_route_that_excludes_it(self, client):
|
||||
settings.scheduler_service_key = "scheduler-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user", headers={"Authorization": "Bearer scheduler-secret"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "forbidden"
|
||||
|
||||
def test_scheduler_key_resolves_and_ignores_consumer_and_tenant_headers(self, client):
|
||||
"""Every kind but 'office' ignores X-Office-Consumer/X-Office-Tenant outright."""
|
||||
settings.scheduler_service_key = "scheduler-secret"
|
||||
response = client.get(
|
||||
"/all-kinds",
|
||||
headers={
|
||||
"Authorization": "Bearer scheduler-secret",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "scheduler", "identity": "scheduler", "tenant": None}
|
||||
|
||||
|
||||
class TestServiceKeyBypassesJWT:
|
||||
def test_matched_service_key_never_calls_get_current_user(self, client):
|
||||
"""Static keys are checked before any JWT parsing (dossier T-1 §1)."""
|
||||
settings.office_service_key = "office-secret"
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.side_effect = AssertionError(
|
||||
"get_current_user must not run when a service key matched"
|
||||
)
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer office-secret",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Consumer": VALID_CONSUMER,
|
||||
"X-Office-Tenant": VALID_TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_get_current_user.assert_not_called()
|
||||
|
||||
def test_unmatched_token_falls_through_to_get_current_user(self, client):
|
||||
"""A wrong-looking token with no matching key must still reach the JWT path."""
|
||||
settings.office_service_key = "office-secret"
|
||||
response = client.get(
|
||||
"/office-tatlock-user", headers={"Authorization": "Bearer not-the-office-key"}
|
||||
)
|
||||
# OIDC disabled -> get_current_user ignores the bad bearer and returns the local user.
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "user", "identity": "local", "tenant": None}
|
||||
|
||||
|
||||
class TestUserTenant:
|
||||
"""A JWT user's tenant comes only from their own token's `tatlock_user`
|
||||
claim — never from a header (T-4). This is the tenant-spoofing guard."""
|
||||
|
||||
def test_user_tenant_comes_from_jwt_claim(self, client):
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {
|
||||
"preferred_username": "jpmschweitzer",
|
||||
"tatlock_user": "jpmschweitzer",
|
||||
}
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={"Authorization": "Bearer some-jwt"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "user", "identity": "jpmschweitzer", "tenant": "jpmschweitzer"}
|
||||
|
||||
def test_user_tenant_ignores_x_office_tenant_header_spoofing_attempt(self, client):
|
||||
"""The header must not be able to put a non-Office caller into a tenant:
|
||||
a JWT carrying the real claim wins over a forged header every time."""
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {
|
||||
"preferred_username": "jpmschweitzer",
|
||||
"tatlock_user": "jpmschweitzer",
|
||||
}
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer some-jwt",
|
||||
"X-Office-Tenant": "someone-elses-tenant",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "user", "identity": "jpmschweitzer", "tenant": "jpmschweitzer"}
|
||||
|
||||
def test_user_tenant_is_none_when_jwt_carries_no_claim_even_with_header_sent(self, client):
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {"preferred_username": "jpmschweitzer"}
|
||||
response = client.get(
|
||||
"/office-tatlock-user",
|
||||
headers={
|
||||
"Authorization": "Bearer some-jwt",
|
||||
"X-Office-Tenant": "someone-elses-tenant",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"kind": "user", "identity": "jpmschweitzer", "tenant": None}
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for config module."""
|
||||
import pytest
|
||||
from src.config import (
|
||||
from src.shared.config import (
|
||||
__version__,
|
||||
Settings,
|
||||
get_settings,
|
||||
@@ -89,10 +89,6 @@ class TestGetSettings:
|
||||
settings2 = get_settings()
|
||||
assert settings1 is settings2
|
||||
|
||||
def test_model_aliases_property(self):
|
||||
"""Model aliases property should return dict."""
|
||||
settings = get_settings()
|
||||
aliases = settings.model_aliases
|
||||
assert isinstance(aliases, dict)
|
||||
assert "gpt-3.5-turbo" in aliases
|
||||
assert "gpt-4" in aliases
|
||||
# Removed: test_model_aliases_property. Settings.model_aliases mapped
|
||||
# gpt-3.5-turbo and gpt-4 onto local models, and was deleted along with the
|
||||
# Ollama integration in c1f16d4. The test outlived the feature it covered.
|
||||
|
||||
@@ -3,6 +3,7 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from src.domains.auth.oidc import oidc_config
|
||||
from src.main import app
|
||||
|
||||
|
||||
@@ -12,6 +13,51 @@ def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestHousekeepingReadsAreLocked:
|
||||
"""
|
||||
T-1 / D-68: api.schweitz.net carries no forward-auth, so an
|
||||
unauthenticated read used to reach Home Assistant directly. These
|
||||
assert the caller gate in front of the GET routes, independent of
|
||||
whatever Home Assistant itself returns.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def oidc_enabled(self):
|
||||
original = (oidc_config.enabled, oidc_config.issuers, oidc_config.audiences)
|
||||
oidc_config.configure(enabled=True, issuers=[], audiences=[])
|
||||
yield
|
||||
oidc_config.enabled, oidc_config.issuers, oidc_config.audiences = original
|
||||
|
||||
def test_no_auth_is_401_on_devices(self, client):
|
||||
response = client.get("/housekeeping/devices")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_no_auth_is_401_on_health(self, client):
|
||||
response = client.get("/housekeeping/health")
|
||||
assert response.status_code == 401
|
||||
|
||||
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
|
||||
def test_tatlock_key_passes_auth_on_devices(self, mock_get_ha, client):
|
||||
"""The one caller this lock exists to keep working (dossier §2)."""
|
||||
from src.shared.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
original = settings.tatlock_service_key
|
||||
settings.tatlock_service_key = "test-tatlock-key"
|
||||
try:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get_states.return_value = []
|
||||
mock_get_ha.return_value = mock_client
|
||||
|
||||
response = client.get(
|
||||
"/housekeeping/devices",
|
||||
headers={"Authorization": "Bearer test-tatlock-key"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
finally:
|
||||
settings.tatlock_service_key = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ha_client():
|
||||
"""Create a mock Home Assistant client."""
|
||||
|
||||
+114
-62
@@ -1,118 +1,173 @@
|
||||
"""Tests for OIDC authentication module."""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from unittest.mock import patch, MagicMock
|
||||
from fastapi import HTTPException
|
||||
|
||||
from src.auth.oidc import OIDCConfig, oidc_config, get_jwks, get_current_user
|
||||
from src.auth.oidc import (
|
||||
OIDCConfig,
|
||||
oidc_config,
|
||||
get_jwks_for_issuer,
|
||||
get_current_user,
|
||||
_jwks_cache,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_jwks_cache():
|
||||
"""
|
||||
The JWKS cache is module-level state, so a fetch in one test would satisfy
|
||||
the next one and hide a regression. Clearing on both sides keeps the tests
|
||||
order-independent.
|
||||
"""
|
||||
_jwks_cache.clear()
|
||||
yield
|
||||
_jwks_cache.clear()
|
||||
|
||||
|
||||
class TestOIDCConfig:
|
||||
"""Test OIDCConfig class."""
|
||||
|
||||
def test_init_defaults(self):
|
||||
"""Config should initialize with disabled state."""
|
||||
"""Config should initialize disabled with no issuers or audiences."""
|
||||
config = OIDCConfig()
|
||||
|
||||
assert config.enabled is False
|
||||
assert config.issuer == ""
|
||||
assert config.audience == ""
|
||||
assert config.jwks_uri == ""
|
||||
assert config.issuers == []
|
||||
assert config.audiences == []
|
||||
|
||||
def test_configure_sets_values(self):
|
||||
"""configure should set all values."""
|
||||
config = OIDCConfig()
|
||||
config.configure(
|
||||
enabled=True,
|
||||
issuer="https://auth.example.com",
|
||||
audience="core-api"
|
||||
issuers=["https://auth.example.com"],
|
||||
audiences=["core-api"],
|
||||
)
|
||||
|
||||
assert config.enabled is True
|
||||
assert config.issuer == "https://auth.example.com"
|
||||
assert config.audience == "core-api"
|
||||
assert config.jwks_uri == "https://auth.example.com/jwks/"
|
||||
assert config.issuers == ["https://auth.example.com"]
|
||||
assert config.audiences == ["core-api"]
|
||||
|
||||
def test_configure_strips_trailing_slash(self):
|
||||
"""configure should handle trailing slash in issuer."""
|
||||
"""configure should normalise issuers by dropping the trailing slash."""
|
||||
config = OIDCConfig()
|
||||
config.configure(
|
||||
enabled=True,
|
||||
issuer="https://auth.example.com/",
|
||||
audience="core-api"
|
||||
issuers=["https://auth.example.com/"],
|
||||
audiences=["core-api"],
|
||||
)
|
||||
|
||||
assert config.jwks_uri == "https://auth.example.com/jwks/"
|
||||
assert config.issuers == ["https://auth.example.com"]
|
||||
|
||||
def test_configure_accepts_multiple_issuers(self):
|
||||
"""The point of the multi-issuer change: more than one is allowed."""
|
||||
config = OIDCConfig()
|
||||
config.configure(
|
||||
enabled=True,
|
||||
issuers=["https://a.example.com/", "https://b.example.com"],
|
||||
audiences=["core-api", "other"],
|
||||
)
|
||||
|
||||
assert config.issuers == ["https://a.example.com", "https://b.example.com"]
|
||||
assert config.audiences == ["core-api", "other"]
|
||||
|
||||
def test_get_jwks_uri_derives_from_issuer(self):
|
||||
"""The JWKS URI is derived per issuer rather than configured."""
|
||||
config = OIDCConfig()
|
||||
|
||||
assert config.get_jwks_uri("https://auth.example.com") == "https://auth.example.com/jwks/"
|
||||
assert config.get_jwks_uri("https://auth.example.com/") == "https://auth.example.com/jwks/"
|
||||
|
||||
def test_is_valid_issuer_only_accepts_configured(self):
|
||||
"""
|
||||
An unconfigured issuer must be rejected. This is the security-relevant
|
||||
half of multi-issuer support: accepting any issuer would let a token
|
||||
from an unrelated identity provider through.
|
||||
"""
|
||||
config = OIDCConfig()
|
||||
config.configure(
|
||||
enabled=True,
|
||||
issuers=["https://auth.example.com"],
|
||||
audiences=["core-api"],
|
||||
)
|
||||
|
||||
assert config.is_valid_issuer("https://auth.example.com") is True
|
||||
assert config.is_valid_issuer("https://auth.example.com/") is True
|
||||
assert config.is_valid_issuer("https://evil.example.com") is False
|
||||
|
||||
|
||||
class TestGetJWKS:
|
||||
"""Test get_jwks function."""
|
||||
class TestGetJWKSForIssuer:
|
||||
"""Test get_jwks_for_issuer function."""
|
||||
|
||||
ISSUER = "https://auth.example.com"
|
||||
|
||||
def test_returns_empty_when_disabled(self):
|
||||
"""get_jwks should return empty dict when OIDC disabled."""
|
||||
# Save original state
|
||||
"""Should return an empty dict when OIDC is disabled."""
|
||||
original_enabled = oidc_config.enabled
|
||||
|
||||
try:
|
||||
oidc_config.enabled = False
|
||||
# Clear the cache
|
||||
get_jwks.cache_clear()
|
||||
|
||||
result = get_jwks()
|
||||
|
||||
assert result == {}
|
||||
assert get_jwks_for_issuer(self.ISSUER) == {}
|
||||
finally:
|
||||
# Restore original state
|
||||
oidc_config.enabled = original_enabled
|
||||
get_jwks.cache_clear()
|
||||
|
||||
@patch("src.auth.oidc.httpx.get")
|
||||
def test_fetches_jwks_when_enabled(self, mock_get):
|
||||
"""get_jwks should fetch JWKS when enabled."""
|
||||
# Save original state
|
||||
"""Should fetch from the issuer's derived JWKS URI."""
|
||||
original_enabled = oidc_config.enabled
|
||||
original_jwks_uri = oidc_config.jwks_uri
|
||||
|
||||
try:
|
||||
oidc_config.enabled = True
|
||||
oidc_config.jwks_uri = "https://auth.example.com/jwks/"
|
||||
get_jwks.cache_clear()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"keys": [{"kid": "test"}]}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json.return_value = {"keys": [{"kid": "abc"}]}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result = get_jwks()
|
||||
result = get_jwks_for_issuer(self.ISSUER)
|
||||
|
||||
assert "keys" in result
|
||||
assert result == {"keys": [{"kid": "abc"}]}
|
||||
mock_get.assert_called_once()
|
||||
assert mock_get.call_args[0][0] == f"{self.ISSUER}/jwks/"
|
||||
finally:
|
||||
oidc_config.enabled = original_enabled
|
||||
oidc_config.jwks_uri = original_jwks_uri
|
||||
get_jwks.cache_clear()
|
||||
|
||||
@patch("src.auth.oidc.httpx.get")
|
||||
def test_raises_exception_on_error(self, mock_get):
|
||||
"""get_jwks should raise HTTPException on fetch error."""
|
||||
# Save original state
|
||||
def test_caches_per_issuer(self, mock_get):
|
||||
"""
|
||||
A second call for the same issuer must not refetch, and a different
|
||||
issuer must. Caching by issuer is the behaviour the multi-issuer change
|
||||
introduced, and a shared cache would have served one issuer's keys for
|
||||
another — which would be a verification bypass, not just a slow path.
|
||||
"""
|
||||
original_enabled = oidc_config.enabled
|
||||
original_jwks_uri = oidc_config.jwks_uri
|
||||
|
||||
try:
|
||||
oidc_config.enabled = True
|
||||
oidc_config.jwks_uri = "https://auth.example.com/jwks/"
|
||||
get_jwks.cache_clear()
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"keys": []}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
mock_get.side_effect = Exception("Connection error")
|
||||
get_jwks_for_issuer(self.ISSUER)
|
||||
get_jwks_for_issuer(self.ISSUER + "/") # same issuer, normalised
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
get_jwks_for_issuer("https://other.example.com")
|
||||
assert mock_get.call_count == 2
|
||||
finally:
|
||||
oidc_config.enabled = original_enabled
|
||||
|
||||
@patch("src.auth.oidc.httpx.get")
|
||||
def test_raises_503_on_fetch_error(self, mock_get):
|
||||
"""A JWKS fetch failure should surface as 503, not leak the cause."""
|
||||
original_enabled = oidc_config.enabled
|
||||
try:
|
||||
oidc_config.enabled = True
|
||||
mock_get.side_effect = Exception("Connection failed")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_jwks()
|
||||
get_jwks_for_issuer(self.ISSUER)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
finally:
|
||||
oidc_config.enabled = original_enabled
|
||||
oidc_config.jwks_uri = original_jwks_uri
|
||||
get_jwks.cache_clear()
|
||||
|
||||
|
||||
class TestGetCurrentUser:
|
||||
@@ -121,9 +176,7 @@ class TestGetCurrentUser:
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_when_disabled(self):
|
||||
"""get_current_user should return None when OIDC disabled."""
|
||||
# Save original state
|
||||
original_enabled = oidc_config.enabled
|
||||
|
||||
try:
|
||||
oidc_config.enabled = False
|
||||
|
||||
@@ -136,9 +189,7 @@ class TestGetCurrentUser:
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_401_when_enabled_without_token(self):
|
||||
"""get_current_user should raise 401 when enabled but no token."""
|
||||
# Save original state
|
||||
original_enabled = oidc_config.enabled
|
||||
|
||||
try:
|
||||
oidc_config.enabled = True
|
||||
|
||||
@@ -157,11 +208,12 @@ class TestOIDCGlobalConfig:
|
||||
"""oidc_config should be an OIDCConfig instance."""
|
||||
assert isinstance(oidc_config, OIDCConfig)
|
||||
|
||||
def test_global_config_starts_disabled(self):
|
||||
"""oidc_config should start disabled by default."""
|
||||
# This tests the initial state before any configure() is called
|
||||
# The actual state depends on app configuration
|
||||
assert hasattr(oidc_config, 'enabled')
|
||||
assert hasattr(oidc_config, 'issuer')
|
||||
assert hasattr(oidc_config, 'audience')
|
||||
assert hasattr(oidc_config, 'jwks_uri')
|
||||
def test_global_config_exposes_the_multi_issuer_surface(self):
|
||||
"""
|
||||
Asserts the shape rather than the values, since the live state depends
|
||||
on app configuration. These four are what callers depend on.
|
||||
"""
|
||||
assert hasattr(oidc_config, "enabled")
|
||||
assert hasattr(oidc_config, "issuers")
|
||||
assert hasattr(oidc_config, "audiences")
|
||||
assert callable(oidc_config.get_jwks_uri)
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
"""
|
||||
Tests for the Scribe domain (dossier core-api T-5 / D-66, D-68, D-73)
|
||||
against `core_api_test`.
|
||||
|
||||
Follows tests/test_book.py's TestClient pattern: `get_async_session` is
|
||||
overridden to the test database for every test, building a fresh
|
||||
engine/session per call because TestClient drives the ASGI app on a
|
||||
different event loop than pytest-asyncio's fixture setup.
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.domains.auth.callers import _IDENTITY_RE, RESERVED_IDENTITIES
|
||||
from src.domains.scribe.models import ADDRESS_NAME_PATTERN, RESERVED_ADDRESS_NAMES
|
||||
from src.main import app
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.database import get_async_session
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
OFFICE_KEY = "test-office-key"
|
||||
TENANT = "jpmschweitzer"
|
||||
OTHER_TENANT = "llm_tester"
|
||||
|
||||
|
||||
def test_address_name_rule_matches_callers_identity_rule():
|
||||
"""models.py deliberately duplicates callers.py's identity shape rather
|
||||
than importing its private constants (see models.py's comment) — assert
|
||||
the two never drift apart instead."""
|
||||
assert ADDRESS_NAME_PATTERN == _IDENTITY_RE.pattern
|
||||
assert RESERVED_ADDRESS_NAMES == RESERVED_IDENTITIES
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def office_key():
|
||||
"""Every Scribe test authenticates as the office caller unless it says otherwise."""
|
||||
original = settings.office_service_key
|
||||
settings.office_service_key = OFFICE_KEY
|
||||
yield
|
||||
settings.office_service_key = original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(clean_scribe_tables, test_database_url):
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
async def _override():
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
app.dependency_overrides[get_async_session] = _override
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.pop(get_async_session, None)
|
||||
|
||||
|
||||
def office_headers(identity: str, consumer: str, tenant: str = TENANT) -> dict:
|
||||
return {
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Identity": identity,
|
||||
"X-Office-Consumer": consumer,
|
||||
"X-Office-Tenant": tenant,
|
||||
}
|
||||
|
||||
|
||||
def register(client, identity: str, consumer: str, tenant: str = TENANT):
|
||||
return client.post(
|
||||
"/scribe/addresses",
|
||||
json={"name": identity},
|
||||
headers=office_headers(identity, consumer, tenant),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Caller matrix — a representative slice; the full matrix is test_callers.py
|
||||
# and test_book.py's TestCallerMatrixOnBookRoutes already exercises
|
||||
# require_caller itself end to end on a sibling domain.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestCallerMatrixOnScribeRoutes:
|
||||
def test_no_auth_is_401_on_scribe_routes(self, client):
|
||||
from src.domains.auth.oidc import oidc_config
|
||||
|
||||
oidc_config.configure(enabled=True, issuers=[], audiences=[])
|
||||
try:
|
||||
response = client.post("/scribe/addresses", json={"name": "jeroen"})
|
||||
finally:
|
||||
oidc_config.configure(enabled=False, issuers=[], audiences=[])
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_office_write_without_identity_is_400(self, client):
|
||||
response = client.post(
|
||||
"/scribe/addresses",
|
||||
json={"name": "jeroen"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Consumer": "office-claude",
|
||||
"X-Office-Tenant": TENANT,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "identity_required"
|
||||
|
||||
def test_office_write_without_tenant_is_400(self, client):
|
||||
response = client.post(
|
||||
"/scribe/addresses",
|
||||
json={"name": "jeroen"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Identity": "jeroen",
|
||||
"X-Office-Consumer": "office-claude",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_required"
|
||||
|
||||
def test_scheduler_key_is_403_on_scribe_addresses(self, client):
|
||||
settings.scheduler_service_key = "test-scheduler-key"
|
||||
try:
|
||||
response = client.post(
|
||||
"/scribe/addresses",
|
||||
json={"name": "jeroen"},
|
||||
headers={"Authorization": "Bearer test-scheduler-key"},
|
||||
)
|
||||
finally:
|
||||
settings.scheduler_service_key = ""
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "forbidden"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# register / list addresses
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRegisterAndListAddresses:
|
||||
def test_register_creates_address_owned_by_caller(self, client):
|
||||
response = register(client, "jeroen", "office-claude")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "jeroen"
|
||||
assert data["owner_consumer"] == "office-claude"
|
||||
assert data["created_by"] == "jeroen"
|
||||
assert data["owned"] is True
|
||||
|
||||
def test_register_is_idempotent_for_the_same_consumer(self, client):
|
||||
first = register(client, "jeroen", "office-claude")
|
||||
second = register(client, "jeroen", "office-claude")
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert second.json()["owned"] is True
|
||||
|
||||
def test_register_by_another_consumer_is_409_address_taken(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
response = register(client, "jeroen", "office-gemini")
|
||||
assert response.status_code == 409
|
||||
assert response.json()["detail"]["code"] == "address_taken"
|
||||
|
||||
@pytest.mark.parametrize("name", ["book", "office", "scheduler", "tatlock", "group-home"])
|
||||
def test_register_reserved_or_group_name_is_400(self, client, name):
|
||||
"""The registering identity ("jeroen") is valid and distinct from
|
||||
the name being registered, so this exercises the service's own
|
||||
reserved/group- check, not the caller header's identity check."""
|
||||
response = client.post(
|
||||
"/scribe/addresses",
|
||||
json={"name": name},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "address_invalid"
|
||||
|
||||
@pytest.mark.parametrize("name", ["Jeroen", "-abc", "a_b", ""])
|
||||
def test_register_malformed_name_is_422(self, client, name):
|
||||
"""Shape alone is a pydantic concern (422), not a ScribeError (400)."""
|
||||
response = client.post(
|
||||
"/scribe/addresses",
|
||||
json={"name": name},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_list_addresses_marks_ownership(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
|
||||
as_claude = client.get(
|
||||
"/scribe/addresses", headers=office_headers("jeroen", "office-claude")
|
||||
).json()
|
||||
owned = {a["name"]: a["owned"] for a in as_claude["addresses"]}
|
||||
assert owned == {"jeroen": True, "tower-control": False}
|
||||
|
||||
def test_list_addresses_is_scoped_to_the_callers_tenant(self, client):
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
|
||||
|
||||
as_other_tenant = client.get(
|
||||
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", OTHER_TENANT)
|
||||
).json()
|
||||
assert len(as_other_tenant["addresses"]) == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# The flow (dossier verification #2): register, send, mailbox, mark,
|
||||
# history, find, from shows identity and consumer.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestMessagingFlow:
|
||||
def test_full_flow(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
|
||||
sent = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "subject": "status", "body": "all green"},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
)
|
||||
assert sent.status_code == 201
|
||||
message = sent.json()
|
||||
assert message["from_identity"] == "jeroen"
|
||||
assert message["from_consumer"] == "office-claude"
|
||||
assert message["topics"] == ["tower-control"]
|
||||
|
||||
mailbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert mailbox["total"] == 1
|
||||
entry = mailbox["entries"][0]
|
||||
assert entry["read_at"] is None
|
||||
assert entry["message"]["id"] == message["id"]
|
||||
assert entry["message"]["body"] == "all green"
|
||||
|
||||
marked = client.patch(
|
||||
f"/scribe/mailbox/{entry['id']}",
|
||||
json={"read": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
assert marked.status_code == 200
|
||||
assert marked.json()["read_at"] is not None
|
||||
|
||||
mailbox_after = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert mailbox_after["entries"][0]["read_at"] is not None
|
||||
|
||||
history = client.get(
|
||||
"/scribe/addresses/tower-control/history",
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert history["address"] == "tower-control"
|
||||
assert len(history["messages"]) == 1
|
||||
assert history["messages"][0]["id"] == message["id"]
|
||||
|
||||
found = client.get(
|
||||
"/scribe/messages",
|
||||
params={"q": "green"},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
).json()
|
||||
assert found["total"] == 1
|
||||
assert found["entries"][0]["message"]["id"] == message["id"]
|
||||
|
||||
def test_history_reading_never_marks_or_consumes(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "hi"},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
)
|
||||
|
||||
client.get(
|
||||
"/scribe/addresses/tower-control/history",
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
mailbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
assert mailbox["entries"][0]["read_at"] is None
|
||||
|
||||
def test_mailbox_lists_unread_first(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
for body in ("first", "second", "third"):
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": body},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
)
|
||||
|
||||
mailbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
first_entry_id = mailbox["entries"][0]["id"]
|
||||
client.patch(
|
||||
f"/scribe/mailbox/{first_entry_id}",
|
||||
json={"read": True},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
|
||||
mailbox_after = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()
|
||||
read_states = [e["read_at"] is None for e in mailbox_after["entries"]]
|
||||
# unread (True) entries sort before the one read (False) entry
|
||||
assert read_states == sorted(read_states, reverse=True)
|
||||
|
||||
def test_tag_then_untag_mailbox_entry(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "hi"},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
)
|
||||
entry_id = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini")
|
||||
).json()["entries"][0]["id"]
|
||||
|
||||
tagged = client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"add_tags": ["urgent"]},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
assert tagged.json()["tags"] == ["urgent"]
|
||||
|
||||
untagged = client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"remove_tags": ["urgent"]},
|
||||
headers=office_headers("tower-control", "office-gemini"),
|
||||
)
|
||||
assert untagged.json()["tags"] == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# send validation
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSendValidation:
|
||||
def test_send_to_unknown_address_is_404_naming_missing_ones(self, client):
|
||||
register(client, "jeroen", "office-claude")
|
||||
response = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["nobody", "tower-control"], "body": "hi"},
|
||||
headers=office_headers("jeroen", "office-claude"),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "unknown_address"
|
||||
assert set(detail["names"]) == {"nobody", "tower-control"}
|
||||
|
||||
def test_send_from_identity_not_registered_is_403(self, client):
|
||||
register(client, "tower-control", "office-gemini")
|
||||
response = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "hi"},
|
||||
headers=office_headers("jeroen", "office-claude"), # jeroen never registered
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "identity_not_owned"
|
||||
|
||||
def test_cannot_send_as_an_address_another_consumer_owns(self, client):
|
||||
"""dossier verification #3: a consumer cannot send *as* an address
|
||||
another consumer owns."""
|
||||
register(client, "jeroen", "office-claude")
|
||||
register(client, "tower-control", "office-gemini")
|
||||
response = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["jeroen"], "body": "impersonating"},
|
||||
# office-gemini claiming to BE jeroen, which office-claude owns
|
||||
headers=office_headers("jeroen", "office-gemini"),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "identity_not_owned"
|
||||
|
||||
def test_send_with_reply_to_in_another_tenant_is_404(self, client):
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||
sent = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "original"},
|
||||
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
|
||||
).json()
|
||||
|
||||
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
|
||||
reply = client.post(
|
||||
"/scribe/messages",
|
||||
json={
|
||||
"to": ["tower-control"],
|
||||
"body": "cross-tenant reply",
|
||||
"reply_to": sent["id"],
|
||||
},
|
||||
headers=office_headers("jeroen", "office-claude", tenant=OTHER_TENANT),
|
||||
)
|
||||
assert reply.status_code == 404
|
||||
assert reply.json()["detail"]["code"] == "not_found"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Isolation (dossier verification #3): the hard tenant boundary, both
|
||||
# directions — every cross-tenant answer is indistinguishable from "does
|
||||
# not exist".
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestTenantIsolation:
|
||||
def test_llm_tester_cannot_list_jpmschweitzer_addresses(self, client):
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
as_other = client.get(
|
||||
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", OTHER_TENANT)
|
||||
).json()
|
||||
assert as_other["addresses"] == []
|
||||
|
||||
def test_llm_tester_cannot_send_to_a_jpmschweitzer_address(self, client):
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT) # TENANT only
|
||||
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
|
||||
response = client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "leaking?"},
|
||||
headers=office_headers("jeroen", "office-claude", OTHER_TENANT),
|
||||
)
|
||||
# From OTHER_TENANT's point of view, "tower-control" does not exist.
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "unknown_address"
|
||||
|
||||
def test_llm_tester_cannot_read_mailbox_of_jpmschweitzer_address(self, client):
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "secret"},
|
||||
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
|
||||
)
|
||||
|
||||
# llm_tester has never registered tower-control, so it cannot even
|
||||
# bind the identity -> 403, indistinguishable from "no such address".
|
||||
response = client.get(
|
||||
"/scribe/mailbox",
|
||||
headers=office_headers("tower-control", "office-gemini", OTHER_TENANT),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "identity_not_owned"
|
||||
|
||||
def test_llm_tester_cannot_see_history_of_a_jpmschweitzer_address(self, client):
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "secret"},
|
||||
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/scribe/addresses/tower-control/history",
|
||||
headers=office_headers("jeroen", "office-claude", OTHER_TENANT),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "unknown_address"
|
||||
|
||||
def test_llm_tester_cannot_find_a_message_of_a_jpmschweitzer_address(self, client):
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "unique-secret-word"},
|
||||
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/scribe/messages",
|
||||
params={"q": "unique-secret-word"},
|
||||
headers=office_headers("tower-control", "office-gemini", OTHER_TENANT),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "identity_not_owned"
|
||||
|
||||
def test_mark_on_an_entry_in_another_tenant_is_404(self, client):
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "secret"},
|
||||
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
|
||||
)
|
||||
entry_id = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini", TENANT)
|
||||
).json()["entries"][0]["id"]
|
||||
|
||||
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
|
||||
response = client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"read": True},
|
||||
headers=office_headers("tower-control", "office-gemini", OTHER_TENANT),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_mark_on_someone_elses_entry_within_the_same_tenant_is_404(self, client):
|
||||
"""Not a tenant crossing, but the same non-disclosure principle:
|
||||
an entry addressed to another identity answers like it doesn't
|
||||
exist, rather than confirming it belongs to someone else."""
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["tower-control"], "body": "secret"},
|
||||
headers=office_headers("jeroen", "office-claude", tenant=TENANT),
|
||||
)
|
||||
entry_id = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("tower-control", "office-gemini", TENANT)
|
||||
).json()["entries"][0]["id"]
|
||||
|
||||
response = client.patch(
|
||||
f"/scribe/mailbox/{entry_id}",
|
||||
json={"read": True},
|
||||
headers=office_headers("jeroen", "office-claude", TENANT), # not tower-control
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"]["code"] == "not_found"
|
||||
|
||||
def test_mailbox_does_not_leak_entries_from_another_tenants_same_named_address(self, client):
|
||||
"""The identity-ownership precheck alone is not the isolation
|
||||
guarantee: a caller legitimately owns "jeroen" in their own tenant,
|
||||
and a *different* "jeroen" (same name, different owner) exists in
|
||||
another tenant with mail of its own. The mailbox query's own tenant
|
||||
filter is what keeps that mail out — this is what mutation check #1
|
||||
(dossier) targets."""
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["jeroen"], "body": "for the TENANT jeroen"},
|
||||
headers=office_headers("tower-control", "office-gemini", tenant=TENANT),
|
||||
)
|
||||
|
||||
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["jeroen"], "body": "for the OTHER_TENANT jeroen"},
|
||||
headers=office_headers("tower-control", "office-gemini", tenant=OTHER_TENANT),
|
||||
)
|
||||
|
||||
mailbox = client.get(
|
||||
"/scribe/mailbox", headers=office_headers("jeroen", "office-claude", TENANT)
|
||||
).json()
|
||||
assert mailbox["total"] == 1
|
||||
assert mailbox["entries"][0]["message"]["body"] == "for the TENANT jeroen"
|
||||
|
||||
def test_find_does_not_leak_messages_from_another_tenants_same_named_address(self, client):
|
||||
"""Same concern as above, for `find` (mutation check #1's other half)."""
|
||||
register(client, "jeroen", "office-claude", tenant=TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["jeroen"], "body": "shared-search-term in TENANT"},
|
||||
headers=office_headers("tower-control", "office-gemini", tenant=TENANT),
|
||||
)
|
||||
|
||||
register(client, "jeroen", "office-claude", tenant=OTHER_TENANT)
|
||||
register(client, "tower-control", "office-gemini", tenant=OTHER_TENANT)
|
||||
client.post(
|
||||
"/scribe/messages",
|
||||
json={"to": ["jeroen"], "body": "shared-search-term in OTHER_TENANT"},
|
||||
headers=office_headers("tower-control", "office-gemini", tenant=OTHER_TENANT),
|
||||
)
|
||||
|
||||
found = client.get(
|
||||
"/scribe/messages",
|
||||
params={"q": "shared-search-term"},
|
||||
headers=office_headers("jeroen", "office-claude", TENANT),
|
||||
).json()
|
||||
assert found["total"] == 1
|
||||
assert found["entries"][0]["message"]["body"] == "shared-search-term in TENANT"
|
||||
|
||||
|
||||
class TestTenantRegistryEnforcement:
|
||||
"""core-api T-8 / workspace D-73: Scribe's own `_require_tenant` now
|
||||
delegates to the tenant registry's one enforcement point, so a Scribe
|
||||
route gains three refusal paths it did not have before, alongside the
|
||||
control that a properly granted, active tenant still succeeds."""
|
||||
|
||||
def test_unknown_tenant_is_403(self, client):
|
||||
response = client.get(
|
||||
"/scribe/addresses",
|
||||
headers=office_headers("jeroen", "office-claude", tenant="no-such-tenant"),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "tenant_unknown"
|
||||
|
||||
def test_suspended_tenant_is_403(self, client, suspended_tenant):
|
||||
response = client.get(
|
||||
"/scribe/addresses",
|
||||
headers=office_headers("jeroen", "office-claude", tenant=suspended_tenant),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "tenant_suspended"
|
||||
|
||||
def test_consumer_without_a_grant_is_403(self, client):
|
||||
response = client.get(
|
||||
"/scribe/addresses",
|
||||
headers=office_headers("jeroen", "office-ungranted", tenant=TENANT),
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "consumer_not_granted"
|
||||
|
||||
def test_granted_consumer_in_an_active_tenant_succeeds(self, client):
|
||||
"""The control: office-claude, granted in jpmschweitzer (active),
|
||||
still reaches the route."""
|
||||
response = client.get(
|
||||
"/scribe/addresses", headers=office_headers("jeroen", "office-claude", tenant=TENANT)
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Tests for Scribe migration 006.
|
||||
|
||||
Runs against `core_api_test` only (see conftest.py — skip when
|
||||
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
||||
Self-contained: starts and ends at head regardless of what ran before it.
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.conftest import run_alembic
|
||||
|
||||
|
||||
async def _table_exists(url: str, table: str) -> bool:
|
||||
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT to_regclass(:name) IS NOT NULL AS present"),
|
||||
{"name": table},
|
||||
)
|
||||
return bool(result.scalar())
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestMigration006RoundTrip:
|
||||
"""Migration 006 must upgrade and downgrade cleanly (dossier verification #1)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_creates_scribe_tables(self, test_database_url):
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
for table in ("scribe_addresses", "scribe_messages", "scribe_mailbox_entries"):
|
||||
assert await _table_exists(test_database_url, table), f"{table} missing after upgrade"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
|
||||
baseline = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert baseline.returncode == 0, baseline.stderr
|
||||
|
||||
down = run_alembic(test_database_url, "downgrade", "005")
|
||||
assert down.returncode == 0, down.stderr
|
||||
assert not await _table_exists(test_database_url, "scribe_addresses")
|
||||
assert not await _table_exists(test_database_url, "scribe_messages")
|
||||
assert not await _table_exists(test_database_url, "scribe_mailbox_entries")
|
||||
# Book tables, created by the migration below this one, must survive
|
||||
# a downgrade of 006 — proves 006 only touches its own tables.
|
||||
assert await _table_exists(test_database_url, "book_records")
|
||||
|
||||
# Leave the schema at head for the rest of the suite.
|
||||
up = run_alembic(test_database_url, "upgrade", "006")
|
||||
assert up.returncode == 0, up.stderr
|
||||
assert await _table_exists(test_database_url, "scribe_addresses")
|
||||
assert await _table_exists(test_database_url, "scribe_messages")
|
||||
assert await _table_exists(test_database_url, "scribe_mailbox_entries")
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Tests for the tenant registry's read API (core-api T-8 / workspace D-73)
|
||||
against `core_api_test`: `GET /tenants/me` and `GET /tenants`.
|
||||
|
||||
Follows tests/test_scribe.py's TestClient pattern: `get_async_session` is
|
||||
overridden to the test database for every test, building a fresh
|
||||
engine/session per call because TestClient drives the ASGI app on a
|
||||
different event loop than pytest-asyncio's fixture setup.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.domains.auth.callers import _TENANT_RE
|
||||
from src.domains.tenants.models import TENANT_ID_PATTERN
|
||||
from src.main import app
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.database import get_async_session
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
OFFICE_KEY = "test-office-key"
|
||||
TATLOCK_KEY = "test-tatlock-key"
|
||||
SCHEDULER_KEY = "test-scheduler-key"
|
||||
TENANT = "jpmschweitzer"
|
||||
OTHER_TENANT = "llm_tester"
|
||||
|
||||
|
||||
def test_tenant_id_pattern_matches_callers_tenant_regex():
|
||||
"""models.py deliberately duplicates callers.py's `_TENANT_RE` shape
|
||||
rather than importing its private constant (see models.py's comment) —
|
||||
assert the two never drift apart instead."""
|
||||
assert TENANT_ID_PATTERN == _TENANT_RE.pattern
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def service_keys():
|
||||
"""Every test here authenticates with one of the three static keys
|
||||
unless it says otherwise."""
|
||||
originals = (
|
||||
settings.office_service_key,
|
||||
settings.tatlock_service_key,
|
||||
settings.scheduler_service_key,
|
||||
)
|
||||
settings.office_service_key = OFFICE_KEY
|
||||
settings.tatlock_service_key = TATLOCK_KEY
|
||||
settings.scheduler_service_key = SCHEDULER_KEY
|
||||
yield
|
||||
(
|
||||
settings.office_service_key,
|
||||
settings.tatlock_service_key,
|
||||
settings.scheduler_service_key,
|
||||
) = originals
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tenant_registry, test_database_url):
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
async def _override():
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
session_factory = async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
app.dependency_overrides[get_async_session] = _override
|
||||
yield TestClient(app)
|
||||
app.dependency_overrides.pop(get_async_session, None)
|
||||
|
||||
|
||||
def office_headers(
|
||||
identity: str = "jeroen", consumer: str = "office-claude", tenant: str = TENANT
|
||||
) -> dict:
|
||||
return {
|
||||
"Authorization": f"Bearer {OFFICE_KEY}",
|
||||
"X-Office-Identity": identity,
|
||||
"X-Office-Consumer": consumer,
|
||||
"X-Office-Tenant": tenant,
|
||||
}
|
||||
|
||||
|
||||
def tatlock_headers() -> dict:
|
||||
return {"Authorization": f"Bearer {TATLOCK_KEY}"}
|
||||
|
||||
|
||||
def scheduler_headers() -> dict:
|
||||
return {"Authorization": f"Bearer {SCHEDULER_KEY}"}
|
||||
|
||||
|
||||
class TestMe:
|
||||
"""`GET /tenants/me` — the cheap "am I allowed here" probe (D-73)."""
|
||||
|
||||
def test_office_caller_sees_its_own_tenant_and_its_grants(self, client):
|
||||
response = client.get("/tenants/me", headers=office_headers())
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["id"] == TENANT
|
||||
assert body["display_name"] == "Jeroen"
|
||||
assert body["kind"] == "household"
|
||||
assert body["status"] == "active"
|
||||
# office-claude is the grant the dossier seeds; office-gemini is a
|
||||
# test-environment-only addition (tests/conftest.py's
|
||||
# tenant_registry fixture) — assert membership, not the exact list,
|
||||
# so this test is not coupled to that test-only extra.
|
||||
assert "office-claude" in body["consumers"]
|
||||
|
||||
def test_other_seeded_tenant_answers_its_own_identity(self, client):
|
||||
response = client.get("/tenants/me", headers=office_headers(tenant=OTHER_TENANT))
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["id"] == OTHER_TENANT
|
||||
assert body["kind"] == "test"
|
||||
|
||||
def test_unknown_tenant_is_403(self, client):
|
||||
response = client.get(
|
||||
"/tenants/me", headers=office_headers(tenant="no-such-tenant")
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "tenant_unknown"
|
||||
|
||||
def test_suspended_tenant_is_403(self, client, suspended_tenant):
|
||||
response = client.get(
|
||||
"/tenants/me", headers=office_headers(tenant=suspended_tenant)
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "tenant_suspended"
|
||||
|
||||
def test_consumer_without_a_grant_is_403(self, client):
|
||||
response = client.get(
|
||||
"/tenants/me", headers=office_headers(consumer="office-ungranted")
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "consumer_not_granted"
|
||||
|
||||
def test_tatlock_caller_is_403_forbidden(self, client):
|
||||
"""`/me` is Office/user-scoped (`require_tenants_reader`), the same
|
||||
reader class as Book and Scribe — tatlock has no "own tenant" to
|
||||
answer with, so it is refused at the caller-kind check, before the
|
||||
service ever runs, rather than reaching a tenant_required 400."""
|
||||
response = client.get("/tenants/me", headers=tatlock_headers())
|
||||
assert response.status_code == 403
|
||||
assert response.json()["detail"]["code"] == "forbidden"
|
||||
|
||||
def test_user_without_tenant_claim_is_400(self, client):
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
|
||||
response = client.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_required"
|
||||
|
||||
def test_user_with_tenant_claim_succeeds(self, client):
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {
|
||||
"preferred_username": "jeroen",
|
||||
"tatlock_user": TENANT,
|
||||
}
|
||||
response = client.get("/tenants/me", headers={"Authorization": "Bearer some-jwt"})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == TENANT
|
||||
|
||||
|
||||
class TestList:
|
||||
"""`GET /tenants` — the hard boundary: another tenant's existence is
|
||||
never revealed to a tenant-bearing caller."""
|
||||
|
||||
def test_tatlock_sees_every_tenant(self, client):
|
||||
response = client.get("/tenants", headers=tatlock_headers())
|
||||
assert response.status_code == 200
|
||||
ids = {t["id"] for t in response.json()["tenants"]}
|
||||
assert {TENANT, OTHER_TENANT}.issubset(ids)
|
||||
|
||||
def test_scheduler_sees_every_tenant(self, client):
|
||||
response = client.get("/tenants", headers=scheduler_headers())
|
||||
assert response.status_code == 200
|
||||
ids = {t["id"] for t in response.json()["tenants"]}
|
||||
assert {TENANT, OTHER_TENANT}.issubset(ids)
|
||||
|
||||
def test_office_caller_sees_only_its_own_tenant_as_a_list_of_one(self, client):
|
||||
response = client.get("/tenants", headers=office_headers(tenant=TENANT))
|
||||
assert response.status_code == 200
|
||||
tenants = response.json()["tenants"]
|
||||
assert len(tenants) == 1
|
||||
assert tenants[0]["id"] == TENANT
|
||||
# The hard boundary (D-73): the other seeded tenant's existence is
|
||||
# not revealed, not even as an id in a list it is excluded from.
|
||||
assert all(t["id"] != OTHER_TENANT for t in tenants)
|
||||
|
||||
def test_other_tenant_caller_sees_only_its_own_as_a_list_of_one(self, client):
|
||||
response = client.get("/tenants", headers=office_headers(tenant=OTHER_TENANT))
|
||||
assert response.status_code == 200
|
||||
tenants = response.json()["tenants"]
|
||||
assert len(tenants) == 1
|
||||
assert tenants[0]["id"] == OTHER_TENANT
|
||||
|
||||
def test_user_without_tenant_claim_is_400(self, client):
|
||||
with patch("src.domains.auth.callers.get_current_user") as mock_get_current_user:
|
||||
mock_get_current_user.return_value = {"preferred_username": "jeroen"}
|
||||
response = client.get("/tenants", headers={"Authorization": "Bearer some-jwt"})
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "tenant_required"
|
||||
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Tests for the tenant registry migration 007 (core-api T-8, workspace D-73).
|
||||
|
||||
Runs against `core_api_test` only (see conftest.py — skip when
|
||||
TEST_DATABASE_URL is unset, hard failure if it resolves to `core_api`).
|
||||
Self-contained: starts and ends at head regardless of what ran before it,
|
||||
so test order never matters for the schema the rest of the suite needs.
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from tests.conftest import run_alembic
|
||||
|
||||
|
||||
async def _table_exists(url: str, table: str) -> bool:
|
||||
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT to_regclass(:name) IS NOT NULL AS present"),
|
||||
{"name": table},
|
||||
)
|
||||
return bool(result.scalar())
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _scalar(url: str, query: str, **params) -> object:
|
||||
asyncpg_url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text(query), params)
|
||||
return result.scalar()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestMigration007RoundTrip:
|
||||
"""Migration 007 must upgrade and downgrade cleanly (dossier verification #2)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upgrade_creates_tenant_tables(self, test_database_url):
|
||||
result = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
for table in ("tenants", "tenant_consumers"):
|
||||
assert await _table_exists(test_database_url, table), f"{table} missing after upgrade"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_downgrade_then_upgrade_round_trips_cleanly(self, test_database_url):
|
||||
baseline = run_alembic(test_database_url, "upgrade", "head")
|
||||
assert baseline.returncode == 0, baseline.stderr
|
||||
|
||||
down = run_alembic(test_database_url, "downgrade", "006")
|
||||
assert down.returncode == 0, down.stderr
|
||||
assert not await _table_exists(test_database_url, "tenants")
|
||||
assert not await _table_exists(test_database_url, "tenant_consumers")
|
||||
# Book and Scribe tables, created by migrations below this one, must
|
||||
# survive a downgrade of 007 — proves 007 only touches its own
|
||||
# tables (and the FKs it added onto theirs).
|
||||
assert await _table_exists(test_database_url, "book_records")
|
||||
assert await _table_exists(test_database_url, "scribe_addresses")
|
||||
|
||||
# Leave the schema at head for the rest of the suite.
|
||||
up = run_alembic(test_database_url, "upgrade", "007")
|
||||
assert up.returncode == 0, up.stderr
|
||||
assert await _table_exists(test_database_url, "tenants")
|
||||
assert await _table_exists(test_database_url, "tenant_consumers")
|
||||
|
||||
|
||||
class TestMigration007Checks:
|
||||
"""The CHECK constraints on `tenants.kind` and `tenants.status`."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kind_check_constraint_exists(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT conname FROM pg_constraint WHERE conname = 'ck_tenants_kind'",
|
||||
)
|
||||
assert found == "ck_tenants_kind"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_check_constraint_exists(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT conname FROM pg_constraint WHERE conname = 'ck_tenants_status'",
|
||||
)
|
||||
assert found == "ck_tenants_status"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kind_check_rejects_an_invalid_kind(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
with pytest.raises(IntegrityError):
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO tenants (id, display_name, kind, status) "
|
||||
"VALUES ('bad-kind-tenant', 'x', 'nonsense', 'active')"
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestMigration007Seed:
|
||||
"""core-api T-8: the registry seeds exactly the tenants/grants the
|
||||
dossier specifies — not a third, and not a second grant."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seeds_jpmschweitzer_and_llm_tester(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
for tenant_id, display_name, kind in (
|
||||
("jpmschweitzer", "Jeroen", "household"),
|
||||
("llm_tester", "Playground", "test"),
|
||||
):
|
||||
row_kind = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT kind FROM tenants WHERE id = :id AND display_name = :name AND status = 'active'",
|
||||
id=tenant_id,
|
||||
name=display_name,
|
||||
)
|
||||
assert row_kind == kind, f"{tenant_id} seed row missing or wrong"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_tenants_grant_only_office_claude(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
for tenant_id in ("jpmschweitzer", "llm_tester"):
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT consumer FROM tenant_consumers WHERE tenant_id = :id AND consumer = 'office-claude'",
|
||||
id=tenant_id,
|
||||
)
|
||||
assert found == "office-claude", f"{tenant_id} is missing its office-claude grant"
|
||||
|
||||
|
||||
class TestMigration007ForeignKeys:
|
||||
"""Every owner_tenant column (five of them, per the dossier) now carries
|
||||
an FK to tenants.id; an unknown owner_tenant is refused at the database,
|
||||
not only in the service layer (dossier verification #5)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"fk_name",
|
||||
[
|
||||
"fk_book_records_owner_tenant",
|
||||
"fk_book_tenant_counters_owner_tenant",
|
||||
"fk_scribe_addresses_owner_tenant",
|
||||
"fk_scribe_messages_owner_tenant",
|
||||
"fk_scribe_mailbox_entries_owner_tenant",
|
||||
],
|
||||
)
|
||||
async def test_owner_tenant_fk_exists(self, test_database_url, fk_name):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
found = await _scalar(
|
||||
test_database_url,
|
||||
"SELECT conname FROM pg_constraint WHERE conname = :name AND contype = 'f'",
|
||||
name=fk_name,
|
||||
)
|
||||
assert found == fk_name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fk_refuses_a_scribe_address_with_an_unknown_owner_tenant(self, test_database_url):
|
||||
run_alembic(test_database_url, "upgrade", "head")
|
||||
asyncpg_url = test_database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
engine = create_async_engine(asyncpg_url, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
with pytest.raises(IntegrityError):
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO scribe_addresses (owner_tenant, name, created_by) "
|
||||
"VALUES ('no-such-tenant', 'fk-probe', 'test-harness')"
|
||||
)
|
||||
)
|
||||
await conn.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -4,6 +4,7 @@ from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
from src.main import app
|
||||
from src.domains.tools.controller import tools_controller
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -36,9 +37,21 @@ class TestDNSLookup:
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.controllers.tools_controller.DNSService")
|
||||
def test_dns_lookup_returns_result(self, mock_dns_class, client):
|
||||
"""DNS lookup should return lookup results."""
|
||||
def test_dns_lookup_returns_result(self, client):
|
||||
"""DNS lookup should return lookup results.
|
||||
|
||||
Patches the live singleton's `dns_service` attribute, not the
|
||||
`src.controllers.tools_controller.DNSService` class: that module is
|
||||
the legacy top-level package (not wired into `src.main`, see
|
||||
CLAUDE.md "Legacy top-level packages"). `client` exercises
|
||||
`src.main.app`, which routes through
|
||||
`src.domains.tools.controller.tools_controller`, a singleton built
|
||||
at import time — so patching the class there would also miss,
|
||||
since `tools_controller.dns_service` is already a constructed
|
||||
instance by the time a test patches the class. Patching the
|
||||
instance attribute directly is the only patch that actually
|
||||
intercepts this request path.
|
||||
"""
|
||||
mock_response = MagicMock()
|
||||
mock_response.success = True
|
||||
mock_response.domain = "example.com"
|
||||
@@ -59,8 +72,8 @@ class TestDNSLookup:
|
||||
|
||||
mock_service = MagicMock()
|
||||
mock_service.lookup = AsyncMock(return_value=mock_response)
|
||||
mock_dns_class.return_value = mock_service
|
||||
|
||||
with patch.object(tools_controller, "dns_service", mock_service):
|
||||
response = client.post(
|
||||
"/tools/dns/lookup",
|
||||
json={"domain": "example.com", "record_type": "A"}
|
||||
|
||||
Reference in New Issue
Block a user