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({
|
||||
|
||||
Reference in New Issue
Block a user