8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
585 lines
19 KiB
Python
585 lines
19 KiB
Python
"""Typed HTTP boundary for G7 multimodal alliance ledgers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from collections.abc import AsyncIterator
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Annotated, Any, Literal
|
|
from urllib.parse import unquote, urlsplit
|
|
from uuid import UUID
|
|
|
|
import asyncpg
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
from fastapi.responses import FileResponse
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
from ..config import Settings, get_settings
|
|
from ..contracts.multimodal_alliance import (
|
|
AlignedVoiceTimeline,
|
|
CalibratedAxisReadModel,
|
|
FusionCalibration,
|
|
ModalityAxisMeasurement,
|
|
)
|
|
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
|
from ..services import multimodal_alliance_store
|
|
|
|
|
|
router = APIRouter(tags=["multimodal-alliance"])
|
|
INTERNAL_TOKEN_HEADER = "X-Vignette-Multimodal-Token"
|
|
MIN_INTERNAL_TOKEN_LENGTH = 32
|
|
_evaluator_db_provider = db_for_ai_view(AIView.EVALUATOR)
|
|
|
|
|
|
def _authenticate_internal(settings: Settings, presented_token: str | None) -> None:
|
|
configured_token = settings.multimodal_alliance_internal_token.get_secret_value()
|
|
if len(configured_token) < MIN_INTERNAL_TOKEN_LENGTH:
|
|
raise HTTPException(
|
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="internal multimodal ingestion is unavailable",
|
|
)
|
|
if presented_token is None:
|
|
raise HTTPException(
|
|
status.HTTP_401_UNAUTHORIZED,
|
|
detail="internal authentication required",
|
|
)
|
|
if not secrets.compare_digest(presented_token, configured_token):
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN,
|
|
detail="internal authentication failed",
|
|
)
|
|
|
|
|
|
async def multimodal_internal_evaluator_db(
|
|
settings: Annotated[Settings, Depends(get_settings)],
|
|
presented_token: Annotated[
|
|
str | None,
|
|
Header(alias=INTERNAL_TOKEN_HEADER),
|
|
] = None,
|
|
) -> AsyncIterator[asyncpg.Connection]:
|
|
"""Authenticate before acquiring a connection or applying evaluator RLS."""
|
|
|
|
_authenticate_internal(settings, presented_token)
|
|
async for conn in _evaluator_db_provider():
|
|
yield conn
|
|
|
|
|
|
InternalDB = Annotated[
|
|
asyncpg.Connection,
|
|
Depends(multimodal_internal_evaluator_db),
|
|
]
|
|
LearnerPrincipal = Annotated[Principal, Depends(require_role(Role.LEARNER))]
|
|
HumanPrincipal = Annotated[
|
|
Principal,
|
|
Depends(require_role(Role.LEARNER, Role.TEACHER, Role.ADMIN)),
|
|
]
|
|
RawAudioPrincipal = Annotated[
|
|
Principal,
|
|
Depends(require_role(Role.LEARNER, Role.ADMIN)),
|
|
]
|
|
|
|
|
|
class MultimodalConsentRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
submission_id: UUID
|
|
consent_status: Literal["granted", "withdrawn", "not_granted"]
|
|
retain_audio: bool = False
|
|
retain_derived_features: bool = False
|
|
transcript_retained: Literal[True] = True
|
|
retention_days: int | None = Field(default=None, ge=1, le=3650)
|
|
policy_version: str = Field(min_length=1, max_length=80)
|
|
reason_code: str | None = Field(default=None, min_length=1, max_length=120)
|
|
|
|
@model_validator(mode="after")
|
|
def preserve_consent_truth(self) -> "MultimodalConsentRequest":
|
|
if self.consent_status == "granted":
|
|
if not self.retain_derived_features or self.retention_days is None:
|
|
raise ValueError(
|
|
"granted consent requires derived retention and expiry"
|
|
)
|
|
elif (
|
|
self.retain_audio
|
|
or self.retain_derived_features
|
|
or self.retention_days is not None
|
|
):
|
|
raise ValueError("ungranted consent cannot retain voice material")
|
|
return self
|
|
|
|
|
|
class MultimodalConsentResponse(BaseModel):
|
|
submission_id: UUID
|
|
consent_snapshot_id: UUID
|
|
consent_status: Literal["granted", "withdrawn", "not_granted"]
|
|
deletion_request_id: UUID | None = None
|
|
idempotent_replay: bool
|
|
|
|
|
|
class MultimodalWithdrawalRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
submission_id: UUID
|
|
policy_version: str = Field(min_length=1, max_length=80)
|
|
reason_code: str = Field(default="learner_withdrawal", min_length=1, max_length=120)
|
|
transcript_retained: Literal[True] = True
|
|
|
|
|
|
class AudioAssetMetadata(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
audio_ref: str = Field(min_length=1, max_length=300)
|
|
audio_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
media_type: Literal[
|
|
"audio/wav", "audio/webm", "audio/ogg", "audio/mpeg", "audio/mp4"
|
|
]
|
|
byte_size: int = Field(gt=0, le=524_288_000)
|
|
|
|
|
|
class MultimodalTimelineRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
submission_id: UUID
|
|
timeline: AlignedVoiceTimeline
|
|
audio_asset: AudioAssetMetadata | None = None
|
|
|
|
|
|
class MultimodalTimelineResponse(BaseModel):
|
|
submission_id: UUID
|
|
timeline_id: UUID
|
|
audio_asset_id: UUID | None = None
|
|
idempotent_replay: bool
|
|
|
|
|
|
class MeasurementProvenance(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", protected_namespaces=())
|
|
|
|
instrument_id: str = Field(min_length=1, max_length=120)
|
|
instrument_version: str = Field(min_length=1, max_length=80)
|
|
model_name: str = Field(min_length=1, max_length=160)
|
|
prompt_version: str = Field(min_length=1, max_length=80)
|
|
|
|
|
|
class MultimodalMeasurementFusionRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
submission_id: UUID
|
|
text_measurement: ModalityAxisMeasurement
|
|
text_provenance: MeasurementProvenance
|
|
voice_measurement: ModalityAxisMeasurement
|
|
voice_provenance: MeasurementProvenance
|
|
calibration: FusionCalibration
|
|
|
|
@model_validator(mode="after")
|
|
def keep_modalities_independent(self) -> "MultimodalMeasurementFusionRequest":
|
|
if self.text_measurement.modality != "text":
|
|
raise ValueError("text_measurement must use text modality")
|
|
if self.voice_measurement.modality != "voice":
|
|
raise ValueError("voice_measurement must use voice modality")
|
|
return self
|
|
|
|
|
|
class MultimodalMeasurementFusionResponse(BaseModel):
|
|
submission_id: UUID
|
|
fusion_record_id: UUID
|
|
result: CalibratedAxisReadModel
|
|
idempotent_replay: bool
|
|
|
|
|
|
class MultimodalDeletionRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
submission_id: UUID
|
|
scopes: list[Literal["audio", "derived_features"]] = Field(
|
|
min_length=1, max_length=2
|
|
)
|
|
|
|
@field_validator("scopes")
|
|
@classmethod
|
|
def unique_scopes(cls, value: list[str]) -> list[str]:
|
|
if len(set(value)) != len(value):
|
|
raise ValueError("deletion scopes must be unique")
|
|
return value
|
|
|
|
|
|
class MultimodalDeletionRequestResponse(BaseModel):
|
|
submission_id: UUID
|
|
deletion_request_id: UUID
|
|
idempotent_replay: bool
|
|
|
|
|
|
class DeletionTombstoneInput(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
scope: Literal["audio", "derived_features"]
|
|
target_ref_hash: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
deletion_proof: str = Field(min_length=1, max_length=300)
|
|
deleted_at: datetime
|
|
|
|
|
|
class MultimodalDeletionCompletionRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
submission_id: UUID
|
|
actor_uid: UUID | None = None
|
|
actor_kind: Literal["retention_worker", "admin"]
|
|
tombstones: list[DeletionTombstoneInput] = Field(min_length=1, max_length=2)
|
|
|
|
@field_validator("tombstones")
|
|
@classmethod
|
|
def unique_tombstone_scopes(
|
|
cls, value: list[DeletionTombstoneInput]
|
|
) -> list[DeletionTombstoneInput]:
|
|
if len({item.scope for item in value}) != len(value):
|
|
raise ValueError("tombstone scopes must be unique")
|
|
return value
|
|
|
|
|
|
class MultimodalDeletionCompletionResponse(BaseModel):
|
|
submission_id: UUID
|
|
deletion_request_id: UUID
|
|
tombstone_ids: list[UUID] = Field(min_length=1)
|
|
idempotent_replay: bool
|
|
|
|
|
|
class MultimodalRetentionSweepRequest(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
limit: int = Field(default=100, ge=1, le=100)
|
|
|
|
|
|
class MultimodalRetentionSweepItem(BaseModel):
|
|
audio_asset_id: UUID
|
|
submission_id: UUID
|
|
deletion_request_id: UUID
|
|
idempotent_replay: bool
|
|
|
|
|
|
class MultimodalRetentionSweepResponse(BaseModel):
|
|
items: list[MultimodalRetentionSweepItem]
|
|
|
|
|
|
class MultimodalSessionMetadataResponse(BaseModel):
|
|
model_config = ConfigDict(protected_namespaces=())
|
|
|
|
session_id: UUID
|
|
learner_id: UUID
|
|
clinical_claim_allowed: Literal[False]
|
|
consent_snapshots: list[dict[str, Any]]
|
|
timelines: list[dict[str, Any]]
|
|
word_timestamps: list[dict[str, Any]]
|
|
voice_events: list[dict[str, Any]]
|
|
measurements: list[dict[str, Any]]
|
|
fusion_decisions: list[dict[str, Any]]
|
|
deletion_requests: list[dict[str, Any]]
|
|
|
|
|
|
class RawAudioAssetResponse(BaseModel):
|
|
"""Browser-safe raw-audio metadata; the private storage handle never crosses HTTP."""
|
|
|
|
model_config = ConfigDict(extra="ignore")
|
|
|
|
audio_asset_id: UUID
|
|
session_id: UUID
|
|
media_type: str = Field(min_length=1, max_length=120)
|
|
byte_size: int = Field(ge=0)
|
|
duration_ms: int = Field(ge=0)
|
|
retained_until: datetime
|
|
created_at: datetime
|
|
|
|
|
|
class RawAudioAccessResponse(BaseModel):
|
|
items: list[RawAudioAssetResponse]
|
|
|
|
|
|
def _raw_audio_storage_root(settings: Settings) -> Path:
|
|
root = Path(settings.user_upload_dir)
|
|
if not root.is_absolute():
|
|
root = Path.cwd() / root
|
|
return (root / "multimodal-audio").resolve()
|
|
|
|
|
|
def _resolve_private_audio_ref(settings: Settings, audio_ref: str) -> Path:
|
|
"""Resolve a private storage handle without exposing or escaping its root."""
|
|
|
|
parsed = urlsplit(audio_ref)
|
|
if parsed.scheme != "private" or parsed.query or parsed.fragment:
|
|
raise HTTPException(
|
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
detail="raw audio storage adapter unavailable",
|
|
)
|
|
relative = unquote(f"{parsed.netloc}{parsed.path}").replace("\\", "/").lstrip("/")
|
|
if not relative:
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND,
|
|
detail="raw audio object not found",
|
|
)
|
|
root = _raw_audio_storage_root(settings)
|
|
candidate = (root / relative).resolve()
|
|
try:
|
|
candidate.relative_to(root)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND,
|
|
detail="raw audio object not found",
|
|
) from exc
|
|
if not candidate.is_file():
|
|
raise HTTPException(
|
|
status.HTTP_404_NOT_FOUND,
|
|
detail="raw audio object not found",
|
|
)
|
|
return candidate
|
|
|
|
|
|
def _http_error(exc: Exception) -> HTTPException:
|
|
if isinstance(exc, multimodal_alliance_store.MultimodalAllianceNotFoundError):
|
|
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
|
if isinstance(exc, multimodal_alliance_store.MultimodalAllianceConflictError):
|
|
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
|
if isinstance(
|
|
exc,
|
|
(
|
|
multimodal_alliance_store.MultimodalConsentRequiredError,
|
|
multimodal_alliance_store.MultimodalConsentWithdrawnError,
|
|
),
|
|
):
|
|
return HTTPException(status.HTTP_409_CONFLICT, detail=str(exc))
|
|
if isinstance(exc, multimodal_alliance_store.MultimodalAllianceStateError):
|
|
return HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
|
raise exc
|
|
|
|
|
|
@router.post(
|
|
"/sessions/{session_id}/multimodal-alliance/consent",
|
|
response_model=MultimodalConsentResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_multimodal_consent(
|
|
session_id: UUID,
|
|
body: MultimodalConsentRequest,
|
|
principal: LearnerPrincipal,
|
|
) -> MultimodalConsentResponse:
|
|
try:
|
|
payload = await multimodal_alliance_store.append_consent_snapshot(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
**body.model_dump(),
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return MultimodalConsentResponse.model_validate(payload)
|
|
|
|
|
|
@router.post(
|
|
"/sessions/{session_id}/multimodal-alliance/withdraw",
|
|
response_model=MultimodalConsentResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def withdraw_multimodal_consent(
|
|
session_id: UUID,
|
|
body: MultimodalWithdrawalRequest,
|
|
principal: LearnerPrincipal,
|
|
) -> MultimodalConsentResponse:
|
|
try:
|
|
payload = await multimodal_alliance_store.append_consent_snapshot(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
submission_id=body.submission_id,
|
|
consent_status="withdrawn",
|
|
retain_audio=False,
|
|
retain_derived_features=False,
|
|
transcript_retained=body.transcript_retained,
|
|
retention_days=None,
|
|
policy_version=body.policy_version,
|
|
reason_code=body.reason_code,
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return MultimodalConsentResponse.model_validate(payload)
|
|
|
|
|
|
@router.post(
|
|
"/internal/sessions/{session_id}/multimodal-alliance/timelines",
|
|
response_model=MultimodalTimelineResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_multimodal_timeline(
|
|
session_id: UUID,
|
|
body: MultimodalTimelineRequest,
|
|
conn: InternalDB,
|
|
) -> MultimodalTimelineResponse:
|
|
try:
|
|
payload = await multimodal_alliance_store.append_timeline(
|
|
conn=conn,
|
|
session_id=session_id,
|
|
submission_id=body.submission_id,
|
|
timeline=body.timeline,
|
|
audio_asset=(
|
|
body.audio_asset.model_dump() if body.audio_asset is not None else None
|
|
),
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return MultimodalTimelineResponse.model_validate(payload)
|
|
|
|
|
|
@router.post(
|
|
"/internal/sessions/{session_id}/multimodal-alliance/measurements",
|
|
response_model=MultimodalMeasurementFusionResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_multimodal_measurement_fusion(
|
|
session_id: UUID,
|
|
body: MultimodalMeasurementFusionRequest,
|
|
conn: InternalDB,
|
|
) -> MultimodalMeasurementFusionResponse:
|
|
try:
|
|
payload = await multimodal_alliance_store.append_measurement_fusion(
|
|
conn=conn,
|
|
session_id=session_id,
|
|
submission_id=body.submission_id,
|
|
text=body.text_measurement,
|
|
voice=body.voice_measurement,
|
|
calibration=body.calibration,
|
|
text_provenance=body.text_provenance.model_dump(),
|
|
voice_provenance=body.voice_provenance.model_dump(),
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return MultimodalMeasurementFusionResponse.model_validate(payload)
|
|
|
|
|
|
@router.post(
|
|
"/sessions/{session_id}/multimodal-alliance/deletion-requests",
|
|
response_model=MultimodalDeletionRequestResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def request_multimodal_deletion(
|
|
session_id: UUID,
|
|
body: MultimodalDeletionRequest,
|
|
principal: RawAudioPrincipal,
|
|
) -> MultimodalDeletionRequestResponse:
|
|
try:
|
|
payload = await multimodal_alliance_store.append_deletion_request(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
submission_id=body.submission_id,
|
|
scopes=body.scopes,
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return MultimodalDeletionRequestResponse.model_validate(payload)
|
|
|
|
|
|
@router.post(
|
|
"/internal/multimodal-alliance/deletion-requests/{deletion_request_id}/complete",
|
|
response_model=MultimodalDeletionCompletionResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def complete_multimodal_deletion(
|
|
deletion_request_id: UUID,
|
|
body: MultimodalDeletionCompletionRequest,
|
|
conn: InternalDB,
|
|
) -> MultimodalDeletionCompletionResponse:
|
|
try:
|
|
payload = await multimodal_alliance_store.complete_deletion(
|
|
conn=conn,
|
|
deletion_request_id=deletion_request_id,
|
|
submission_id=body.submission_id,
|
|
tombstones=[item.model_dump() for item in body.tombstones],
|
|
actor_uid=body.actor_uid,
|
|
actor_kind=body.actor_kind,
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return MultimodalDeletionCompletionResponse.model_validate(payload)
|
|
|
|
|
|
@router.post(
|
|
"/internal/multimodal-alliance/retention/sweep",
|
|
response_model=MultimodalRetentionSweepResponse,
|
|
)
|
|
async def sweep_multimodal_retention(
|
|
body: MultimodalRetentionSweepRequest,
|
|
conn: InternalDB,
|
|
) -> MultimodalRetentionSweepResponse:
|
|
try:
|
|
items = await multimodal_alliance_store.request_expired_retention_deletions(
|
|
conn=conn,
|
|
as_of=datetime.now(UTC),
|
|
limit=body.limit,
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return MultimodalRetentionSweepResponse.model_validate({"items": items})
|
|
|
|
|
|
@router.get(
|
|
"/sessions/{session_id}/multimodal-alliance",
|
|
response_model=MultimodalSessionMetadataResponse,
|
|
)
|
|
async def get_multimodal_session_metadata(
|
|
session_id: UUID,
|
|
principal: HumanPrincipal,
|
|
) -> MultimodalSessionMetadataResponse:
|
|
try:
|
|
payload = await multimodal_alliance_store.read_session_metadata(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return MultimodalSessionMetadataResponse.model_validate(payload)
|
|
|
|
|
|
@router.get(
|
|
"/sessions/{session_id}/multimodal-alliance/raw-audio",
|
|
response_model=RawAudioAccessResponse,
|
|
)
|
|
async def get_multimodal_raw_audio_access(
|
|
session_id: UUID,
|
|
principal: RawAudioPrincipal,
|
|
) -> RawAudioAccessResponse:
|
|
try:
|
|
items = await multimodal_alliance_store.read_raw_audio_access(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
return RawAudioAccessResponse(items=items)
|
|
|
|
|
|
@router.get(
|
|
"/sessions/{session_id}/multimodal-alliance/raw-audio/{audio_asset_id}",
|
|
response_class=FileResponse,
|
|
)
|
|
async def play_multimodal_raw_audio(
|
|
session_id: UUID,
|
|
audio_asset_id: UUID,
|
|
settings: Annotated[Settings, Depends(get_settings)],
|
|
principal: RawAudioPrincipal,
|
|
) -> FileResponse:
|
|
"""Stream a retained object through the authenticated API; never reveal its handle."""
|
|
|
|
try:
|
|
asset = await multimodal_alliance_store.read_raw_audio_asset(
|
|
principal=principal,
|
|
session_id=session_id,
|
|
audio_asset_id=audio_asset_id,
|
|
)
|
|
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
|
raise _http_error(exc) from exc
|
|
audio_path = _resolve_private_audio_ref(settings, str(asset["audio_ref"]))
|
|
return FileResponse(
|
|
path=audio_path,
|
|
media_type=str(asset["media_type"]),
|
|
headers={
|
|
"Cache-Control": "private, no-store",
|
|
"Content-Disposition": "inline",
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
)
|
|
|
|
|
|
__all__ = ["router"]
|