gabriel / musehub public
wire.py python
1,151 lines 44.0 KB
Raw
sha256:9c9cffc9f53400a171f808053cb3fc68eb1da1c44a329b5253a0515905db51fb Merge 'security/131-wire-write-authz' into 'dev' — proposal… Human 10 days ago
1 """Wire protocol endpoints — Muse CLI push/fetch transport.
2
3 URL pattern mirrors Git's Smart HTTP protocol:
4
5 muse remote add origin https://musehub.ai/gabriel/muse
6
7 Active endpoints:
8
9 GET /{owner}/{slug}/refs — branch heads + domain metadata (pre-flight)
10 POST /{owner}/{slug}/push/mpack-presign — get presigned PUT URL for whole mpack
11 POST /{owner}/{slug}/push/unpack-mpack — server indexes mpack from R2
12 POST /{owner}/{slug}/fetch/mpack — server builds and returns fetch mpack
13 POST /{owner}/{slug}/fetch/presign — presigned GET URLs for large fetches
14
15 These routes MUST be registered before the wildcard UI router in main.py
16 (/{owner}/{repo_slug}/...) so FastAPI matches the concrete third-segment
17 paths first.
18 """
19
20 import json
21 import logging
22
23 import msgpack
24 import pydantic
25 from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
26 from fastapi.responses import Response
27 from sqlalchemy.ext.asyncio import AsyncSession
28
29 from sqlalchemy import select
30 from musehub.auth.dependencies import optional_token, require_valid_token, TokenClaims
31 from musehub.config import get_settings
32 from musehub.db.database import get_db as get_session
33 from musehub.db.musehub_abuse_models import MusehubDailyPushBytes
34 from musehub.db.musehub_collaborator_models import MusehubCollaborator
35 from musehub.db.musehub_repo_models import MusehubBranch, MusehubObject, MusehubRepo
36 from musehub.models.wire import WireFetchRequest
37 from musehub.types.json_types import JSONObject
38
39 from musehub.api.validation import BranchParam, SlugParam
40 from musehub.rate_limits import limiter, WIRE_PUSH_LIMIT, WIRE_FETCH_LIMIT, OBJECT_LIMIT, REPAIR_LIMIT
41 from musehub.services.musehub_repository import get_repo_row_by_owner_slug
42 from musehub.services.musehub_wire import (
43 FetchCommitNotFound,
44 FetchNotIndexedError,
45 MPackNotReadyError,
46 FetchNotReady,
47 MPackValidationError,
48 ObjectHashMismatch,
49 record_mpack_bytes_uploaded,
50 wire_fetch,
51 wire_fetch_mpack,
52 wire_fetch_objects,
53 wire_push_mpack_presign,
54 wire_push_unpack_mpack,
55 wire_refs,
56 wire_repair_commit,
57 wire_repair_object,
58 wire_repair_snapshot,
59 )
60 from musehub.storage import get_backend
61
62 logger = logging.getLogger(__name__)
63
64 router = APIRouter(tags=["Wire Protocol"])
65
66 # ── helpers ────────────────────────────────────────────────────────────────────
67
68 async def _resolve_repo(
69 session: AsyncSession,
70 owner: SlugParam,
71 slug: SlugParam,
72 ) -> MusehubRepo:
73 """Resolve owner/slug → repo row or raise 404."""
74 repo = await get_repo_row_by_owner_slug(session, owner, slug)
75 if repo is None:
76 raise HTTPException(
77 status_code=status.HTTP_404_NOT_FOUND,
78 detail=f"repo '{owner}/{slug}' not found",
79 )
80 return repo
81
82 async def _resolve_repo_id(
83 session: AsyncSession,
84 owner: SlugParam,
85 slug: SlugParam,
86 ) -> str:
87 """Resolve owner/slug → repo_id or raise 404.
88
89 Used only by endpoints that perform their own write-authorization check
90 against ``repo_id`` afterward (repair-object/snapshot/commit) or that are
91 read-only. Write endpoints that need ``_assert_writable`` should call
92 ``_resolve_repo`` instead, since that check needs the full repo row.
93 """
94 return (await _resolve_repo(session, owner, slug)).repo_id
95
96 async def _assert_readable(
97 repo: MusehubRepo,
98 claims: TokenClaims | None,
99 session: AsyncSession,
100 ) -> None:
101 """Raise 404 if *repo* is private and the caller is not the owner or a collaborator.
102
103 Returns 404 (not 403) to avoid leaking that the repo exists.
104 """
105 if repo.visibility == "public":
106 return
107 caller_handle: str | None = claims.handle if claims else None
108 if caller_handle == repo.owner:
109 return
110 # Check collaborators with at least read permission
111 if caller_handle:
112 collab_row = (await session.execute(
113 select(MusehubCollaborator).where(
114 MusehubCollaborator.repo_id == repo.repo_id,
115 MusehubCollaborator.identity_handle == caller_handle,
116 MusehubCollaborator.accepted_at.isnot(None),
117 )
118 )).scalar_one_or_none()
119 if collab_row is not None:
120 return
121 raise HTTPException(
122 status_code=status.HTTP_404_NOT_FOUND,
123 detail="repo not found",
124 )
125
126 async def _assert_writable(
127 repo: MusehubRepo,
128 claims: TokenClaims,
129 session: AsyncSession,
130 ) -> None:
131 """Raise 403 unless the caller is the repo owner or an accepted write/admin collaborator.
132
133 Unlike ``_assert_readable``, visibility never widens write access — a
134 public repo still requires owner or write/admin collaborator status to
135 push to it directly. See musehub issue #131.
136 """
137 caller_handle: str = claims.handle
138 if caller_handle == repo.owner:
139 return
140 collab_row = (await session.execute(
141 select(MusehubCollaborator).where(
142 MusehubCollaborator.repo_id == repo.repo_id,
143 MusehubCollaborator.identity_handle == caller_handle,
144 MusehubCollaborator.accepted_at.isnot(None),
145 MusehubCollaborator.permission.in_(["write", "admin"]),
146 )
147 )).scalar_one_or_none()
148 if collab_row is None:
149 raise HTTPException(
150 status_code=status.HTTP_403_FORBIDDEN,
151 detail="only the repo owner or a write/admin collaborator may write to this repo",
152 )
153
154 # ── Wire helpers ─────────────────────────────────────────────────────────────
155
156 def _mpack_response(data: JSONObject, request: Request) -> Response:
157 """Encode *data* as msgpack based on the client's Accept header.
158
159 Clients send ``Accept: application/x-msgpack`` and always receive
160 binary msgpack. The dict may contain ``bytes`` values (e.g. object
161 content) which msgpack handles natively.
162 """
163 accept = request.headers.get("accept", "")
164 if "application/x-msgpack" in accept:
165 return Response(
166 content=msgpack.packb(data, use_bin_type=True),
167 media_type="application/x-msgpack",
168 )
169 return Response(content=json.dumps(data), media_type="application/json")
170
171 def _decode_request_body(raw: bytes, content_type: str) -> JSONObject:
172 """Decode an HTTP request body from msgpack or JSON.
173
174 Clients send ``Content-Type: application/x-msgpack``; JSON is also
175 accepted as a fallback for compatibility.
176 """
177 if "application/x-msgpack" in content_type or "application/x-muse-mpack" in content_type:
178 decoded = msgpack.unpackb(raw, raw=False)
179 if not isinstance(decoded, dict):
180 raise ValueError("msgpack body must be a mapping")
181 return dict(decoded)
182 parsed = json.loads(raw)
183 if not isinstance(parsed, dict):
184 raise ValueError("JSON body must be a mapping")
185 return dict(parsed)
186
187 # ── wire endpoints ─────────────────────────────────────────────────────────────
188
189 @router.get(
190 "/{owner}/{slug}/refs",
191 summary="Get branch heads (muse pull / muse push pre-flight)",
192 response_description="Repo metadata and current branch heads",
193 )
194 @limiter.limit(WIRE_FETCH_LIMIT)
195 async def get_refs(
196 request: Request,
197 owner: SlugParam,
198 slug: SlugParam,
199 _claims: TokenClaims | None = Depends(optional_token),
200 session: AsyncSession = Depends(get_session),
201 ) -> Response:
202 """Return branch heads and domain metadata for a repo.
203
204 Called by ``muse push`` and ``muse pull`` as a pre-flight to determine
205 what the remote already has. Equivalent to Git's:
206 ``GET /owner/repo/info/refs?service=git-upload-pack``
207
208 Private repos are only visible to their owner — unauthenticated callers
209 receive a 404 (same response as a non-existent repo, to avoid leaking
210 the existence of private repos).
211
212 Response:
213 ```json
214 {
215 "repo_id": "...",
216 "domain": "code",
217 "default_branch": "main",
218 "branch_heads": {"main": "sha...", "dev": "sha..."}
219 }
220 ```
221 """
222 repo = await _resolve_repo(session, owner, slug)
223 await _assert_readable(repo, _claims, session)
224 result = await wire_refs(session, repo.repo_id)
225 if result is None:
226 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="repo not found")
227 return _mpack_response(result.model_dump(), request)
228
229 @router.post(
230 "/{owner}/{slug}/push/mpack-presign",
231 summary="Return one presigned PUT URL for a whole mpack",
232 status_code=status.HTTP_200_OK,
233 )
234 @limiter.limit(OBJECT_LIMIT)
235 async def push_mpack_presign(
236 request: Request,
237 owner: SlugParam,
238 slug: SlugParam,
239 claims: TokenClaims = Depends(require_valid_token),
240 session: AsyncSession = Depends(get_session),
241 ) -> Response:
242 """Return one presigned PUT URL so the client can upload the entire mpack.
243
244 Request body (msgpack):
245 mpack_key str — sha256:<hex> of the mpack bytes
246 size_bytes int — advisory byte count
247
248 Response (msgpack):
249 upload_url str — presigned PUT URL valid for 1 hour
250 mpack_key str — echoed back for the client to pass to unpack-mpack
251 """
252 repo = await _resolve_repo(session, owner, slug)
253 await _assert_writable(repo, claims, session)
254 raw = await request.body()
255 ct = request.headers.get("Content-Type", "")
256 data = _decode_request_body(raw, ct)
257 mpack_key = str(data.get("mpack_key", "") or "")
258 size_bytes = int(data.get("size_bytes", 0))
259 logger.warning("[mpack-presign] received mpack_key=%s size_bytes=%d content_type=%r", mpack_key, size_bytes, ct)
260 if not mpack_key:
261 raise HTTPException(
262 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
263 detail="mpack-presign requires mpack_key",
264 )
265 # mpack_key must be "sha256:<64-hex>" — anything else will never match
266 # what the client PUT to MinIO and will fail integrity check in unpack-mpack.
267 if not mpack_key.startswith("sha256:") or len(mpack_key) != 7 + 64:
268 raise HTTPException(
269 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
270 detail="mpack_key must be 'sha256:<64-hex>'",
271 )
272 _settings = get_settings()
273 if size_bytes > _settings.mpack_max_bytes:
274 raise HTTPException(
275 status_code=status.HTTP_413_CONTENT_TOO_LARGE,
276 detail=(
277 f"mpack size {size_bytes:,} bytes exceeds limit "
278 f"{_settings.mpack_max_bytes:,} bytes"
279 ),
280 )
281 # 4a — per-user daily byte limit
282 if _settings.mpack_daily_upload_limit_bytes > 0:
283 from sqlalchemy import select as _select, func as _func
284 import datetime as _dt
285 today = _dt.date.today()
286 _result = await session.execute(
287 _select(_func.coalesce(
288 _func.sum(MusehubDailyPushBytes.bytes_uploaded), 0
289 )).where(
290 MusehubDailyPushBytes.identity_id == claims.identity_id,
291 MusehubDailyPushBytes.date == today,
292 )
293 )
294 daily_total = int(_result.scalar() or 0)
295 if daily_total >= _settings.mpack_daily_upload_limit_bytes:
296 raise HTTPException(
297 status_code=status.HTTP_429_TOO_MANY_REQUESTS,
298 detail=(
299 f"daily upload limit of {_settings.mpack_daily_upload_limit_bytes:,} bytes reached; "
300 "try again tomorrow"
301 ),
302 )
303 await record_mpack_bytes_uploaded(session, claims.identity_id, size_bytes)
304 await session.commit()
305 result = await wire_push_mpack_presign(mpack_key, size_bytes)
306 return _mpack_response(result, request)
307
308
309 @router.post(
310 "/{owner}/{slug}/push/unpack-mpack",
311 summary="Read an mpack from storage, index all contents into PG",
312 status_code=status.HTTP_200_OK,
313 )
314 @limiter.limit(WIRE_PUSH_LIMIT)
315 async def push_unpack_mpack(
316 request: Request,
317 owner: SlugParam,
318 slug: SlugParam,
319 claims: TokenClaims = Depends(require_valid_token),
320 session: AsyncSession = Depends(get_session),
321 ) -> Response:
322 """Server reads a previously uploaded mpack from MinIO and indexes it.
323
324 Request body (msgpack):
325 mpack_key str — sha256:<hex> used when the client called mpack-presign
326
327 Response (msgpack):
328 commits_written int
329 snapshots_written int
330 blobs_written int
331 """
332 raw = await request.body()
333 ct = request.headers.get("Content-Type", "")
334 data = _decode_request_body(raw, ct)
335 mpack_key = data.get("mpack_key", "")
336 logger.warning("[unpack-mpack] received mpack_key=%s raw_body_len=%d content_type=%r", mpack_key, len(raw), ct)
337 if not mpack_key:
338 raise HTTPException(
339 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
340 detail="unpack-pack requires mpack_key",
341 )
342 branch = str(data.get("branch") or "main")
343 head_commit_id = str(data.get("head") or "")
344 commits_count = int(data.get("commits_count") or 0)
345 blobs_count = int(data.get("blobs_count") or 0)
346 force = bool(data.get("force") or False)
347 _settings = get_settings()
348 if commits_count > _settings.mpack_max_commits:
349 raise HTTPException(
350 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
351 detail=f"commits_count {commits_count:,} exceeds limit {_settings.mpack_max_commits:,}",
352 )
353 if blobs_count > _settings.mpack_max_objects:
354 raise HTTPException(
355 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
356 detail=f"blobs_count {blobs_count:,} exceeds limit {_settings.mpack_max_objects:,}",
357 )
358 repo = await _resolve_repo(session, owner, slug)
359 await _assert_writable(repo, claims, session)
360 repo_id = repo.repo_id
361 from musehub.services.musehub_wire import NonFastForwardError as _NonFastForwardError
362 try:
363 result = await wire_push_unpack_mpack(
364 session, repo_id, mpack_key, claims.handle,
365 branch=branch, head_commit_id=head_commit_id,
366 commits_count=commits_count, blobs_count=blobs_count,
367 force=force,
368 )
369 except _NonFastForwardError as exc:
370 raise HTTPException(
371 status_code=status.HTTP_409_CONFLICT,
372 detail=str(exc),
373 )
374 except ValueError as exc:
375 raise HTTPException(
376 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
377 detail=str(exc),
378 )
379
380 # Enqueue intel + file-last-commits + gc + profile.snapshot after every push.
381 try:
382 from musehub.services.musehub_jobs import enqueue_push_intel as _enqueue_push_intel
383 repo_row = await session.get(MusehubRepo, repo_id)
384 domain_id = repo_row.domain_id if repo_row else None
385 head = result.get("head", head_commit_id)
386 await _enqueue_push_intel(
387 session,
388 repo_id,
389 head,
390 domain_id=domain_id,
391 branch=branch,
392 owner=owner,
393 mpack_key=mpack_key,
394 )
395 await session.commit()
396 except Exception:
397 logger.exception(
398 "enqueue_push_intel failed for repo=%s — push succeeded, intel skipped",
399 repo_id[:16],
400 )
401
402 return _mpack_response(result, request)
403
404
405 @router.post(
406 "/{owner}/{slug}/repair-object",
407 summary="Replace a corrupt stored object with verified correct bytes (owner/write only)",
408 status_code=status.HTTP_200_OK,
409 )
410 @limiter.limit(REPAIR_LIMIT)
411 async def repair_object(
412 request: Request,
413 owner: SlugParam,
414 slug: SlugParam,
415 claims: TokenClaims = Depends(require_valid_token),
416 session: AsyncSession = Depends(get_session),
417 ) -> Response:
418 """Replace a stored object's bytes with correct content, verified by SHA-256.
419
420 Intended for operators to repair objects that were stored with wrong bytes —
421 e.g. objects produced by a failed delta reconstruction where the base was
422 zlib-compressed and the delta result is garbage.
423
424 Request body (msgpack):
425 object_id str — full "sha256:<64-hex>" content ID
426 content bytes — the correct raw bytes for this object
427
428 The endpoint verifies SHA-256(content) == object_id before writing.
429 Only the repo owner or a write/admin collaborator may call this.
430 """
431 raw = await request.body()
432 ct = request.headers.get("Content-Type", "")
433 data = _decode_request_body(raw, ct)
434 object_id: str = data.get("object_id", "")
435 content: bytes = data.get("content", b"")
436 if not object_id or not isinstance(content, bytes):
437 raise HTTPException(
438 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
439 detail="repair-object requires object_id (str) and content (bytes)",
440 )
441 repo_id = await _resolve_repo_id(session, owner, slug)
442 caller_id: str | None = claims.handle
443 try:
444 result = await wire_repair_object(session, repo_id, object_id, content, caller_id)
445 except PermissionError as exc:
446 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
447 except ObjectHashMismatch as exc:
448 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
449 except ValueError as exc:
450 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
451 return _mpack_response(result, request)
452
453 @router.post(
454 "/{owner}/{slug}/repair-snapshot",
455 summary="Replace a corrupt snapshot manifest with verified correct content (owner/write only)",
456 status_code=status.HTTP_200_OK,
457 )
458 @limiter.limit(REPAIR_LIMIT)
459 async def repair_snapshot(
460 request: Request,
461 owner: SlugParam,
462 slug: SlugParam,
463 claims: TokenClaims = Depends(require_valid_token),
464 session: AsyncSession = Depends(get_session),
465 ) -> Response:
466 """Replace a stored snapshot's manifest with correct content, verified by snapshot_id.
467
468 Intended for operators to repair snapshots that were stored with an empty
469 or corrupted manifest_blob — e.g. snapshots affected by the R2 empty-object
470 bug. Uses force-overwrite (unlike the push path which uses ON CONFLICT DO
471 NOTHING), so an existing row with wrong content is corrected in place.
472
473 Request body (msgpack):
474 snapshot_id str — full "sha256:<64-hex>" snapshot ID
475 manifest dict[str,str] — {path: object_id} mapping
476 directories list[str] — directory paths (may be empty)
477
478 The endpoint recomputes compute_snapshot_id(manifest, directories) and
479 verifies it matches snapshot_id before writing.
480 Only the repo owner or a write/admin collaborator may call this.
481 """
482 raw = await request.body()
483 ct = request.headers.get("Content-Type", "")
484 data = _decode_request_body(raw, ct)
485 snapshot_id: str = data.get("snapshot_id", "")
486 manifest: JSONObject = data.get("manifest", {})
487 directories: list[str] = data.get("directories", [])
488 if not snapshot_id or not isinstance(manifest, dict):
489 raise HTTPException(
490 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
491 detail="repair-snapshot requires snapshot_id (str) and manifest (dict)",
492 )
493 repo_id = await _resolve_repo_id(session, owner, slug)
494 caller_id: str | None = claims.handle
495 try:
496 result = await wire_repair_snapshot(
497 session, repo_id, snapshot_id, manifest, directories, caller_id
498 )
499 except PermissionError as exc:
500 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
501 except ObjectHashMismatch as exc:
502 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
503 except ValueError as exc:
504 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
505 return _mpack_response(result, request)
506
507 @router.post(
508 "/{owner}/{slug}/repair-commit",
509 summary="Replace a corrupt commit record with verified-correct content (owner/write only)",
510 status_code=status.HTTP_200_OK,
511 )
512 @limiter.limit(REPAIR_LIMIT)
513 async def repair_commit(
514 request: Request,
515 owner: SlugParam,
516 slug: SlugParam,
517 claims: TokenClaims = Depends(require_valid_token),
518 session: AsyncSession = Depends(get_session),
519 ) -> Response:
520 """Replace a stored commit's identity fields with correct content, verified by commit_id.
521
522 Intended for operators to repair commits whose stored row no longer reproduces its
523 commit_id — e.g. commits the rc10 object-store migration stamped with a
524 signer_public_key without recomputing the id, so the serve path's hash check fails
525 on clone and the commit (and all descendants) is dropped.
526
527 Request body (msgpack):
528 commit dict — a wire commit record (WireCommit shape). Its identity fields
529 (parent ids, snapshot_id, message, committed_at, author,
530 signer_public_key) must reproduce commit_id.
531
532 The endpoint recomputes the commit identity (round-tripping committed_at exactly as
533 the serve path does) and verifies it matches commit_id before writing.
534 Only the repo owner or a write/admin collaborator may call this.
535 """
536 raw = await request.body()
537 ct = request.headers.get("Content-Type", "")
538 data = _decode_request_body(raw, ct)
539 commit: JSONObject = data.get("commit", {})
540 if not isinstance(commit, dict) or not commit.get("commit_id"):
541 raise HTTPException(
542 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
543 detail="repair-commit requires commit (dict) with a commit_id",
544 )
545 repo_id = await _resolve_repo_id(session, owner, slug)
546 caller_id: str | None = claims.handle
547 try:
548 result = await wire_repair_commit(session, repo_id, commit, caller_id)
549 except PermissionError as exc:
550 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
551 except ObjectHashMismatch as exc:
552 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
553 except ValueError as exc:
554 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
555 return _mpack_response(result, request)
556
557 @router.post(
558 "/{owner}/{slug}/fetch/objects",
559 summary="Fetch raw content for a list of object IDs",
560 status_code=status.HTTP_200_OK,
561 )
562 @limiter.limit(OBJECT_LIMIT)
563 async def fetch_objects(
564 request: Request,
565 owner: SlugParam,
566 slug: SlugParam,
567 _claims: TokenClaims | None = Depends(optional_token),
568 session: AsyncSession = Depends(get_session),
569 ) -> Response:
570 """Return raw bytes for each requested object ID as concatenated msgpack frames.
571
572 Request body (msgpack):
573 object_ids list[str] — canonical sha256:<hex> IDs to fetch
574
575 Response body: concatenated msgpack dicts, one per found object:
576 {"object_id": str, "content": bytes}
577
578 Objects not found are silently omitted.
579 """
580 raw = await request.body()
581 ct = request.headers.get("Content-Type", "")
582 data = _decode_request_body(raw, ct)
583 object_ids = data.get("object_ids", [])
584 if not isinstance(object_ids, list):
585 raise HTTPException(
586 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
587 detail="fetch/objects requires object_ids (list)",
588 )
589 repo = await _resolve_repo(session, owner, slug)
590 await _assert_readable(repo, _claims, session)
591 objects = await wire_fetch_objects(session, repo.repo_id, [str(o) for o in object_ids])
592 import msgpack as _mp
593 body = b"".join(_mp.packb(obj, use_bin_type=True) for obj in objects)
594 return Response(content=body, media_type="application/x-msgpack")
595
596 @router.post(
597 "/{owner}/{slug}/fetch/presign",
598 summary="Wire — presigned fetch for large repos",
599 status_code=status.HTTP_200_OK,
600 )
601 @limiter.limit(WIRE_FETCH_LIMIT)
602 async def fetch_presign(
603 request: Request,
604 owner: SlugParam,
605 slug: SlugParam,
606 _claims: TokenClaims | None = Depends(optional_token),
607 session: AsyncSession = Depends(get_session),
608 ) -> Response:
609 """Return per-object presigned GET URLs for a large fetch delta.
610
611 For large fetches (≥ 500 objects or ≥ 50 MB) Cloudflare's origin timeout
612 kills the response before the client receives everything. This
613 endpoint generates one presigned R2 GET URL per needed object — zero object
614 bytes are read server-side. The client downloads all objects in parallel
615 directly from R2, bypassing Cloudflare entirely.
616
617 **Request body** (``Content-Type: application/x-msgpack``):
618 msgpack ``{"want": [str], "have": [str], "depth": int|null, "ttl_seconds": int}``
619
620 **Response body** (``Content-Type: application/x-msgpack``):
621 msgpack ``{"presign": bool, "blob_urls": {oid: url}, "commits": [...],
622 "snapshots": [...], "branch_heads": {...}, "repo_id": str,
623 "domain": str, "default_branch": str,
624 "expires_at": str|null, "commit_count": int, "blob_count": int}``
625
626 When ``presign=False`` the delta is below the threshold or the backend does
627 not support presigned URLs — the client should fall back to ``fetch/mpack``.
628 """
629 from musehub.services.musehub_wire import wire_fetch_presign
630 raw = await request.body()
631 data = _decode_request_body(raw, "application/x-msgpack")
632 ttl_seconds = int(data.pop("ttl_seconds", 3600))
633 body = WireFetchRequest.model_validate(data)
634 repo = await _resolve_repo(session, owner, slug)
635 await _assert_readable(repo, _claims, session)
636 result = await wire_fetch_presign(session, repo.repo_id, body, ttl_seconds)
637 return _mpack_response(result, request)
638
639
640 @router.post(
641 "/{owner}/{slug}/fetch",
642 summary="Wire — fetch protocol step 1 (issue #68)",
643 status_code=status.HTTP_200_OK,
644 )
645 @limiter.limit(WIRE_FETCH_LIMIT)
646 async def fetch(
647 request: Request,
648 owner: SlugParam,
649 slug: SlugParam,
650 _claims: TokenClaims | None = Depends(optional_token),
651 session: AsyncSession = Depends(get_session),
652 ) -> Response:
653 """Compute the fetch delta and return a presigned GET URL for the mpack.
654
655 **Request body** (``Content-Type: application/x-msgpack``):
656 ``{"want": [sha256:...], "have": [sha256:...]}``
657
658 **Response body** (``Content-Type: application/x-msgpack``):
659 ``{"mpack_id": "sha256:...", "mpack_url": "...", "commit_count": N, "object_count": N}``
660
661 Returns 422 if want is empty or entries are malformed.
662 Returns 404 if any want commit_id is unknown.
663 Returns 503 with Retry-After if needed objects are not yet indexed.
664 """
665 raw = await request.body()
666 data = _decode_request_body(raw, "application/x-msgpack")
667 want: list[str] = data.get("want") or []
668 have: list[str] = data.get("have") or []
669
670 if not want:
671 raise HTTPException(status_code=422, detail="want must be non-empty")
672
673 repo = await _resolve_repo(session, owner, slug)
674 await _assert_readable(repo, _claims, session)
675
676 try:
677 result = await wire_fetch(session, repo.repo_id, want, have)
678 except MPackValidationError as exc:
679 raise HTTPException(status_code=422, detail=str(exc)) from exc
680 except FetchCommitNotFound as exc:
681 raise HTTPException(status_code=404, detail=str(exc)) from exc
682 except FetchNotReady as exc:
683 from fastapi.responses import Response as _Resp
684 body = msgpack.packb({"error": str(exc), "retry_after": 60}, use_bin_type=True)
685 return _Resp(
686 content=body,
687 status_code=503,
688 headers={"Retry-After": "60", "Content-Type": "application/x-msgpack"},
689 )
690
691 return _mpack_response(result, request)
692
693
694 @router.post(
695 "/{owner}/{slug}/fetch/mpack",
696 summary="Wire — single-mpack fetch (issue #47)",
697 status_code=status.HTTP_200_OK,
698 )
699 @limiter.limit(WIRE_FETCH_LIMIT)
700 async def fetch_mpack(
701 request: Request,
702 owner: SlugParam,
703 slug: SlugParam,
704 _claims: TokenClaims | None = Depends(optional_token),
705 session: AsyncSession = Depends(get_session),
706 ) -> Response:
707 """Return the fetch delta as a single content-addressed mpack.
708
709 Phase 2 protocol: server assembles the fetch mpack, stores it ephemerally
710 in MinIO, and returns a presigned GET URL. The client GETs the mpack
711 directly from MinIO, verifies sha256, then calls apply_mpack().
712
713 **Request body** (``Content-Type: application/x-msgpack``):
714 msgpack ``{"want": [str], "have": [str], "ttl_seconds": int}``
715
716 **Response body** (``Content-Type: application/x-msgpack``):
717 msgpack ``{"mpack_url": str|null, "mpack_id": str|null,
718 "commit_count": int, "blob_count": int}``
719
720 Returns 503 with ``Retry-After: 60`` if needed objects are not yet indexed
721 (mpack.index background job still running).
722 """
723 raw = await request.body()
724 data = _decode_request_body(raw, "application/x-msgpack")
725 ttl_seconds = int(data.pop("ttl_seconds", 3600))
726 want: list[str] = data.get("want") or []
727 have: list[str] = data.get("have") or []
728 repo = await _resolve_repo(session, owner, slug)
729 await _assert_readable(repo, _claims, session)
730 try:
731 result = await wire_fetch_mpack(session, repo.repo_id, want, have, ttl_seconds)
732 except MPackNotReadyError:
733 return Response(
734 content="mpack not ready — prebuild in progress. Retry shortly.",
735 status_code=503,
736 headers={"Retry-After": "30"},
737 )
738 except FetchNotIndexedError as exc:
739 return Response(
740 content=f"Objects not yet indexed: {exc.missing_count} missing. Retry shortly.",
741 status_code=503,
742 headers={"Retry-After": "60"},
743 )
744 return _mpack_response(result, request)
745
746
747 # ── release wire endpoints ─────────────────────────────────────────────────────
748
749 @router.post(
750 "/{owner}/{slug}/releases",
751 summary="Push a release from muse CLI",
752 status_code=status.HTTP_201_CREATED,
753 )
754 @limiter.limit(WIRE_PUSH_LIMIT)
755 async def wire_create_release(
756 request: Request,
757 owner: SlugParam,
758 slug: SlugParam,
759 background_tasks: BackgroundTasks,
760 claims: TokenClaims = Depends(require_valid_token),
761 session: AsyncSession = Depends(get_session),
762 ) -> Response:
763 """Accept a ``ReleaseDict`` payload from ``muse release push``.
764
765 The body must be a JSON object matching the CLI ``ReleaseRecord.to_dict()``
766 shape (application/json). Returns immediately with the server-assigned
767 ``release_id``; semantic analysis runs as a background task so the push
768 is never blocked by analysis time.
769
770 Only the repo owner or a write/admin collaborator may push releases —
771 the endpoint mirrors the auth model of ``POST /{owner}/{slug}/push``.
772 """
773 from musehub.services import musehub_releases as rel_svc
774 from musehub.services.release_analysis import analyse_release_background
775
776 repo = await _resolve_repo(session, owner, slug)
777 await _assert_writable(repo, claims, session)
778
779 raw = await request.body()
780 ct = request.headers.get("Content-Type", "")
781 data = _decode_request_body(raw, ct)
782
783 repo_id = repo.repo_id
784
785 try:
786 response = await rel_svc.create_release_from_dict(session, repo_id, data)
787 except ValueError as exc:
788 raise HTTPException(
789 status_code=status.HTTP_409_CONFLICT,
790 detail=str(exc),
791 )
792
793 await session.commit()
794 logger.info("✅ wire: release %s pushed for %s/%s", response.tag, owner, slug)
795
796 # Fire semantic analysis after the response is sent. Opens its own
797 # session so it is independent of the request lifecycle.
798 background_tasks.add_task(
799 analyse_release_background, repo_id, response.release_id
800 )
801
802 return _mpack_response({"release_id": response.release_id}, request)
803
804 @router.delete(
805 "/{owner}/{slug}/branches/{branch_name:path}",
806 summary="Delete a branch from MuseHub",
807 status_code=status.HTTP_200_OK,
808 )
809 @limiter.limit(WIRE_PUSH_LIMIT)
810 async def wire_delete_branch(
811 request: Request,
812 owner: SlugParam,
813 slug: SlugParam,
814 branch_name: str,
815 claims: TokenClaims = Depends(require_valid_token),
816 session: AsyncSession = Depends(get_session),
817 ) -> Response:
818 """Delete a branch pushed by ``muse push``.
819
820 Idiomatic equivalent of ``git push origin --delete <branch>``. The branch
821 ref is removed from MuseHub; commits and objects are unaffected.
822
823 Only the repo owner may delete branches. Attempting to delete the
824 repository's default branch is rejected with 409.
825 """
826 from sqlalchemy import delete as sql_delete
827
828 repo = await _resolve_repo(session, owner, slug)
829
830 caller_id: str | None = claims.handle
831 if caller_id != repo.owner:
832 raise HTTPException(
833 status_code=status.HTTP_403_FORBIDDEN,
834 detail="only the repo owner may delete branches",
835 )
836
837 default_branch: str = getattr(repo, "default_branch", None) or "main"
838 if branch_name == default_branch:
839 raise HTTPException(
840 status_code=status.HTTP_409_CONFLICT,
841 detail=f"cannot delete the default branch '{default_branch}'",
842 )
843
844 result = await session.execute(
845 sql_delete(MusehubBranch).where(
846 MusehubBranch.repo_id == repo.repo_id,
847 MusehubBranch.name == branch_name,
848 ).returning(MusehubBranch.name)
849 )
850 deleted_name: str | None = result.scalar_one_or_none()
851 if deleted_name is None:
852 raise HTTPException(
853 status_code=status.HTTP_404_NOT_FOUND,
854 detail=f"branch '{branch_name}' not found",
855 )
856
857 await session.commit()
858 logger.info("✅ wire: branch %s deleted from %s/%s", branch_name, owner, slug)
859 return _mpack_response({"deleted": branch_name}, request)
860
861 @router.delete(
862 "/{owner}/{slug}/releases/{tag:path}",
863 summary="Retract a release from MuseHub",
864 status_code=status.HTTP_200_OK,
865 )
866 @limiter.limit(WIRE_PUSH_LIMIT)
867 async def wire_delete_release(
868 request: Request,
869 owner: SlugParam,
870 slug: SlugParam,
871 tag: str,
872 claims: TokenClaims = Depends(require_valid_token),
873 session: AsyncSession = Depends(get_session),
874 ) -> Response:
875 """Retract a release pushed by ``muse release push``.
876
877 Removes the named release label from MuseHub. The underlying commits and
878 snapshots are not affected — they remain in the content-addressed object
879 store and are still reachable by their SHA-256.
880
881 Only the repo owner may retract releases.
882 """
883 from musehub.services import musehub_releases as rel_svc
884
885 repo = await _resolve_repo(session, owner, slug)
886
887 caller_id: str | None = claims.handle
888 if caller_id != repo.owner:
889 raise HTTPException(
890 status_code=status.HTTP_403_FORBIDDEN,
891 detail="only the repo owner may retract releases",
892 )
893
894 deleted = await rel_svc.delete_release_by_tag(session, repo.repo_id, tag)
895 if not deleted:
896 raise HTTPException(
897 status_code=status.HTTP_404_NOT_FOUND,
898 detail=f"release '{tag}' not found",
899 )
900
901 await session.commit()
902 logger.info("✅ wire: release %s retracted from %s/%s", tag, owner, slug)
903 return _mpack_response({"retracted": tag}, request)
904
905 # ── wire-tag endpoints ─────────────────────────────────────────────────────────
906
907 @router.post(
908 "/{owner}/{slug}/tags",
909 summary="Push lightweight wire tags from muse CLI",
910 status_code=status.HTTP_200_OK,
911 )
912 @limiter.limit(WIRE_PUSH_LIMIT)
913 async def wire_push_tags(
914 request: Request,
915 owner: SlugParam,
916 slug: SlugParam,
917 claims: TokenClaims = Depends(require_valid_token),
918 session: AsyncSession = Depends(get_session),
919 ) -> Response:
920 """Upsert a batch of lightweight semantic tags pushed from the Muse CLI.
921
922 The body must be a msgpack object with a ``tags`` key holding a list of
923 ``WireTag`` dicts. The server upserts them — pushing the same tag label
924 twice for the same repo is a no-op (``commit_id`` is refreshed).
925
926 Returns ``{"stored": <count>}`` as msgpack or JSON.
927 """
928 from musehub.models.musehub import WireTagInput
929 from musehub.services import musehub_wire_tags as tag_svc
930
931 repo = await _resolve_repo(session, owner, slug)
932 await _assert_writable(repo, claims, session)
933
934 raw = await request.body()
935 ct = request.headers.get("Content-Type", "")
936 try:
937 data = _decode_request_body(raw, ct)
938 except (ValueError, Exception):
939 raise HTTPException(
940 status_code=status.HTTP_400_BAD_REQUEST,
941 detail="Request body must be a valid msgpack or JSON object",
942 )
943
944 repo_id = repo.repo_id
945
946 tags_raw = data.get("tags", [])
947 if not isinstance(tags_raw, list):
948 raise HTTPException(
949 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
950 detail="'tags' must be a list",
951 )
952
953 tags: list[WireTagInput] = []
954 for item in tags_raw:
955 if not isinstance(item, dict):
956 continue
957 tag_id_raw = item.get("tag_id", "")
958 commit_id_raw = item.get("commit_id", "")
959 tag_label_raw = item.get("tag", "")
960 created_at_raw = item.get("created_at", "")
961 tags.append(
962 WireTagInput(
963 tag_id=str(tag_id_raw) if isinstance(tag_id_raw, str) else "",
964 commit_id=str(commit_id_raw) if isinstance(commit_id_raw, str) else "",
965 tag=str(tag_label_raw) if isinstance(tag_label_raw, str) else "",
966 created_at=str(created_at_raw) if isinstance(created_at_raw, str) else "",
967 )
968 )
969
970 stored = await tag_svc.store_wire_tags(session, repo_id, tags)
971 await session.commit()
972 logger.info("✅ wire: %d tag(s) pushed for %s/%s", stored, owner, slug)
973 return _mpack_response({"stored": stored}, request)
974
975 # ── version-tag endpoints ──────────────────────────────────────────────────────
976
977 @router.post(
978 "/{owner}/{slug}/version-tags",
979 summary="Push semantic-version tags from muse CLI",
980 status_code=status.HTTP_200_OK,
981 )
982 @limiter.limit(WIRE_PUSH_LIMIT)
983 async def wire_push_version_tags(
984 request: Request,
985 owner: SlugParam,
986 slug: SlugParam,
987 force: bool = False,
988 claims: TokenClaims = Depends(require_valid_token),
989 session: AsyncSession = Depends(get_session),
990 ) -> Response:
991 """Upsert a batch of semantic-version tags pushed from the Muse CLI.
992
993 The body must be a msgpack object with a ``tags`` key holding a list of
994 version-tag dicts. Re-pushing the same tag label without ``force=true``
995 is a no-op if the commit_id is unchanged (skipped). With ``force=true``
996 the commit_id is overwritten.
997
998 Returns ``{"stored": <count>, "skipped": <count>}`` as msgpack or JSON.
999 Returns 409 if a tag already exists with a different commit_id and
1000 ``force`` is not set.
1001 """
1002 from musehub.services import musehub_version_tags as vtag_svc
1003
1004 repo = await _resolve_repo(session, owner, slug)
1005 await _assert_writable(repo, claims, session)
1006
1007 raw = await request.body()
1008 ct = request.headers.get("Content-Type", "")
1009 try:
1010 data = _decode_request_body(raw, ct)
1011 except (ValueError, Exception):
1012 raise HTTPException(
1013 status_code=status.HTTP_400_BAD_REQUEST,
1014 detail="Request body must be a valid msgpack or JSON object",
1015 )
1016
1017 repo_id = repo.repo_id
1018
1019 tags_raw = data.get("tags", [])
1020 if not isinstance(tags_raw, list):
1021 raise HTTPException(
1022 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
1023 detail="'tags' must be a list",
1024 )
1025
1026 tags: list[dict] = []
1027 for item in tags_raw:
1028 if not isinstance(item, dict):
1029 continue
1030 tags.append(item)
1031
1032 # Without force, detect conflicts (same tag label, different commit_id) first.
1033 if not force:
1034 from sqlalchemy import select as sa_select
1035 from musehub.db.musehub_repo_models import MusehubVersionTag as _VTag
1036 for item in tags:
1037 tag_label = str(item.get("tag", "")).strip()
1038 commit_id = str(item.get("commit_id", "")).strip()
1039 if not tag_label or not commit_id:
1040 continue
1041 existing = await session.scalar(
1042 sa_select(_VTag).where(
1043 _VTag.repo_id == repo_id,
1044 _VTag.tag == tag_label,
1045 ).limit(1)
1046 )
1047 if existing is not None and existing.commit_id != commit_id:
1048 raise HTTPException(
1049 status_code=status.HTTP_409_CONFLICT,
1050 detail=f"Tag '{tag_label}' already points to a different commit. Use force=true to override.",
1051 )
1052
1053 stored, skipped = await vtag_svc.store_version_tags(session, repo_id, tags, force=force)
1054 await session.commit()
1055 logger.info(
1056 "✅ wire: %d version tag(s) stored, %d skipped for %s/%s",
1057 stored, skipped, owner, slug,
1058 )
1059 return _mpack_response({"stored": stored, "skipped": skipped}, request)
1060
1061
1062 @router.get(
1063 "/{owner}/{slug}/version-tags",
1064 summary="Fetch all version tags for a repo",
1065 status_code=status.HTTP_200_OK,
1066 )
1067 @limiter.limit(OBJECT_LIMIT)
1068 async def wire_get_version_tags(
1069 request: Request,
1070 owner: SlugParam,
1071 slug: SlugParam,
1072 _claims: TokenClaims = Depends(require_valid_token),
1073 session: AsyncSession = Depends(get_session),
1074 ) -> Response:
1075 """Return all semantic-version tags stored for a repo.
1076
1077 Returns ``{"tags": [...]}`` as msgpack or JSON, with tags ordered by
1078 semver descending (newest first).
1079 """
1080 from musehub.services import musehub_version_tags as vtag_svc
1081
1082 repo_id = await _resolve_repo_id(session, owner, slug)
1083 tags = await vtag_svc.list_version_tags(session, repo_id)
1084 logger.info("✅ wire: returning %d version tag(s) for %s/%s", len(tags), owner, slug)
1085 return _mpack_response({"tags": tags}, request)
1086
1087 # ── content-addressed CDN ──────────────────────────────────────────────────────
1088
1089 @router.get(
1090 "/o/{object_id:path}",
1091 summary="Content-addressed object CDN endpoint",
1092 response_description="Raw binary blob",
1093 tags=["Objects"],
1094 )
1095 @limiter.limit(OBJECT_LIMIT)
1096 async def get_object(
1097 request: Request,
1098 object_id: str,
1099 repo_id: str | None = None,
1100 _claims: TokenClaims | None = Depends(optional_token),
1101 session: AsyncSession = Depends(get_session),
1102 ) -> Response:
1103 """Serve a content-addressed binary object.
1104
1105 Objects are immutable (ID is derived from content hash), so the response
1106 carries ``Cache-Control: max-age=31536000, immutable`` — safe to place
1107 behind CloudFront forever.
1108 """
1109 from musehub.storage.backends import read_object_bytes
1110 backend = get_backend()
1111 try:
1112 raw = await backend.get(object_id)
1113 except ValueError:
1114 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid object path")
1115 if raw is None:
1116 obj_row = await session.scalar(
1117 select(MusehubObject).where(MusehubObject.object_id == object_id).limit(1)
1118 )
1119 if obj_row is not None:
1120 raw = await read_object_bytes(obj_row, session=session)
1121 if raw is None:
1122 logger.error(
1123 "OBJECT_404 object_id=%s repo_id=%s backend=%s",
1124 object_id,
1125 repo_id,
1126 type(get_backend()).__name__,
1127 )
1128 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="object not found")
1129
1130 # Integrity check: verify stored bytes match the content-addressed ID.
1131 import hashlib as _hashlib
1132 actual_hash = _hashlib.sha256(raw).hexdigest()
1133 expected_hex = object_id.removeprefix("sha256:")
1134 if actual_hash != expected_hex:
1135 logger.error(
1136 "OBJECT_CORRUPT object_id=%s actual_hash=sha256:%s size=%d backend=%s",
1137 object_id,
1138 actual_hash,
1139 len(raw),
1140 type(get_backend()).__name__,
1141 )
1142
1143 return Response(
1144 content=raw,
1145 media_type="application/octet-stream",
1146 headers={
1147 "Cache-Control": "public, max-age=31536000, immutable",
1148 "ETag": f'"{object_id}"',
1149 },
1150 )
1151
File History 4 commits
sha256:9c9cffc9f53400a171f808053cb3fc68eb1da1c44a329b5253a0515905db51fb Merge 'security/131-wire-write-authz' into 'dev' — proposal… Human 10 days ago
sha256:21657e37428e6b2f840caa01cf2e3d17ce86ba194561edc22b330057ea7b9886 Merge 'infra/staging-rds-encrypt-at-rest' into 'dev' — prop… Human 11 days ago
sha256:3fadb0439bba9451b89229676971c0d4a40900dec7810e9d5f8791b8d950d505 fix: install.sh version from latest published tarball, not … Sonnet 4.6 minor 113 days ago
sha256:763eb2cb8675073b84c19345b27586d2ed939a9aee97c5479b69f502f1a70eff fix(tests): update test suite to match current implementation Sonnet 4.6 patch 135 days ago