Files
tech-log-frontend/src/contracts/release-artifacts.ts
T
DongHyeonka 3b641906b8 feat: select the TechLog Studio adapter from runtime configuration
Adds TECH_LOG_STUDIO_SOURCE (MOCK | HTTP, default MOCK) to the V2 runtime
config schema so a build can switch createTechLogFeatureInstalledInput
between the mock and HTTP Studio gateways without a rebuild. V1 documents
predate the key and always normalize to MOCK. The HTTP gateway is
constructed with only { operations } per Task 4's actual signature -
no CSRF provider is wired here; that lands with attachCredentials at a
later composition-root task.

Updates every existing Studio test call site to the new required
createTechLogFeatureInstalledInput(context) signature via a shared
tests/helpers/studio-install-context.ts MOCK fixture, so the whole
existing Studio suite keeps exercising the mock adapter unchanged.
2026-08-18 01:57:15 +09:00

290 lines
8.9 KiB
TypeScript

import { z } from "zod";
import { contractSetSchema } from "./contract-set.ts";
const versionSchema = z.string().regex(/^\d+(?:\.\d+){0,2}$/);
function assertEndpointUrl(
value: string,
local: boolean,
options: Readonly<{ trailingSlashPath?: boolean }> = {},
): void {
const parsed = new URL(value);
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
parsed.username ||
parsed.password ||
parsed.hash ||
parsed.search ||
(!local && parsed.protocol !== "https:")
) {
throw new TypeError("invalid");
}
if (options.trailingSlashPath && !parsed.pathname.endsWith("/")) {
throw new TypeError("invalid");
}
}
export function isValidReleaseManifestUrl(value: string): boolean {
if (!value.startsWith("/") || value.startsWith("//")) return false;
if (new TextEncoder().encode(value).byteLength > 256) return false;
if (value.includes("?") || value.includes("#") || value.includes("\\")) {
return false;
}
if (/%2f|%5c/i.test(value)) return false;
return !value
.split("/")
.some((segment) => segment === "." || segment === "..");
}
export const capabilityOverrideArtifactSchema = z
.object({
REALTIME: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"),
WEB_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"),
SERVICE_WORKER: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"),
OFFLINE_COMMANDS: z.enum(["DEFAULT", "DISABLED"]).default("DEFAULT"),
})
.strict()
.default({
REALTIME: "DEFAULT",
WEB_WORKER: "DEFAULT",
SERVICE_WORKER: "DEFAULT",
OFFLINE_COMMANDS: "DEFAULT",
});
/**
* §3.5 / §6.1. A runtime switch that can take an installed feature out of
* service without a rebuild.
*
* Values are `DEFAULT | DISABLED` for the same reason `CAPABILITY_OVERRIDES`
* is: a configuration document may subtract from what the build installed and
* may never add to it. Keys are feature ids; naming a feature this build does
* not contain is inert rather than an error, so a shared configuration
* document can cover several builds.
*/
export const featureOverrideArtifactSchema = z
.record(
z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u, "feature id is invalid"),
z.enum(["DEFAULT", "DISABLED"]),
)
.default({});
type RuntimeConfigArtifactDraft = Readonly<{
APP_ENV: "local" | "development" | "staging" | "production";
API_BASE_URL: string;
TELEMETRY_ENABLED: boolean;
TELEMETRY_ENDPOINT?: string;
AUTH_MODE: "external" | "demo";
RELEASE_MANIFEST_URL: string;
}>;
function runtimeConfigArtifactInvariants(
config: RuntimeConfigArtifactDraft,
context: z.RefinementCtx,
): void {
const local = config.APP_ENV === "local" || config.APP_ENV === "development";
if (config.TELEMETRY_ENABLED && !config.TELEMETRY_ENDPOINT) {
context.addIssue({
code: "custom",
path: ["TELEMETRY_ENDPOINT"],
message: "required when telemetry is enabled",
});
}
if (!local && config.AUTH_MODE === "demo") {
context.addIssue({
code: "custom",
path: ["AUTH_MODE"],
message: "demo authentication is limited to local environments",
});
}
try {
assertEndpointUrl(config.API_BASE_URL, local, { trailingSlashPath: true });
} catch {
context.addIssue({
code: "custom",
path: ["API_BASE_URL"],
message:
"absolute credential-free URL ending in / is required; HTTPS outside local",
});
}
if (config.TELEMETRY_ENDPOINT) {
try {
assertEndpointUrl(config.TELEMETRY_ENDPOINT, local);
} catch {
context.addIssue({
code: "custom",
path: ["TELEMETRY_ENDPOINT"],
message:
"absolute credential-free URL is required; HTTPS outside local",
});
}
}
if (!isValidReleaseManifestUrl(config.RELEASE_MANIFEST_URL)) {
context.addIssue({
code: "custom",
path: ["RELEASE_MANIFEST_URL"],
message: "same-origin absolute path without query, hash or traversal",
});
}
}
const runtimeConfigArtifactFields = {
APP_ENV: z.enum(["local", "development", "staging", "production"]),
API_BASE_URL: z.url(),
REQUEST_TIMEOUT_MS: z.int().min(100).max(60_000).default(10_000),
MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2),
TELEMETRY_ENABLED: z.boolean(),
TELEMETRY_ENDPOINT: z.url().optional(),
AUTH_MODE: z.enum(["external", "demo"]),
RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"),
RELEASE_ID: z.string().min(1).optional(),
BUILD_ID: z.string().min(1).optional(),
} as const;
export const runtimeConfigV1ArtifactSchema = z
.object({
...runtimeConfigArtifactFields,
CONFIG_SCHEMA_VERSION: z.literal("1"),
API_CONTRACT_VERSION: versionSchema,
})
.strict()
.superRefine(runtimeConfigArtifactInvariants);
export const runtimeConfigV2ArtifactSchema = z
.object({
...runtimeConfigArtifactFields,
CONFIG_SCHEMA_VERSION: z.literal("2.0"),
CAPABILITY_OVERRIDES: capabilityOverrideArtifactSchema,
FEATURE_OVERRIDES: featureOverrideArtifactSchema,
// TechLog Studio adapter selection. Backend is not live yet, so the
// default is the in-memory mock; a document may opt a build into HTTP.
TECH_LOG_STUDIO_SOURCE: z.enum(["MOCK", "HTTP"]).default("MOCK"),
})
.strict()
.superRefine(runtimeConfigArtifactInvariants);
export const runtimeConfigArtifactSchema = z.discriminatedUnion(
"CONFIG_SCHEMA_VERSION",
[
runtimeConfigV1ArtifactSchema,
runtimeConfigV2ArtifactSchema,
],
);
const releaseManifestArtifactFields = {
appVersion: z.string().min(1),
buildId: z.string().min(1),
commitSha: z.string().min(1),
assetManifestHash: z.string().min(1),
releaseId: z.string().min(1),
builtAt: z.string().min(1),
routeChunks: z.record(z.string().min(1), z.string().min(1)),
} as const;
export const releaseManifestV1ArtifactSchema = z
.object({
schemaVersion: z.literal(1),
...releaseManifestArtifactFields,
configSchemaVersion: versionSchema,
apiContractVersion: versionSchema,
})
.strict();
export const releaseManifestV2ArtifactSchema = z
.object({
schemaVersion: z.literal(2),
...releaseManifestArtifactFields,
configSchemaVersion: z.literal("2.0"),
contractSet: contractSetSchema,
})
.strict();
export const releaseManifestArtifactSchema = z.discriminatedUnion(
"schemaVersion",
[releaseManifestV1ArtifactSchema, releaseManifestV2ArtifactSchema],
);
export const buildManifestArtifactSchema = z
.object({
schemaVersion: z.literal(1),
buildId: z.string().min(1),
commitSha: z.string().min(1),
releaseId: z.string().min(1),
moduleInventoryHash: z.string().min(1),
generatedAt: z.string().min(1),
buildContext: z
.object({
nodeVersion: z.string().min(1),
packageManagerVersion: z.string().min(1),
runnerImage: z.string().min(1),
sourceDateEpoch: z.string().min(1).nullable(),
})
.strict(),
outputs: z
.object({
directory: z.string().min(1),
viteManifest: z.string().min(1),
moduleInventory: z.string().min(1),
routeChunks: z.record(z.string().min(1), z.string().min(1)),
runtimeConfigSchema: z.string().min(1),
})
.strict(),
})
.strict();
export type RuntimeConfigV1Artifact = z.output<
typeof runtimeConfigV1ArtifactSchema
>;
export type RuntimeConfigV2Artifact = z.output<
typeof runtimeConfigV2ArtifactSchema
>;
export type CapabilityOverrideArtifact = z.output<
typeof capabilityOverrideArtifactSchema
>;
export type RuntimeConfigArtifact = z.output<typeof runtimeConfigArtifactSchema>;
export type ReleaseManifestV1Artifact = z.output<
typeof releaseManifestV1ArtifactSchema
>;
export type ReleaseManifestV2Artifact = z.output<
typeof releaseManifestV2ArtifactSchema
>;
export type ReleaseArtifact = z.output<typeof releaseManifestArtifactSchema>;
export type BuildManifestArtifact = z.output<typeof buildManifestArtifactSchema>;
export function parseReleaseArtifact(value: unknown): ReleaseArtifact {
return releaseManifestArtifactSchema.parse(value);
}
export function parseRuntimeConfigArtifact(value: unknown): RuntimeConfigArtifact {
return runtimeConfigArtifactSchema.parse(value);
}
export function parseBuildManifestArtifact(value: unknown): BuildManifestArtifact {
return buildManifestArtifactSchema.parse(value);
}
export function projectReleaseTokens(release: ReleaseArtifact) {
const common = {
schemaVersion: release.schemaVersion,
appVersion: release.appVersion,
buildId: release.buildId,
commitSha: release.commitSha,
configSchemaVersion: release.configSchemaVersion,
assetManifestHash: release.assetManifestHash,
releaseId: release.releaseId,
builtAt: release.builtAt,
} as const;
return release.schemaVersion === 1
? Object.freeze({
...common,
schemaVersion: 1 as const,
apiContractVersion: release.apiContractVersion,
})
: Object.freeze({
...common,
schemaVersion: 2 as const,
contractSetDigest: release.contractSet.setDigest,
});
}