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:
co-authored by
Claude Opus 5
parent
d9c2d8bc5e
commit
c9c832c365
@@ -27,6 +27,8 @@ import { createConditionalValidatorStore } from "../adapters/query-cache/conditi
|
||||
import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.ts";
|
||||
import { createBrowserMutationIntentFactory } from "../adapters/platform/browser-mutation-intent-factory.ts";
|
||||
import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.ts";
|
||||
import { createCsrfTokenProvider } from "../features/tech-log/adapters/http/studio-session-csrf.ts";
|
||||
import type { StudioOperationExecutor as TechLogStudioOperationExecutor } from "../features/tech-log/adapters/http/http-studio-gateway.ts";
|
||||
import type { AuthSessionPort } from "../application/ports/auth-session-port.ts";
|
||||
import type { ReleaseInfo } from "../application/ports/release-info-port.ts";
|
||||
import {
|
||||
@@ -420,6 +422,43 @@ export async function createRuntimeAdapters(
|
||||
location.reload();
|
||||
},
|
||||
});
|
||||
/**
|
||||
* §7.7 / Task 7. There is exactly one CSRF provider per Studio session, and
|
||||
* it is owned by the composition root — not by the TechLog feature input —
|
||||
* because two collaborators share it: `attachCredentials` below (the only
|
||||
* path by which `x-csrf-token` reaches the 18 JSON operations) and the
|
||||
* multipart upload transport, which bypasses the platform executor
|
||||
* entirely and must set the header itself. If each built its own provider,
|
||||
* one Studio session would hold two different tokens.
|
||||
*
|
||||
* `execute` calls `getStudioSession` through `contractOperations`, which is
|
||||
* declared further below — a real ordering hazard, since `attachCredentials`
|
||||
* (needed to build `contractHttp`, needed to build `contractOperations`)
|
||||
* needs this provider first. `getStudioSession` is a SAFE operation and
|
||||
* needs no CSRF itself, so there is no true cycle: the callback below only
|
||||
* *runs* once the whole runtime is composed and a Studio request is made,
|
||||
* by which point `contractOperations` is assigned. `let` plus a forward
|
||||
* reference inside this closure defers the read to call time instead of
|
||||
* declaration time.
|
||||
*/
|
||||
let contractOperations!: TechLogStudioOperationExecutor;
|
||||
const techLogCsrf = createCsrfTokenProvider({
|
||||
async execute(options) {
|
||||
const outcome = await contractOperations.execute(
|
||||
"getStudioSession",
|
||||
{},
|
||||
{
|
||||
routeId: "TECH_LOG_STUDIO",
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
},
|
||||
);
|
||||
if (outcome.kind !== "SUCCESS") {
|
||||
throw new Error("studio session is unavailable");
|
||||
}
|
||||
const value = outcome.value as { csrfToken: string; csrfHeaderName: string };
|
||||
return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName };
|
||||
},
|
||||
});
|
||||
const contractHttp = createContractHttpExecutor({
|
||||
baseUrl: config.API_BASE_URL,
|
||||
maxRetryAttempts: config.MAX_RETRY_ATTEMPTS,
|
||||
@@ -436,6 +475,25 @@ export async function createRuntimeAdapters(
|
||||
if (serverStateScope.getPhase() !== "READY") {
|
||||
return Object.freeze({ kind: "SCOPE_FENCED" as const });
|
||||
}
|
||||
if (operation.authProfileId === "TECH_LOG_STUDIO_SESSION") {
|
||||
// Studio authenticates with a session cookie and carries only the
|
||||
// CSRF token as a proof header. Read operations use this profile too
|
||||
// — the server does not require the header for them — so a failure
|
||||
// to fetch the token fails only this one request (`UNAVAILABLE`) and
|
||||
// is not promoted to a session-level `UNAUTHENTICATED`, which would
|
||||
// trigger a global re-authentication flow the session itself did not
|
||||
// warrant.
|
||||
try {
|
||||
return Object.freeze({
|
||||
kind: "READY" as const,
|
||||
headers: Object.freeze({
|
||||
"x-csrf-token": await techLogCsrf.token({ signal: authContext.signal }),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
}
|
||||
}
|
||||
const state = authSession.getState();
|
||||
if (state === "integration-failed") {
|
||||
return Object.freeze({ kind: "UNAVAILABLE" as const });
|
||||
@@ -465,7 +523,7 @@ export async function createRuntimeAdapters(
|
||||
},
|
||||
observe: createHttpObservationProjector({ diagnostics, telemetry }),
|
||||
});
|
||||
const contractOperations = Object.freeze({
|
||||
contractOperations = Object.freeze({
|
||||
async execute(
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
@@ -499,6 +557,11 @@ export async function createRuntimeAdapters(
|
||||
});
|
||||
if (outcome.kind === "UNAUTHENTICATED") {
|
||||
authSession.onUnauthenticated();
|
||||
// The Studio session (and the CSRF token it issued) expired. The
|
||||
// cache owner discards it here, not the gateway — the gateway has no
|
||||
// way to know a 401 on one operation invalidates a token shared by
|
||||
// every other in-flight and future Studio request.
|
||||
techLogCsrf.invalidate();
|
||||
}
|
||||
return outcome;
|
||||
},
|
||||
@@ -506,6 +569,9 @@ export async function createRuntimeAdapters(
|
||||
const featureInputs = createInstalledFeatureInputs({
|
||||
contractOperations,
|
||||
studioSource: config.TECH_LOG_STUDIO_SOURCE,
|
||||
apiBaseUrl: config.API_BASE_URL,
|
||||
requestTimeoutMs: config.REQUEST_TIMEOUT_MS,
|
||||
csrf: techLogCsrf,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ApplicationFeatureInputs } from "../application/ports/in/applicati
|
||||
import { createReferenceFeatureInstalledInput } from "./reference-feature/adapters/create-reference-feature-input.ts";
|
||||
import { REFERENCE_FEATURE_ID } from "./reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { createTechLogFeatureInstalledInput } from "./tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import type { CsrfTokenProvider } from "./tech-log/adapters/http/studio-session-csrf.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "./tech-log/application/tech-log-feature-input.ts";
|
||||
import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts";
|
||||
|
||||
@@ -20,7 +21,12 @@ type InstalledFeatureInputs = Readonly<
|
||||
|
||||
export function createInstalledFeatureInputs(
|
||||
context: Parameters<typeof createReferenceFeatureInstalledInput>[0] &
|
||||
Readonly<{ studioSource: "MOCK" | "HTTP" }>,
|
||||
Readonly<{
|
||||
studioSource: "MOCK" | "HTTP";
|
||||
apiBaseUrl: string;
|
||||
requestTimeoutMs: number;
|
||||
csrf: CsrfTokenProvider;
|
||||
}>,
|
||||
): InstalledFeatureInputs {
|
||||
const techLogFeature = createTechLogFeatureInstalledInput(context);
|
||||
if (!INSTALLED_PRODUCT_FEATURE_IDS.includes(REFERENCE_FEATURE_ID)) {
|
||||
|
||||
@@ -2,10 +2,13 @@ import {
|
||||
TECH_LOG_FEATURE_ID,
|
||||
type TechLogFeatureInput,
|
||||
} from "../application/tech-log-feature-input.ts";
|
||||
import { createAssetUploadTransport } from "./http/asset-upload-transport.ts";
|
||||
import { createHttpStudioAssetGateway } from "./http/http-studio-asset-gateway.ts";
|
||||
import {
|
||||
createHttpStudioGateway,
|
||||
type StudioOperationExecutor,
|
||||
} from "./http/http-studio-gateway.ts";
|
||||
import type { CsrfTokenProvider } from "./http/studio-session-csrf.ts";
|
||||
import { createMockStudioGateway } from "./mock/mock-studio-gateway.ts";
|
||||
import { publicContentQueries } from "./static/public-query.ts";
|
||||
|
||||
@@ -15,15 +18,35 @@ import { publicContentQueries } from "./static/public-query.ts";
|
||||
* platform's `attachCredentials` collaborator at the composition root. This
|
||||
* context only has to say which adapter to construct and hand it the
|
||||
* composed contract executor.
|
||||
*
|
||||
* CSRF has exactly one provider per Studio session, owned by the composition
|
||||
* root — it is shared with the platform's credential collaborator, so it is
|
||||
* threaded in here rather than constructed locally.
|
||||
*/
|
||||
export type TechLogInstallContext = Readonly<{
|
||||
studioSource: "MOCK" | "HTTP";
|
||||
contractOperations: StudioOperationExecutor;
|
||||
apiBaseUrl: string;
|
||||
requestTimeoutMs: number;
|
||||
csrf: CsrfTokenProvider;
|
||||
}>;
|
||||
|
||||
export function createTechLogFeatureInstalledInput(
|
||||
context: TechLogInstallContext,
|
||||
) {
|
||||
// The Asset gateway is needed even on MOCK: with no backend behind it, the
|
||||
// list comes back empty and uploads fail with a transport error — the UI
|
||||
// surfacing that state is the correct behavior, not a bug to route around.
|
||||
const createStudioAssetGateway = () =>
|
||||
createHttpStudioAssetGateway({
|
||||
operations: context.contractOperations,
|
||||
csrf: context.csrf,
|
||||
upload: createAssetUploadTransport({
|
||||
baseUrl: context.apiBaseUrl,
|
||||
timeoutMs: context.requestTimeoutMs,
|
||||
}),
|
||||
});
|
||||
|
||||
const createStudioGateway = () =>
|
||||
context.studioSource === "MOCK"
|
||||
? createMockStudioGateway()
|
||||
@@ -32,6 +55,7 @@ export function createTechLogFeatureInstalledInput(
|
||||
const input: TechLogFeatureInput = Object.freeze({
|
||||
publicContent: publicContentQueries,
|
||||
createStudioGateway,
|
||||
createStudioAssetGateway,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts";
|
||||
import type { UploadAssetForm } from "../../application/ports/studio-asset-gateway.ts";
|
||||
import type { Asset, ProblemDetails } from "../../contracts/studio/contract.ts";
|
||||
import type { StudioAssetUploadTransport } from "./http-studio-asset-gateway.ts";
|
||||
import { STUDIO_ERROR_CODES } from "./studio-error-mapping.ts";
|
||||
|
||||
const CODES = new Set<string>(STUDIO_ERROR_CODES);
|
||||
|
||||
/**
|
||||
* The contract runtime can only express `requestBody: "NONE" | "JSON"` and the
|
||||
* low-level client always serializes the body as JSON (see
|
||||
* `external-contract-runtime.ts` and `client.ts:719`). The canonical
|
||||
* `POST /assets` is multipart, so this one operation is split into a narrow
|
||||
* seam instead. If upload moves to presigned/resumable transfer, or the
|
||||
* platform grows a MULTIPART mode, only this file is replaced.
|
||||
*/
|
||||
export function createAssetUploadTransport(
|
||||
deps: Readonly<{
|
||||
baseUrl: string;
|
||||
timeoutMs: number;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
}>,
|
||||
): StudioAssetUploadTransport {
|
||||
const doFetch = deps.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
const endpoint = new URL("api/v1/studio/assets", deps.baseUrl).href;
|
||||
|
||||
function unavailable(detail: string): StudioGatewayError {
|
||||
return new StudioGatewayError({
|
||||
type: "https://techlog.local/problems/studio-unavailable",
|
||||
title: "STUDIO_UNAVAILABLE",
|
||||
status: 503,
|
||||
detail,
|
||||
code: "STUDIO_UNAVAILABLE",
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
async upload(form: UploadAssetForm, headers, options) {
|
||||
const body = new FormData();
|
||||
body.append("file", form.file, form.file.name);
|
||||
body.append("kind", form.kind);
|
||||
if (form.altText !== undefined) body.append("altText", form.altText);
|
||||
if (form.decorative !== undefined) body.append("decorative", String(form.decorative));
|
||||
|
||||
const timeout = AbortSignal.timeout(deps.timeoutMs);
|
||||
const signal = options?.signal
|
||||
? AbortSignal.any([options.signal, timeout])
|
||||
: timeout;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
// content-type is never set by hand here. fetch generates the
|
||||
// multipart boundary; setting it manually produces a body the server
|
||||
// cannot parse.
|
||||
response = await doFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { ...headers },
|
||||
body,
|
||||
signal,
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (error) {
|
||||
throw unavailable(
|
||||
error instanceof Error ? `Upload transport failed: ${error.message}` : "Upload transport failed.",
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 201 || response.status === 200) {
|
||||
return (await response.json()) as Asset;
|
||||
}
|
||||
|
||||
let problem: ProblemDetails | null;
|
||||
try {
|
||||
problem = (await response.json()) as ProblemDetails;
|
||||
} catch {
|
||||
problem = null;
|
||||
}
|
||||
if (problem && typeof problem.code === "string" && CODES.has(problem.code)) {
|
||||
throw new StudioGatewayError(problem);
|
||||
}
|
||||
throw unavailable(`Upload returned an uncontracted status ${response.status}.`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PublicContentQueries } from "./ports/public-content-queries.ts";
|
||||
import type { StudioAssetGateway } from "./ports/studio-asset-gateway.ts";
|
||||
import type { StudioGateway } from "./ports/studio-gateway.ts";
|
||||
|
||||
export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
||||
@@ -6,6 +7,7 @@ export const TECH_LOG_FEATURE_ID = "tech-log" as const;
|
||||
export type TechLogFeatureInput = Readonly<{
|
||||
publicContent: PublicContentQueries;
|
||||
createStudioGateway(): StudioGateway;
|
||||
createStudioAssetGateway(): StudioAssetGateway;
|
||||
}>;
|
||||
|
||||
declare module "../../../application/ports/in/application-api.ts" {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user