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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docker/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,15 @@ services:
- ..:/workspace
- api-venv:/workspace/.venv # keep the container venv off the host checkout
environment:
VISIONSET_DEV_TOKEN: dev-token
# Which workspace this server serves. Inside the bind mount, and
# **/workspace-data/ is already git-ignored, so nothing it writes can be
# committed by accident.
#
# Compose does not create it — `WorkspaceService.init` does. Until it
# exists, /health answers normally and every protected route answers
# 500 NOT_A_WORKSPACE, which is the intended legible failure rather than
# a server that silently serves nothing.
VISIONSET_WORKSPACE: /workspace/workspace-data/dev
command: uv run uvicorn visionset.server.main:app --reload --host 0.0.0.0 --port 8000
ports:
- "8000:8000"
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ contracts (kernel purity, headless annotator) are described there and enforced i
| [persistence.md](persistence.md) | The metadata store: repositories, unit of work, table layout, migrations and `format_version` |
| [examples.md](examples.md) | The two runnable examples: the whole cycle in one pass, ingest on its own, and what each is built to demonstrate |
| [api.md](api.md) | The REST surface: the one error body, why clients branch on `code` and not on the status, what decides 404 / 409 / 422, what a 5xx does and does not tell you, and which codes are worth retrying |
| [auth.md](auth.md) | Who may call it: per-workspace API tokens, why only a digest is stored, why every refusal is one identical 401, immediate revocation, and how a protected route is built |
20 changes: 20 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ endpoint — what a failure looks like, and how to read one.
The routes themselves are described by [`openapi.json`](../openapi.json) at the repo root, which
is generated (`uv run python scripts/export_openapi.py`) and diffed in CI. Never hand-edit it.

## Authentication

Every endpoint except `/health` requires a workspace API token:

```
Authorization: Bearer vst_hK3n...
```

Missing, malformed, unknown and revoked are one identical **401** with a `WWW-Authenticate:
Bearer` challenge — deliberately indistinguishable, so a client cannot use the response to probe
which credentials exist. Tokens come from `visionset token create`; the server serves the single
workspace named by `VISIONSET_WORKSPACE`, and one pointed at something else answers 500
`NOT_A_WORKSPACE`. See [auth.md](auth.md) for the whole picture, including how to build a
protected route.

## The error body

Every failure — a domain refusal, a missing route, a malformed payload, an unhandled bug —
Expand Down Expand Up @@ -170,3 +185,8 @@ inside its own `unit_of_work()`.
**422 is declared at app level, and that is load-bearing.** It displaces FastAPI's generated
`HTTPValidationError`, keeping that model — and the second error shape it implies — out of
`openapi.json` entirely. A test asserts it never comes back.

**401 is *not* declared at app level, and that is load-bearing too.** `/health` is public and
cannot 401, so the guard and its documented response travel together on the router —
`protected_router()` in `server/dependencies.py`. Build every non-public router with it rather
than repeating `Depends(require_token)` per route; see [auth.md](auth.md).
137 changes: 137 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Authentication

A VisionSet workspace is operated with an **API token**. There is one kind of credential, it is
scoped to one workspace, and holding a valid one means holding the whole workspace: granular
permissions are deliberately not here.

```
visionset token create --name ci # prints the secret once — issue #26
```

```
Authorization: Bearer vst_hK3n...
```

Every REST endpoint except `/health` requires it. The CLI and the MCP server do not: they call
the SDK in the same process, on a machine whose filesystem the caller already has.

## The token

| Field | |
| --- | --- |
| `name` | What an operator calls it. Unique per workspace, case-insensitively. |
| `created_at` | When it was issued. |
| `revoked_at` | When it was burned, or absent while it still works. |

The secret itself is **not** stored — only its SHA-256 digest. `TokenService.create` returns the
plaintext exactly once, in an `IssuedToken`, and nothing can recover it afterwards. The remedy
for a lost token is a new token.

> ### Why a digest and not a password KDF
>
> A KDF (argon2, bcrypt) exists to make *low-entropy, human-chosen* input expensive to guess. A
> VisionSet secret is 256 bits from `secrets.token_urlsafe`: there is no dictionary to run and no
> guessing budget that terminates. Two more reasons make it the right call rather than merely a
> defensible one. Verification runs on **every request** and compares the presentation against
> every token the workspace holds, so a 100 ms KDF would cost N × 100 ms *per request* — the
> opposite cost model to a login form, where the check runs once and rate limiting bounds it. And
> `hashlib` is stdlib, where a KDF is a dependency taken on for no gain.
>
> The accepted consequence, stated rather than hidden: the digest is unsalted and deterministic,
> so two identical secrets hash identically. That requires drawing the same 256-bit value twice —
> and it is exactly the property that lets verification be a digest comparison rather than N key
> derivations.

Names are unique per workspace so that `visionset token revoke ci` resolves to one credential.
Uniqueness is enforced twice, the way project names are: `uq_token_workspace_name` (`COLLATE
NOCASE`) is the guarantee, and `TokenService`'s pre-check is the error message.

## Issuing and revoking

`TokenService` is the one door. `AuthProvider` — the port all three surfaces authenticate through
— stays a single method, `verify(token) -> bool`; minting and revoking are use cases, and widening
the port would oblige every future provider to implement issuance it has no business doing.

**Revocation is immediate and one-way.** `revoke` takes `confirm=True`, because it breaks every
client holding that secret at the next request and there is no `unrevoke`: reinstating a secret
somebody decided to burn is worse than issuing a fresh one, since the reason for burning it does
not expire. Revoking twice is a no-op that keeps the first timestamp, so a retried command is
safe. The row stays — it is the record that the credential existed and when it died — which is
also why revocation does not free the name.

Nothing caches a verdict. "Revoked, therefore refused" has to mean *now*, so the provider reads
the workspace on every call. That read is cheap: WAL readers never block a writer, and a
read-only unit of work takes no lock at all.

## What a refusal looks like

A missing header, a non-bearer scheme, an empty credential, an unknown token and a revoked token
are **one answer**, byte for byte:

```
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

{"code": "UNAUTHORIZED", "message": "Invalid or missing bearer token", "detail": null}
```

That uniformity is the point. A 401 that distinguished "no such token" from "revoked" would let
anyone enumerate a workspace's credentials one request at a time.

A failure to *decide* is not a refusal. If the store is unreachable or damaged, `verify` raises
rather than answering `False`, and the client sees a 503 (`WORKSPACE_BUSY`) or a 500
(`WORKSPACE_CORRUPT`). Reporting an outage as a bad credential sends an operator hunting for the
wrong thing.

## Which workspace the server serves

One, named by **`VISIONSET_WORKSPACE`**, defaulting to the process's working directory. It is
opened by the first request that needs it and kept for the life of the process — never at import
time, because `scripts/export_openapi.py` imports the application in a checkout that has no
workspace.

A server pointed at something that is not a workspace answers **500 `NOT_A_WORKSPACE`**, opaque
body plus an `incident_id`, with the path in the log only. That is a deployment fault, not a
client error. It arrives *instead of* a 401 even when no token was sent, because the workspace is
resolved before the credential is looked at — the ordering is what keeps authentication
overridable in tests.

> The full resolution rule — a `--workspace` flag, the environment variable, cwd detection, and a
> documented precedence shared with the CLI — belongs to issue #26.
> `server/dependencies.py::resolve_workspace_root` is the provisional half, reading the variable
> #26 will keep.

## For contributors

Build every non-public router with **`protected_router()`**:

```python
from visionset.server.dependencies import WorkspaceDep, protected_router
from visionset.server.errors import ERROR_RESPONSES

router = protected_router(prefix="/projects", tags=["projects"])


@router.get("/{project_id}", responses={404: ERROR_RESPONSES[404]})
def get_project(project_id: UUID, workspace: WorkspaceDep) -> ProjectOut: ...
```

It carries the dependency *and* the documented 401 together, because a route that declares one
without the other is a lie in `openapi.json` either way. Do not repeat `Depends(require_token)`
per route — "everything except `/health`" should be a property of how routers are constructed,
not something each reviewer has to notice — and do not add 401 to `UNIVERSAL_ERROR_RESPONSES`,
because `/health` is public and cannot 401.
`tests/server/test_openapi_contract.py` walks the spec and fails on either mistake.

`/docs`, `/redoc` and `/openapi.json` stay public. They are `include_in_schema=False`, and the
spec is already a committed artifact in a public repository: a contract you must authenticate to
read is a contract nobody generates a client from.

**There is no MCP tool for token administration, and that is deliberate.** Every other tool
operates on *datasets*; one that minted a credential would operate on *access to the workspace* —
a privilege-escalation primitive pointed at the agent's own sandbox, producing a durable secret
that outlives the session. The secret is shown exactly once, and an agent's "once" is a
transcript: `confirm: true` guards accidental mutation, not exfiltration. Whoever launched
`visionset mcp` already had workspace access, so a second credential adds capability and subtracts
accountability. `list_tokens` is the only defensible candidate and is still operator surface
rather than dataset surface.
22 changes: 19 additions & 3 deletions docs/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ mapping layer is the thing to extend.

Every persisted entity has a UUID primary key and **at most one parent** — a Project
belongs to a Workspace, an Annotation to an Asset. That regularity is why a single
generic repository serves all fourteen entity types:
generic repository serves all fifteen entity types:

```python
with store.unit_of_work() as uow:
Expand Down Expand Up @@ -48,7 +48,8 @@ never raises `ProjectNameTaken`.
But a rule with no backstop is a wish, so the store carries the constraint too:
`uq_project_workspace_name` on `project (workspace_id, name COLLATE NOCASE)`, alongside
`uq_schema_project_version`, `uq_member_dataset_asset`, `uq_release_dataset_tag`,
`uq_asset_project_content_hash` and `uq_source_project_kind_path_fps`. The invariant then survives
`uq_asset_project_content_hash`, `uq_source_project_kind_path_fps` and
`uq_token_workspace_name`. The invariant then survives
a service bug, a forgotten code path, and a second process.

The last of those is the only index here whose terms are not all columns: its fourth is
Expand Down Expand Up @@ -159,8 +160,9 @@ MIGRATIONS: list[Migration] = [
Migration(version=8, name="ingest_pipeline", upgrade=...),
Migration(version=9, name="ingest_job_progress", upgrade=...),
Migration(version=10, name="asset_thumbnail", upgrade=...),
Migration(version=11, name="api_tokens", upgrade=...),
]
FORMAT_VERSION: int = MIGRATIONS[-1].version # 10
FORMAT_VERSION: int = MIGRATIONS[-1].version # 11
```

`initialize()` reads the version stamped in `_visionset_meta` and runs whatever is
Expand Down Expand Up @@ -270,6 +272,14 @@ preview in the blob store. No foreign key, so 008's "a column carrying a key can
NULL is not a legacy value something has to tolerate but the ordinary state of an asset nobody
has rendered a preview for yet. `IngestService.backfill_thumbnails` reads exactly that state.

Migration 011 is the first since 001 to create a **table** rather than alter one, and that
changes which tool does the idempotency: `checkfirst=True` on the `Table` asks `has_table`, a
plain catalogue lookup, and brings both of `token`'s indexes with it — so neither is issued
separately and 008's expression-index trap cannot be met here at all. Nothing to refuse and
nothing to count, which is a claim rather than an oversight: the table existed on no earlier
generation, so there is no legacy row to be honest about, and nothing references it, so
`PRAGMA foreign_keys = ON` has nothing to cascade.

The fresh-versus-migrated test is only as strong as how far back
`_downgrade_to_version_one` walks, so every migration added there needs its undo added too.
Migrations 006 and 007 are the two places that undo cannot borrow its DDL from `_tables`,
Expand All @@ -281,6 +291,12 @@ altered, for the reasons 008 gives, so nothing later rebuilds it. The compensati
real `ALTER` runs on the way back up from generation 1, which is why it needs no generation twin
of `test_migration_nine_alters_a_table_migration_eight_rebuilt`.

Migration 011's undo is a single `DROP TABLE`, and it carries a sharper obligation than 010's:
**without it the fresh-versus-migrated test would still pass.** The table would simply survive the
downgrade and 011 would `checkfirst`-skip, so the `CREATE` nobody ran would be reported as
agreeing with itself. The undo is not what keeps an existing test honest — it is the only thing
that exercises the migration at all.

`format_version` here is the *database* generation. Validating the on-disk workspace layout
around it — directories, the blob-store root, what makes a directory a workspace at all —
belongs to `WorkspaceService`; see [workspaces.md](workspaces.md).
29 changes: 19 additions & 10 deletions docs/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,27 @@ or one whose process was killed, is all four entries.
Everything written since the last checkpoint lives in `visionset.db-wal` until then. Close the
workspace first, or copy all three files together.

Three of the five ports have no line in that layout, and that is the point: the [event
bus](events.md) is in-process and the two [media processors](media.md) are decoders, so none of
them leaves anything behind. They are composed here anyway, because a workspace is what services
Four of the six ports have no line in that layout, and that is the point: the [event
bus](events.md) is in-process, the two [media processors](media.md) are decoders, and the
[auth provider](auth.md) reads a table inside the database above, so none of them leaves anything
behind. They are composed here anyway, because a workspace is what services
are handed and every port has to arrive with it. One of each per open workspace, built by
`event_bus_factory`, `image_processor_factory` and `video_processor_factory` — never a
module-level singleton, which two workspaces open at once must not share.
`event_bus_factory`, `image_processor_factory`, `video_processor_factory` and
`auth_provider_factory` — never a module-level singleton, which two workspaces open at once must
not share.

`auth_provider_factory` is the one that takes arguments: `(metadata_store, workspace_id)`, because
it is the first port derived from another rather than from the path. No kernel service uses it —
it exists for the surfaces above — and it is composed here anyway, because the alternative is a
delivery module naming a kernel adapter.

A new port is appended **last** to `WorkspaceService.__init__`, never inserted: `init` and `open`
bind those arguments positionally, so a parameter added in the middle silently re-binds every one
after it.

`WorkspaceService` is the only place in the kernel that names `SqliteMetadataStore`,
`FilesystemBlobStore`, `InProcessEventBus`, `PillowImageProcessor` or `FfmpegVideoProcessor`.
`FilesystemBlobStore`, `InProcessEventBus`, `PillowImageProcessor`, `FfmpegVideoProcessor` or
`StoredTokenAuthProvider`.
Everything above it — later
surface, the CLI, MCP — gets an open service and reaches the ports through it, so swapping
an adapter is a change to two functions and to nowhere else.
Expand Down Expand Up @@ -254,10 +262,11 @@ Four habits that keep the boundary honest:
workspace-level rules with it.
- One `unit_of_work()` per operation, and do the whole operation inside it.
- Reach the ports through the handle — `workspace.metadata_store`, `workspace.blob_store`,
`workspace.event_bus`, `workspace.image_processor`, `workspace.video_processor`. No service
other than `workspace_service` should name `SqliteMetadataStore`, `FilesystemBlobStore`,
`InProcessEventBus`, `PillowImageProcessor` or `FfmpegVideoProcessor` — if a second one does,
the composition point has stopped being single.
`workspace.event_bus`, `workspace.image_processor`, `workspace.video_processor`,
`workspace.auth_provider`. No service other than `workspace_service` should name
`SqliteMetadataStore`, `FilesystemBlobStore`, `InProcessEventBus`, `PillowImageProcessor`,
`FfmpegVideoProcessor` or `StoredTokenAuthProvider` — if a second one does, the composition
point has stopped being single.
- Publish [events](events.md) *after* the `unit_of_work()` block, never inside it. An
announcement is about work that committed, and a subscriber that raises must have nothing
left to roll back.
19 changes: 14 additions & 5 deletions src/visionset/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

from __future__ import annotations

import secrets
from typing import Annotated

import typer
Expand Down Expand Up @@ -55,7 +54,17 @@ def mcp() -> None:
def token_create(
name: Annotated[str, typer.Option("--name", help="Human-readable token name.")],
) -> None:
"""Generate an API token (no persistence yet)."""
token = f"vst_{secrets.token_urlsafe(32)}"
typer.echo(f"Created token '{name}' (not persisted yet):")
typer.echo(token)
"""Issue an API token (stub — see issue #26).

Persistence landed with the kernel's ``TokenService``; wiring this command to
it needs workspace resolution, which issue #26 owns along with ``token list``
and ``token revoke``.

Until then this refuses rather than printing something. It used to echo a
plausible ``vst_...`` string that was never stored — harmless while nothing
could authenticate, and actively misleading now that real tokens exist and
that one would not be among them.
"""
typer.echo(f"Cannot issue token {name!r} yet: the CLI has no workspace to write it to.")
typer.echo("Token issuance lands in issue #26 (visionset token create/list/revoke).")
raise typer.Exit(code=1)
4 changes: 4 additions & 0 deletions src/visionset/kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
SchemaNotFound,
SchemaVersionConflict,
SourceNotFound,
TokenNameTaken,
TokenNotFound,
UnknownAttribute,
UnserializableManifest,
UnsupportedGeometry,
Expand Down Expand Up @@ -100,6 +102,8 @@
"SchemaNotFound",
"SchemaVersionConflict",
"SourceNotFound",
"TokenNameTaken",
"TokenNotFound",
"UnknownAttribute",
"UnserializableManifest",
"UnsupportedGeometry",
Expand Down
2 changes: 2 additions & 0 deletions src/visionset/kernel/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
from visionset.kernel.adapters.in_process_event_bus import InProcessEventBus
from visionset.kernel.adapters.pillow_image_processor import PillowImageProcessor
from visionset.kernel.adapters.sqlite_metadata_store import SqliteMetadataStore
from visionset.kernel.adapters.stored_token_auth_provider import StoredTokenAuthProvider

__all__ = [
"FfmpegVideoProcessor",
"FilesystemBlobStore",
"InProcessEventBus",
"PillowImageProcessor",
"SqliteMetadataStore",
"StoredTokenAuthProvider",
]
Loading
Loading