Initial Commit
This commit is contained in:
97
packages/api-stack/api/api-graphql-ui-altair/README.md
Normal file
97
packages/api-stack/api/api-graphql-ui-altair/README.md
Normal 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
|
||||
|
||||
93
packages/api-stack/api/api-graphql-ui-altair/package.json
Normal file
93
packages/api-stack/api/api-graphql-ui-altair/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { BundledAsset, ResolvedAsset } from "./assets.js";
|
||||
export {
|
||||
buildDefaultAssetRegistry,
|
||||
defineAsset,
|
||||
findExternalHostReferences,
|
||||
resolveAsset,
|
||||
} from "./assets.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { GraphqlUiAltairErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
GRAPHQL_UI_ALTAIR_CODES,
|
||||
GraphqlUiAltairError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
CreateAltairUiInternals,
|
||||
RouterPlugin,
|
||||
} from "./create-altair-ui.js";
|
||||
export { createAltairUi } from "./create-altair-ui.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
ALTAIR_CSP_HEADER_NAME,
|
||||
ALTAIR_CSP_HEADER_VALUE,
|
||||
buildAltairResponseHeaders,
|
||||
} from "./csp.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { RenderAltairShellInput } from "./render-shell.js";
|
||||
export { renderAltairShell } from "./render-shell.js";
|
||||
@@ -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.
|
||||
@@ -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(""><script>");
|
||||
});
|
||||
|
||||
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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>`;
|
||||
}
|
||||
53
packages/api-stack/api/api-graphql-ui-altair/src/index.ts
Normal file
53
packages/api-stack/api/api-graphql-ui-altair/src/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
AltairEnvironment,
|
||||
AltairUiOptions,
|
||||
AltairUiOptionsInput,
|
||||
} from "./options.js";
|
||||
export {
|
||||
altairUiOptionsSchema,
|
||||
parseAltairUiOptions,
|
||||
} from "./options.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {});
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-graphql-ui-altair/tsconfig.json
Normal file
12
packages/api-stack/api/api-graphql-ui-altair/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
36
packages/api-stack/api/api-graphql-ui-altair/vite.config.ts
Normal file
36
packages/api-stack/api/api-graphql-ui-altair/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user