The public surface — 17 of the 28 registered routes — reads from a 29KB
TypeScript fixture and never touches the backend. `TECH_LOG_STUDIO_SOURCE`
only ever switched the Studio gateways; `publicContent` was wired to the
static adapter unconditionally, so no configuration could make the public
site show published content. This is the first half of closing that: the
contract and the switch, with the adapter still to come.
The generator now vendors both canonical contracts instead of one. They are
independent — different services on different schedules — so each carries
its own digest and operation list, and updating one leaves the other's drift
gate quiet.
`TECH_LOG_PUBLIC_SOURCE` is deliberately a second flag rather than a rename
of the Studio one. The combination that matters right now is exactly the one
a single flag cannot express: the authoring backend is live while the public
read API does not exist yet. production stays on MOCK for that reason —
pointing it at HTTP today would empty the live site — and moves when the
backend serves /api/v1/public.
Also records the compatibility evidence the registry gate wanted for the
Studio access change in fff5e6f. That gate has been failing since, which is
on me: the change was real and breaking, and it shipped without the note
explaining that route ids and schemas are untouched and only the access
classification moves.
294 lines
9.2 KiB
TypeScript
294 lines
9.2 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"),
|
|
// TechLog public-read adapter selection, defaulted the same way and for
|
|
// the same reason. Held apart from the Studio switch so one surface can
|
|
// move to HTTP without dragging the other with it.
|
|
TECH_LOG_PUBLIC_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,
|
|
});
|
|
}
|