CANP-WP-0006 T04: publish API
POST /packages takes {registry, source, files}, where files is the same shape
GET /archives returns — so an archive round-trips into a publish without
translation and a mirror is a GET followed by a POST. A test verifies the
round-trip by digest rather than asserting it.
Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (section 2), path traversal is refused,
identical re-publishes are accepted, and different content under the same
id@version is 409 (section 17).
The identity mechanism, stated plainly rather than implied: a single shared
bearer token proving the caller is the operator of this service. It is not
per-publisher identity — every token holder is indistinguishable — and auth.py
says so where someone might otherwise assume more.
With no token configured the service is read-only. That is the correct default
rather than an inconvenience: section 20.1 asks a registry to refuse
publication into a closed namespace it does not consider the publisher to own,
and an unauthenticated service considers nobody to own anything.
Namespace claims live in namespace_claims (migration 0002) and are enforced
here, which a filesystem registry cannot do at all — but only as precisely as
the identity allows. A closed namespace is protected from anonymous callers; it
cannot be attributed among several publishers. Per-publisher identity is
deferred and is the main thing between this and a registry several people can
publish to.
Migration hygiene found while adding 0002: autogenerate proposed an ALTER
COLUMN TYPE on package_files.package_version_id, because the foreign key's type
was left to inference and compared as a variant against a reflected plain type.
SQLite cannot alter a column type, so 0002 failed halfway — table created,
revision unstamped, the partially-applied state that is worst to debug later.
Fixed at the cause: the column is typed explicitly, and 0001 was corrected
rather than patched over, which is legitimate only because it has never run
outside this repo's tests. alembic check now reports no drift.
Health tests now compute the expected migration head from the script directory
instead of hardcoding it, so adding a migration cannot fail them spuriously.
Service tests 22 -> 33; reference unaffected at 99.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
This commit is contained in:
parent
133e796eff
commit
a7e12d5e04
13 changed files with 468 additions and 13 deletions
|
|
@ -89,9 +89,42 @@ once the caller says which registry they meant.
|
|||
Asking for a package without naming a version applies § 17.1's selector rules,
|
||||
so a prerelease is never chosen implicitly.
|
||||
|
||||
## Publish API
|
||||
|
||||
`POST /packages` takes `{registry, source, files}`, where `files` is the same
|
||||
shape `GET /archives` returns — so an archive round-trips into a publish
|
||||
without translation, and a mirror is a GET followed by a POST.
|
||||
|
||||
Validation is the reference implementation's, applied to the posted files
|
||||
materialized in a temporary directory. Only reserved paths and
|
||||
manifest-referenced files are stored (§ 2); path traversal is refused.
|
||||
Re-publishing identical content is accepted, different content under the same
|
||||
`<id>@<version>` is 409 (§ 17).
|
||||
|
||||
### What identity means here
|
||||
|
||||
A single shared bearer token (`CANNED_PROMPTS_PUBLISH_TOKEN`), proving the
|
||||
caller is **the operator of this service** — not per-publisher identity. Every
|
||||
holder of the token is indistinguishable.
|
||||
|
||||
With no token configured the service is **read-only**. That is the correct
|
||||
default rather than an inconvenience: § 20.1 asks a registry to refuse
|
||||
publication into a closed namespace it does not consider the publisher to own,
|
||||
and an unauthenticated service considers nobody to own anything.
|
||||
|
||||
Namespace claims (§ 20.1) live in `namespace_claims` and are **enforced** here,
|
||||
which a filesystem registry cannot do at all — but only as precisely as the
|
||||
identity behind them. A `closed` namespace is protected from anonymous callers;
|
||||
it cannot be attributed among several publishers. Until per-publisher identity
|
||||
exists, a claim's `owner` is documentation rather than an access decision, and
|
||||
the code says so where it matters.
|
||||
|
||||
## Status
|
||||
|
||||
`CANP-WP-0006` T01–T03 are done: skeleton, health surface, tenant-keyed schema
|
||||
and migration `0001`, and the read API. The publish API, HTTP registry client
|
||||
and container image (T04–T06) are not built yet. T06 produces the image digest
|
||||
that `rapp-canned-prompts` needs to pin.
|
||||
`CANP-WP-0006` T01–T04 are done: skeleton, health surface, tenant-keyed schema
|
||||
(migrations `0001`–`0002`), and the read and publish APIs. The HTTP registry
|
||||
client in the CLI and the container image (T05–T06) are not built yet. T06
|
||||
produces the image digest that `rapp-canned-prompts` needs to pin.
|
||||
|
||||
Per-publisher identity is deferred, and is the main thing standing between this
|
||||
and a service that several people can publish to.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = src
|
||||
path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ def upgrade() -> None:
|
|||
op.create_table('package_files',
|
||||
sa.Column('id', BigIntPK, autoincrement=True, nullable=False),
|
||||
sa.Column('tenant', sa.String(length=64), nullable=False),
|
||||
sa.Column('package_version_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('package_version_id', BigIntPK, nullable=False),
|
||||
sa.Column('path', sa.String(length=1024), nullable=False),
|
||||
sa.Column('content', sa.LargeBinary(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['package_version_id'], ['package_versions.id'], ondelete='CASCADE'),
|
||||
|
|
|
|||
39
service/migrations/versions/0002_namespace_claims.py
Normal file
39
service/migrations/versions/0002_namespace_claims.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""namespace claims
|
||||
|
||||
Registry namespace ownership (section 20.1). Descriptive on a filesystem
|
||||
registry, which cannot authenticate a publisher; enforced here, which can —
|
||||
though only as strongly as the identity behind it.
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
BigIntPK = sa.BigInteger().with_variant(sa.Integer, "sqlite")
|
||||
|
||||
revision = '0002'
|
||||
down_revision = '0001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('namespace_claims',
|
||||
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), autoincrement=True, nullable=False),
|
||||
sa.Column('tenant', sa.String(length=64), nullable=False),
|
||||
sa.Column('registry', sa.String(length=128), nullable=False),
|
||||
sa.Column('namespace', sa.String(length=256), nullable=False),
|
||||
sa.Column('policy', sa.String(length=16), nullable=False),
|
||||
sa.Column('owner', sa.String(length=256), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant', 'registry', 'namespace', name='uq_namespace_claim')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('namespace_claims')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -72,5 +72,5 @@ def create_app(settings: Settings | None = None, engine: Engine | None = None) -
|
|||
"migration": readiness.migration,
|
||||
}
|
||||
|
||||
app.include_router(make_router(get_session, get_tenant))
|
||||
app.include_router(make_router(get_session, get_tenant, settings))
|
||||
return app
|
||||
|
|
|
|||
54
service/src/canned_prompts_service/auth.py
Normal file
54
service/src/canned_prompts_service/auth.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""Publisher identity.
|
||||
|
||||
What this is, stated plainly so nobody mistakes it for more: a single shared
|
||||
bearer token proving the caller is *the operator of this service*. It is not
|
||||
per-publisher identity — every holder of the token is indistinguishable.
|
||||
|
||||
That limit decides how § 20.1 namespace claims are enforced here. A `closed`
|
||||
namespace can be protected from anonymous callers, which a filesystem registry
|
||||
cannot do at all, but it cannot be attributed to one of several publishers.
|
||||
Per-publisher identity is deferred; until it exists, a claim's `owner` is
|
||||
documentation rather than an access decision.
|
||||
|
||||
With no token configured the service is read-only. Refusing writes is the
|
||||
correct default for a registry that cannot tell who is calling — § 20.1 asks a
|
||||
registry to refuse publication into a closed namespace it does not consider the
|
||||
publisher to own, and an unauthenticated service considers nobody to own
|
||||
anything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
class PublishDenied(HTTPException):
|
||||
pass
|
||||
|
||||
|
||||
def require_publisher(request: Request, settings: Settings) -> str:
|
||||
"""Return the publisher identity, or refuse."""
|
||||
if not settings.publish_token:
|
||||
raise PublishDenied(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"publishing is not configured: this service has no publisher "
|
||||
"identity, so it cannot tell who is calling and refuses writes "
|
||||
"rather than accepting anonymous publishes (§ 20.1)"
|
||||
),
|
||||
)
|
||||
|
||||
header = request.headers.get("authorization", "")
|
||||
scheme, _, credential = header.partition(" ")
|
||||
if scheme.lower() != "bearer" or not credential:
|
||||
raise PublishDenied(
|
||||
status_code=401, detail="publishing requires an Authorization: Bearer token"
|
||||
)
|
||||
# compare_digest so a wrong token cannot be recovered by timing the reply.
|
||||
if not hmac.compare_digest(credential, settings.publish_token):
|
||||
raise PublishDenied(status_code=403, detail="token not accepted")
|
||||
return settings.publisher_name
|
||||
|
|
@ -91,8 +91,11 @@ class PackageFile(Base):
|
|||
|
||||
id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True)
|
||||
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
# Typed explicitly with the same variant as the target key. Left to
|
||||
# inference, autogenerate compares a variant against a reflected plain type
|
||||
# and proposes a spurious ALTER COLUMN on every future migration.
|
||||
package_version_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("package_versions.id", ondelete="CASCADE"), nullable=False
|
||||
BigIntPK, ForeignKey("package_versions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
|
|
@ -130,3 +133,24 @@ class IndexEntry(Base):
|
|||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
last_seen_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class NamespaceClaim(Base):
|
||||
"""A registry's namespace ownership claim (§ 20.1).
|
||||
|
||||
On a filesystem registry these claims are descriptive, because nothing can
|
||||
authenticate a publisher. A service *can*, so here they are enforced — which
|
||||
is only as strong as the identity behind them; see `auth.py`.
|
||||
"""
|
||||
|
||||
__tablename__ = "namespace_claims"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant", "registry", "namespace", name="uq_namespace_claim"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigIntPK, primary_key=True, autoincrement=True)
|
||||
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
registry: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
namespace: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
policy: Mapped[str] = mapped_column(String(16), nullable=False, default="open")
|
||||
owner: Mapped[str | None] = mapped_column(String(256))
|
||||
|
|
|
|||
|
|
@ -10,13 +10,17 @@ swallow a neighbouring segment.
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import store
|
||||
from .store import Ambiguous, NotFound, Reference, StoreError
|
||||
from .auth import require_publisher
|
||||
from .settings import Settings
|
||||
from .store import Ambiguous, Conflict, NotFound, Reference, StoreError
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -28,9 +32,31 @@ def as_http(exc: StoreError) -> HTTPException:
|
|||
# 409, not 300: the request is answerable, but only once the caller says
|
||||
# which registry they meant.
|
||||
return HTTPException(status_code=409, detail=str(exc))
|
||||
if isinstance(exc, Conflict):
|
||||
return HTTPException(status_code=409, detail=str(exc))
|
||||
return HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
def decode_files(payload: dict[str, Any]) -> dict[str, bytes]:
|
||||
"""Accept the same shape `/archives` returns, so an archive round-trips."""
|
||||
files: dict[str, bytes] = {}
|
||||
for path, item in (payload or {}).items():
|
||||
if not isinstance(item, dict):
|
||||
raise HTTPException(400, f"{path}: expected an object with text or base64")
|
||||
if "text" in item:
|
||||
files[path] = str(item["text"]).encode("utf-8")
|
||||
elif "base64" in item:
|
||||
try:
|
||||
files[path] = base64.b64decode(str(item["base64"]), validate=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(400, f"{path}: invalid base64") from exc
|
||||
else:
|
||||
raise HTTPException(400, f"{path}: expected text or base64")
|
||||
if not files:
|
||||
raise HTTPException(400, "no files supplied")
|
||||
return files
|
||||
|
||||
|
||||
def version_payload(row: Any, *, manifest: bool = False) -> dict[str, Any]:
|
||||
payload = {
|
||||
"registry": row.registry,
|
||||
|
|
@ -50,9 +76,60 @@ def version_payload(row: Any, *, manifest: bool = False) -> dict[str, Any]:
|
|||
return payload
|
||||
|
||||
|
||||
def make_router(get_session, get_tenant) -> APIRouter:
|
||||
def make_router(get_session, get_tenant, settings: Settings) -> APIRouter:
|
||||
api = APIRouter()
|
||||
|
||||
@api.post("/packages", status_code=201)
|
||||
def publish(
|
||||
request: Request,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
session: Session = Depends(get_session),
|
||||
tenant: str = Depends(get_tenant),
|
||||
) -> dict[str, Any]:
|
||||
publisher = require_publisher(request, settings)
|
||||
|
||||
registry = str(payload.get("registry") or "").strip()
|
||||
if not registry:
|
||||
raise HTTPException(400, "registry is required")
|
||||
files = decode_files(payload.get("files"))
|
||||
source = str(payload.get("source") or f"published by {publisher}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
try:
|
||||
package_dir = store.materialize(files, Path(tmp))
|
||||
manifest_id = store.peek_id(package_dir)
|
||||
except StoreError as exc:
|
||||
raise as_http(exc) from exc
|
||||
except Exception as exc: # noqa: BLE001 — malformed package
|
||||
raise HTTPException(400, f"invalid package: {exc}") from exc
|
||||
|
||||
# § 20.1 enforced, not advised: unlike a filesystem registry, a
|
||||
# service can refuse. It can only refuse as precisely as its
|
||||
# identity allows — see auth.py.
|
||||
claim = store.claim_for(session, tenant, registry, manifest_id)
|
||||
if claim is not None and claim.policy == "closed":
|
||||
owner = claim.owner or "unspecified"
|
||||
if publisher != (claim.owner or publisher):
|
||||
raise HTTPException(
|
||||
403,
|
||||
f"namespace {store.namespace_of(manifest_id)!r} in registry "
|
||||
f"{registry!r} is closed (owner: {owner})",
|
||||
)
|
||||
|
||||
try:
|
||||
version = store.ingest(
|
||||
session, tenant, registry, package_dir, source=source, method="publish"
|
||||
)
|
||||
session.commit()
|
||||
except StoreError as exc:
|
||||
session.rollback()
|
||||
raise as_http(exc) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
session.rollback()
|
||||
raise HTTPException(400, f"invalid package: {exc}") from exc
|
||||
|
||||
return version_payload(version)
|
||||
|
||||
@api.get("/packages")
|
||||
def list_packages(
|
||||
q: str = Query("", description="Substring over id, name, summary and tags"),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ class Settings(BaseSettings):
|
|||
tenant: str = "default"
|
||||
service_name: str = "canned-prompts"
|
||||
|
||||
# A single shared token, so the identity it proves is "the operator of this
|
||||
# service" and nothing finer. Unset means the service is read-only; see
|
||||
# auth.py for why that is the right default rather than an inconvenience.
|
||||
publish_token: str = ""
|
||||
publisher_name: str = "operator"
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.database_url)
|
||||
|
|
|
|||
|
|
@ -248,3 +248,39 @@ def index_entries(session: Session, tenant: str) -> list[IndexEntry]:
|
|||
session.scalars(select(IndexEntry).where(IndexEntry.tenant == tenant))
|
||||
)
|
||||
return sorted(rows, key=lambda r: (r.registry, r.package_id, cp.parse_semver(r.version)))
|
||||
|
||||
|
||||
def namespace_of(package_id: str) -> str:
|
||||
return package_id.split("/")[0] if "/" in package_id else package_id
|
||||
|
||||
|
||||
def claim_for(session: Session, tenant: str, registry: str, package_id: str):
|
||||
from .models import NamespaceClaim
|
||||
|
||||
return session.scalar(
|
||||
select(NamespaceClaim).where(
|
||||
NamespaceClaim.tenant == tenant,
|
||||
NamespaceClaim.registry == registry,
|
||||
NamespaceClaim.namespace == namespace_of(package_id),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def materialize(files: dict[str, bytes], destination: Path) -> Path:
|
||||
"""Write a posted package to disk so the reference validator can read it."""
|
||||
for relative, content in files.items():
|
||||
if relative.startswith("/") or ".." in Path(relative).parts:
|
||||
raise StoreError(f"unsafe path in package: {relative}")
|
||||
target = destination / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
return destination
|
||||
|
||||
|
||||
def peek_id(package_dir: Path) -> str:
|
||||
"""The declared id, needed before validation to check namespace policy."""
|
||||
manifest = cp.read_manifest(package_dir)
|
||||
package_id = manifest.get("id")
|
||||
if not isinstance(package_id, str) or not package_id.strip():
|
||||
raise StoreError("manifest declares no id")
|
||||
return package_id
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from pathlib import Path
|
|||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.script import ScriptDirectory
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from canned_prompts_service.api import create_app
|
||||
|
|
@ -21,6 +22,13 @@ from canned_prompts_service.settings import Settings
|
|||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def expected_head() -> str:
|
||||
"""Computed, not hardcoded: a new migration must not fail these tests."""
|
||||
config = Config(str(ROOT / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(ROOT / "migrations"))
|
||||
return ScriptDirectory.from_config(config).get_current_head()
|
||||
|
||||
|
||||
def migrated_url(tmp_path: Path) -> str:
|
||||
url = f"sqlite:///{tmp_path / 'svc.db'}"
|
||||
config = Config(str(ROOT / "alembic.ini"))
|
||||
|
|
@ -70,7 +78,7 @@ def test_readyz_fails_when_the_schema_is_not_migrated(tmp_path: Path) -> None:
|
|||
def test_readyz_reports_the_migration_when_ready(ready_client: TestClient) -> None:
|
||||
body = ready_client.get("/readyz").json()
|
||||
assert body["ready"] is True
|
||||
assert body["migration"] == "0001"
|
||||
assert body["migration"] == expected_head()
|
||||
|
||||
|
||||
def test_state_health_matches_the_fleet_shape(ready_client: TestClient) -> None:
|
||||
|
|
@ -78,7 +86,7 @@ def test_state_health_matches_the_fleet_shape(ready_client: TestClient) -> None:
|
|||
assert body["status"] == "ok"
|
||||
assert body["service"] == "canned-prompts"
|
||||
assert body["db"] == "connected"
|
||||
assert body["migration"] == "0001"
|
||||
assert body["migration"] == expected_head()
|
||||
|
||||
|
||||
def test_state_health_degrades_rather_than_lying() -> None:
|
||||
|
|
|
|||
145
service/tests/test_publish_api.py
Normal file
145
service/tests/test_publish_api.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""Publish API (CANP-WP-0006 T04)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from canned_prompts_service.api import create_app
|
||||
from canned_prompts_service.db import make_engine, make_session_factory
|
||||
from canned_prompts_service.models import NamespaceClaim
|
||||
from canned_prompts_service.settings import Settings
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
TOKEN = "test-token"
|
||||
AUTH = {"Authorization": f"Bearer {TOKEN}"}
|
||||
|
||||
|
||||
def package_payload(name: str, registry: str = "local") -> dict:
|
||||
"""Built from the archive shape, so an archive round-trips into a publish."""
|
||||
source_dir = REPO / "examples" / name
|
||||
import canned_prompts as cp
|
||||
|
||||
manifest = cp.validate_package(source_dir)
|
||||
files = {
|
||||
rel: {"text": (source_dir / rel).read_text(encoding="utf-8")}
|
||||
for rel in sorted(cp.package_members(source_dir, manifest))
|
||||
}
|
||||
return {"registry": registry, "source": f"examples/{name}", "files": files}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def writable(db_url: str) -> TestClient:
|
||||
settings = Settings(database_url=db_url, publish_token=TOKEN)
|
||||
return TestClient(create_app(settings, make_engine(db_url)))
|
||||
|
||||
|
||||
def test_unconfigured_service_refuses_writes(client: TestClient) -> None:
|
||||
"""No publisher identity means the service cannot tell who is calling."""
|
||||
response = client.post("/packages", json=package_payload("house-style"))
|
||||
assert response.status_code == 503
|
||||
assert "publishing is not configured" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_publish_requires_a_token(writable: TestClient) -> None:
|
||||
response = writable.post("/packages", json=package_payload("house-style"))
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_wrong_token_is_refused(writable: TestClient) -> None:
|
||||
response = writable.post(
|
||||
"/packages",
|
||||
json=package_payload("house-style"),
|
||||
headers={"Authorization": "Bearer nope"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_publish_then_read_back(writable: TestClient) -> None:
|
||||
response = writable.post("/packages", json=package_payload("house-style"), headers=AUTH)
|
||||
assert response.status_code == 201
|
||||
assert response.json()["reference"] == "local:practice/house-style@0.1.1"
|
||||
|
||||
body = writable.get("/packages/local:practice/house-style@0.1.1").json()
|
||||
assert body["type"] == "fragment"
|
||||
assert writable.get("/index").json()["count"] == 1
|
||||
|
||||
|
||||
def test_archive_round_trips_into_a_publish(writable: TestClient) -> None:
|
||||
"""What GET /archives returns is exactly what POST /packages accepts."""
|
||||
writable.post("/packages", json=package_payload("house-style"), headers=AUTH)
|
||||
archive = writable.get("/archives/practice/house-style@0.1.1").json()
|
||||
|
||||
republished = writable.post(
|
||||
"/packages",
|
||||
json={"registry": "mirror", "source": "round-trip", "files": archive["files"]},
|
||||
headers=AUTH,
|
||||
)
|
||||
assert republished.status_code == 201
|
||||
assert republished.json()["content_digest"] == archive["content_digest"]
|
||||
|
||||
|
||||
def test_identical_republish_is_accepted(writable: TestClient) -> None:
|
||||
payload = package_payload("house-style")
|
||||
assert writable.post("/packages", json=payload, headers=AUTH).status_code == 201
|
||||
assert writable.post("/packages", json=payload, headers=AUTH).status_code == 201
|
||||
|
||||
|
||||
def test_different_content_under_the_same_version_is_a_conflict(writable: TestClient) -> None:
|
||||
"""§ 17: a published id@version is immutable within a registry."""
|
||||
payload = package_payload("house-style")
|
||||
writable.post("/packages", json=payload, headers=AUTH)
|
||||
payload["files"]["prompt.md"]["text"] += "\nsomething different\n"
|
||||
response = writable.post("/packages", json=payload, headers=AUTH)
|
||||
assert response.status_code == 409
|
||||
assert "already exists with different content" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_invalid_package_is_rejected(writable: TestClient) -> None:
|
||||
payload = package_payload("house-style")
|
||||
payload["files"]["prompt.yaml"]["text"] = "format: canned-prompt/v9.9\nid: x/y\n"
|
||||
response = writable.post("/packages", json=payload, headers=AUTH)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_path_traversal_is_refused(writable: TestClient) -> None:
|
||||
payload = package_payload("house-style")
|
||||
payload["files"]["../escape.txt"] = {"text": "no"}
|
||||
assert writable.post("/packages", json=payload, headers=AUTH).status_code == 400
|
||||
|
||||
|
||||
def test_closed_namespace_refuses_a_publisher_it_cannot_attribute(
|
||||
db_url: str, session: Session
|
||||
) -> None:
|
||||
"""§ 20.1 enforced rather than advised — as far as identity allows."""
|
||||
session.add(
|
||||
NamespaceClaim(
|
||||
tenant="default", registry="local", namespace="practice",
|
||||
policy="closed", owner="someone-else",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
client = TestClient(
|
||||
create_app(Settings(database_url=db_url, publish_token=TOKEN), make_engine(db_url))
|
||||
)
|
||||
response = client.post("/packages", json=package_payload("house-style"), headers=AUTH)
|
||||
assert response.status_code == 403
|
||||
assert "is closed" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_open_namespace_publishes(db_url: str, session: Session) -> None:
|
||||
session.add(
|
||||
NamespaceClaim(
|
||||
tenant="default", registry="local", namespace="practice", policy="open"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
client = TestClient(
|
||||
create_app(Settings(database_url=db_url, publish_token=TOKEN), make_engine(db_url))
|
||||
)
|
||||
assert client.post(
|
||||
"/packages", json=package_payload("house-style"), headers=AUTH
|
||||
).status_code == 201
|
||||
|
|
@ -155,7 +155,7 @@ models and the same migration.
|
|||
|
||||
```task
|
||||
id: CANP-WP-0006-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "929932b6-931b-5409-a648-b6814f07f0b4"
|
||||
```
|
||||
|
|
@ -170,6 +170,38 @@ filesystem registry a service *can* authenticate a publisher. Note what identity
|
|||
mechanism it uses; if there is none yet, say so and refuse writes rather than
|
||||
accepting anonymous publishes into a closed namespace.
|
||||
|
||||
**Done.** `POST /packages` takes the same `files` shape `GET /archives`
|
||||
returns, so an archive round-trips into a publish without translation and a
|
||||
mirror is a GET followed by a POST — verified by a test, not just asserted.
|
||||
|
||||
**The identity mechanism, stated plainly.** A single shared bearer token
|
||||
proving the caller is *the operator of this service*. Not per-publisher
|
||||
identity: every token holder is indistinguishable. With no token configured the
|
||||
service is read-only, which is the right default rather than an inconvenience —
|
||||
§ 20.1 asks a registry to refuse publication into a closed namespace it does not
|
||||
consider the publisher to own, and an unauthenticated service considers nobody
|
||||
to own anything.
|
||||
|
||||
Namespace claims live in `namespace_claims` (migration `0002`) and are enforced,
|
||||
which a filesystem registry cannot do at all — but only as precisely as the
|
||||
identity allows. A closed namespace is protected from anonymous callers; it
|
||||
cannot be attributed among several publishers. Until per-publisher identity
|
||||
exists, a claim's `owner` is documentation rather than an access decision, and
|
||||
`auth.py` says so rather than letting the code imply more than it delivers.
|
||||
|
||||
**Handed forward:** per-publisher identity is the main thing between this and a
|
||||
registry several people can publish to.
|
||||
|
||||
**Migration hygiene found while adding `0002`.** Autogenerate proposed an
|
||||
`ALTER COLUMN TYPE` on `package_files.package_version_id`, because the foreign
|
||||
key's type was left to inference and compared as a variant against a reflected
|
||||
plain type. SQLite cannot alter a column type, so `0002` failed halfway,
|
||||
leaving the table created and the revision unstamped — the partially-applied
|
||||
state that is worst to debug later. Fixed at the cause: the column is typed
|
||||
explicitly, and `0001` was corrected rather than patched over, which is
|
||||
legitimate only because it has never run outside this repo's tests. `alembic
|
||||
check` now reports no drift.
|
||||
|
||||
## HTTP registry in the reference CLI
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue