Strip /api prefix from 13 legacy router APIRouter() declarations and dual-mount
each on prefix="/api" (backward compat) and prefix="/api/v1" (versioned surface)
in app.py. Auth, service-accounts, api-keys, assistants-compat remain single-mount.
Update 10 test files to pass prefix="/api" when directly including stripped routers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner/admin self-service CRUD for service accounts: POST create,
GET list, PATCH status; workspace-scoped with 404 existence hiding
for cross-workspace targets. Gated by require_workspace_admin.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a single script that walks the 5 verification layers in order
and emits pass/fail counts at the end:
static — boundary scans + full pytest (baseline ≥ 3250 passed,
≤ 18 fails matching known caplog flake set) + ruff lint
paths — .deer-flow/users/ should be empty (or absent);
workspaces/{wid}/threads/{tid}/... layout in place;
exercises `make migrate-paths DRY_RUN=1`
rds — PG reachable; alembic at 0003; service_accounts +
api_keys + external_users tables present;
idx_api_keys_active partial index has
"WHERE revoked_at IS NULL" predicate; workspace_id is
NOT NULL on thread_meta / runs / feedback / run_events;
UNIQUE(workspace_id, thread_id) on thread_meta
(requires DATABASE_URL; skipped if unset)
runtime — curl /health on the Gateway (requires `make dev`;
skips downstream e2e if unreachable)
e2e — register two users via /api/auth/register, capture each
session's csrf_token, create one thread per user,
cross-access GET + DELETE both return 404 (per PR6
"404 not 403" contract), same-workspace GET returns 200
Each layer is independently runnable: `./verify_stage0.sh paths rds`.
With no args runs all five.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/test_pr8_metadata_registration.py: opens a fresh SQLite
engine via init_engine() and asserts inspect(conn).get_table_names()
contains service_accounts, api_keys, and external_users. Guards
against an ORM row class being added under deerflow/persistence/* but
accidentally left out of deerflow/persistence/models/__init__.py —
which would leave the table un-provisioned at startup and surface as
a confusing "no such table" later in Stage 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds deerflow/persistence/external_user/{__init__,model}.py with
ExternalUserRow:
- id: UUID36 PK
- workspace_id: FK workspaces ON DELETE CASCADE (redundant with SA's
workspace_id but stored directly to speed workspace-scoped queries
that span multiple SAs)
- service_account_id: FK service_accounts ON DELETE CASCADE
- external_id: String(128) — caller-supplied X-External-User-Id
- display_name: String(128) nullable (admin UI only, not auth-relevant)
- metadata_json: JSON nullable=False default {} — plan tier / region /
custom tags
- created_at / last_seen_at (UTC)
- UniqueConstraint (service_account_id, external_id)
name=uq_external_users_sa_external — the same external_id may be
reused under a different SA, but is upsert-unique under a single SA
T8.5 tests:
- test_unique_service_account_id_plus_external_id: second row with
same (SA, external_id) raises IntegrityError
- test_cascade_on_service_account_delete: deleting parent SA removes
all external_users rows under it
Registered in deerflow/persistence/models/__init__.py — all three PR8
tables (service_accounts / api_keys / external_users) are now wired
into Base.metadata.create_all().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds deerflow/persistence/api_key/{__init__,model}.py with ApiKeyRow:
- id: UUID36 PK
- service_account_id: FK service_accounts ON DELETE CASCADE
- key_prefix: String(16), UNIQUE — global uniqueness preserved even
after revoke so audit logs never reference an ambiguous prefix
- key_hash: String(128) sha-256 hex (plaintext returned only once at
create time)
- name / scopes / rate_limit_rpm / expires_at / last_used_at /
revoked_at — all nullable or default-providing
- created_at: UTC
- Index idx_api_keys_sa (service_account_id) — list keys for an SA
- Index idx_api_keys_active (key_prefix) WHERE revoked_at IS NULL —
partial index, dual-dialect via sqlite_where + postgresql_where,
shrinks the hot-path lookup index by excluding revoked keys
T8.4 tests:
- test_unique_key_prefix_enforced: column-level UNIQUE blocks two
rows from sharing key_prefix (active or revoked alike)
- test_active_index_declares_both_dialect_where_clauses: schema
introspection confirms idx_api_keys_active has both
`dialect_options.sqlite.where` and `dialect_options.postgresql.where`
set to `revoked_at IS NULL` — guards against accidental loss of the
dual-driver hint when the Index is edited later
- test_cascade_on_service_account_delete: deleting the parent SA
removes all api_keys rows
Registered in deerflow/persistence/models/__init__.py.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
T8.2 test_cascade_on_workspace_delete: seed user → workspace → SA,
delete workspace, assert SA row is gone (ondelete CASCADE on
workspace_id FK).
T8.3 test_restrict_on_created_by_user_delete: seed user → workspace
→ SA, then attempt DELETE FROM users WHERE id = created_by, assert
IntegrityError raises and the SA row survives (ondelete RESTRICT on
created_by FK).
SQLite enforces FKs because the engine's connect-listener turns on
`PRAGMA foreign_keys = ON` for every new connection — see engine.py
init_engine().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>