Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
# @suaveplan/api-graphql-ui-altair — Altair GraphQL UI plugin for the Suaveplan API plugin system. Mounts the Altair shell at GET /graphql/ui via createAltairUi(opts), serves bundled (no-CDN) assets from a hashed static map, embeds graphqlEndpoint / subscriptionEndpoint / initialQuery / initialHeaders / initialEnvironments into the shell, attaches a strict CSP (no script 'unsafe-inline'), increments graphql.ui.requests_total{ ui: "altair" } via an injectable MeterProvider, and refuses endpoint collisions with the GRAPHQL_UI_ALTAIR_ENDPOINT_COLLISION code. Structural sibling of api-graphql-ui-graphiql.
Altair GraphQL UI plugin for the Suaveplan API plugin
system. Structural sibling of `@suaveplan/api-graphql-ui-graphiql`.
<!-- begin:auto-docs -->
## Documentation tree
- [assets](./src/assets/assets.md) — `assets` ships the Altair shell as a static-asset map keyed by logical filename. Every entry carries a content-type, th…
- [assets](./src/assets/assets.md) — `assets` ships the Altair shell as a static-asset map keyed by logical filename. Every entry carries a content-type, th…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` is the package's single source of truth for the two stable error codes the Altair UI plugin emits. Centra…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` is the package's single source of truth for the two stable error codes the Altair UI plugin emits. Centra…
- [factory](./src/factory/create-altair-ui.md) — `factory/create-altair-ui` is the package's public entry point. `createAltairUi(opts)` validates the caller's options a…
- [create-altair-ui](./src/factory/create-altair-ui.md) — `factory/create-altair-ui` is the package's public entry point. `createAltairUi(opts)` validates the caller's options a…
- [headers](./src/headers/csp.md) — `headers/csp` produces the Content-Security-Policy and the two ancillary security headers (`X-Content-Type-Options`, `R…
- [csp](./src/headers/csp.md) — `headers/csp` produces the Content-Security-Policy and the two ancillary security headers (`X-Content-Type-Options`, `R…
- [html](./src/html/render-shell.md) — `html/render-shell` produces the single HTML document the Altair UI plugin serves at `<uiEndpoint>`. The shell mounts a…
- [render-shell](./src/html/render-shell.md) — `html/render-shell` produces the single HTML document the Altair UI plugin serves at `<uiEndpoint>`. The shell mounts a…
- [instruments](./src/instruments/instruments.md) — `instruments` owns the package's single OpenTelemetry counter, `graphql.ui.requests_total`, labelled with the constant…
- [instruments](./src/instruments/instruments.md) — `instruments` owns the package's single OpenTelemetry counter, `graphql.ui.requests_total`, labelled with the constant…
- [options](./src/options/options.md) — `options` owns the Zod-validated configuration surface every consumer of the Altair UI plugin passes through. The schem…
- [options](./src/options/options.md) — `options` owns the Zod-validated configuration surface every consumer of the Altair UI plugin passes through. The schem…
<!-- end:auto-docs -->
## What this package is
- **Opt-in.** The host serves nothing at `/graphql/ui` unless this
plugin (or its GraphiQL sibling) is wired against the router.
- **Bundled assets only — no CDN.** Every asset the HTML shell loads
ships inside this package's `dist/` output. The plugin works in
air-gapped, offline-first environments.
- **Alternative to GraphiQL, not co-existing.** Operators wire one or
the other against `/graphql/ui`. Both at once is refused by host
config (the engine adapters return 404 there when neither is wired).
## Quick start
```ts
import { createAltairUi } from "@suaveplan/api-graphql-ui-altair";
import { Router } from "@suaveplan/api-router";
const router = new Router();
const altair = createAltairUi();
altair.mount(router);
// router.match("GET", "/graphql/ui") resolves to the Altair HTML handler.
```
## Configuration
```ts
createAltairUi({
uiEndpoint: "/graphql/ui", // default
graphqlEndpoint: "/graphql", // default
subscriptionEndpoint: "/graphql/ws", // default
theme: "system", // "light" | "dark" | "system"
initialQuery: "",
initialHeaders: { "x-tenant": "acme" },
initialEnvironments: {
production: {
url: "https://api.example.com/graphql",
subscriptionsEndpoint: "wss://api.example.com/graphql",
headers: { "x-env": "prod" },
variables: { region: "us-east-1" },
},
},
});
```
## Security posture
- Strict Content-Security-Policy on every HTML response.
- `script-src 'self'` — no inline scripts.
- `style-src 'self' 'unsafe-inline'` — required for the Altair shell.
- `frame-ancestors 'none'` — clickjacking defence.
- `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`.
- Static assets carry `Cache-Control: public, max-age=31536000, immutable`.
## Observability
Emits one OTel counter: `graphql.ui.requests_total` with label
`ui="altair"`. The metric name is deliberately identical to the
GraphiQL sibling so dashboards can sum across both UIs by dropping
the `ui` label.
## Error codes
- `GRAPHQL_UI_ALTAIR.ASSET_MISSING` — request for a static asset the
bundled registry doesn't recognise (404).
- `GRAPHQL_UI_ALTAIR.ENDPOINT_COLLISION` — `mount()` called against a
router that already serves `GET <uiEndpoint>` (typically the
GraphiQL sibling is wired against the same path).
## License
MIT

View File

@@ -0,0 +1,93 @@
{
"name": "@suaveplan/api-graphql-ui-altair",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-7 Altair GraphQL UI plugin for the Suaveplan API plugin foundation. Mounts the Altair shell at GET /graphql/ui via createAltairUi(opts), serves bundled (no-CDN) assets from a hashed static map, embeds graphqlEndpoint / subscriptionEndpoint / initialQuery / initialHeaders / initialEnvironments into the shell, attaches a strict CSP (no script 'unsafe-inline'), increments graphql.ui.requests_total{ ui: \"altair\" } via an injectable MeterProvider, and refuses endpoint collisions with the GRAPHQL_UI_ALTAIR_ENDPOINT_COLLISION code. Structural sibling of api-graphql-ui-graphiql.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./factory": {
"types": "./dist/factory/index.d.ts",
"default": "./dist/factory/index.js"
},
"./options": {
"types": "./dist/options/index.d.ts",
"default": "./dist/options/index.js"
},
"./html": {
"types": "./dist/html/index.d.ts",
"default": "./dist/html/index.js"
},
"./assets": {
"types": "./dist/assets/index.d.ts",
"default": "./dist/assets/index.js"
},
"./headers": {
"types": "./dist/headers/index.d.ts",
"default": "./dist/headers/index.js"
},
"./instruments": {
"types": "./dist/instruments/index.d.ts",
"default": "./dist/instruments/index.js"
},
"./error-codes": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-router": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"peerDependencies": {
"@opentelemetry/api": "catalog:observability"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,85 @@
# `assets` — Bundled asset registry, resolver, CDN-clean check
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`assets` ships the Altair shell as a static-asset map keyed by logical
filename. Every entry carries a content-type, the payload bytes, and a
short SHA-256 hash derived once at module init so cache-bust URLs are
stable across deploys with unchanged payloads. The shell is shipped
inline (no FS reads at request time, no CDN fetches) which preserves
the Altair plugin's hermetic-bundle guarantee — the package can boot in
air-gapped, offline-first environments and still serve a usable UI.
## Features
- Default registry: `altair.js` + `altair.css`.
- `defineAsset(name, contentType, body)` returns a frozen `BundledAsset`.
- `resolveAsset(registry, uiEndpointBase, name)` produces a
`ResolvedAsset` with a hash-busted URL.
- Missing names throw `GraphqlUiAltairError` (`ASSET_MISSING`).
- `findExternalHostReferences` is the CDN-clean conformance check.
- Hash derivation is SHA-256, truncated to 16 hex chars — same
cardinality as a `git rev-parse --short=16`.
## Usage: Basic
```ts
import {
buildDefaultAssetRegistry,
resolveAsset,
} from "@suaveplan/api-graphql-ui-altair/assets";
const registry = buildDefaultAssetRegistry();
const jsAsset = resolveAsset(registry, "/graphql/ui", "altair.js");
console.log(jsAsset.url); // /graphql/ui/assets/<hash>/altair.js
```
## Usage: Advanced
```ts
import {
buildDefaultAssetRegistry,
defineAsset,
findExternalHostReferences,
} from "@suaveplan/api-graphql-ui-altair/assets";
// Extend the registry with an operator-specific asset.
const registry = new Map(buildDefaultAssetRegistry());
registry.set(
"logo.svg",
defineAsset(
"logo.svg",
"image/svg+xml",
'<svg xmlns="http://www.w3.org/2000/svg" />'
)
);
const offenders = findExternalHostReferences(registry);
if (offenders.length > 0) {
throw new Error("CDN leak: " + offenders.join(", "));
}
```
## API Reference
- `BundledAsset` — `{ name, contentType, body, hash }`.
- `ResolvedAsset extends BundledAsset` — adds `url`.
- `defineAsset(name, contentType, body): BundledAsset`.
- `buildDefaultAssetRegistry(): ReadonlyMap<string, BundledAsset>`.
- `resolveAsset(registry, uiEndpointBase, name): ResolvedAsset` — throws
`GraphqlUiAltairError` (`ASSET_MISSING`) on misses.
- `findExternalHostReferences(registry): readonly string[]`.
## Implementation Notes
The asset bodies are held as JavaScript template literals so the build
emits them as plain string constants — no FS access at boot, no
`process.cwd()` dependence. The hash is computed via `node:crypto` to
match the same hashing the rest of the project's static-asset
pipeline uses; the truncation to 16 hex chars is intentional — a
collision in a single deploy's asset set is vanishingly unlikely and
the shorter URL is easier on the eyes in production logs.

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "../error-codes/error-codes.js";
import {
buildDefaultAssetRegistry,
defineAsset,
findExternalHostReferences,
resolveAsset,
} from "./assets.js";
describe("assets", () => {
it("defineAsset attaches a hash derived from the body", {
timeout: 5000,
}, () => {
const a = defineAsset(
"a.js",
"application/javascript",
"console.log(1)"
);
expect(a.hash).toMatch(/^[0-9a-f]{16}$/);
});
it("defineAsset hashes a Uint8Array body too", { timeout: 5000 }, () => {
const a = defineAsset(
"blob.bin",
"application/octet-stream",
new Uint8Array([1, 2, 3, 4])
);
expect(a.hash).toMatch(/^[0-9a-f]{16}$/);
});
it("buildDefaultAssetRegistry yields altair.js + altair.css", {
timeout: 5000,
}, () => {
const reg = buildDefaultAssetRegistry();
expect(reg.size).toBe(2);
expect(reg.has("altair.js")).toBe(true);
expect(reg.has("altair.css")).toBe(true);
});
it("resolveAsset returns a URL with hash + name", { timeout: 5000 }, () => {
const reg = buildDefaultAssetRegistry();
const r = resolveAsset(reg, "/graphql/ui", "altair.js");
expect(r.url).toMatch(
/^\/graphql\/ui\/assets\/[0-9a-f]{16}\/altair\.js$/
);
expect(r.contentType).toContain("application/javascript");
});
it("rendered shell references no external hosts", { timeout: 5000 }, () => {
const reg = buildDefaultAssetRegistry();
expect(findExternalHostReferences(reg)).toEqual([]);
});
it("missing asset returns GRAPHQL_UI_ALTAIR_ASSET_MISSING", {
timeout: 5000,
}, () => {
const reg = buildDefaultAssetRegistry();
try {
resolveAsset(reg, "/graphql/ui", "does-not-exist.js");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiAltairError);
expect((err as GraphqlUiAltairError).altairCode).toBe(
GRAPHQL_UI_ALTAIR_CODES.ASSET_MISSING
);
}
});
it("findExternalHostReferences flags a polluted asset", {
timeout: 5000,
}, () => {
const reg = new Map(buildDefaultAssetRegistry());
reg.set(
"rogue.js",
defineAsset(
"rogue.js",
"application/javascript",
"fetch('https://evil.example.com/x')"
)
);
const out = findExternalHostReferences(reg);
expect(out).toContain("rogue.js");
});
it("findExternalHostReferences scans Uint8Array bodies", {
timeout: 5000,
}, () => {
const body = new TextEncoder().encode("ref: http://example.com");
const reg = new Map<string, ReturnType<typeof defineAsset>>([
["b.bin", defineAsset("b.bin", "application/octet-stream", body)],
]);
expect(findExternalHostReferences(reg)).toEqual(["b.bin"]);
});
it("BundledAsset entries are frozen", { timeout: 5000 }, () => {
const reg = buildDefaultAssetRegistry();
const js = reg.get("altair.js");
if (!js) throw new Error("missing");
expect(Object.isFrozen(js)).toBe(true);
});
});

View File

@@ -0,0 +1,206 @@
import { createHash } from "node:crypto";
import {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "../error-codes/error-codes.js";
/**
* A bundled static asset — JS, CSS, font, etc. The body is held as
* UTF-8 text or as a base64 string so the package can ship asset
* payloads inline (no FS reads at request time, no CDN fetches).
*/
export interface BundledAsset {
/** Logical filename, e.g. `altair.js`. */
readonly name: string;
/** Content type, e.g. `application/javascript; charset=utf-8`. */
readonly contentType: string;
/** Asset payload — `string` (UTF-8) or `Uint8Array` (binary). */
readonly body: string | Uint8Array;
/**
* Cache-bust hash — short SHA-256 prefix derived from `body`.
* Embedded into the HTML shell's `<script src>` and `<link href>`
* attributes so CDNs honour `Cache-Control: immutable`.
*/
readonly hash: string;
}
/**
* Resolved record returned by {@link resolveAsset}. Distinct from the
* registry entry — the resolved record carries the full URL path the
* shell should reference.
*/
export interface ResolvedAsset extends BundledAsset {
/** `<uiEndpoint>/assets/<hash>/<name>`. */
readonly url: string;
}
function sha256(input: string | Uint8Array): string {
return createHash("sha256")
.update(typeof input === "string" ? Buffer.from(input, "utf8") : input)
.digest("hex")
.slice(0, 16);
}
/**
* Build a single asset entry. The cache-bust hash is derived once at
* module init from the payload itself, so re-deploys with unchanged
* payloads keep the same URL (good for CDN warm-caches) and changed
* payloads bust the cache automatically.
*/
export function defineAsset(
name: string,
contentType: string,
body: string | Uint8Array
): BundledAsset {
return Object.freeze({
name,
contentType,
body,
hash: sha256(body),
});
}
/**
* The bundled Altair shell — JS-driven UI that consumes the embedded
* config block from the HTML and renders the Altair GraphQL client.
*
* The shell is deliberately compact: it parses the
* `<script id="altair-config" type="application/json">` block embedded
* by `render-shell`, mounts a root container, wires the configured
* GraphQL + subscription endpoints, and renders the named
* environments. The implementation is intentionally minimal — it
* provides the structural Altair experience (query editor, headers,
* environments) without pulling in the multi-megabyte `altair-static`
* bundle at install time. Operators wanting the full Altair UI can
* extend the asset map at construction (see `buildAssetRegistry`).
*/
const ALTAIR_SHELL_JS = `/* @suaveplan/api-graphql-ui-altair shell */
(function () {
"use strict";
var configEl = document.getElementById("altair-config");
if (!configEl) {
throw new Error("Altair config block missing");
}
var cfg = JSON.parse(configEl.textContent || "{}");
var root = document.getElementById("altair-root");
if (!root) {
throw new Error("Altair root container missing");
}
var theme = cfg.theme === "system"
? (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: cfg.theme;
root.setAttribute("data-altair-theme", theme);
root.setAttribute("data-altair-graphql-endpoint", cfg.graphqlEndpoint);
root.setAttribute("data-altair-subscription-endpoint", cfg.subscriptionEndpoint);
var envs = Object.keys(cfg.initialEnvironments || {});
root.setAttribute("data-altair-environments", envs.join(","));
var label = document.createElement("h1");
label.textContent = "Altair GraphQL Client";
label.setAttribute("data-altair-label", "true");
root.appendChild(label);
var endpoint = document.createElement("p");
endpoint.textContent = "Endpoint: " + cfg.graphqlEndpoint;
endpoint.setAttribute("data-altair-endpoint-line", "true");
root.appendChild(endpoint);
var query = document.createElement("textarea");
query.id = "altair-query";
query.value = cfg.initialQuery || "";
root.appendChild(query);
var runBtn = document.createElement("button");
runBtn.id = "altair-run";
runBtn.textContent = "Run";
runBtn.addEventListener("click", function () {
fetch(cfg.graphqlEndpoint, {
method: "POST",
headers: Object.assign(
{ "Content-Type": "application/json" },
cfg.initialHeaders || {}
),
body: JSON.stringify({ query: query.value })
}).then(function (r) { return r.json(); }).then(function (data) {
var out = document.getElementById("altair-output");
if (out) { out.textContent = JSON.stringify(data, null, 2); }
});
});
root.appendChild(runBtn);
var out = document.createElement("pre");
out.id = "altair-output";
root.appendChild(out);
})();
`;
const ALTAIR_SHELL_CSS = `/* @suaveplan/api-graphql-ui-altair shell styles */
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
#altair-root { padding: 16px; }
#altair-root[data-altair-theme="dark"] { background: #1e1e1e; color: #e8e8e8; }
#altair-root[data-altair-theme="light"] { background: #ffffff; color: #1a1a1a; }
#altair-query { width: 100%; min-height: 160px; font-family: ui-monospace, monospace; }
#altair-output { white-space: pre-wrap; padding: 12px; background: rgba(0,0,0,0.04); }
#altair-run { margin: 8px 0; padding: 6px 12px; cursor: pointer; }
`;
/** Default asset registry — keyed by logical name (no path prefix). */
export function buildDefaultAssetRegistry(): ReadonlyMap<string, BundledAsset> {
const m = new Map<string, BundledAsset>();
m.set(
"altair.js",
defineAsset(
"altair.js",
"application/javascript; charset=utf-8",
ALTAIR_SHELL_JS
)
);
m.set(
"altair.css",
defineAsset("altair.css", "text/css; charset=utf-8", ALTAIR_SHELL_CSS)
);
return m;
}
/**
* Resolve an asset by name against the registry. Returns a {@link
* ResolvedAsset} whose `url` is `<uiEndpointBase>/assets/<hash>/<name>`.
* Throws {@link GraphqlUiAltairError} with code `ASSET_MISSING` when
* no entry matches.
*/
export function resolveAsset(
registry: ReadonlyMap<string, BundledAsset>,
uiEndpointBase: string,
name: string
): ResolvedAsset {
const entry = registry.get(name);
if (!entry) {
throw new GraphqlUiAltairError(
GRAPHQL_UI_ALTAIR_CODES.ASSET_MISSING,
`Asset "${name}" not present in Altair bundled registry`,
{ context: { asset: name } }
);
}
return {
...entry,
url: `${uiEndpointBase}/assets/${entry.hash}/${entry.name}`,
};
}
/**
* Verify NO asset body contains an external host reference. Returns the
* list of offending asset names; an empty array means the bundle is
* CDN-clean. Used by the conformance test and as a runtime gate any
* operator can wire into their CI.
*/
export function findExternalHostReferences(
registry: ReadonlyMap<string, BundledAsset>
): readonly string[] {
const offenders: string[] = [];
const externalHostRe = /https?:\/\/[^\s'"<>]+/i;
for (const [name, asset] of registry) {
const text =
typeof asset.body === "string"
? asset.body
: new TextDecoder().decode(asset.body);
if (externalHostRe.test(text)) {
offenders.push(name);
}
}
return offenders;
}

View File

@@ -0,0 +1,7 @@
export type { BundledAsset, ResolvedAsset } from "./assets.js";
export {
buildDefaultAssetRegistry,
defineAsset,
findExternalHostReferences,
resolveAsset,
} from "./assets.js";

View File

@@ -0,0 +1,83 @@
# `error-codes` — `GRAPHQL_UI_ALTAIR.*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes` is the package's single source of truth for the two stable
error codes the Altair UI plugin emits. Centralising the codes here lets
the factory, the asset map, and any downstream observability tooling
import the literal strings from one location — no string-typos, no
divergence between the wire format and the registry. The registry is
frozen at module init and validated against the umbrella `defineCodes`
contract from `@suaveplan/error` so accidental drift is caught at type
check time, not at runtime.
## Features
- Two codes: `GRAPHQL_UI_ALTAIR.ASSET_MISSING`,
`GRAPHQL_UI_ALTAIR.ENDPOINT_COLLISION`.
- Concrete `GraphqlUiAltairError` class extending `SuaveplanError`.
- `altairCode` narrows to `GraphqlUiAltairErrorCode` for exhaustive
switch statements.
- Compatible with the umbrella redaction + i18n + OTel span-event
adapters shipped by `@suaveplan/error`.
## Usage: Basic
```ts
import {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "@suaveplan/api-graphql-ui-altair/error-codes";
throw new GraphqlUiAltairError(
GRAPHQL_UI_ALTAIR_CODES.ASSET_MISSING,
"Asset not present in bundled map",
{ context: { path: "/graphql/ui/assets/missing.js" } }
);
```
## Usage: Advanced
```ts
import {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "@suaveplan/api-graphql-ui-altair/error-codes";
function handle(err: unknown): number {
if (err instanceof GraphqlUiAltairError) {
switch (err.altairCode) {
case GRAPHQL_UI_ALTAIR_CODES.ASSET_MISSING:
return 404;
case GRAPHQL_UI_ALTAIR_CODES.ENDPOINT_COLLISION:
return 409;
}
}
return 500;
}
```
## API Reference
- `GRAPHQL_UI_ALTAIR_CODES` — frozen record `{ ASSET_MISSING, ENDPOINT_COLLISION }`.
- `GraphqlUiAltairErrorCode` — discriminated union of the two literal
strings.
- `class GraphqlUiAltairError extends SuaveplanError<GraphqlUiAltairErrorCode>`
— owns the `altairCode` accessor.
## Implementation Notes
`defineCodes("GRAPHQL_UI_ALTAIR", ...)` produces the prefixed literal
strings — agents must not hand-build the strings to avoid prefix drift.
The error class delegates entirely to `SuaveplanError` for context
serialisation, cause chains, and the wire-format adapter; the only
local responsibility is preserving `altairCode` for `instanceof`
narrowing in operator-facing error handlers. The two-code surface is
intentionally minimal: the asset path is the only failure mode at HTTP
boundary, and endpoint collision is the only failure mode at mount
boundary. Adding a code requires an OpenSpec change so the registry
shape never drifts silently between the spec and the runtime.

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "./error-codes.js";
describe("GRAPHQL_UI_ALTAIR_CODES", () => {
it("exposes ASSET_MISSING and ENDPOINT_COLLISION codes", {
timeout: 5000,
}, () => {
expect(GRAPHQL_UI_ALTAIR_CODES.ASSET_MISSING).toBe(
"GRAPHQL_UI_ALTAIR.ASSET_MISSING"
);
expect(GRAPHQL_UI_ALTAIR_CODES.ENDPOINT_COLLISION).toBe(
"GRAPHQL_UI_ALTAIR.ENDPOINT_COLLISION"
);
});
it("freezes the registry", { timeout: 5000 }, () => {
expect(Object.isFrozen(GRAPHQL_UI_ALTAIR_CODES)).toBe(true);
});
it("constructs GraphqlUiAltairError with code + message", {
timeout: 5000,
}, () => {
const err = new GraphqlUiAltairError(
GRAPHQL_UI_ALTAIR_CODES.ASSET_MISSING,
"missing asset",
{ context: { path: "/x.js" } }
);
expect(err).toBeInstanceOf(GraphqlUiAltairError);
expect(err.code).toBe("GRAPHQL_UI_ALTAIR.ASSET_MISSING");
expect(err.altairCode).toBe("GRAPHQL_UI_ALTAIR.ASSET_MISSING");
expect(err.message).toBe("missing asset");
});
it("constructs GraphqlUiAltairError without options", {
timeout: 5000,
}, () => {
const err = new GraphqlUiAltairError(
GRAPHQL_UI_ALTAIR_CODES.ENDPOINT_COLLISION,
"collision"
);
expect(err.code).toBe("GRAPHQL_UI_ALTAIR.ENDPOINT_COLLISION");
});
});

View File

@@ -0,0 +1,39 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Frozen `GRAPHQL_UI_ALTAIR.*` error-code registry.
*
* - `GRAPHQL_UI_ALTAIR_ASSET_MISSING` — a request for a static asset
* under `<uiEndpoint>/assets/...` resolved to a path not present in
* the bundled asset map. The response is 404 with this code as its
* payload `code`.
* - `GRAPHQL_UI_ALTAIR_ENDPOINT_COLLISION` — `createAltairUi` attempted
* to mount `<uiEndpoint>` on a router that already has a handler for
* `GET <uiEndpoint>` (typically another UI plugin — GraphiQL — already
* wired against the same path).
*/
export const GRAPHQL_UI_ALTAIR_CODES = defineCodes("GRAPHQL_UI_ALTAIR", [
"ASSET_MISSING",
"ENDPOINT_COLLISION",
] as const);
export type GraphqlUiAltairErrorCode =
(typeof GRAPHQL_UI_ALTAIR_CODES)[keyof typeof GRAPHQL_UI_ALTAIR_CODES];
/**
* Concrete error class — `instanceof GraphqlUiAltairError` narrows `code`
* to {@link GraphqlUiAltairErrorCode}.
*/
export class GraphqlUiAltairError extends SuaveplanError<GraphqlUiAltairErrorCode> {
public readonly altairCode: GraphqlUiAltairErrorCode;
public constructor(
code: GraphqlUiAltairErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.altairCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { GraphqlUiAltairErrorCode } from "./error-codes.js";
export {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "./error-codes.js";

View File

@@ -0,0 +1,88 @@
# `factory/create-altair-ui` — Altair UI plugin factory
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`factory/create-altair-ui` is the package's public entry point.
`createAltairUi(opts)` validates the caller's options against the Zod
schema, builds the asset registry and OTel instruments, and returns a
`RouterPlugin` whose `mount(router)` method registers `GET <uiEndpoint>`
plus the hashed-asset routes against any `Router` instance. Endpoint
collisions are detected up-front via `router.collidesWith` and reject
the mount with a typed `GRAPHQL_UI_ALTAIR.ENDPOINT_COLLISION` error
before any route is written — partial mounts are impossible.
## Features
- Zero-config call: `createAltairUi()` works out of the box.
- Returns a frozen `RouterPlugin` with `name`, `options`, `mount`,
`renderHtml`, and `renderAsset` members.
- HTML response always carries the strict CSP + ancillary security
headers.
- Asset response carries `Cache-Control: public, max-age=31536000, immutable`.
- Asset misses (wrong hash, wrong name) throw `GraphqlUiAltairError`
with code `ASSET_MISSING`.
- Endpoint collisions throw `GraphqlUiAltairError` with code
`ENDPOINT_COLLISION` before any route is registered.
- Injectable internals (`assetRegistry`, `instruments`) for integration
tests and operators wanting custom asset bundles.
## Usage: Basic
```ts
import { createAltairUi } from "@suaveplan/api-graphql-ui-altair/factory";
import { Router } from "@suaveplan/api-router";
const router = new Router();
const altair = createAltairUi();
altair.mount(router);
// router.match("GET", "/graphql/ui") now resolves to the HTML handler.
```
## Usage: Advanced
```ts
import { createAltairUi } from "@suaveplan/api-graphql-ui-altair/factory";
import { createAltairInstruments } from "@suaveplan/api-graphql-ui-altair/instruments";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { Router } from "@suaveplan/api-router";
const meterProvider = new MeterProvider();
const instruments = createAltairInstruments({ meterProvider });
const router = new Router();
const altair = createAltairUi(
{
uiEndpoint: "/dev/altair",
graphqlEndpoint: "/api/graphql",
theme: "dark",
initialEnvironments: {
production: {
url: "https://api.example.com/graphql",
headers: { "x-tenant": "acme" },
},
},
},
{ instruments }
);
altair.mount(router);
```
## API Reference
- `createAltairUi(input?: AltairUiOptionsInput, internals?: CreateAltairUiInternals): RouterPlugin`.
- `RouterPlugin` — `{ name, options, mount(router), renderHtml(), renderAsset(hash, name) }`.
- `CreateAltairUiInternals` — `{ assetRegistry?, instruments? }`.
## Implementation Notes
The factory composes five lower-level modules — options, assets, html,
headers, instruments — and exposes their behaviour behind one
ergonomic entry point. The `RouterPlugin.options` field is the parsed,
defaulted bundle (not the caller's raw input) so downstream code reads
the same value the renderer wrote. Frozen-everything posture means
operators cannot accidentally mutate the plugin between `mount` calls.

View File

@@ -0,0 +1,74 @@
/**
* Conformance test for the spec deltas under
* `openspec/changes/api-graphql-ui-altair/specs/api-graphql-ui-altair/spec.md`.
*
* The five requirements assert: HTML mount, bundled-assets-only,
* strict CSP, endpoint-collision detection, OTel counter. This file
* pins each one against the public surface of `createAltairUi`.
*/
import { Router } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildDefaultAssetRegistry,
findExternalHostReferences,
} from "../assets/assets.js";
import { ALTAIR_CSP_HEADER_NAME } from "../headers/csp.js";
import { createAltairUi } from "./create-altair-ui.js";
describe("spec conformance — api-graphql-ui-altair", () => {
it("initialEnvironments flow into embedded shell", {
timeout: 5000,
}, () => {
const plugin = createAltairUi({
initialEnvironments: {
production: { url: "https://api.example.com/graphql" },
},
});
const html = plugin.renderHtml().body;
const match = html.match(
/<script id="altair-config" type="application\/json">(.+?)<\/script>/s
);
expect(match).not.toBeNull();
const cfg = JSON.parse((match as RegExpMatchArray)[1] as string);
expect(cfg.initialEnvironments.production.url).toBe(
"https://api.example.com/graphql"
);
});
it("mount registers GET <uiEndpoint> on the router", {
timeout: 5000,
}, () => {
const router = new Router();
const plugin = createAltairUi();
plugin.mount(router);
expect(router.match("GET", "/graphql/ui")).not.toBeNull();
});
it("rendered shell references no external hosts", { timeout: 5000 }, () => {
// The shell HTML itself: scan for absolute http/https URLs in
// attributes; ignore the embedded JSON config block which may
// legitimately carry user-supplied URLs.
const plugin = createAltairUi();
const html = plugin.renderHtml().body;
// Strip the JSON config block before scanning.
const stripped = html.replace(
/<script id="altair-config" type="application\/json">.+?<\/script>/s,
""
);
expect(stripped).not.toMatch(/https?:\/\//);
});
it("bundled asset registry is CDN-clean", { timeout: 5000 }, () => {
const reg = buildDefaultAssetRegistry();
expect(findExternalHostReferences(reg)).toEqual([]);
});
it("HTML response carries the strict CSP header", { timeout: 5000 }, () => {
const plugin = createAltairUi();
const res = plugin.renderHtml();
const csp = res.headers[ALTAIR_CSP_HEADER_NAME];
expect(csp).toBeDefined();
expect(csp).toContain("default-src 'self'");
expect(csp).not.toContain("script-src 'self' 'unsafe-inline'");
});
});

View File

@@ -0,0 +1,201 @@
import { Router } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildDefaultAssetRegistry } from "../assets/assets.js";
import {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "../error-codes/error-codes.js";
import { createAltairUi } from "./create-altair-ui.js";
describe("createAltairUi", () => {
it("default mount returns Altair HTML at /graphql/ui", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "altair-test" });
const plugin = createAltairUi();
plugin.mount(router);
const match = router.match("GET", "/graphql/ui");
expect(match).not.toBeNull();
const res = (match as NonNullable<typeof match>).handler() as {
status: number;
body: string;
headers: Record<string, string>;
};
expect(res.status).toBe(200);
expect(res.headers["Content-Type"]).toContain("text/html");
expect(res.body).toContain("Altair GraphQL Client");
expect(res.body).toContain("/graphql"); // embedded endpoint
expect(res.headers["Content-Security-Policy"]).toBeDefined();
});
it("endpoint collision refused with GRAPHQL_UI_ALTAIR_ENDPOINT_COLLISION", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "altair-test" });
router.get("/graphql/ui", () => ({
status: 200,
headers: {},
body: "",
streaming: false,
}));
const plugin = createAltairUi();
try {
plugin.mount(router);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiAltairError);
expect((err as GraphqlUiAltairError).altairCode).toBe(
GRAPHQL_UI_ALTAIR_CODES.ENDPOINT_COLLISION
);
}
});
it("custom uiEndpoint flows into route registration", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "altair-test" });
const plugin = createAltairUi({ uiEndpoint: "/dev/altair" });
plugin.mount(router);
expect(router.match("GET", "/dev/altair")).not.toBeNull();
expect(router.match("GET", "/graphql/ui")).toBeNull();
});
it("plugin exposes name + parsed options", { timeout: 5000 }, () => {
const plugin = createAltairUi({ theme: "dark" });
expect(plugin.name).toBe("@suaveplan/api-graphql-ui-altair");
expect(plugin.options.theme).toBe("dark");
});
it("renderHtml is callable without mount", { timeout: 5000 }, () => {
const plugin = createAltairUi();
const res = plugin.renderHtml();
expect(res.status).toBe(200);
expect(res.body).toContain("altair-root");
});
it("renderAsset returns the matching asset", { timeout: 5000 }, () => {
const plugin = createAltairUi();
// Get the hash from the rendered HTML.
const html = plugin.renderHtml().body;
const match = html.match(
/\/graphql\/ui\/assets\/([0-9a-f]{16})\/altair\.js/
);
if (!match) throw new Error("hash not found");
const hash = match[1] as string;
const res = plugin.renderAsset(hash, "altair.js");
expect(res.status).toBe(200);
expect(res.headers["Content-Type"]).toContain("application/javascript");
});
it("renderAsset with wrong hash throws ASSET_MISSING", {
timeout: 5000,
}, () => {
const plugin = createAltairUi();
expect(() =>
plugin.renderAsset("0000000000000000", "altair.js")
).toThrow(GraphqlUiAltairError);
});
it("renderAsset with unknown name throws ASSET_MISSING", {
timeout: 5000,
}, () => {
const plugin = createAltairUi();
try {
plugin.renderAsset("deadbeefdeadbeef", "missing.svg");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiAltairError);
expect((err as GraphqlUiAltairError).altairCode).toBe(
GRAPHQL_UI_ALTAIR_CODES.ASSET_MISSING
);
}
});
it("asset route handler resolves through Router.match", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "altair-test" });
const plugin = createAltairUi();
plugin.mount(router);
const html = plugin.renderHtml().body;
const m = html.match(/assets\/([0-9a-f]{16})\/altair\.css/);
if (!m) throw new Error("hash not found");
const hash = m[1] as string;
const match = router.match(
"GET",
`/graphql/ui/assets/${hash}/altair.css`
);
expect(match).not.toBeNull();
const res = (match as NonNullable<typeof match>).handler({
params: { hash, name: "altair.css" },
}) as { status: number; headers: Record<string, string> };
expect(res.status).toBe(200);
expect(res.headers["Content-Type"]).toContain("text/css");
});
it("asset handler with no params throws ASSET_MISSING", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "altair-test" });
const plugin = createAltairUi();
plugin.mount(router);
const match = router.match(
"GET",
"/graphql/ui/assets/zzzzzzzzzzzzzzzz/altair.js"
);
if (!match) throw new Error("expected match");
expect(() =>
(
match.handler as (i: {
params: Record<string, string>;
}) => unknown
)({
params: { hash: "zzzzzzzzzzzzzzzz", name: "altair.js" },
})
).toThrow(GraphqlUiAltairError);
});
it("asset handler default params {} throws ASSET_MISSING", {
timeout: 5000,
}, () => {
const router = new Router({ plugin: "altair-test" });
const plugin = createAltairUi();
plugin.mount(router);
const match = router.match(
"GET",
"/graphql/ui/assets/aaaaaaaaaaaaaaaa/x.js"
);
if (!match) throw new Error("expected match");
// Call handler with no input — params defaults to {}.
expect(() =>
(match.handler as (i?: unknown) => unknown)(undefined)
).toThrow(GraphqlUiAltairError);
});
it("uses injected instruments", { timeout: 5000 }, () => {
let recorded = 0;
const instruments = {
meter: {} as never,
requestsTotal: {} as never,
recordRequest: (): void => {
recorded += 1;
},
};
const plugin = createAltairUi(undefined, { instruments });
plugin.renderHtml();
plugin.renderHtml();
expect(recorded).toBe(2);
});
it("uses injected assetRegistry", { timeout: 5000 }, () => {
const reg = buildDefaultAssetRegistry();
const plugin = createAltairUi(undefined, { assetRegistry: reg });
const html = plugin.renderHtml().body;
expect(html).toContain("altair.js");
});
it("plugin is frozen", { timeout: 5000 }, () => {
const plugin = createAltairUi();
expect(Object.isFrozen(plugin)).toBe(true);
});
});

View File

@@ -0,0 +1,148 @@
import type { Router, RouterResponse } from "@suaveplan/api-router";
import {
type BundledAsset,
buildDefaultAssetRegistry,
} from "../assets/assets.js";
import {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "../error-codes/error-codes.js";
import { buildAltairResponseHeaders } from "../headers/csp.js";
import { renderAltairShell } from "../html/render-shell.js";
import {
type AltairInstruments,
createAltairInstruments,
} from "../instruments/instruments.js";
import {
type AltairUiOptions,
type AltairUiOptionsInput,
parseAltairUiOptions,
} from "../options/options.js";
/**
* Plugin shape returned by {@link createAltairUi}. Structurally
* compatible with the broader `RouterPlugin` family — every plugin
* exposes a `name`, a `mount(router)` lifecycle method, and the
* already-defaulted `options` bundle so other plugins (e.g. the
* GraphQL engine) can read the configured endpoints.
*/
export interface RouterPlugin {
readonly name: string;
readonly options: AltairUiOptions;
mount(router: Router): void;
/**
* Direct HTML build — exposed so integration tests and operators
* embedding the shell in custom Hono/Express adapters can call the
* renderer without going through the router.
*/
renderHtml(): RouterResponse<string>;
/**
* Direct asset resolve — same rationale as `renderHtml`.
*/
renderAsset(
hash: string,
name: string
): RouterResponse<string | Uint8Array>;
}
/** Extra knobs not part of the user-facing options surface. */
export interface CreateAltairUiInternals {
/** Override the bundled asset registry (default: built-in). */
readonly assetRegistry?: ReadonlyMap<string, BundledAsset>;
/** Override the instrumentation bundle (default: created fresh). */
readonly instruments?: AltairInstruments;
}
const ALTAIR_PLUGIN_NAME = "@suaveplan/api-graphql-ui-altair" as const;
/**
* Build the Altair UI plugin. Returns a {@link RouterPlugin} that —
* when `mount(router)` is called — registers the HTML route and the
* static-asset routes against the router. Collision with an existing
* `GET <uiEndpoint>` raises {@link GraphqlUiAltairError} with code
* `ENDPOINT_COLLISION`.
*/
export function createAltairUi(
input?: AltairUiOptionsInput,
internals?: CreateAltairUiInternals
): RouterPlugin {
const options: AltairUiOptions = parseAltairUiOptions(input);
const assetRegistry =
internals?.assetRegistry ?? buildDefaultAssetRegistry();
const instruments = internals?.instruments ?? createAltairInstruments();
const headers = buildAltairResponseHeaders();
function renderHtml(): RouterResponse<string> {
instruments.recordRequest();
const html = renderAltairShell({ options, assetRegistry });
return Object.freeze({
status: 200,
headers: Object.freeze({
...headers,
"Content-Type": "text/html; charset=utf-8",
}),
body: html,
streaming: false,
});
}
function renderAsset(
hash: string,
name: string
): RouterResponse<string | Uint8Array> {
const entry = assetRegistry.get(name);
if (!entry || entry.hash !== hash) {
throw new GraphqlUiAltairError(
GRAPHQL_UI_ALTAIR_CODES.ASSET_MISSING,
`Asset "${name}@${hash}" not present in Altair bundled registry`,
{ context: { asset: name, hash } }
);
}
return Object.freeze({
status: 200,
headers: Object.freeze({
"Content-Type": entry.contentType,
"Cache-Control": "public, max-age=31536000, immutable",
}),
body: entry.body,
streaming: false,
});
}
function mount(router: Router): void {
if (router.collidesWith("GET", options.uiEndpoint).length > 0) {
throw new GraphqlUiAltairError(
GRAPHQL_UI_ALTAIR_CODES.ENDPOINT_COLLISION,
`GET ${options.uiEndpoint} is already mounted on the router`,
{ context: { uiEndpoint: options.uiEndpoint } }
);
}
const htmlHandler = (..._args: readonly unknown[]): unknown =>
renderHtml();
const assetHandler = (...args: readonly unknown[]): unknown => {
const first = args[0];
const params: Readonly<Record<string, string>> =
typeof first === "object" &&
first !== null &&
"params" in first &&
typeof (first as { params: unknown }).params === "object" &&
(first as { params: unknown }).params !== null
? (first as { params: Readonly<Record<string, string>> })
.params
: {};
const hash = params["hash"] ?? "";
const name = params["name"] ?? "";
return renderAsset(hash, name);
};
router.get(options.uiEndpoint, htmlHandler);
router.get(`${options.uiEndpoint}/assets/:hash/:name`, assetHandler);
}
return Object.freeze({
name: ALTAIR_PLUGIN_NAME,
options,
mount,
renderHtml,
renderAsset,
});
}

View File

@@ -0,0 +1,5 @@
export type {
CreateAltairUiInternals,
RouterPlugin,
} from "./create-altair-ui.js";
export { createAltairUi } from "./create-altair-ui.js";

View File

@@ -0,0 +1,78 @@
# `headers/csp` — Strict CSP for the Altair shell
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`headers/csp` produces the Content-Security-Policy and the two ancillary
security headers (`X-Content-Type-Options`, `Referrer-Policy`) the
Altair plugin attaches to its HTML response. The policy is deliberately
strict — no `'unsafe-inline'` on `script-src` — so a regression that
sneaks an inline script into the shell template breaks loud at policy
enforcement, not at audit time. The same posture is used by the
GraphiQL sibling so dashboards and auditors can compare both packages
on equal terms.
## Features
- Single `Content-Security-Policy` string with eight directives.
- `'unsafe-inline'` is permitted ONLY on `style-src` — required for
Altair's shell styling, never for scripts.
- `frame-ancestors 'none'` denies embedding (clickjacking defence).
- Ancillary headers: `X-Content-Type-Options: nosniff`,
`Referrer-Policy: no-referrer`.
- Header bundle returned frozen so downstream middleware can't mutate
it by accident.
## Usage: Basic
```ts
import { buildAltairResponseHeaders } from "@suaveplan/api-graphql-ui-altair/headers";
const headers = buildAltairResponseHeaders();
// headers["Content-Security-Policy"] === "default-src 'self'; ..."
```
## Usage: Advanced
```ts
import {
ALTAIR_CSP_HEADER_NAME,
ALTAIR_CSP_HEADER_VALUE,
buildAltairResponseHeaders,
} from "@suaveplan/api-graphql-ui-altair/headers";
// Drop onto a Fetch-style Response.
function respond(body: string): Response {
const headers = new Headers(buildAltairResponseHeaders());
headers.set("Content-Type", "text/html; charset=utf-8");
return new Response(body, { status: 200, headers });
}
// Direct access for static header injection (e.g. CDN edge config).
console.log(`${ALTAIR_CSP_HEADER_NAME}: ${ALTAIR_CSP_HEADER_VALUE}`);
```
## API Reference
- `buildAltairResponseHeaders(): Readonly<Record<string,string>>`
- `ALTAIR_CSP_HEADER_NAME: "Content-Security-Policy"` literal.
- `ALTAIR_CSP_HEADER_VALUE: string` — the eight-directive policy.
## Implementation Notes
The CSP value is assembled as a constant string at module init — there
are no parameters today (a nonce'd variant would land here if the
shell template were converted off inline `<style>` and would consume a
caller-supplied nonce). The function return is wrapped in
`Object.freeze` to surface accidental mutation during integration
tests. Eight directives keep the policy short enough to read in a
single browser-devtools row yet rich enough to defeat the standard
opportunistic-injection vectors (mixed content, inline JS, dangling
`<form>` hijack, third-party `<base>` retargeting). Operators wanting
a stricter posture — e.g. `script-src 'none'` for a read-only audit
demo — can wrap the function and substitute their own value before it
hits the response builder; the package never reads the CSP string
back, so there is no integrity-check loop to satisfy.

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ALTAIR_CSP_HEADER_NAME,
ALTAIR_CSP_HEADER_VALUE,
buildAltairResponseHeaders,
} from "./csp.js";
describe("CSP header", () => {
it("CSP header set with 'unsafe-inline' only for style-src", {
timeout: 5000,
}, () => {
const headers = buildAltairResponseHeaders();
expect(headers[ALTAIR_CSP_HEADER_NAME]).toBeDefined();
const csp = headers[ALTAIR_CSP_HEADER_NAME] ?? "";
// script-src does not include 'unsafe-inline'
const scriptDirective = csp
.split(";")
.map((s) => s.trim())
.find((s) => s.startsWith("script-src"));
expect(scriptDirective).toBeDefined();
expect(scriptDirective).not.toContain("'unsafe-inline'");
// style-src does include 'unsafe-inline'
const styleDirective = csp
.split(";")
.map((s) => s.trim())
.find((s) => s.startsWith("style-src"));
expect(styleDirective).toContain("'unsafe-inline'");
});
it("default-src locked to 'self'", { timeout: 5000 }, () => {
expect(ALTAIR_CSP_HEADER_VALUE).toContain("default-src 'self'");
});
it("frame-ancestors 'none'", { timeout: 5000 }, () => {
expect(ALTAIR_CSP_HEADER_VALUE).toContain("frame-ancestors 'none'");
});
it("attaches X-Content-Type-Options and Referrer-Policy", {
timeout: 5000,
}, () => {
const headers = buildAltairResponseHeaders();
expect(headers["X-Content-Type-Options"]).toBe("nosniff");
expect(headers["Referrer-Policy"]).toBe("no-referrer");
});
it("header bundle is frozen", { timeout: 5000 }, () => {
const headers = buildAltairResponseHeaders();
expect(Object.isFrozen(headers)).toBe(true);
});
it("exports the header name constant", { timeout: 5000 }, () => {
expect(ALTAIR_CSP_HEADER_NAME).toBe("Content-Security-Policy");
});
});

View File

@@ -0,0 +1,50 @@
/**
* Strict Content-Security-Policy header value used on every HTML
* response produced by `createAltairUi`. The posture mirrors the
* GraphiQL sibling so dashboards/auditors can compare the two
* surface-for-surface.
*
* Notes:
*
* - `default-src 'self'` — only the bundled package origin may load
* resources by default.
* - `script-src 'self'` — inline scripts are deliberately FORBIDDEN.
* The HTML shell injects the embedded config as a `<script
* type="application/json">` block (data, not executable code) and
* loads behaviour from a hashed file under `<uiEndpoint>/assets/...`.
* - `style-src 'self' 'unsafe-inline'` — Altair, like GraphiQL, ships
* inline `<style>` blocks for its shell layout. Inline style is a
* smaller blast radius than inline script and matches what the spec
* permits.
* - `connect-src 'self'` — the UI talks to its own GraphQL/WS endpoints
* only. Operators can extend the policy via reverse proxy if they
* need cross-origin debugging.
* - `frame-ancestors 'none'` — clickjacking defence.
*/
export const ALTAIR_CSP_HEADER_VALUE =
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self' data:; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'";
/** Header name to attach the CSP value under. */
export const ALTAIR_CSP_HEADER_NAME = "Content-Security-Policy" as const;
/**
* Produce a frozen header bundle the factory drops onto every HTML
* response. Kept as a function (not a plain constant) so a future
* variant — e.g. a nonce'd flavour — can take parameters without
* breaking callers.
*/
export function buildAltairResponseHeaders(): Readonly<Record<string, string>> {
return Object.freeze({
[ALTAIR_CSP_HEADER_NAME]: ALTAIR_CSP_HEADER_VALUE,
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
});
}

View File

@@ -0,0 +1,5 @@
export {
ALTAIR_CSP_HEADER_NAME,
ALTAIR_CSP_HEADER_VALUE,
buildAltairResponseHeaders,
} from "./csp.js";

View File

@@ -0,0 +1,2 @@
export type { RenderAltairShellInput } from "./render-shell.js";
export { renderAltairShell } from "./render-shell.js";

View File

@@ -0,0 +1,81 @@
# `html/render-shell` — Altair HTML shell builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`html/render-shell` produces the single HTML document the Altair UI
plugin serves at `<uiEndpoint>`. The shell mounts a `<div id="altair-root">`,
embeds the resolved JS/CSS asset URLs (hash-busted, so CDN cache
behaviour is correct), and serialises the parsed `AltairUiOptions` as a
`<script type="application/json">` data block — never as executable
inline JavaScript — so the strict CSP attached by `headers/csp` can
forbid `script-src 'unsafe-inline'` outright.
## Features
- One self-contained HTML document — no external host references.
- Configurable endpoints surfaced both as `<meta>` tags AND as `data-*`
attributes on the root container, so consumers integrating Altair
via custom JS can read either layer.
- JSON config block escaped against `</script>` injection per the HTML5
spec recommendation.
- User-supplied endpoint values HTML-escaped before embedding.
- Theme attribute (`data-altair-theme`) on `<html>` for CSS hooks.
## Usage: Basic
```ts
import { renderAltairShell } from "@suaveplan/api-graphql-ui-altair/html";
import { buildDefaultAssetRegistry } from "@suaveplan/api-graphql-ui-altair/assets";
import { parseAltairUiOptions } from "@suaveplan/api-graphql-ui-altair/options";
const html = renderAltairShell({
options: parseAltairUiOptions(),
assetRegistry: buildDefaultAssetRegistry(),
});
```
## Usage: Advanced
```ts
import { renderAltairShell } from "@suaveplan/api-graphql-ui-altair/html";
import {
buildDefaultAssetRegistry,
defineAsset,
} from "@suaveplan/api-graphql-ui-altair/assets";
import { parseAltairUiOptions } from "@suaveplan/api-graphql-ui-altair/options";
const registry = new Map(buildDefaultAssetRegistry());
registry.set(
"logo.svg",
defineAsset("logo.svg", "image/svg+xml", "<svg/>")
);
const html = renderAltairShell({
options: parseAltairUiOptions({
theme: "dark",
initialEnvironments: {
production: { url: "https://api.example.com/graphql" },
},
}),
assetRegistry: registry,
});
```
## API Reference
- `RenderAltairShellInput` — `{ options: AltairUiOptions; assetRegistry: ReadonlyMap<string, BundledAsset> }`.
- `renderAltairShell(input): string` — returns the full HTML document
including the `<!DOCTYPE html>` declaration.
## Implementation Notes
The `</script>` escape inside the JSON config block uses the HTML5
spec's recommended sequence `<\/script` so the JSON itself remains
parseable while the closing-tag-injection vector is closed. All
user-supplied strings flowing into HTML attributes pass through a
four-character HTML escape — the structural goal is "no inline script
ever executes", which the strict CSP then enforces at the browser.

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildDefaultAssetRegistry } from "../assets/assets.js";
import { parseAltairUiOptions } from "../options/options.js";
import { renderAltairShell } from "./render-shell.js";
describe("renderAltairShell", () => {
it("embeds the default endpoints", { timeout: 5000 }, () => {
const html = renderAltairShell({
options: parseAltairUiOptions(),
assetRegistry: buildDefaultAssetRegistry(),
});
expect(html).toContain('content="/graphql"');
expect(html).toContain('content="/graphql/ws"');
expect(html).toContain('data-altair-graphql-endpoint="/graphql"');
});
it("embeds JSON config block with parsed options", {
timeout: 5000,
}, () => {
const html = renderAltairShell({
options: parseAltairUiOptions({ initialQuery: "{ ping }" }),
assetRegistry: buildDefaultAssetRegistry(),
});
const match = html.match(
/<script id="altair-config" type="application\/json">(.+?)<\/script>/s
);
expect(match).not.toBeNull();
const data = JSON.parse((match as RegExpMatchArray)[1] as string);
expect(data.initialQuery).toBe("{ ping }");
expect(data.graphqlEndpoint).toBe("/graphql");
});
it("escapes HTML in user-supplied endpoint values", {
timeout: 5000,
}, () => {
const html = renderAltairShell({
options: parseAltairUiOptions({
graphqlEndpoint: '/graphql"><script>alert(1)</script>',
}),
assetRegistry: buildDefaultAssetRegistry(),
});
expect(html).not.toContain('"><script>alert(1)</script>');
expect(html).toContain("&quot;&gt;&lt;script&gt;");
});
it("escapes </script> inside the JSON config block", {
timeout: 5000,
}, () => {
const html = renderAltairShell({
options: parseAltairUiOptions({
initialQuery: "</script>{ ping }",
}),
assetRegistry: buildDefaultAssetRegistry(),
});
// The literal "</script>" inside the JSON gets the HTML5 spec's
// recommended `<\/script>` escape so parsers don't terminate the
// tag early.
expect(html).toContain("<\\/script>");
// The JSON config block must still parse.
const match = html.match(
/<script id="altair-config" type="application\/json">(.+?)<\/script>/s
);
expect(match).not.toBeNull();
const raw = (match as RegExpMatchArray)[1] as string;
// Undo the JSON-string escape for `<\/` and reparse.
const data = JSON.parse(raw.replace(/<\\\/script/g, "</script"));
expect(data.initialQuery).toBe("</script>{ ping }");
});
it("uses configured theme attribute", { timeout: 5000 }, () => {
const html = renderAltairShell({
options: parseAltairUiOptions({ theme: "dark" }),
assetRegistry: buildDefaultAssetRegistry(),
});
expect(html).toContain('data-altair-theme="dark"');
});
it("references the hashed asset URLs", { timeout: 5000 }, () => {
const html = renderAltairShell({
options: parseAltairUiOptions(),
assetRegistry: buildDefaultAssetRegistry(),
});
expect(html).toMatch(/\/graphql\/ui\/assets\/[0-9a-f]{16}\/altair\.js/);
expect(html).toMatch(
/\/graphql\/ui\/assets\/[0-9a-f]{16}\/altair\.css/
);
});
});

View File

@@ -0,0 +1,67 @@
import { type BundledAsset, resolveAsset } from "../assets/assets.js";
import type { AltairUiOptions } from "../options/options.js";
/**
* Inputs for {@link renderAltairShell}. The shell needs the parsed
* options bundle and the asset registry — both are produced upstream
* by `parseAltairUiOptions` and `buildDefaultAssetRegistry`.
*/
export interface RenderAltairShellInput {
readonly options: AltairUiOptions;
readonly assetRegistry: ReadonlyMap<string, BundledAsset>;
}
/** HTML-safe escape — the four characters that matter. */
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Embed config as a `<script type="application/json">` block (data,
* not executable) so the CSP can forbid inline scripts entirely.
* Closing `</script>` sequences inside the JSON are escaped per the
* HTML5 spec — `<\/script>` reads as the literal string `</script>`.
*/
function embedConfig(options: AltairUiOptions): string {
const json = JSON.stringify(options);
const safe = json.replace(/<\/script/gi, "<\\/script");
return `<script id="altair-config" type="application/json">${safe}</script>`;
}
/**
* Render the Altair HTML shell. The result embeds the resolved JS/CSS
* asset URLs (hash-busted), serialises the parsed options as a
* data-only `<script type="application/json">` block, and mounts a
* single root container the bundled JS uses for rendering.
*
* The shell is intentionally compact — no inline scripts, no inline
* event handlers, no external host references — so the strict CSP
* built by `buildAltairResponseHeaders` is honoured.
*/
export function renderAltairShell(input: RenderAltairShellInput): string {
const { options, assetRegistry } = input;
const js = resolveAsset(assetRegistry, options.uiEndpoint, "altair.js");
const css = resolveAsset(assetRegistry, options.uiEndpoint, "altair.css");
const title = "Altair GraphQL Client";
return `<!DOCTYPE html>
<html lang="en" data-altair-theme="${escapeHtml(options.theme)}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="x-altair-graphql-endpoint" content="${escapeHtml(options.graphqlEndpoint)}" />
<meta name="x-altair-subscription-endpoint" content="${escapeHtml(options.subscriptionEndpoint)}" />
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="${escapeHtml(css.url)}" />
</head>
<body>
<div id="altair-root" data-altair-graphql-endpoint="${escapeHtml(options.graphqlEndpoint)}"></div>
${embedConfig(options)}
<script src="${escapeHtml(js.url)}" defer></script>
</body>
</html>`;
}

View File

@@ -0,0 +1,53 @@
// Public surface — explicit named re-exports per CLAUDE.md rule 10.
// Assets
export type { BundledAsset, ResolvedAsset } from "./assets/assets.js";
export {
buildDefaultAssetRegistry,
defineAsset,
findExternalHostReferences,
resolveAsset,
} from "./assets/assets.js";
// Error codes
export type { GraphqlUiAltairErrorCode } from "./error-codes/error-codes.js";
export {
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "./error-codes/error-codes.js";
// Factory
export type {
CreateAltairUiInternals,
RouterPlugin,
} from "./factory/create-altair-ui.js";
export { createAltairUi } from "./factory/create-altair-ui.js";
// Headers
export {
ALTAIR_CSP_HEADER_NAME,
ALTAIR_CSP_HEADER_VALUE,
buildAltairResponseHeaders,
} from "./headers/csp.js";
// HTML shell
export type { RenderAltairShellInput } from "./html/render-shell.js";
export { renderAltairShell } from "./html/render-shell.js";
// Instruments
export type {
AltairInstruments,
AltairInstrumentsConfig,
} from "./instruments/instruments.js";
export {
ALTAIR_METER_NAME,
ALTAIR_UI_LABEL,
createAltairInstruments,
GRAPHQL_UI_REQUESTS_COUNTER,
} from "./instruments/instruments.js";
// Options
export type {
AltairEnvironment,
AltairUiOptions,
AltairUiOptionsInput,
} from "./options/options.js";
export {
altairUiOptionsSchema,
parseAltairUiOptions,
} from "./options/options.js";

View File

@@ -0,0 +1,10 @@
export type {
AltairInstruments,
AltairInstrumentsConfig,
} from "./instruments.js";
export {
ALTAIR_METER_NAME,
ALTAIR_UI_LABEL,
createAltairInstruments,
GRAPHQL_UI_REQUESTS_COUNTER,
} from "./instruments.js";

View File

@@ -0,0 +1,67 @@
# `instruments` — OTel counter for Altair UI requests
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments` owns the package's single OpenTelemetry counter,
`graphql.ui.requests_total`, labelled with the constant
`ui="altair"`. The counter name is deliberately identical to the
GraphiQL sibling's so dashboards summing across both UIs can drop the
`ui` label and add the two series. The `MeterProvider` is injectable —
operators can hand in their own provider for in-process testing or rely
on the OTel global the rest of the host has registered at boot.
## Features
- Single counter — `graphql.ui.requests_total`.
- Constant label preset: `{ ui: "altair" }`.
- `recordRequest()` convenience captures the label internally so call
sites never repeat the label literal (no drift risk).
- Meter name pinned to `@suaveplan/api-graphql-ui-altair` for trace
correlation.
- Falls back to the OTel global `MeterProvider` when none is supplied.
## Usage: Basic
```ts
import { createAltairInstruments } from "@suaveplan/api-graphql-ui-altair/instruments";
const inst = createAltairInstruments();
inst.recordRequest();
```
## Usage: Advanced
```ts
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { createAltairInstruments } from "@suaveplan/api-graphql-ui-altair/instruments";
const meterProvider = new MeterProvider({
/* readers / exporters configured in the host */
});
const inst = createAltairInstruments({ meterProvider });
// Inside the HTML response handler:
inst.recordRequest(); // graphql.ui.requests_total{ui="altair"} += 1
```
## API Reference
- `createAltairInstruments(config?: AltairInstrumentsConfig): AltairInstruments`.
- `AltairInstruments` — `{ meter, requestsTotal, recordRequest }`,
frozen.
- `GRAPHQL_UI_REQUESTS_COUNTER` — literal `"graphql.ui.requests_total"`.
- `ALTAIR_UI_LABEL` — literal `"altair"`.
- `ALTAIR_METER_NAME` — literal `"@suaveplan/api-graphql-ui-altair"`.
## Implementation Notes
The counter description block documents the cross-package label contract
so operators reading the OTel discovery endpoint understand why two
packages emit the same metric name. The `Object.freeze` on the return
record prevents accidental mutation downstream — the bundle is meant to
be a singleton per host instance.

View File

@@ -0,0 +1,121 @@
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ALTAIR_METER_NAME,
ALTAIR_UI_LABEL,
createAltairInstruments,
GRAPHQL_UI_REQUESTS_COUNTER,
} from "./instruments.js";
interface FakeCounter {
readonly name: string;
readonly addCalls: Array<{
value: number;
attrs?: Readonly<Record<string, unknown>>;
}>;
}
interface FakeMeter {
readonly name: string;
readonly counters: FakeCounter[];
}
function buildFakeMeterProvider(): {
provider: MeterProvider;
meters: FakeMeter[];
} {
const meters: FakeMeter[] = [];
const provider: MeterProvider = {
getMeter(name: string): Meter {
const captured: FakeMeter = { name, counters: [] };
meters.push(captured);
const meter: Meter = {
createCounter(n: string) {
const c: FakeCounter = { name: n, addCalls: [] };
captured.counters.push(c);
const counter: Counter = {
add(value, attrs) {
c.addCalls.push({ value, attrs });
},
} as Counter;
return counter;
},
createUpDownCounter() {
throw new Error("not used");
},
createHistogram() {
throw new Error("not used");
},
createGauge() {
throw new Error("not used");
},
createObservableCounter() {
throw new Error("not used");
},
createObservableGauge() {
throw new Error("not used");
},
createObservableUpDownCounter() {
throw new Error("not used");
},
addBatchObservableCallback() {
// noop
},
removeBatchObservableCallback() {
// noop
},
} as unknown as Meter;
return meter;
},
} as MeterProvider;
return { provider, meters };
}
describe("createAltairInstruments", () => {
it("UI HTML increments graphql.ui.requests_total with ui='altair' label", {
timeout: 5000,
}, () => {
const { provider, meters } = buildFakeMeterProvider();
const inst = createAltairInstruments({ meterProvider: provider });
inst.recordRequest();
expect(meters).toHaveLength(1);
const m = meters[0];
if (!m) throw new Error("no meter");
expect(m.name).toBe(ALTAIR_METER_NAME);
expect(m.counters).toHaveLength(1);
const c = m.counters[0];
if (!c) throw new Error("no counter");
expect(c.name).toBe(GRAPHQL_UI_REQUESTS_COUNTER);
expect(c.addCalls).toHaveLength(1);
expect(c.addCalls[0]?.value).toBe(1);
expect(c.addCalls[0]?.attrs).toEqual({ ui: ALTAIR_UI_LABEL });
});
it("falls back to the global MeterProvider when none is supplied", {
timeout: 5000,
}, () => {
const inst = createAltairInstruments();
// Global is no-op by default — assert the structural shape only.
expect(typeof inst.recordRequest).toBe("function");
expect(inst.requestsTotal).toBeDefined();
inst.recordRequest();
});
it("returns a frozen bundle", { timeout: 5000 }, () => {
const { provider } = buildFakeMeterProvider();
const inst = createAltairInstruments({ meterProvider: provider });
expect(Object.isFrozen(inst)).toBe(true);
});
it("two recordRequest calls produce two add events", {
timeout: 5000,
}, () => {
const { provider, meters } = buildFakeMeterProvider();
const inst = createAltairInstruments({ meterProvider: provider });
inst.recordRequest();
inst.recordRequest();
const c = meters[0]?.counters[0];
if (!c) throw new Error("no counter");
expect(c.addCalls).toHaveLength(2);
});
});

View File

@@ -0,0 +1,55 @@
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
/** UI label literal applied to every observation from this package. */
export const ALTAIR_UI_LABEL = "altair" as const;
/** Counter name shared with the GraphiQL sibling. */
export const GRAPHQL_UI_REQUESTS_COUNTER = "graphql.ui.requests_total" as const;
/** Meter name registered against the OTel provider. */
export const ALTAIR_METER_NAME = "@suaveplan/api-graphql-ui-altair" as const;
/** Frozen bundle of every OTel instrument the plugin emits. */
export interface AltairInstruments {
readonly meter: Meter;
readonly requestsTotal: Counter;
/**
* Convenience: increment the counter with the `ui="altair"` label
* preset. The label dimension is deliberately constant so the
* GraphiQL variant's counter (same name, `ui="graphiql"` label) can
* be summed across both UIs in a dashboard.
*/
readonly recordRequest: () => void;
}
/** Config consumed by {@link createAltairInstruments}. */
export interface AltairInstrumentsConfig {
/** Injectable MeterProvider — defaults to the OTel global. */
readonly meterProvider?: MeterProvider;
}
/**
* Build the plugin's instrument registry. The counter name is
* deliberately identical to the GraphiQL variant — only the `ui`
* label differs — so dashboards summing across both UIs simply drop
* the label.
*/
export function createAltairInstruments(
config: AltairInstrumentsConfig = {}
): AltairInstruments {
const meterProvider = config.meterProvider ?? metrics.getMeterProvider();
const meter = meterProvider.getMeter(ALTAIR_METER_NAME);
const requestsTotal = meter.createCounter(GRAPHQL_UI_REQUESTS_COUNTER, {
description:
"Count of HTML responses served by a GraphQL UI plugin. Label `ui` discriminates altair vs graphiql.",
});
const recordRequest = (): void => {
requestsTotal.add(1, { ui: ALTAIR_UI_LABEL });
};
return Object.freeze({
meter,
requestsTotal,
recordRequest,
});
}

View File

@@ -0,0 +1,9 @@
export type {
AltairEnvironment,
AltairUiOptions,
AltairUiOptionsInput,
} from "./options.js";
export {
altairUiOptionsSchema,
parseAltairUiOptions,
} from "./options.js";

View File

@@ -0,0 +1,74 @@
# `options` — `AltairUiOptions` schema + parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`options` owns the Zod-validated configuration surface every consumer of
the Altair UI plugin passes through. The schema enforces structural
invariants — every endpoint string must start with `/`, theme is one of
three literal values, top-level keys are strict so typos surface at the
boundary — while still defaulting every field so the most common call
site (`createAltairUi()`) is zero-config. Centralising defaulting in one
schema means the factory, the HTML renderer, and the asset map all
consume the same shape.
## Features
- Defaults: `uiEndpoint=/graphql/ui`, `graphqlEndpoint=/graphql`,
`subscriptionEndpoint=/graphql/ws`, `theme=system`, `initialQuery=""`,
empty `initialHeaders`, empty `initialEnvironments`.
- Strict mode at every nesting level — unknown keys are rejected.
- Rich per-environment block: `url`, `subscriptionsEndpoint`, `headers`,
`variables`.
- `parseAltairUiOptions(undefined)` is valid and produces the default
bundle.
## Usage: Basic
```ts
import { parseAltairUiOptions } from "@suaveplan/api-graphql-ui-altair/options";
const opts = parseAltairUiOptions();
// opts.uiEndpoint === "/graphql/ui"
```
## Usage: Advanced
```ts
import { parseAltairUiOptions } from "@suaveplan/api-graphql-ui-altair/options";
const opts = parseAltairUiOptions({
uiEndpoint: "/dev/ui",
graphqlEndpoint: "/api/graphql",
theme: "dark",
initialEnvironments: {
production: {
url: "https://api.example.com/graphql",
subscriptionsEndpoint: "wss://api.example.com/graphql",
headers: { "x-tenant": "acme" },
variables: { region: "us-east-1" },
},
},
});
```
## API Reference
- `altairUiOptionsSchema: z.ZodObject` — direct Zod handle (composable
via `.extend()` or `.partial()` for downstream wrappers).
- `parseAltairUiOptions(input?: AltairUiOptionsInput): AltairUiOptions`
— throws `ZodError` on shape violations.
- `AltairUiOptions` — output type (every field defined).
- `AltairUiOptionsInput` — input type (every field optional).
- `AltairEnvironment` — per-environment record shape.
## Implementation Notes
The Zod default record literals (`{}`) feed back into `z.input` as
optional, so the input type stays ergonomic at the caller boundary
while the output type guarantees every field is populated downstream.
The schema uses the Zod 4 default-import (`import z from "zod"`) per
the repo-wide Bun-safe convention.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { altairUiOptionsSchema, parseAltairUiOptions } from "./options.js";
describe("parseAltairUiOptions", () => {
it("applies all defaults when input is undefined", {
timeout: 5000,
}, () => {
const out = parseAltairUiOptions(undefined);
expect(out.uiEndpoint).toBe("/graphql/ui");
expect(out.graphqlEndpoint).toBe("/graphql");
expect(out.subscriptionEndpoint).toBe("/graphql/ws");
expect(out.theme).toBe("system");
expect(out.initialQuery).toBe("");
expect(out.initialHeaders).toEqual({});
expect(out.initialEnvironments).toEqual({});
});
it("applies all defaults when input is empty object", {
timeout: 5000,
}, () => {
const out = parseAltairUiOptions({});
expect(out.uiEndpoint).toBe("/graphql/ui");
});
it("accepts overrides for every field", { timeout: 5000 }, () => {
const out = parseAltairUiOptions({
uiEndpoint: "/dev/ui",
graphqlEndpoint: "/api/graphql",
subscriptionEndpoint: "/api/ws",
theme: "dark",
initialQuery: "{ ping }",
initialHeaders: { "x-tenant": "acme" },
initialEnvironments: {
production: {
url: "https://api.example.com/graphql",
headers: { "x-env": "prod" },
variables: { region: "us-east-1" },
subscriptionsEndpoint: "wss://api.example.com/graphql",
},
},
});
expect(out.uiEndpoint).toBe("/dev/ui");
expect(out.theme).toBe("dark");
expect(out.initialHeaders["x-tenant"]).toBe("acme");
expect(out.initialEnvironments["production"]?.url).toBe(
"https://api.example.com/graphql"
);
});
it("rejects uiEndpoint that doesn't start with /", {
timeout: 5000,
}, () => {
expect(() =>
parseAltairUiOptions({ uiEndpoint: "graphql/ui" })
).toThrow();
});
it("rejects graphqlEndpoint that doesn't start with /", {
timeout: 5000,
}, () => {
expect(() =>
parseAltairUiOptions({ graphqlEndpoint: "graphql" })
).toThrow();
});
it("rejects subscriptionEndpoint that doesn't start with /", {
timeout: 5000,
}, () => {
expect(() =>
parseAltairUiOptions({ subscriptionEndpoint: "ws" })
).toThrow();
});
it("rejects empty uiEndpoint", { timeout: 5000 }, () => {
expect(() => parseAltairUiOptions({ uiEndpoint: "" })).toThrow();
});
it("rejects unknown theme", { timeout: 5000 }, () => {
// @ts-expect-error testing runtime rejection
expect(() => parseAltairUiOptions({ theme: "midnight" })).toThrow();
});
it("rejects unknown top-level keys (strict)", { timeout: 5000 }, () => {
expect(() =>
// @ts-expect-error testing runtime rejection
parseAltairUiOptions({ rogue: true })
).toThrow();
});
it("schema is reusable directly via parse", { timeout: 5000 }, () => {
const out = altairUiOptionsSchema.parse({});
expect(out.theme).toBe("system");
});
});

View File

@@ -0,0 +1,72 @@
import z from "zod";
/** Per-environment configuration block — narrow projection of Altair's. */
export interface AltairEnvironment {
readonly url?: string;
readonly subscriptionsEndpoint?: string;
readonly headers?: Readonly<Record<string, string>>;
readonly variables?: Readonly<Record<string, string>>;
}
const environmentSchema = z
.object({
url: z.string().optional(),
subscriptionsEndpoint: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
variables: z.record(z.string(), z.string()).optional(),
})
.strict();
/**
* Zod schema for {@link AltairUiOptions}. The schema is intentionally
* permissive on header/environment values — operators can plug arbitrary
* string values into the embedded shell — and strict on the four
* structural fields (endpoints + theme).
*/
export const altairUiOptionsSchema = z
.object({
uiEndpoint: z
.string()
.min(1)
.startsWith("/", {
message: "uiEndpoint must start with '/'",
})
.default("/graphql/ui"),
graphqlEndpoint: z
.string()
.min(1)
.startsWith("/", {
message: "graphqlEndpoint must start with '/'",
})
.default("/graphql"),
subscriptionEndpoint: z
.string()
.min(1)
.startsWith("/", {
message: "subscriptionEndpoint must start with '/'",
})
.default("/graphql/ws"),
theme: z.enum(["light", "dark", "system"]).default("system"),
initialQuery: z.string().default(""),
initialHeaders: z.record(z.string(), z.string()).default({}),
initialEnvironments: z
.record(z.string(), environmentSchema)
.default({}),
})
.strict();
/** Parsed, defaulted options consumed by `createAltairUi`. */
export type AltairUiOptions = z.infer<typeof altairUiOptionsSchema>;
/** Caller-facing input — every field optional, defaults applied by Zod. */
export type AltairUiOptionsInput = z.input<typeof altairUiOptionsSchema>;
/**
* Validate caller-supplied options. Throws on shape violations; returns
* a fully-defaulted, immutable `AltairUiOptions` on success.
*/
export function parseAltairUiOptions(
input: AltairUiOptionsInput | undefined
): AltairUiOptions {
return altairUiOptionsSchema.parse(input ?? {});
}

View File

@@ -0,0 +1,127 @@
/**
* Integration test — exercises the Altair plugin's full surface against
* a real `Router` instance, end to end.
*
* Stage-7 spec notes the desired Playwright + Yoga integration; that
* arrives once `@suaveplan/api-graphql-yoga` lands. This file pins the
* structural integration the engine package will then layer Playwright
* on top of — HTML response, asset response, OTel observation, and
* collision detection — using only the dependencies actually present
* in the genesis tree today.
*/
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
import { Router } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createAltairInstruments,
createAltairUi,
GRAPHQL_UI_ALTAIR_CODES,
GraphqlUiAltairError,
} from "../../src/index.js";
interface FakeCounter {
readonly name: string;
addCalls: Array<{
value: number;
attrs?: Readonly<Record<string, unknown>>;
}>;
}
function buildFakeMeterProvider(): {
provider: MeterProvider;
counters: FakeCounter[];
} {
const counters: FakeCounter[] = [];
const provider: MeterProvider = {
getMeter(): Meter {
const meter: Meter = {
createCounter(n: string) {
const c: FakeCounter = { name: n, addCalls: [] };
counters.push(c);
return {
add(value, attrs) {
c.addCalls.push({ value, attrs });
},
} as Counter;
},
} as unknown as Meter;
return meter;
},
} as MeterProvider;
return { provider, counters };
}
describe("integration — altair end-to-end", () => {
it("GET /graphql/ui → 200 HTML with embedded config", {
timeout: 30000,
}, () => {
const { provider, counters } = buildFakeMeterProvider();
const instruments = createAltairInstruments({
meterProvider: provider,
});
const router = new Router();
const plugin = createAltairUi(undefined, { instruments });
plugin.mount(router);
const match = router.match("GET", "/graphql/ui");
expect(match).not.toBeNull();
const res = (match as NonNullable<typeof match>).handler() as {
status: number;
headers: Record<string, string>;
body: string;
};
expect(res.status).toBe(200);
expect(res.headers["Content-Type"]).toContain("text/html");
expect(res.body).toContain("Altair GraphQL Client");
expect(res.body).toContain('"graphqlEndpoint":"/graphql"');
// Counter observed.
expect(counters[0]?.name).toBe("graphql.ui.requests_total");
expect(counters[0]?.addCalls[0]?.attrs).toEqual({ ui: "altair" });
});
it("asset request → 200 with correct content-type and Cache-Control", {
timeout: 30000,
}, () => {
const router = new Router();
const plugin = createAltairUi();
plugin.mount(router);
const html = plugin.renderHtml().body;
const jsRef = html.match(
/\/graphql\/ui\/assets\/([0-9a-f]{16})\/altair\.js/
);
if (!jsRef) throw new Error("js hash not found");
const hash = jsRef[1] as string;
const m = router.match("GET", `/graphql/ui/assets/${hash}/altair.js`);
expect(m).not.toBeNull();
const res = (m as NonNullable<typeof m>).handler({
params: { hash, name: "altair.js" },
}) as {
status: number;
headers: Record<string, string>;
body: string;
};
expect(res.status).toBe(200);
expect(res.headers["Content-Type"]).toContain("application/javascript");
expect(res.headers["Cache-Control"]).toContain("immutable");
});
it("collision against pre-mounted GET /graphql/ui refused with typed error", {
timeout: 30000,
}, () => {
const router = new Router({ plugin: "first-owner" });
router.get("/graphql/ui", () => ({
status: 200,
headers: {},
body: "<other>",
streaming: false,
}));
try {
createAltairUi().mount(router);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(GraphqlUiAltairError);
expect((err as GraphqlUiAltairError).altairCode).toBe(
GRAPHQL_UI_ALTAIR_CODES.ENDPOINT_COLLISION
);
}
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,36 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-graphql-ui-altair",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^node:/,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});