Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli/src/backends/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { CliError, dim, errMessage, header, note, ok, step, warn } from "../log.
import {
awsWorkloadArchitecture,
isDigestPinned,
portalPluginRoutesEnv,
sandboxCoreEnv,
securityScreenEnv,
type AwsConfig,
Expand Down Expand Up @@ -261,6 +262,11 @@ export function serviceEnvironment(config: QmConfig, service: ServiceName): Reco
env.WEB_UI_UPSTREAM = `http://web-ui.${aws.networking.cloudMapNamespace}:8080`;
env.ADMIN_UPSTREAM = `http://admin.${aws.networking.cloudMapNamespace}:8080`;
env.PORTAL_XFF_TRUSTED_HOPS = "1";
const pluginRoutes = portalPluginRoutesEnv(
config,
(plugin) => `http://${plugin}.${aws.networking.cloudMapNamespace}:8080`,
);
if (pluginRoutes) env.PORTAL_PLUGIN_ROUTES = pluginRoutes;
}
if (config.services.includes("auth")) {
Object.assign(
Expand Down
4 changes: 3 additions & 1 deletion cli/src/backends/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
type LogOpts,
type ServiceName,
} from "../services.ts";
import { dockerBasePort, sandboxCoreEnv, securityScreenEnv, type QmConfig } from "../config.ts";
import { dockerBasePort, portalPluginRoutesEnv, sandboxCoreEnv, securityScreenEnv, type QmConfig } from "../config.ts";
import { discoverPlugins, type ResolvedPlugin } from "../plugins.ts";
import { computedSecrets, runtimeSecretNames, secretsForService } from "../secrets.ts";
import { readDeploymentState, withDeploymentLock, writeDeploymentState, type DeploymentState } from "../state.ts";
Expand Down Expand Up @@ -285,6 +285,8 @@ export function dockerServiceEnv(config: QmConfig, service: ServiceName): Record
if (service === "portal") {
if (config.services.includes("web-ui")) out.WEB_UI_UPSTREAM = "http://web-ui:8080";
if (config.services.includes("admin")) out.ADMIN_UPSTREAM = "http://admin:8080";
const pluginRoutes = portalPluginRoutesEnv(config, (plugin) => `http://${plugin}:8080`);
if (pluginRoutes) out.PORTAL_PLUGIN_ROUTES = pluginRoutes;
}
if (config.services.includes("auth")) {
Object.assign(
Expand Down
6 changes: 6 additions & 0 deletions cli/src/backends/fly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import {
appPrefixOf,
CONFIG_FILENAME,
portalPluginRoutesEnv,
sandboxCoreEnv,
securityScreenEnv,
updateConfigImageOverrides,
Expand Down Expand Up @@ -199,6 +200,10 @@ function deriveToml(ctx: FlyCtx, service: ServiceName): string {
...(sandboxEnv.FLY_BASE_IMAGE ? { FLY_DEPLOY_BASE_IMAGE: sandboxEnv.FLY_BASE_IMAGE } : {}),
}
: {};
const portalRoutes =
service === "portal"
? portalPluginRoutesEnv(ctx.config, (plugin) => `http://${ctx.appPrefix}-${plugin}.internal:8080`)
: undefined;
const overrides: Record<string, string> = {
...spec.managed(ctx.serviceCtx),
...sandboxEnv,
Expand All @@ -207,6 +212,7 @@ function deriveToml(ctx: FlyCtx, service: ServiceName): string {
...configuredEnv,
...(service === "core" ? securityScreenEnv(ctx.config) : {}),
...deploymentEnv,
...(portalRoutes ? { PORTAL_PLUGIN_ROUTES: portalRoutes } : {}),
[FLY_DEPLOYMENT_ID_ENV]: flyDeploymentId(ctx.flyOrg, ctx.orgId, ctx.appPrefix),
};
const provided = new Set(Object.keys(overrides));
Expand Down
86 changes: 86 additions & 0 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export interface PluginEntry {
secrets?: PluginSecret[];
}

export interface PortalRoute {
pathPrefix: string;
plugin: string;
access: "session" | "signed-upstream";
}

export interface SandboxConfig {
backend?: "sprites" | "aws";
app?: string;
Expand Down Expand Up @@ -139,6 +145,7 @@ export interface QmConfig {
basePort?: number;
services: DeclaredServiceName[];
plugins: PluginEntry[];
portalRoutes?: PortalRoute[];
skills: string[];
env: Partial<Record<DeclaredServiceName, Record<string, string>>>;
secretEnv?: Partial<Record<DeclaredServiceName, Record<string, string>>>;
Expand All @@ -165,6 +172,19 @@ export function securityScreenEnv(config: Pick<QmConfig, "securityScreen">): Rec
};
}

export function portalPluginRoutesEnv(
config: Pick<QmConfig, "portalRoutes">,
upstreamBaseFor: (plugin: string) => string,
): string | undefined {
const routes = [...(config.portalRoutes ?? [])].sort(
(left, right) => right.pathPrefix.length - left.pathPrefix.length || left.pathPrefix.localeCompare(right.pathPrefix),
);
if (!routes.length) return undefined;
return JSON.stringify(
routes.map(({ pathPrefix, plugin, access }) => ({ pathPrefix, access, upstreamBase: upstreamBaseFor(plugin) })),
);
}

export function configPathInDir(dir: string): string | undefined {
const candidate = resolve(dir, CONFIG_FILENAME);
return existsSync(candidate) ? candidate : undefined;
Expand Down Expand Up @@ -548,6 +568,7 @@ function validate(raw: unknown, path: string): QmConfig {
}

const plugins = validatePlugins(o["plugins"], path);
const portalRoutes = validatePortalRoutes(o["portalRoutes"], path, services, plugins);
const skills = validateStringArray(o["skills"], path, "skills");
const env = validateServiceMap(o["env"], path, "env", (v, k) => validateStringMap(v, path, `env.${k}`));
const secretEnv = validateServiceMap(o["secretEnv"], path, "secretEnv", (v, k) => {
Expand Down Expand Up @@ -627,6 +648,14 @@ function validate(raw: unknown, path: string): QmConfig {
`${path}: "plugins[${i}].env.PORT" is managed by the deployment target and cannot be overridden`,
);
}
if (plugin.env?.PORTAL_PLUGIN_ROUTES !== undefined) {
throw new CliError(
`${path}: "plugins[${i}].env.PORTAL_PLUGIN_ROUTES" is managed by portalRoutes and cannot be overridden`,
);
}
}
if (env.portal?.PORTAL_PLUGIN_ROUTES !== undefined) {
throw new CliError(`${path}: "env.portal.PORTAL_PLUGIN_ROUTES" is managed by portalRoutes and cannot be overridden`);
}
const imageOverrides = validateServiceMap(o["imageOverrides"], path, "imageOverrides", (v, k) => {
if (typeof v !== "string") throw new CliError(`${path}: "imageOverrides.${k}" must be a string`);
Expand All @@ -641,6 +670,7 @@ function validate(raw: unknown, path: string): QmConfig {
target,
services,
plugins,
portalRoutes,
skills,
env,
imageOverrides,
Expand Down Expand Up @@ -710,6 +740,62 @@ function validate(raw: unknown, path: string): QmConfig {
return out;
}

const BUILT_IN_PORTAL_PREFIXES = ["/", "/admin", "/auth", "/idp", "/api", "/v1", "/connect", "/drop", "/d"];

function portalPrefixesOverlap(left: string, right: string): boolean {
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
}

function validatePortalRoutes(
raw: unknown,
path: string,
services: DeclaredServiceName[],
plugins: PluginEntry[],
): PortalRoute[] {
if (raw === undefined) return [];
if (!Array.isArray(raw)) throw new CliError(`${path}: "portalRoutes" must be an array`);
if (raw.length && !services.includes("portal")) {
throw new CliError(`${path}: "portalRoutes" requires the "portal" service`);
}
const pluginNames = new Set(plugins.map((plugin) => plugin.name));
const routes = raw.map((value, index): PortalRoute => {
const field = `portalRoutes[${index}]`;
if (!isPlainObject(value)) throw new CliError(`${path}: ${field} must be an object`);
const allowed = new Set(["pathPrefix", "plugin", "access"]);
for (const key of Object.keys(value)) {
if (!allowed.has(key)) throw new CliError(`${path}: ${field}.${key} is not recognized`);
}
const pathPrefix = value.pathPrefix;
if (
typeof pathPrefix !== "string" ||
!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/.test(pathPrefix) ||
/%2f|%5c/i.test(pathPrefix)
) {
throw new CliError(`${path}: ${field}.pathPrefix must be a normalized absolute path prefix without encoded separators`);
}
if (BUILT_IN_PORTAL_PREFIXES.some((builtIn) => builtIn !== "/" && portalPrefixesOverlap(pathPrefix, builtIn))) {
throw new CliError(`${path}: ${field}.pathPrefix collides with a built-in portal route`);
}
const plugin = value.plugin;
if (typeof plugin !== "string" || !pluginNames.has(plugin)) {
throw new CliError(`${path}: ${field}.plugin names an unknown plugin`);
}
const access = value.access;
if (access !== "session" && access !== "signed-upstream") {
throw new CliError(`${path}: ${field}.access must be "session" or "signed-upstream"`);
}
return { pathPrefix, plugin, access };
});
for (let i = 0; i < routes.length; i += 1) {
for (let j = i + 1; j < routes.length; j += 1) {
if (portalPrefixesOverlap(routes[i]!.pathPrefix, routes[j]!.pathPrefix)) {
throw new CliError(`${path}: portalRoutes path prefixes overlap`);
}
}
}
return routes;
}

function configuredHarness(config: QmConfig): string {
return config.env.core?.HARNESS?.trim() || (config.target === "fly" ? "pi" : "mock");
}
Expand Down
26 changes: 26 additions & 0 deletions cli/test/auth-broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,32 @@ test("docker and AWS wire the broker with parity", () => {
assert.equal(serviceEnvironment(aws, "auth").PORT, "8080");
});

test("docker and Fly derive portal plugin upstreams from deployment topology", () => {
const docker = configWith(`{
"contract": 1, "orgId": "acme", "publicUrl": "https://agent.example.com", "target": "docker",
"services": ["core", "web-ui", "portal"],
"plugins": [{ "name": "programme" }, { "name": "edge-registry" }], "skills": [],
"portalRoutes": [
{ "pathPrefix": "/edge/v1", "plugin": "edge-registry", "access": "signed-upstream" },
{ "pathPrefix": "/programme", "plugin": "programme", "access": "session" }
],
"env": {}
}`);
assert.deepEqual(JSON.parse(dockerServiceEnv(docker, "portal").PORTAL_PLUGIN_ROUTES!), [
{ pathPrefix: "/programme", access: "session", upstreamBase: "http://programme:8080" },
{ pathPrefix: "/edge/v1", access: "signed-upstream", upstreamBase: "http://edge-registry:8080" },
]);

const fly: QmConfig = { ...docker, target: "fly", appPrefix: "ycqm", region: "sjc", flyOrg: "acme" };
const portal = derivedTomlFor(fly, "portal", repoRoot);
const encoded = portal.match(/PORTAL_PLUGIN_ROUTES = (.+)/)?.[1];
assert.ok(encoded);
assert.deepEqual(JSON.parse(JSON.parse(encoded)), [
{ pathPrefix: "/programme", access: "session", upstreamBase: "http://ycqm-programme.internal:8080" },
{ pathPrefix: "/edge/v1", access: "signed-upstream", upstreamBase: "http://ycqm-edge-registry.internal:8080" },
]);
});

test("the broker's generated secrets reach both sides under the right names", () => {
const config = brokerConfig();
const secrets = computedSecrets(config);
Expand Down
15 changes: 15 additions & 0 deletions cli/test/aws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,21 @@ test("AWS environment derives identity, public URLs, private wiring, and MicroVM
assert.equal(core.PORT, "8080");
});

test("AWS derives portal plugin routes from Cloud Map and sorts them deterministically", () => {
const routed: QmConfig = {
...config,
plugins: [{ name: "programme" }, { name: "edge-registry" }],
portalRoutes: [
{ pathPrefix: "/edge/v1", plugin: "edge-registry", access: "signed-upstream" },
{ pathPrefix: "/programme", plugin: "programme", access: "session" },
],
};
assert.deepEqual(JSON.parse(serviceEnvironment(routed, "portal").PORTAL_PLUGIN_ROUTES!), [
{ pathPrefix: "/programme", access: "session", upstreamBase: "http://programme.acme.internal:8080" },
{ pathPrefix: "/edge/v1", access: "signed-upstream", upstreamBase: "http://edge-registry.acme.internal:8080" },
]);
});

test("AWS routes security screen proxy configuration and its token only to core", () => {
const screened: QmConfig = {
...config,
Expand Down
58 changes: 58 additions & 0 deletions cli/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,64 @@ test("plugins: image is OPTIONAL (source plugins); env attaches to either; bad i
);
});

test("portalRoutes validates session and signed-upstream plugin mounts", () => {
const portalRoutes = [
{ pathPrefix: "/programme", plugin: "programme", access: "session" },
{ pathPrefix: "/edge/v1", plugin: "edge-registry", access: "signed-upstream" },
];
withConfig(
{
services: ["core", "web-ui", "portal"],
plugins: [{ name: "programme" }, { name: "edge-registry" }],
portalRoutes,
},
({ path }) => assert.deepEqual(loadConfigAt(path).config.portalRoutes, portalRoutes),
);
});

test("portalRoutes rejects unsafe, ambiguous, and unresolved mounts", () => {
const base = { services: ["core", "portal"], plugins: [{ name: "programme" }, { name: "edge-registry" }] };
for (const pathPrefix of ["programme", "/", "/admin", "/api/jobs", "/edge%2fv1", "/edge%5Cv1", "/edge/"]) {
withConfig(
{ ...base, portalRoutes: [{ pathPrefix, plugin: "programme", access: "session" }] },
({ path }) => assert.throws(() => loadConfigAt(path), /portalRoutes/),
);
}
withConfig(
{ ...base, portalRoutes: [{ pathPrefix: "/programme", plugin: "missing", access: "session" }] },
({ path }) => assert.throws(() => loadConfigAt(path), /unknown plugin/),
);
withConfig(
{
...base,
portalRoutes: [
{ pathPrefix: "/edge", plugin: "edge-registry", access: "session" },
{ pathPrefix: "/edge/v1", plugin: "edge-registry", access: "signed-upstream" },
],
},
({ path }) => assert.throws(() => loadConfigAt(path), /overlap/),
);
withConfig(
{
services: ["core"],
plugins: [{ name: "programme" }],
portalRoutes: [{ pathPrefix: "/programme", plugin: "programme", access: "session" }],
},
({ path }) => assert.throws(() => loadConfigAt(path), /requires.*portal/),
);
});

test("PORTAL_PLUGIN_ROUTES is deployment-managed", () => {
withConfig(
{ services: ["core", "portal"], env: { portal: { PORTAL_PLUGIN_ROUTES: "[]" } } },
({ path }) => assert.throws(() => loadConfigAt(path), /PORTAL_PLUGIN_ROUTES.*managed/),
);
withConfig(
{ plugins: [{ name: "programme", env: { PORTAL_PLUGIN_ROUTES: "[]" } }] },
({ path }) => assert.throws(() => loadConfigAt(path), /PORTAL_PLUGIN_ROUTES.*managed/),
);
});

test("env (per-service) and imageOverrides validate by service name", () => {
withConfig({ env: { core: { PUBLIC_WEB_URL: "http://x" } }, imageOverrides: { core: "ghcr.io/x:1" } }, ({ path }) => {
const { config } = loadConfigAt(path);
Expand Down
Loading