Move browser authentication behind a generated-contract BFF

This commit is contained in:
ms2k 2026-07-19 18:21:17 +09:00
commit fa7220ee33
3 changed files with 7259 additions and 2825 deletions

View file

@ -3,6 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parse, stringify } from "yaml";
import { format } from "prettier";
const root = dirname(fileURLToPath(import.meta.url));
const repository = resolve(root, "../..");
@ -62,12 +63,31 @@ for (const schema of Object.values(bundle.$defs)) {
delete schema.$schema;
delete schema.$id;
}
assertWebSchemasSupported(bundle.$defs);
const events = {
...bundle,
$id: "https://remora.invalid/api/events.schema.json",
title: "Remora WebSocket frame",
$ref: "#/$defs/EventFrame",
};
const webContracts = await format(emitTypeScriptContracts(bundle.$defs), {
parser: "typescript",
semi: false,
singleQuote: true,
});
const webSchemas = await format(
`export const schemas = ${JSON.stringify(bundle.$defs, null, 2)} as const\n`,
{
parser: "typescript",
semi: false,
singleQuote: true,
},
);
const webDecoders = await format(emitTypeScriptDecoders(), {
parser: "typescript",
semi: false,
singleQuote: true,
});
const outputs = [
[resolve(repository, "docs/api/openapi.yaml"), openapi],
[resolve(repository, "docs/api/events.schema.json"), `${JSON.stringify(events, null, 2)}\n`],
@ -75,6 +95,9 @@ const outputs = [
resolve(repository, "crates/remora-contracts/src/generated_wire.rs"),
readFileSync(generatedRust, "utf8"),
],
[resolve(repository, "web/src/api/contracts.ts"), webContracts],
[resolve(repository, "web/src/api/generated-schema.ts"), webSchemas],
[resolve(repository, "web/src/api/decode.ts"), webDecoders],
];
let stale = false;
@ -105,3 +128,179 @@ function rewriteBundleReferences(value) {
}
Object.values(value).forEach(rewriteBundleReferences);
}
function emitTypeScriptContracts(definitions) {
const lines = [
"// Generated from contracts/typespec/main.tsp. Do not edit.",
"",
];
for (const [name, schema] of Object.entries(definitions)) {
lines.push(`export type ${name}Dto = ${typeScriptType(schema)}`, "");
}
return `${lines.join("\n")}\n`;
}
function typeScriptType(schema) {
if (schema.$ref) {
return `${schema.$ref.replace(/^#\/\$defs\//, "").replace(/\.json$/, "")}Dto`;
}
if (schema.const !== undefined) {
return JSON.stringify(schema.const);
}
if (schema.enum) {
return schema.enum.map((value) => JSON.stringify(value)).join(" | ");
}
const alternatives = schema.oneOf ?? schema.anyOf;
if (alternatives) {
return alternatives.map(typeScriptType).join(" | ");
}
if (schema.allOf || schema.type === "object" || schema.properties) {
const members = [];
if (schema.allOf) {
members.push(...schema.allOf.map(typeScriptType));
}
if (schema.properties || schema.type === "object") {
const required = new Set(schema.required ?? []);
const properties = Object.entries(schema.properties ?? {}).map(
([name, property]) =>
`${propertyName(name)}${required.has(name) ? "" : "?"}: ${typeScriptType(property)}`,
);
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
properties.push(`[key: string]: ${typeScriptType(schema.additionalProperties)}`);
}
members.push(`{ ${properties.join("; ")} }`);
}
return members.length === 0 ? "Record<string, never>" : members.join(" & ");
}
if (schema.type === "array") {
return `Array<${typeScriptType(schema.items)}>`;
}
if (schema.type === "string") return "string";
if (schema.type === "integer" || schema.type === "number") return "number";
if (schema.type === "boolean") return "boolean";
if (schema.type === "null") return "null";
throw new Error(`TypeScript schema emission does not support ${JSON.stringify(schema)}`);
}
function propertyName(name) {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
}
function assertWebSchemasSupported(definitions) {
for (const [name, schema] of Object.entries(definitions)) {
assertWebSchemaSupported(schema, name);
}
}
function assertWebSchemaSupported(schema, location) {
const allowed = new Set([
"$ref",
"type",
"properties",
"required",
"items",
"oneOf",
"anyOf",
"allOf",
"const",
"enum",
"additionalProperties",
"unevaluatedProperties",
"minItems",
"maxItems",
"minLength",
"maxLength",
"pattern",
"format",
"minimum",
"maximum",
"description",
"default",
"not",
]);
for (const key of Object.keys(schema)) {
if (!allowed.has(key)) {
throw new Error(`Web schema emission does not support ${location}.${key}`);
}
}
if (
schema.format !== undefined &&
schema.format !== "uri" &&
schema.format !== "date-time"
) {
throw new Error(`Web schema emission does not support ${location} format ${schema.format}`);
}
for (const [name, property] of Object.entries(schema.properties ?? {})) {
assertWebSchemaSupported(property, `${location}.${name}`);
}
if (schema.items) assertWebSchemaSupported(schema.items, `${location}[]`);
for (const [kind, alternatives] of [
["oneOf", schema.oneOf],
["anyOf", schema.anyOf],
["allOf", schema.allOf],
]) {
alternatives?.forEach((member, index) =>
assertWebSchemaSupported(member, `${location}.${kind}[${index}]`),
);
}
if (schema.additionalProperties) {
assertWebSchemaSupported(schema.additionalProperties, `${location}.*`);
}
if (schema.unevaluatedProperties) {
const keys = Object.keys(schema.unevaluatedProperties);
if (
keys.length === 1 &&
keys[0] === "not" &&
Object.keys(schema.unevaluatedProperties.not).length === 0
) {
return;
}
assertWebSchemaSupported(schema.unevaluatedProperties, `${location}.*`);
}
if (schema.not !== undefined) {
throw new Error(`Web schema emission does not support general negation at ${location}`);
}
}
function emitTypeScriptDecoders() {
const roots = [
["JobFailure", "JobFailure"],
["JobSubmission", "JobSubmission"],
["EventFrame", "EventFrame"],
["EventTicket", "EventTicket"],
["JobPage", "JobPage"],
["JobResource", "JobResource"],
["DeletionOperation", "DeletionOperation"],
["AttemptPage", "AttemptPage"],
["ResultDescriptor", "ResultDescriptor"],
["ResultMemberPage", "ResultMemberPage"],
["ResultMemberAccess", "ResultMemberAccess"],
["SourceCredential", "SourceCredential"],
["SourceCredentialPage", "SourceCredentialPage"],
["ApiError", "ApiError"],
["AttemptOutcome", "AttemptOutcomeRequest"],
];
const imports = roots
.map(([, type]) => ` ${type}Dto,`)
.join("\n");
const functions = roots
.map(
([functionName, type]) =>
`function assert${functionName}(value: unknown): asserts value is ${type}Dto {\n` +
` validateSchema('${type}', value)\n` +
`}\n\n` +
`export function decode${functionName}(value: unknown): ${type}Dto {\n` +
` assert${functionName}(value)\n` +
` return value\n` +
`}`,
)
.join("\n\n");
return `// Generated from contracts/typespec/main.tsp. Do not edit.
import type {
${imports}
} from './contracts'
import { validateSchema } from './validate'
${functions}
`;
}

View file

@ -12,6 +12,7 @@
"@typespec/json-schema": "1.14.0",
"@typespec/openapi": "1.14.0",
"@typespec/openapi3": "1.14.0",
"prettier": "3.9.5",
"yaml": "2.9.0"
}
},

View file

@ -14,6 +14,7 @@
"@typespec/json-schema": "1.14.0",
"@typespec/openapi": "1.14.0",
"@typespec/openapi3": "1.14.0",
"prettier": "3.9.5",
"yaml": "2.9.0"
}
}