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.
290 lines
8.9 KiB
TypeScript
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,
|
|
});
|
|
}
|