feat: add the TechLog asset multipart upload transport

Wires the whole Asset capability into the running application: the
multipart upload transport (the contract runtime can only express JSON
bodies), a single composition-root-owned CSRF provider shared between
the platform's credential collaborator (18 JSON operations) and the
upload transport (1 multipart operation), and Studio/StudioShell
exposure of the Asset gateway alongside the existing document gateway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 02:39:51 +09:00
co-authored by Claude Opus 5
parent d9c2d8bc5e
commit c9c832c365
15 changed files with 473 additions and 3 deletions
@@ -7,6 +7,7 @@ import {
} from "react";
import { isStudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
import type {
@@ -25,6 +26,9 @@ import { useBeforeUnload } from "./components/use-before-unload.ts";
type StudioProviderProps = Readonly<{
children: ReactNode;
createGateway: () => StudioGateway;
// Optional so test harnesses that only exercise the document gateway keep
// working unchanged. `StudioShell` always supplies one in the running app.
createAssetGateway?: () => StudioAssetGateway;
resolvePublishedLabel?: ResolvePublishedLabel;
now?: () => Date;
navigate?: (href: string) => void;
@@ -48,11 +52,18 @@ const missingPublishedLabel: ResolvePublishedLabel = () => undefined;
export function StudioProvider({
children,
createGateway,
createAssetGateway,
resolvePublishedLabel = missingPublishedLabel,
now = () => new Date("2026-08-14T01:00:00.000Z"),
navigate = defaultNavigate,
}: StudioProviderProps) {
const [gateway] = useState<StudioGateway>(() => createGateway());
// Same lazy, called-once-per-mount pattern as `gateway`. Both are keyed to
// the same provider generation in `StudioShell`, so a persisted `pageshow`
// remount recreates them together — never one without the other.
const [assetGateway] = useState<StudioAssetGateway | null>(
() => createAssetGateway?.() ?? null,
);
const [editor, setEditor] = useState<StudioEditorState | null>(null);
const [pendingHref, setPendingHref] = useState<string | null>(null);
const [requestAnnouncement, setRequestAnnouncement] = useState("");
@@ -148,6 +159,7 @@ export function StudioProvider({
const value = useMemo<StudioContextValue>(
() => ({
gateway,
assetGateway,
resolvePublishedLabel,
now,
editor,
@@ -160,6 +172,7 @@ export function StudioProvider({
clearEditor,
}),
[
assetGateway,
beginEditor,
clearEditor,
editor,
@@ -47,6 +47,10 @@ export function StudioShell({ children }: StudioShellProps) {
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioGateway(),
[application],
);
const createAssetGateway = useCallback(
() => application.features.get(TECH_LOG_FEATURE_ID).createStudioAssetGateway(),
[application],
);
const resolvePublishedLabel = useCallback(
(path: string) =>
application.features
@@ -69,6 +73,7 @@ export function StudioShell({ children }: StudioShellProps) {
<StudioProvider
key={generation}
createGateway={createGateway}
createAssetGateway={createAssetGateway}
resolvePublishedLabel={resolvePublishedLabel}
navigate={navigateInternal}
>
@@ -1,5 +1,6 @@
import { createContext, useContext, useMemo } from "react";
import type { StudioAssetGateway } from "../../application/ports/studio-asset-gateway.ts";
import type { StudioGateway } from "../../application/ports/studio-gateway.ts";
import type { ResolvePublishedLabel } from "../../domain/public-render-content.ts";
import type {
@@ -18,6 +19,10 @@ export type StudioEditorState = Readonly<{
export type StudioContextValue = Readonly<{
gateway: StudioGateway;
// `null` only in test harnesses that render `StudioProvider` without an
// `createAssetGateway` prop. `StudioShell` — the real app path — always
// supplies one, so production code sees this populated.
assetGateway: StudioAssetGateway | null;
resolvePublishedLabel: ResolvePublishedLabel;
now(): Date;
editor: StudioEditorState | null;