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