diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bd2223..8c89ef6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,4 @@ jobs: registry-url: https://registry.npmjs.org - run: bun install --frozen-lockfile - run: bun run check + - run: bun run check:packages diff --git a/bun.lock b/bun.lock index a7f801d..9dbdb55 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "miakapi", @@ -15,8 +16,33 @@ "typescript": "7.0.2", }, }, + "packages/cli": { + "name": "@miakapp/cli", + "version": "4.0.0-alpha.0", + "bin": { + "miakapp": "./bin/miakapp.js", + }, + "devDependencies": { + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "typescript": "7.0.2", + }, + }, + "packages/component": { + "name": "@miakapp/component", + "version": "4.0.0-alpha.0", + "devDependencies": { + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "typescript": "7.0.2", + }, + }, }, "packages": { + "@miakapp/cli": ["@miakapp/cli@workspace:packages/cli"], + + "@miakapp/component": ["@miakapp/component@workspace:packages/component"], + "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], diff --git a/package.json b/package.json index 42c36df..1ed23cb 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Typed coordinator and trusted-browser SDK for Miakapp", "type": "module", "packageManager": "bun@1.2.23", + "workspaces": [ + "packages/*" + ], "engines": { "bun": ">=1.2.23", "node": ">=22.9" @@ -28,14 +31,18 @@ "scripts": { "build": "tsc -p tsconfig.build.json", "build:contract": "tsc -p tsconfig.contract.json", + "check": "bun run typecheck && bun run build && bun run test && bun run smoke:node && bun run smoke:browser && bun run test:contract && bun run pack:check", + "check:cli": "bun --filter @miakapp/cli check", + "check:component": "bun --filter @miakapp/component check", + "check:packages": "bun run check:cli && bun run check:component && bun run check:template", + "check:template": "bun run build && bun --filter @miakapp/component build && bun --filter @miakapp/cli build && cd templates/home && rm -rf node_modules && bun install --frozen-lockfile && bun run check", "pack:check": "npm pack --dry-run", "prepublishOnly": "bun run check", "smoke:browser": "node scripts/check-browser-bundle.mjs", "smoke:node": "node test/node-smoke.mjs", - "test": "bun test", + "test": "bun test test/*.test.ts examples/*.test.ts packages/*/test/*.test.ts", "test:contract": "bun run build:contract && node scripts/check-contract.mjs", - "typecheck": "tsc --noEmit", - "check": "bun run typecheck && bun run build && bun run test && bun run smoke:node && bun run smoke:browser && bun run test:contract && bun run pack:check" + "typecheck": "tsc --noEmit" }, "repository": { "type": "git", diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..aefa23e --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,133 @@ +# @miakapp/cli + +Build, validate, publish and roll back one Miakapp home component. + +The CLI is the deployment mechanism, not the source of truth. **Git belongs to +you**: this tool never commits, never rewrites sources it did not generate and +never bundles for you. It reads a project file, verifies the artifact bytes you +built, and talks to the control plane over the closed RFC 0004 §13.2 surface. + +It is written for a coding agent first and a person second. Every outcome is one +stable exit code and one stable failure kind, and `--json` prints exactly one +object on stdout. + +```bash +bunx @miakapp/cli init --home my-home --control-plane https://control.miakapp.app +bunx @miakapp/cli check +bunx @miakapp/cli publish --expected-generation 0 +``` + +## The project file + +`miakapp.yaml` sits at the root of your repository: + +```yaml +schema: miakapp.project/1 +home: my-home +control_plane: https://control.miakapp.app + +component: + artifact: dist/component.js + release: 2026-09-13.1 + requires: + state_read: + - climate.living_room.temperature + event_subscribe: [] + event_publish: [] + call: + - lighting.set + presentation: [] + +coordinator: + entry: coordinator/main.ts +``` + +`requires` is the closed RFC 0002 capability object. Lists are de-duplicated and +sorted before they are bound into an upload capability, so the value the CLI +sends is byte-identical on every later reconciliation read. + +The parser is a deliberately small YAML subset: block mappings, block sequences +of scalars, two-space indentation, comments, quoted and plain scalars, and `[]` +for an empty list. Anything else — tabs, anchors, tags, multi-line scalars, +duplicate keys — is rejected with the offending line rather than guessed at. + +## Commands + +| Command | What it does | +| --- | --- | +| `init` | Writes `miakapp.yaml`. Never overwrites an existing one. | +| `check` | Offline. Parses the project, verifies the artifact, prints the digest. | +| `publish` | Capability → delivery → finalization → activation, in one run. | +| `activate` | Activates an already finalized digest at a new generation. | +| `rollback` | Alias of `activate`, for returning to a known-good digest. | +| `release ` | Reads one finalized release record. | +| `upload ` | Reads one upload status, to reconcile a lost request. | + +`check` is the command to run in CI and before every publication. It costs +nothing, touches no network and catches the four artifact rules the broker's +pinned parser would reject anyway: module syntax, dynamic `import`, a source-map +directive and the ABI 1 token ceiling. + +## Authorization + +The Home Key is read from `MIAKAPP_HOME_KEY` and from nowhere else. No command +accepts it as an argument, because an argument lands in shell history, in a +process listing and in most CI logs. + +```bash +export MIAKAPP_HOME_KEY="$(op read op://home/miakapp/home-key)" # or your own vault +bunx @miakapp/cli publish --expected-generation 4 +``` + +The key is exchanged for a five-minute `components:publish` token before every +run. Publication endpoints never accept the Home Key itself, and the CLI never +prints either credential. + +## Generations + +Activation is a compare-and-set: `--expected-generation` is the generation you +believe the pointer holds, and `--generation` (default: expected + 1) is the one +you are publishing. A stale expectation fails with `generation_conflict` and +exit code 6 rather than last-write-wins. + +`--expected-generation` is required rather than discovered, because RFC 0004 +§13.2 publishes no read for the current pointer: the pointer lives in +`components/{homeID}` and reaches clients as authenticated platform data. Until +that surface exists, the CLI asks you for the number instead of guessing one — +inventing an endpoint would be worse than an explicit flag. + +## Exit codes + +| Code | Kind | Meaning | +| --- | --- | --- | +| 0 | — | success | +| 1 | `usage` | the invocation is malformed | +| 2 | `project` | `miakapp.yaml` is missing or invalid | +| 3 | `artifact` | the artifact is missing, malformed or not publishable | +| 4 | `authorization` | no Home Key, or the control plane refused it | +| 5 | `contract` | the control plane answered outside its own schema | +| 6 | `conflict` | the generation precondition failed | +| 7 | `unknown_outcome` | the effect on the control plane is undetermined | + +Code 7 is the one that matters. It means a mutating request may already have +committed, so the next step is a read — `miakapp upload ` or +`miakapp release ` — and never a blind retry with a fresh capability. + +## Failure output + +```console +$ miakapp publish --expected-generation 0 +miakapp: conflict: Activation failed with HTTP 409 generation_conflict (request Zq1...) + Another publication advanced the pointer. Read the active generation and retry + the activation with the observed expected_generation. +``` + +```console +$ miakapp publish --expected-generation 0 --json +{"ok":false,"kind":"conflict","exit_code":6,"message":"...","remedy":"..."} +``` + +## Status + +Alpha, tracking Miakapp 4. The package is `private` until the control plane it +talks to is deployed; publishing it to npm is a deliberate, separate step. diff --git a/packages/cli/bin/miakapp.js b/packages/cli/bin/miakapp.js new file mode 100755 index 0000000..b32e688 --- /dev/null +++ b/packages/cli/bin/miakapp.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { run } from '../dist/main.js'; + +process.exitCode = await run(process.argv.slice(2), { + write: (text) => void process.stdout.write(text), + writeError: (text) => void process.stderr.write(text), + cwd: () => process.cwd(), + env: (name) => process.env[name], +}); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..eb2ef8b --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,50 @@ +{ + "name": "@miakapp/cli", + "version": "4.0.0-alpha.0", + "description": "Agent-first Miakapp command line: check, build, publish, activate and roll back home components", + "type": "module", + "private": true, + "engines": { + "bun": ">=1.2.23", + "node": ">=22.9" + }, + "bin": { + "miakapp": "./bin/miakapp.js" + }, + "files": [ + "bin", + "dist", + "LICENSE", + "README.md" + ], + "sideEffects": false, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "bun test test/", + "typecheck": "tsc --noEmit", + "check": "bun run typecheck && bun run build && bun run test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Miakapp/MiakAPI.git", + "directory": "packages/cli" + }, + "publishConfig": { + "access": "public", + "tag": "next" + }, + "keywords": [ + "Miakapp", + "MiakAPI", + "cli", + "smart-home", + "components" + ], + "author": "Mathieu Colmon", + "license": "ISC", + "devDependencies": { + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "typescript": "7.0.2" + } +} diff --git a/packages/cli/src/artifact.ts b/packages/cli/src/artifact.ts new file mode 100644 index 0000000..b4f8850 --- /dev/null +++ b/packages/cli/src/artifact.ts @@ -0,0 +1,344 @@ +import { createHash } from 'node:crypto'; +import { artifactError } from './errors.js'; +import { + MAXIMUM_ARTIFACT_BYTES, + MAXIMUM_PROGRAM_TOKENS, +} from './internal/names.js'; + +export interface Artifact { + /** Exact bytes that will be uploaded; nothing re-encodes them later. */ + readonly bytes: Uint8Array; + /** SHA-256 of those bytes as unpadded base64url, per RFC 0002 §7.1. */ + readonly sha256: string; + readonly size: number; + readonly tokens: number; +} + +export type ArtifactFindingKind = + | 'module_syntax' + | 'dynamic_import' + | 'source_map_directive' + | 'token_ceiling' + | 'unterminated'; + +export interface ArtifactFinding { + readonly kind: ArtifactFindingKind; + readonly line: number; + readonly detail: string; +} + +const IDENTIFIER_START = /[$_\p{ID_Start}]/u; +const IDENTIFIER_PART = /[$_\u200c\u200d\p{ID_Continue}]/u; + +/** + * Tokens after which a `/` starts a regular expression rather than a division. + * The list is deliberately conservative: a wrong guess only changes how the + * scanner counts, never whether the broker's pinned parser accepts the program. + */ +const REGEX_ALLOWED_AFTER = new Set([ + 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', + 'throw', 'typeof', 'void', 'yield', +]); + +function isRegexPosition(previous: string | undefined): boolean { + if (previous === undefined) return true; + if (REGEX_ALLOWED_AFTER.has(previous)) return true; + if (/^[A-Za-z_$]/.test(previous)) return false; + if (/^[0-9]/.test(previous)) return false; + return previous !== ')' && previous !== ']' && previous !== '}' && previous !== '++' + && previous !== '--' && previous[0] !== '"' && previous[0] !== "'" && previous[0] !== '`'; +} + +/** + * Publisher pre-check for the RFC 0002 §7.2 artifact rules. + * + * RFC 0002 makes the broker's pinned platform parser authoritative; this scan + * exists so an agent learns about module syntax, a dynamic import, a source-map + * directive or an oversized program before spending an upload capability. It + * reports findings rather than throwing, so one run can show every problem. + */ +export function scanArtifactSource(source: string): { + tokens: number; + findings: readonly ArtifactFinding[]; +} { + const findings: ArtifactFinding[] = []; + let index = 0; + let line = 1; + let tokens = 0; + let previous: string | undefined; + let lineStartOnlyWhitespace = true; + + const record = (kind: ArtifactFindingKind, detail: string): void => { + if (findings.some((finding) => finding.kind === kind && finding.line === line)) return; + findings.push({ kind, line, detail }); + }; + + const advanceLines = (text: string): void => { + for (const character of text) if (character === '\n') line += 1; + }; + + while (index < source.length) { + const character = source[index] as string; + if (character === '\n') { + line += 1; + lineStartOnlyWhitespace = true; + index += 1; + continue; + } + if (character === ' ' || character === '\t' || character === '\r' || character === '\f' + || character === '\v' || character === '\u00a0' || character === '\ufeff') { + index += 1; + continue; + } + if (character === '/' && source[index + 1] === '/') { + const end = source.indexOf('\n', index); + const comment = source.slice(index, end === -1 ? source.length : end); + if (/^\/\/[#@]\s*sourceMappingURL=/.test(comment)) { + record('source_map_directive', comment.trim()); + } + index = end === -1 ? source.length : end; + continue; + } + if (character === '/' && source[index + 1] === '*') { + const end = source.indexOf('*/', index + 2); + if (end === -1) { + record('unterminated', 'Unterminated block comment'); + break; + } + const comment = source.slice(index, end + 2); + if (/^\/\*[#@]\s*sourceMappingURL=/.test(comment)) { + record('source_map_directive', comment.slice(0, 64)); + } + advanceLines(comment); + index = end + 2; + continue; + } + if (character === '"' || character === "'") { + const end = scanQuoted(source, index, character); + if (end === -1) { + record('unterminated', 'Unterminated string literal'); + break; + } + advanceLines(source.slice(index, end)); + previous = `${character}string`; + tokens += 1; + index = end; + lineStartOnlyWhitespace = false; + continue; + } + if (character === '`') { + const end = scanTemplate(source, index); + if (end === -1) { + record('unterminated', 'Unterminated template literal'); + break; + } + advanceLines(source.slice(index, end)); + previous = '`template'; + tokens += 1; + index = end; + lineStartOnlyWhitespace = false; + continue; + } + if (character === '/' && isRegexPosition(previous)) { + const end = scanRegex(source, index); + if (end === -1) { + record('unterminated', 'Unterminated regular expression literal'); + break; + } + previous = '/regex'; + tokens += 1; + index = end; + lineStartOnlyWhitespace = false; + continue; + } + if (IDENTIFIER_START.test(character)) { + let end = index + 1; + while (end < source.length && IDENTIFIER_PART.test(source[end] as string)) end += 1; + const word = source.slice(index, end); + if (word === 'import') { + const next = nextSignificant(source, end); + if (next === '(') record('dynamic_import', 'import( expression'); + else if (next === '.') record('dynamic_import', 'import.meta reference'); + else record('module_syntax', 'import declaration'); + } else if (word === 'export' && lineStartOnlyWhitespace) { + record('module_syntax', 'export declaration'); + } + previous = word; + tokens += 1; + index = end; + lineStartOnlyWhitespace = false; + continue; + } + if (character >= '0' && character <= '9') { + let end = index + 1; + while (end < source.length && /[0-9a-fA-FxXoObBn._]/.test(source[end] as string)) end += 1; + previous = source.slice(index, end); + tokens += 1; + index = end; + lineStartOnlyWhitespace = false; + continue; + } + const punctuator = scanPunctuator(source, index); + previous = punctuator; + tokens += 1; + index += punctuator.length; + lineStartOnlyWhitespace = false; + if (tokens > MAXIMUM_PROGRAM_TOKENS) break; + } + + if (tokens > MAXIMUM_PROGRAM_TOKENS) { + findings.push({ + kind: 'token_ceiling', + line, + detail: `Program exceeds the ABI 1 ceiling of ${MAXIMUM_PROGRAM_TOKENS} lexical tokens`, + }); + } + return { tokens, findings }; +} + +function scanQuoted(source: string, start: number, quote: string): number { + let index = start + 1; + while (index < source.length) { + const character = source[index]; + if (character === '\\') { + index += 2; + continue; + } + if (character === quote) return index + 1; + if (character === '\n') return -1; + index += 1; + } + return -1; +} + +function scanTemplate(source: string, start: number): number { + let index = start + 1; + while (index < source.length) { + const character = source[index]; + if (character === '\\') { + index += 2; + continue; + } + if (character === '`') return index + 1; + if (character === '$' && source[index + 1] === '{') { + let depth = 1; + index += 2; + while (index < source.length && depth > 0) { + const inner = source[index]; + if (inner === '{') depth += 1; + else if (inner === '}') depth -= 1; + else if (inner === '`') { + const nested = scanTemplate(source, index); + if (nested === -1) return -1; + index = nested; + continue; + } else if (inner === '"' || inner === "'") { + const nested = scanQuoted(source, index, inner); + if (nested === -1) return -1; + index = nested; + continue; + } + index += 1; + } + continue; + } + index += 1; + } + return -1; +} + +function scanRegex(source: string, start: number): number { + let index = start + 1; + let inClass = false; + while (index < source.length) { + const character = source[index]; + if (character === '\\') { + index += 2; + continue; + } + if (character === '\n') return -1; + if (character === '[') inClass = true; + else if (character === ']') inClass = false; + else if (character === '/' && !inClass) { + index += 1; + while (index < source.length && IDENTIFIER_PART.test(source[index] as string)) index += 1; + return index; + } + index += 1; + } + return -1; +} + +const PUNCTUATORS = [ + '>>>=', '...', '===', '!==', '**=', '<<=', '>>=', '>>>', '&&=', '||=', '??=', + '=>', '==', '!=', '<=', '>=', '&&', '||', '??', '?.', '++', '--', '+=', '-=', + '*=', '/=', '%=', '&=', '|=', '^=', '<<', '>>', '**', +]; + +function scanPunctuator(source: string, index: number): string { + for (const punctuator of PUNCTUATORS) { + if (source.startsWith(punctuator, index)) return punctuator; + } + return source[index] as string; +} + +function nextSignificant(source: string, from: number): string | undefined { + let index = from; + while (index < source.length) { + const character = source[index] as string; + if (/\s/.test(character)) { + index += 1; + continue; + } + if (character === '/' && source[index + 1] === '/') { + const end = source.indexOf('\n', index); + if (end === -1) return undefined; + index = end + 1; + continue; + } + if (character === '/' && source[index + 1] === '*') { + const end = source.indexOf('*/', index + 2); + if (end === -1) return undefined; + index = end + 2; + continue; + } + return character; + } + return undefined; +} + +/** + * Verifies the exact bytes an upload would deliver and derives the digest and + * size that bind the upload capability. + */ +export function prepareArtifact(bytes: Uint8Array): Artifact { + if (bytes.byteLength === 0) throw artifactError('Artifact is empty'); + if (bytes.byteLength > MAXIMUM_ARTIFACT_BYTES) { + throw artifactError( + `Artifact is ${bytes.byteLength} bytes, above the ABI 1 ceiling of ${MAXIMUM_ARTIFACT_BYTES}`, + ); + } + let source: string; + try { + source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw artifactError('Artifact is not valid UTF-8'); + } + const { tokens, findings } = scanArtifactSource(source); + if (findings.length > 0) { + const detail = findings + .map((finding) => ` line ${finding.line}: ${finding.kind} — ${finding.detail}`) + .join('\n'); + throw artifactError( + `Artifact violates the RFC 0002 §7.2 artifact rules:\n${detail}`, + 'Bundle to one self-contained classic Worker program with no module syntax, ' + + 'no dynamic import and no source map.', + ); + } + return Object.freeze({ + bytes, + sha256: createHash('sha256').update(bytes).digest('base64url'), + size: bytes.byteLength, + tokens, + }); +} diff --git a/packages/cli/src/control-plane.ts b/packages/cli/src/control-plane.ts new file mode 100644 index 0000000..578a447 --- /dev/null +++ b/packages/cli/src/control-plane.ts @@ -0,0 +1,171 @@ +import { authorizationError, contractError } from './errors.js'; +import { + canonicalHttpsUrl, + cancelBody, + readBoundedBody, + requestBody, + type FetchLike, +} from './internal/http.js'; +import { boundedString, exactRecord, parseJson } from './internal/json.js'; + +/** RFC 0004 §3 caps the discovery document at 4 KiB. */ +const MAXIMUM_DISCOVERY_BYTES = 4_096; + +/** RFC 0004 §7 leases every access token for at most five minutes. */ +const MAXIMUM_TOKEN_LIFETIME_MS = 330_000; + +const HOME_KEY = /^mhk1_([A-Za-z0-9_-]{22})_([A-Za-z0-9_-]{43})$/; +const DISCOVERY_PATH = '/.well-known/miakapp-control-plane'; + +export interface Discovery { + readonly issuer: string; + readonly jwksUri: string; + readonly exchangeEndpoint: string; + readonly userRelayExchangeEndpoint: string; + readonly pushAudience: string; + readonly componentsAudience: string; +} + +export interface PublisherToken { + readonly accessToken: string; + readonly expiresAtMs: number; + readonly keyId: string; + readonly keyLabel: string; +} + +export interface ControlPlaneOptions { + readonly issuer: string; + readonly fetch?: FetchLike; +} + +export function homeKeyId(homeKey: string): string { + const match = HOME_KEY.exec(homeKey); + const keyId = match?.[1]; + if (keyId === undefined) { + throw authorizationError( + 'The Home Key is not a valid mhk1 credential', + 'Create a new Home Key with the components:publish scope and store it outside Git.', + ); + } + return keyId; +} + +/** + * Reads the pinned deployment profile. The CLI never follows an issuer, JWKS URL + * or audience supplied by a token: only this document, fetched from the issuer + * the operator configured, selects endpoints. + */ +export async function fetchDiscovery(options: ControlPlaneOptions): Promise { + const issuer = canonicalHttpsUrl(options.issuer, 'issuer'); + if (issuer.endsWith('/')) throw contractError('issuer must not have a trailing slash'); + const fetcher = options.fetch ?? globalThis.fetch; + const response = await fetcher(`${issuer}${DISCOVERY_PATH}`, { + method: 'GET', + headers: { accept: 'application/json' }, + redirect: 'error', + }); + if (response.status !== 200) { + cancelBody(response); + throw contractError(`Discovery document returned HTTP ${response.status}`); + } + const body = await readBoundedBody(response, MAXIMUM_DISCOVERY_BYTES); + const document = exactRecord(parseJson(body), [ + 'schema', + 'issuer', + 'jwks_uri', + 'exchange_endpoint', + 'user_relay_exchange_endpoint', + 'push_audience', + 'components_audience', + ]); + if (document.schema !== 'miakapp.control-plane-discovery/1') { + throw contractError('Discovery document has an unsupported schema'); + } + if (document.issuer !== issuer) { + throw contractError('Discovery document advertises a different issuer'); + } + return Object.freeze({ + issuer, + jwksUri: canonicalHttpsUrl(document.jwks_uri, 'jwks_uri'), + exchangeEndpoint: canonicalHttpsUrl(document.exchange_endpoint, 'exchange_endpoint'), + userRelayExchangeEndpoint: canonicalHttpsUrl( + document.user_relay_exchange_endpoint, + 'user_relay_exchange_endpoint', + ), + pushAudience: canonicalHttpsUrl(document.push_audience, 'push_audience'), + componentsAudience: canonicalHttpsUrl(document.components_audience, 'components_audience'), + }); +} + +/** + * Exchanges a Home Key for the five-minute `components:publish` profile. + * + * RFC 0004 §7.2 attenuates the issued token to exactly that one scope, and + * §7.3 omits `relay_url` for this purpose: a component token is useless at a + * relay. Publication APIs never accept the Home Key itself. + */ +export async function exchangePublisherToken( + discovery: Discovery, + homeKey: string, + options: { readonly fetch?: FetchLike } = {}, +): Promise { + const keyId = homeKeyId(homeKey); + const fetcher = options.fetch ?? globalThis.fetch; + const response = await fetcher(discovery.exchangeEndpoint, { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `Bearer ${homeKey}`, + 'content-type': 'application/json', + }, + body: requestBody({ purpose: 'components' }), + // No request-side `cache` hint: Node and Bun have no HTTP cache to opt out + // of, and the response-side no-store headers checked below are the actual + // guarantee that this token is never stored by an intermediary. + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + }); + if (response.status !== 200) { + cancelBody(response); + throw authorizationError( + `Component token exchange returned HTTP ${response.status}`, + 'Confirm the Home Key exists, is not revoked and holds the components:publish scope.', + ); + } + if (response.headers.get('cache-control') !== 'no-store' + || response.headers.get('pragma') !== 'no-cache' + || response.headers.get('referrer-policy') !== 'no-referrer') { + cancelBody(response); + throw contractError('Component token response is missing its required no-store headers'); + } + const body = await readBoundedBody(response, 65_536); + const document = exactRecord(parseJson(body), [ + 'schema', + 'access_token', + 'token_type', + 'expires_at_ms', + 'key', + ]); + const key = exactRecord(document.key, ['id', 'label']); + const accessToken = boundedString(document.access_token, 1, 8_192); + const expiresAtMs = document.expires_at_ms; + if (document.schema !== 'miakapp.access-token/1' + || document.token_type !== 'Bearer' + || accessToken.split('.').length !== 3 + || typeof expiresAtMs !== 'number' + || !Number.isSafeInteger(expiresAtMs) + || key.id !== keyId) { + throw contractError('Component token response does not match the closed exchange schema'); + } + const now = Date.now(); + if (expiresAtMs <= now || expiresAtMs - now > MAXIMUM_TOKEN_LIFETIME_MS) { + throw contractError('Component token lease is expired or longer than the five-minute ceiling'); + } + return Object.freeze({ + accessToken, + expiresAtMs, + keyId, + keyLabel: boundedString(key.label, 1, 64), + }); +} diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts new file mode 100644 index 0000000..c8a9e30 --- /dev/null +++ b/packages/cli/src/errors.ts @@ -0,0 +1,66 @@ +/** + * Deterministic CLI outcomes. + * + * An agent drives this tool without reading prose, so every failure maps to + * exactly one stable exit code and one stable machine-readable kind. New kinds + * may be added; existing codes never change meaning. + */ +export const EXIT_CODE = { + success: 0, + usage: 1, + project: 2, + artifact: 3, + authorization: 4, + contract: 5, + conflict: 6, + unknown_outcome: 7, +} as const; + +export type CliFailureKind = Exclude; + +/** + * `unknown_outcome` is reserved for a request whose effect on the control plane + * is undetermined. The caller must reconcile with a read before acting again; + * it must never blindly retry with a new capability. + */ +export class CliError extends Error { + readonly kind: CliFailureKind; + readonly exitCode: number; + readonly remedy: string | undefined; + + constructor(kind: CliFailureKind, message: string, remedy?: string) { + super(message); + this.name = 'MiakappCliError'; + this.kind = kind; + this.exitCode = EXIT_CODE[kind]; + this.remedy = remedy; + } +} + +export function usageError(message: string, remedy?: string): CliError { + return new CliError('usage', message, remedy); +} + +export function projectError(message: string, remedy?: string): CliError { + return new CliError('project', message, remedy); +} + +export function artifactError(message: string, remedy?: string): CliError { + return new CliError('artifact', message, remedy); +} + +export function authorizationError(message: string, remedy?: string): CliError { + return new CliError('authorization', message, remedy); +} + +export function contractError(message: string, remedy?: string): CliError { + return new CliError('contract', message, remedy); +} + +export function conflictError(message: string, remedy?: string): CliError { + return new CliError('conflict', message, remedy); +} + +export function unknownOutcomeError(message: string, remedy: string): CliError { + return new CliError('unknown_outcome', message, remedy); +} diff --git a/packages/cli/src/internal/http.ts b/packages/cli/src/internal/http.ts new file mode 100644 index 0000000..b6acc0b --- /dev/null +++ b/packages/cli/src/internal/http.ts @@ -0,0 +1,140 @@ +import { contractError } from '../errors.js'; +import { parseJson, type JsonValue } from './json.js'; + +/** RFC 0004 §4.2: one response body is at most 64 KiB outside the stated exceptions. */ +export const MAXIMUM_RESPONSE_BYTES = 65_536; + +/** RFC 0004 §4.2: one request body is at most 16 KiB. */ +export const MAXIMUM_REQUEST_BYTES = 16_384; + +export type FetchLike = (input: string, init: RequestInit) => Promise; + +export interface HttpsEndpoint { + readonly url: string; + readonly pathname: string; +} + +/** + * Every control-plane URL is an exact absolute HTTPS identifier with no user + * information, query or fragment. A value that does not round-trip through + * `URL` is rejected rather than normalized. + * + * One deviation from byte-for-byte equality is required rather than convenient. + * RFC 0004 §3 states that `issuer` has no trailing slash, and its own example + * issuer is the bare origin `https://control.example.test`. `URL` serializes + * that back with a trailing slash, so demanding exact equality would reject + * every origin-only issuer the RFC describes and force an arbitrary path + * segment onto every deployment. An empty path is therefore accepted in either + * spelling; nothing else is relaxed, so a stray query, a default port or a + * non-normalized path is still refused. + */ +export function canonicalHttpsUrl(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || value.length > 2_048) { + throw contractError(`${label} is not a bounded URL`); + } + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw contractError(`${label} is not a URL`); + } + const roundTrips = parsed.href === value + || (parsed.pathname === '/' && parsed.href === `${value}/`); + if (parsed.protocol !== 'https:' + || parsed.hostname === '' + || parsed.username !== '' + || parsed.password !== '' + || parsed.search !== '' + || parsed.hash !== '' + || !roundTrips) { + throw contractError(`${label} is not a canonical HTTPS identifier`); + } + return value; +} + +export function cancelBody(response: Response | undefined): void { + try { + const cancellation = response?.body?.cancel(); + void cancellation?.catch(() => undefined); + } catch { + // A hostile Fetch implementation cannot change the caller's outcome. + } +} + +export async function readBoundedBody( + response: Response, + maximumBytes: number, +): Promise { + const declared = response.headers.get('content-length'); + if (declared !== null + && (!/^(?:0|[1-9][0-9]*)$/.test(declared) || Number(declared) > maximumBytes)) { + cancelBody(response); + throw contractError('Response declared a body above the permitted ceiling'); + } + if (response.body === null) throw contractError('Response carried no body'); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const item = await reader.read(); + if (item.done) break; + size += item.value.byteLength; + if (size > maximumBytes) { + void reader.cancel().catch(() => undefined); + throw contractError('Response body exceeded the permitted ceiling'); + } + chunks.push(item.value); + } + } finally { + reader.releaseLock(); + } + if (declared !== null && Number(declared) !== size) { + throw contractError('Response body did not match its declared Content-Length'); + } + const body = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +} + +export async function readJsonBody(response: Response): Promise { + const body = await readBoundedBody(response, MAXIMUM_RESPONSE_BYTES); + if (body.byteLength === 0) throw contractError('Response carried an empty JSON body'); + return parseJson(body); +} + +export function requestBody(value: unknown): string { + const body = JSON.stringify(value); + if (new TextEncoder().encode(body).byteLength > MAXIMUM_REQUEST_BYTES) { + throw contractError('Request body exceeds the 16 KiB control-plane ceiling'); + } + return body; +} + +/** + * A control-plane request never follows a redirect, never carries a cookie and + * never negotiates compression for an exact identifier. + */ +export function jsonRequestInit( + method: 'GET' | 'POST', + token: string, + body: string | undefined, + signal: AbortSignal, +): RequestInit { + const headers: Record = { + accept: 'application/json', + authorization: `Bearer ${token}`, + }; + if (body !== undefined) headers['content-type'] = 'application/json; charset=utf-8'; + return { + method, + headers, + redirect: 'error', + signal, + ...(body === undefined ? {} : { body }), + }; +} diff --git a/packages/cli/src/internal/json.ts b/packages/cli/src/internal/json.ts new file mode 100644 index 0000000..efab53f --- /dev/null +++ b/packages/cli/src/internal/json.ts @@ -0,0 +1,226 @@ +/** + * Strict JSON decoding for control-plane responses. + * + * The SDK keeps an equivalent parser for the access-token exchange, but its + * value and string ceilings are tuned to that one response. Publication + * responses carry the closed RFC 0002 `requires` object, so this parser applies + * the general RFC 0004 §4.2 limits instead. Both reject duplicate keys, + * prototype-polluting keys, trailing content and non-UTF-8 input; neither + * accepts an unknown field anywhere an exact shape is expected. + */ +const MAXIMUM_DEPTH = 16; +const MAXIMUM_VALUES = 2_048; +const MAXIMUM_STRING_BYTES = 16_384; +const UTF8 = new TextEncoder(); +const CONTROL_CHARACTER = /\p{Cc}/u; +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +const NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/; + +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue }; + +export class InvalidJsonError extends Error { + constructor() { + super('Invalid control-plane JSON'); + this.name = 'InvalidJsonError'; + } +} + +function invalid(): never { + throw new InvalidJsonError(); +} + +function hasUnpairedSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const following = value.charCodeAt(index + 1); + if (index + 1 >= value.length || following < 0xdc00 || following > 0xdfff) return true; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true; + } + } + return false; +} + +export function parseJson(input: Uint8Array): JsonValue { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(input); + } catch { + return invalid(); + } + let index = 0; + let values = 0; + + const skipWhitespace = (): void => { + while (index < text.length) { + const code = text.charCodeAt(index); + if (code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) break; + index += 1; + } + }; + + const parseString = (): string => { + if (text[index] !== '"') return invalid(); + const start = index; + index += 1; + let escaped = false; + while (index < text.length) { + const character = text[index]; + if (!escaped && character === '"') { + index += 1; + let decoded: unknown; + try { + decoded = JSON.parse(text.slice(start, index)) as unknown; + } catch { + return invalid(); + } + if (typeof decoded !== 'string' + || hasUnpairedSurrogate(decoded) + || UTF8.encode(decoded).byteLength > MAXIMUM_STRING_BYTES) return invalid(); + return decoded; + } + escaped = !escaped && character === '\\'; + index += 1; + } + return invalid(); + }; + + const parseValue = (depth: number): JsonValue => { + if (depth > MAXIMUM_DEPTH) return invalid(); + values += 1; + if (values > MAXIMUM_VALUES) return invalid(); + skipWhitespace(); + const character = text[index]; + if (character === '"') return parseString(); + if (character === '-' || (character !== undefined && character >= '0' && character <= '9')) { + const match = NUMBER.exec(text.slice(index)); + if (match === null) return invalid(); + index += match[0].length; + const number = Number(match[0]); + if (!Number.isFinite(number)) return invalid(); + return number; + } + if (text.startsWith('true', index)) { + index += 4; + return true; + } + if (text.startsWith('false', index)) { + index += 5; + return false; + } + if (text.startsWith('null', index)) { + index += 4; + return null; + } + if (character === '[') { + index += 1; + const items: JsonValue[] = []; + skipWhitespace(); + if (text[index] === ']') { + index += 1; + return items; + } + while (true) { + items.push(parseValue(depth + 1)); + skipWhitespace(); + if (text[index] === ']') { + index += 1; + return items; + } + if (text[index] !== ',') return invalid(); + index += 1; + skipWhitespace(); + } + } + if (character === '{') { + index += 1; + const record = Object.create(null) as { [key: string]: JsonValue }; + const keys = new Set(); + skipWhitespace(); + if (text[index] === '}') { + index += 1; + return record; + } + while (true) { + const key = parseString(); + if (keys.has(key) || FORBIDDEN_KEYS.has(key)) return invalid(); + keys.add(key); + skipWhitespace(); + if (text[index] !== ':') return invalid(); + index += 1; + record[key] = parseValue(depth + 1); + skipWhitespace(); + if (text[index] === '}') { + index += 1; + return record; + } + if (text[index] !== ',') return invalid(); + index += 1; + skipWhitespace(); + } + } + return invalid(); + }; + + skipWhitespace(); + const parsed = parseValue(1); + skipWhitespace(); + if (index !== text.length) return invalid(); + return parsed; +} + +/** Accepts only the exact declared key set: an unknown field is a rejection. */ +export function exactRecord( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Readonly> { + if (value === null || Array.isArray(value) || typeof value !== 'object') return invalid(); + const keys = Object.keys(value); + const allowed = new Set([...required, ...optional]); + if (Reflect.ownKeys(value).length !== keys.length + || required.some((key) => !Object.hasOwn(value, key)) + || keys.some((key) => !allowed.has(key))) return invalid(); + return value as Readonly>; +} + +export function boundedString(value: unknown, minimum: number, maximum: number): string { + if (typeof value !== 'string' + || hasUnpairedSurrogate(value) + || CONTROL_CHARACTER.test(value)) return invalid(); + const bytes = UTF8.encode(value).byteLength; + if (bytes < minimum || bytes > maximum) return invalid(); + return value; +} + +export function stringArray(value: unknown, maximumItems: number): readonly string[] { + if (!Array.isArray(value) || value.length > maximumItems) return invalid(); + return value.map((item) => { + if (typeof item !== 'string') return invalid(); + return item; + }); +} + +export function positiveInteger(value: unknown, maximum: number): number { + if (typeof value !== 'number' + || !Number.isSafeInteger(value) + || value <= 0 + || value > maximum) return invalid(); + return value; +} + +/** RFC 3339 instant as produced by the control plane; compared as an exact string. */ +export function instant(value: unknown): string { + const text = boundedString(value, 20, 64); + const parsed = Date.parse(text); + if (!Number.isFinite(parsed)) return invalid(); + return text; +} diff --git a/packages/cli/src/internal/names.ts b/packages/cli/src/internal/names.ts new file mode 100644 index 0000000..0ddcae3 --- /dev/null +++ b/packages/cli/src/internal/names.ts @@ -0,0 +1,115 @@ +/** + * Identifier and name grammars shared by RFC 0001 §5.2, RFC 0002 §7.1 and + * RFC 0004 §4.1. Every value is validated as an exact byte string: nothing here + * case-folds, Unicode-normalizes or trims. + */ +const CONTROL_CHARACTER = /\p{Cc}/u; +const UTF8 = new TextEncoder(); + +const HOME_ID = /^[a-z][a-z0-9-]{1,61}[a-z0-9]$/; +const BASE64URL = /^[A-Za-z0-9_-]+$/; +const PRESENTATION_HANDLE = /^media\.[^.]/; + +/** RFC 0002 §7.1 caps the artifact at 2 MiB decoded. */ +export const MAXIMUM_ARTIFACT_BYTES = 2_097_152; + +/** RFC 0002 §10 aborts tokenization beyond this program ceiling. */ +export const MAXIMUM_PROGRAM_TOKENS = 100_000; + +/** + * RFC 0002 requires each requirement list to be "duplicate-free, bounded" but + * states no per-list ceiling. This is the CLI-side bound derived from the + * RFC 0004 §4.2 16 KiB request body and 2,048-value JSON limits. + */ +export const MAXIMUM_REQUIREMENTS_PER_LIST = 256; + +export const REQUIREMENT_KINDS = [ + 'state_read', + 'event_subscribe', + 'event_publish', + 'call', + 'presentation', +] as const; + +export type RequirementKind = (typeof REQUIREMENT_KINDS)[number]; + +export type Requirements = { + readonly [Kind in RequirementKind]: readonly string[]; +}; + +export function utf8Bytes(value: string): number { + return UTF8.encode(value).byteLength; +} + +export function isHomeId(value: unknown): value is string { + return typeof value === 'string' && HOME_ID.test(value) && value.length <= 63; +} + +/** 16 random bytes as unpadded base64url: upload IDs, Home Key IDs, JWS IDs. */ +export function isRandomId(value: unknown): value is string { + return typeof value === 'string' && value.length === 22 && canonicalBase64Url(value, 16); +} + +/** 32 random bytes as unpadded base64url: upload capability secrets and digests. */ +export function isUploadToken(value: unknown): value is string { + return typeof value === 'string' && value.length === 43 && canonicalBase64Url(value, 32); +} + +/** A SHA-256 digest encoded as unpadded base64url, per RFC 0002 §7.1. */ +export function isDigest(value: unknown): value is string { + return isUploadToken(value); +} + +export function canonicalBase64Url(value: string, bytes: number): boolean { + if (!BASE64URL.test(value)) return false; + const decoded = Buffer.from(value, 'base64url'); + return decoded.byteLength === bytes && decoded.toString('base64url') === value; +} + +/** RFC 0002 §7.1: non-empty UTF-8 of at most 64 bytes. */ +export function isRelease(value: unknown): value is string { + if (typeof value !== 'string' || CONTROL_CHARACTER.test(value)) return false; + const bytes = utf8Bytes(value); + return bytes >= 1 && bytes <= 64; +} + +/** `generation` is a positive safe integer. */ +export function isGeneration(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value > 0; +} + +/** + * RFC 0001 §5.2 dotted name: 1..256 UTF-8 bytes, no control characters, no `*`, + * no leading or trailing dot, no empty dotted segment. + */ +export function isDottedName(value: unknown): value is string { + if (typeof value !== 'string' || CONTROL_CHARACTER.test(value) || value.includes('*')) { + return false; + } + const bytes = utf8Bytes(value); + if (bytes < 1 || bytes > 256) return false; + return value.split('.').every((segment) => segment.length > 0); +} + +/** + * A state, event or call requirement: an exact dotted name, or a dotted prefix + * whose only `*` is the reserved trailing `.*` ACL suffix. + */ +export function isRequirementPattern(value: unknown): value is string { + if (typeof value !== 'string') return false; + if (!value.endsWith('.*')) return isDottedName(value); + const prefix = value.slice(0, -2); + return utf8Bytes(value) <= 256 && isDottedName(prefix); +} + +/** + * ABI 1 presentation requirements are exact `media.` handles. A wildcard grant + * is forbidden so each host-owned surface stays explicitly enumerated. + */ +export function isPresentationHandle(value: unknown): value is string { + return isDottedName(value) && typeof value === 'string' && PRESENTATION_HANDLE.test(value); +} + +export function isValidRequirement(kind: RequirementKind, value: unknown): boolean { + return kind === 'presentation' ? isPresentationHandle(value) : isRequirementPattern(value); +} diff --git a/packages/cli/src/internal/requirements.ts b/packages/cli/src/internal/requirements.ts new file mode 100644 index 0000000..a1d9d32 --- /dev/null +++ b/packages/cli/src/internal/requirements.ts @@ -0,0 +1,63 @@ +import { projectError } from '../errors.js'; +import { + MAXIMUM_REQUIREMENTS_PER_LIST, + REQUIREMENT_KINDS, + isValidRequirement, + type RequirementKind, + type Requirements, +} from './names.js'; + +/** + * The closed RFC 0002 `requires` object. Lists are duplicate-free and sorted by + * UTF-16 code unit so the value the CLI binds into an upload capability is + * byte-identical on every later reconciliation read. + */ +export function canonicalRequirements(value: unknown): Requirements { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw projectError('requires must be an object with the five closed capability lists'); + } + const unknown = Object.keys(value).filter( + (key) => !(REQUIREMENT_KINDS as readonly string[]).includes(key), + ); + if (unknown.length > 0) { + throw projectError(`requires has unknown capability lists: ${unknown.join(', ')}`); + } + const entries = REQUIREMENT_KINDS.map((kind) => [kind, list(value, kind)] as const); + return Object.freeze(Object.fromEntries(entries)) as Requirements; +} + +function list(source: object, kind: RequirementKind): readonly string[] { + const value = (source as Record)[kind]; + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value)) throw projectError(`requires.${kind} must be an array`); + if (value.length > MAXIMUM_REQUIREMENTS_PER_LIST) { + throw projectError( + `requires.${kind} exceeds ${MAXIMUM_REQUIREMENTS_PER_LIST} entries`, + ); + } + const seen = new Set(); + for (const item of value) { + if (!isValidRequirement(kind, item)) { + throw projectError( + `requires.${kind} contains an invalid requirement: ${JSON.stringify(item)}`, + kind === 'presentation' + ? 'ABI 1 presentation requirements are exact media.* handles; wildcards are forbidden.' + : 'Use an exact RFC 0001 dotted name or a dotted prefix ending in .*', + ); + } + if (seen.has(item as string)) { + throw projectError(`requires.${kind} repeats ${JSON.stringify(item)}`); + } + seen.add(item as string); + } + return Object.freeze([...seen].sort()); +} + +/** Validates a `requires` object received from the control plane. */ +export function sameRequirements(left: Requirements, right: Requirements): boolean { + return REQUIREMENT_KINDS.every((kind) => { + const a = left[kind]; + const b = right[kind]; + return a.length === b.length && a.every((item, index) => item === b[index]); + }); +} diff --git a/packages/cli/src/internal/yaml.ts b/packages/cli/src/internal/yaml.ts new file mode 100644 index 0000000..985c861 --- /dev/null +++ b/packages/cli/src/internal/yaml.ts @@ -0,0 +1,195 @@ +import { projectError } from '../errors.js'; + +/** + * A deliberately closed YAML subset for `miakapp.yaml`. + * + * The configuration file is authored by humans and agents, so it stays readable + * YAML; everything beyond the subset below is rejected with the offending line + * rather than interpreted. This keeps the CLI dependency-free and keeps the + * grammar small enough to reason about. + * + * Supported: block mappings, block sequences of scalars, two-space indentation, + * `#` comments, plain scalars, single- and double-quoted scalars, `true`, + * `false`, `null`, `~`, JSON numbers and the empty sequence `[]`. + * + * `[]` is the one flow form accepted, because RFC 0002's `requires` object is + * closed at five lists and a home that requests no capability of a given kind + * must still be able to write that list down. A non-empty flow sequence stays + * rejected: it has a block form. + * + * Rejected: tabs, anchors, aliases, tags, non-empty flow collections, + * multi-line scalars, multiple documents, duplicate keys and merge keys. + */ +export type YamlValue = string | number | boolean | null | YamlValue[] | YamlMapping; + +export interface YamlMapping { + readonly [key: string]: YamlValue; +} + +const KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/; +const NUMBER = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?$/; +const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); +const MAXIMUM_LINES = 2_048; +const MAXIMUM_DEPTH = 8; + +interface Line { + readonly number: number; + readonly indent: number; + readonly content: string; +} + +function fail(line: number, message: string): never { + throw projectError(`miakapp.yaml line ${line}: ${message}`); +} + +function stripComment(raw: string, number: number): string { + let quote: string | undefined; + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + if (quote !== undefined) { + if (character === '\\' && quote === '"') index += 1; + else if (character === quote) quote = undefined; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (character === '#' && (index === 0 || /\s/.test(raw[index - 1] as string))) { + return raw.slice(0, index).trimEnd(); + } + } + if (quote !== undefined) fail(number, 'unterminated quoted scalar'); + return raw.trimEnd(); +} + +function readLines(source: string): Line[] { + const lines: Line[] = []; + const raw = source.split('\n'); + if (raw.length > MAXIMUM_LINES) { + throw projectError(`miakapp.yaml exceeds ${MAXIMUM_LINES} lines`); + } + raw.forEach((text, offset) => { + const number = offset + 1; + if (text.includes('\t')) fail(number, 'tabs are not permitted'); + if (text.includes('\r')) fail(number, 'carriage returns are not permitted'); + const content = stripComment(text, number); + if (content.trim() === '') return; + if (content.trimStart() === '---' || content.trimStart() === '...') { + fail(number, 'document markers are not permitted'); + } + const indent = content.length - content.trimStart().length; + if (indent % 2 !== 0) fail(number, 'indentation must be a multiple of two spaces'); + lines.push({ number, indent, content: content.trimStart() }); + }); + return lines; +} + +function parseScalar(text: string, number: number): YamlValue { + if (text === '') return ''; + const first = text[0] as string; + if (first === '"' || first === "'") { + if (text.length < 2 || !text.endsWith(first)) fail(number, 'unterminated quoted scalar'); + const inner = text.slice(1, -1); + if (first === "'") { + if (inner.includes("'")) fail(number, "single-quoted escapes are not supported"); + return inner; + } + try { + const decoded = JSON.parse(text) as unknown; + if (typeof decoded !== 'string') fail(number, 'invalid double-quoted scalar'); + return decoded; + } catch { + return fail(number, 'invalid double-quoted scalar'); + } + } + if (text === '[]') return []; + if ('&*!|>%@`'.includes(first) || first === '[' || first === '{') { + fail(number, `the ${first} scalar form is not supported`); + } + if (text === 'true') return true; + if (text === 'false') return false; + if (text === 'null' || text === '~') return null; + if (NUMBER.test(text)) { + const number_ = Number(text); + if (!Number.isFinite(number_)) fail(number, 'number is not finite'); + return number_; + } + if (text.includes(': ') || text.endsWith(':')) { + fail(number, 'a plain scalar cannot contain a mapping separator'); + } + return text; +} + +function parseBlock(lines: Line[], from: number, indent: number, depth: number): { + value: YamlValue; + next: number; +} { + if (depth > MAXIMUM_DEPTH) { + fail((lines[from] as Line).number, `nesting exceeds ${MAXIMUM_DEPTH} levels`); + } + const first = lines[from]; + if (first === undefined) fail(0, 'unexpected end of file'); + if (first.content.startsWith('- ') || first.content === '-') { + const items: YamlValue[] = []; + let index = from; + while (index < lines.length) { + const line = lines[index] as Line; + if (line.indent < indent) break; + if (line.indent > indent) fail(line.number, 'unexpected indentation inside a sequence'); + if (!line.content.startsWith('- ') && line.content !== '-') { + fail(line.number, 'a sequence cannot be mixed with mapping keys'); + } + if (line.content === '-') fail(line.number, 'a sequence item must be a scalar'); + items.push(parseScalar(line.content.slice(2).trim(), line.number)); + index += 1; + } + return { value: items, next: index }; + } + + const mapping: Record = Object.create(null) as Record; + const seen = new Set(); + let index = from; + while (index < lines.length) { + const line = lines[index] as Line; + if (line.indent < indent) break; + if (line.indent > indent) fail(line.number, 'unexpected indentation'); + const separator = line.content.indexOf(':'); + if (separator === -1) fail(line.number, 'expected a "key: value" mapping entry'); + const key = line.content.slice(0, separator); + if (!KEY.test(key)) fail(line.number, `invalid key ${JSON.stringify(key)}`); + if (FORBIDDEN_KEYS.has(key)) fail(line.number, `the key ${key} is forbidden`); + if (seen.has(key)) fail(line.number, `duplicate key ${key}`); + seen.add(key); + const rest = line.content.slice(separator + 1).trim(); + if (rest !== '') { + mapping[key] = parseScalar(rest, line.number); + index += 1; + continue; + } + const child = lines[index + 1]; + if (child === undefined || child.indent <= indent) { + mapping[key] = null; + index += 1; + continue; + } + if (child.indent !== indent + 2) fail(child.number, 'a nested block indents by two spaces'); + const block = parseBlock(lines, index + 1, child.indent, depth + 1); + mapping[key] = block.value; + index = block.next; + } + return { value: mapping, next: index }; +} + +export function parseYaml(source: string): YamlMapping { + const lines = readLines(source); + if (lines.length === 0) throw projectError('miakapp.yaml is empty'); + const first = lines[0] as Line; + if (first.indent !== 0) fail(first.number, 'the document must start at column one'); + const { value, next } = parseBlock(lines, 0, 0, 1); + if (next !== lines.length) fail((lines[next] as Line).number, 'unexpected trailing content'); + if (Array.isArray(value) || value === null || typeof value !== 'object') { + throw projectError('miakapp.yaml must be a mapping'); + } + return value; +} diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts new file mode 100644 index 0000000..17b84e8 --- /dev/null +++ b/packages/cli/src/main.ts @@ -0,0 +1,614 @@ +/** + * Agent-first command surface. + * + * The CLI builds, validates, publishes and rolls back one home component. Git + * stays the user's: this tool never writes history, never rewrites sources it + * did not generate and never invents a control-plane endpoint. Every command is + * a pure function of the project file, the artifact bytes on disk and the + * arguments it was given. + * + * Two properties matter more than ergonomics here, because the usual caller is + * a coding agent rather than a person: + * + * - one stable exit code and one stable failure kind per outcome (see + * {@link EXIT_CODE}), so a wrapper decides without parsing prose; + * - `--json`, which prints exactly one closed object on stdout. + */ +import { prepareArtifact, type Artifact } from './artifact.js'; +import { exchangePublisherToken, fetchDiscovery } from './control-plane.js'; +import { + CliError, + EXIT_CODE, + artifactError, + authorizationError, + projectError, + usageError, +} from './errors.js'; +import type { FetchLike } from './internal/http.js'; +import { isDigest, isRelease, REQUIREMENT_KINDS, type Requirements } from './internal/names.js'; +import { PROJECT_FILE, PROJECT_SCHEMA, findProjectFile, parseProject, type Project } from './project.js'; +import { + activateRelease, + publish, + readRelease, + readUpload, + type ComponentPointer, + type PublicationTarget, +} from './publication.js'; + +export const CLI_VERSION = '4.0.0-alpha.0'; + +/** + * The Home Key is read from the environment only. A secret passed as an + * argument would land in shell history, in a process listing and in most CI + * logs, so no command accepts one. + */ +export const HOME_KEY_VARIABLE = 'MIAKAPP_HOME_KEY'; + +export interface FileSystem { + read(path: string): Promise; + write(path: string, bytes: Uint8Array): Promise; + exists(path: string): Promise; +} + +export interface CliHost { + write(text: string): void; + writeError(text: string): void; + cwd(): string; + env(name: string): string | undefined; + /** Injected by tests; defaults to `node:fs/promises`. */ + files?: FileSystem; + /** Injected by tests; defaults to the platform `fetch`. */ + fetch?: FetchLike; +} + +type Field = readonly [key: string, value: string | number | readonly string[]]; + +interface CommandResult { + readonly summary: string; + readonly fields: readonly Field[]; + readonly json: Record; +} + +interface Invocation { + readonly command: string; + readonly options: ReadonlyMap; + readonly flags: ReadonlySet; + readonly positional: readonly string[]; +} + +const USAGE = `miakapp ${CLI_VERSION} — build, publish and roll back a Miakapp home component + +Usage + miakapp [options] + +Commands + init Write ${PROJECT_FILE} in the current directory + check Validate the project and the artifact offline + publish Upload, finalize and activate the built artifact + activate Activate an already finalized digest at a new generation + rollback Alias of activate, for returning to a known-good digest + release Read one finalized release record + upload Read one upload status, to reconcile a lost request + help Print this text + version Print the CLI version + +Common options + --json Print one machine-readable object on stdout + --project Start the ${PROJECT_FILE} search here (default: cwd) + +publish options + --expected-generation Generation the pointer is expected to hold (required) + --generation Generation to publish (default: expected + 1) + --release Override component.release from ${PROJECT_FILE} + +activate / rollback options + --sha256 Finalized artifact digest (required) + --expected-generation Generation the pointer is expected to hold (required) + --generation Generation to publish (default: expected + 1) + +init options + --home Home ID to write into ${PROJECT_FILE} (required) + --control-plane Control-plane issuer (required) + --artifact Built artifact path (default: dist/component.js) + --release Initial release name (default: 0.1.0) + +Environment + ${HOME_KEY_VARIABLE} Home Key with the components:publish scope. Required by + publish, activate, rollback, release and upload. It is never + accepted as an argument and never printed. + +Exit codes + 0 success 1 usage 2 project 3 artifact + 4 authorization 5 contract 6 conflict 7 unknown outcome +`; + +const GLOBAL_FLAGS = ['json'] as const; +const GLOBAL_OPTIONS = ['project'] as const; + +const COMMAND_OPTIONS: Record = { + init: ['home', 'control-plane', 'artifact', 'release'], + check: [], + publish: ['expected-generation', 'generation', 'release'], + activate: ['sha256', 'expected-generation', 'generation'], + rollback: ['sha256', 'expected-generation', 'generation'], + release: [], + upload: [], + help: [], + version: [], +}; + +/** + * Parses `--name value`, `--name=value` and bare flags. + * + * Options are a closed set per command: an unrecognized one is a usage error + * rather than a silently ignored argument, so a mistyped flag can never turn a + * publication into a different publication. + */ +export function parseArguments(argv: readonly string[]): Invocation { + const first = argv[0]; + if (first === undefined || first === '--help' || first === '-h') { + return { command: 'help', options: new Map(), flags: new Set(), positional: [] }; + } + if (first === '--version' || first === '-v') { + return { command: 'version', options: new Map(), flags: new Set(), positional: [] }; + } + if (first.startsWith('-')) throw usageError(`Expected a command, received ${first}`); + + const command = first; + const allowed = COMMAND_OPTIONS[command]; + if (allowed === undefined) { + throw usageError(`Unknown command: ${command}`, 'Run miakapp help for the command list.'); + } + + const options = new Map(); + const flags = new Set(); + const positional: string[] = []; + + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index] as string; + if (!argument.startsWith('--')) { + positional.push(argument); + continue; + } + const separator = argument.indexOf('='); + const name = separator === -1 ? argument.slice(2) : argument.slice(2, separator); + if (name === '') throw usageError('Encountered a bare -- separator'); + if ((GLOBAL_FLAGS as readonly string[]).includes(name)) { + if (separator !== -1) throw usageError(`--${name} does not take a value`); + flags.add(name); + continue; + } + const known = (GLOBAL_OPTIONS as readonly string[]).includes(name) || allowed.includes(name); + if (!known) { + throw usageError( + `Unknown option --${name} for ${command}`, + 'Run miakapp help for the options this command accepts.', + ); + } + if (options.has(name)) throw usageError(`--${name} was given twice`); + if (separator !== -1) { + options.set(name, argument.slice(separator + 1)); + continue; + } + const value = argv[index + 1]; + if (value === undefined || value.startsWith('--')) { + throw usageError(`--${name} requires a value`); + } + options.set(name, value); + index += 1; + } + return { command, options, flags, positional }; +} + +function requiredOption(invocation: Invocation, name: string): string { + const value = invocation.options.get(name); + if (value === undefined || value === '') throw usageError(`--${name} is required`); + return value; +} + +function generationOption(invocation: Invocation, name: string): number { + const raw = requiredOption(invocation, name); + if (!/^(0|[1-9][0-9]*)$/.test(raw)) { + throw usageError(`--${name} must be a decimal non-negative integer, received ${raw}`); + } + const value = Number(raw); + if (!Number.isSafeInteger(value)) throw usageError(`--${name} is above the safe integer range`); + return value; +} + +/** `--generation` defaults to one above the expected generation, never higher. */ +function generationPair(invocation: Invocation): { + expectedGeneration: number; + generation: number; +} { + const expectedGeneration = generationOption(invocation, 'expected-generation'); + const generation = invocation.options.has('generation') + ? generationOption(invocation, 'generation') + : expectedGeneration + 1; + if (generation <= expectedGeneration) { + throw usageError('--generation must be strictly above --expected-generation'); + } + return { expectedGeneration, generation }; +} + +function digestOption(invocation: Invocation, name: string): string { + const value = requiredOption(invocation, name); + if (!isDigest(value)) { + throw usageError(`--${name} must be a SHA-256 digest as 43 base64url characters`); + } + return value; +} + +function homeKey(host: CliHost): string { + const value = host.env(HOME_KEY_VARIABLE); + if (value === undefined || value === '') { + throw authorizationError( + `${HOME_KEY_VARIABLE} is not set`, + `Export a Home Key holding components:publish as ${HOME_KEY_VARIABLE}. ` + + 'It is never accepted as a command-line argument.', + ); + } + return value; +} + +async function nodeFileSystem(): Promise { + const fs = await import('node:fs/promises'); + return { + async read(path) { + return new Uint8Array(await fs.readFile(path)); + }, + async write(path, bytes) { + await fs.writeFile(path, bytes, { flag: 'wx' }); + }, + async exists(path) { + try { + await fs.access(path); + return true; + } catch { + return false; + } + }, + }; +} + +async function files(host: CliHost): Promise { + return host.files ?? await nodeFileSystem(); +} + +async function loadProject(host: CliHost, invocation: Invocation): Promise { + const filesystem = await files(host); + const start = invocation.options.get('project') ?? host.cwd(); + const path = await findProjectFile(start, (candidate) => filesystem.exists(candidate)); + const root = path.slice(0, path.length - PROJECT_FILE.length - 1); + let source: string; + try { + source = new TextDecoder('utf-8', { fatal: true }).decode(await filesystem.read(path)); + } catch { + throw projectError(`${path} is not readable as UTF-8 text`); + } + return parseProject(root, source); +} + +async function loadArtifact(host: CliHost, project: Project): Promise { + const filesystem = await files(host); + if (!await filesystem.exists(project.artifactPath)) { + throw artifactError( + `No artifact at ${project.artifactPath}`, + 'Build the component before publishing; the CLI never bundles sources itself.', + ); + } + return prepareArtifact(await filesystem.read(project.artifactPath)); +} + +async function publicationTarget(host: CliHost, project: Project): Promise { + const key = homeKey(host); + const options = host.fetch === undefined ? {} : { fetch: host.fetch }; + const discovery = await fetchDiscovery({ issuer: project.issuer, ...options }); + const token = await exchangePublisherToken(discovery, key, options); + return { issuer: discovery.issuer, homeId: project.homeId, token: token.accessToken, ...options }; +} + +function requirementFields(requires: Requirements): readonly Field[] { + return REQUIREMENT_KINDS.map((kind): Field => [`requires.${kind}`, requires[kind]]); +} + +function pointerResult(summary: string, pointer: ComponentPointer): CommandResult { + return { + summary, + fields: [ + ['home', pointer.homeId], + ['generation', pointer.generation], + ['release', pointer.release], + ['sha256', pointer.sha256], + ['size', pointer.size], + ['url', pointer.url], + ...requirementFields(pointer.requires), + ], + json: { + home_id: pointer.homeId, + generation: pointer.generation, + release: pointer.release, + abi: pointer.abi, + url: pointer.url, + sha256: pointer.sha256, + size: pointer.size, + requires: pointer.requires, + }, + }; +} + +function projectTemplate(fields: { + home: string; + controlPlane: string; + artifact: string; + release: string; +}): string { + return `schema: ${PROJECT_SCHEMA} +home: ${fields.home} +control_plane: ${fields.controlPlane} + +component: + artifact: ${fields.artifact} + release: ${fields.release} + requires: + state_read: [] + event_subscribe: [] + event_publish: [] + call: [] + presentation: [] +`; +} + +async function runInit(host: CliHost, invocation: Invocation): Promise { + const filesystem = await files(host); + const root = invocation.options.get('project') ?? host.cwd(); + const path = `${root}/${PROJECT_FILE}`; + if (await filesystem.exists(path)) { + throw projectError( + `${path} already exists`, + 'The CLI never overwrites a project file; edit it or remove it first.', + ); + } + const release = invocation.options.get('release') ?? '0.1.0'; + if (!isRelease(release)) { + throw usageError('--release must be 1..64 UTF-8 bytes without control characters'); + } + const source = projectTemplate({ + home: requiredOption(invocation, 'home'), + controlPlane: requiredOption(invocation, 'control-plane'), + artifact: invocation.options.get('artifact') ?? 'dist/component.js', + release, + }); + // Parsed before it is written, so init can never emit a file check rejects. + parseProject(root, source); + await filesystem.write(path, new TextEncoder().encode(source)); + return { + summary: `Wrote ${path}`, + fields: [['project', path]], + json: { project: path, schema: PROJECT_SCHEMA }, + }; +} + +async function runCheck(host: CliHost, invocation: Invocation): Promise { + const project = await loadProject(host, invocation); + const artifact = await loadArtifact(host, project); + const filesystem = await files(host); + const coordinator = project.coordinatorEntry; + if (coordinator !== undefined && !await filesystem.exists(coordinator)) { + throw projectError(`coordinator.entry does not exist: ${coordinator}`); + } + return { + summary: `${project.homeId} release ${project.release} is publishable`, + fields: [ + ['home', project.homeId], + ['control_plane', project.issuer], + ['release', project.release], + ['artifact', project.artifactPath], + ['sha256', artifact.sha256], + ['size', artifact.size], + ['tokens', artifact.tokens], + ...requirementFields(project.requires), + ], + json: { + home_id: project.homeId, + control_plane: project.issuer, + release: project.release, + artifact: project.artifactPath, + sha256: artifact.sha256, + size: artifact.size, + tokens: artifact.tokens, + requires: project.requires, + }, + }; +} + +async function runPublish(host: CliHost, invocation: Invocation): Promise { + const { expectedGeneration, generation } = generationPair(invocation); + const project = await loadProject(host, invocation); + const release = invocation.options.get('release') ?? project.release; + if (!isRelease(release)) { + throw usageError('--release must be 1..64 UTF-8 bytes without control characters'); + } + const artifact = await loadArtifact(host, project); + const target = await publicationTarget(host, project); + const { pointer } = await publish(target, artifact, { + release, + requires: project.requires, + expectedGeneration, + generation, + }); + return pointerResult( + `Published ${release} as generation ${pointer.generation}`, + pointer, + ); +} + +async function runActivate(host: CliHost, invocation: Invocation): Promise { + const sha256 = digestOption(invocation, 'sha256'); + const { expectedGeneration, generation } = generationPair(invocation); + const project = await loadProject(host, invocation); + const target = await publicationTarget(host, project); + // Activation is checked against a readable finalized record first, so a typo + // in a digest fails as an artifact error instead of spending a CAS attempt. + const existing = await readRelease(target, sha256); + if (existing === undefined) { + throw artifactError( + `No finalized release for ${sha256}`, + 'Activate only a digest this home has already published.', + ); + } + const pointer = await activateRelease(target, { sha256, expectedGeneration, generation }); + return pointerResult( + `Activated ${existing.release} as generation ${pointer.generation}`, + pointer, + ); +} + +async function runRelease(host: CliHost, invocation: Invocation): Promise { + const sha256 = invocation.positional[0]; + if (sha256 === undefined) throw usageError('release requires one sha256 argument'); + if (!isDigest(sha256)) { + throw usageError('The release digest must be 43 base64url characters'); + } + const project = await loadProject(host, invocation); + const target = await publicationTarget(host, project); + const record = await readRelease(target, sha256); + if (record === undefined) { + throw artifactError(`No finalized release for ${sha256}`); + } + return { + summary: `Release ${record.release} finalized at ${record.finalizedAt}`, + fields: [ + ['release', record.release], + ['sha256', record.sha256], + ['size', record.size], + ['finalized_at', record.finalizedAt], + ...requirementFields(record.requires), + ], + json: { + release: record.release, + abi: record.abi, + sha256: record.sha256, + size: record.size, + requires: record.requires, + finalized_at: record.finalizedAt, + }, + }; +} + +async function runUpload(host: CliHost, invocation: Invocation): Promise { + const uploadId = invocation.positional[0]; + if (uploadId === undefined) throw usageError('upload requires one uploadId argument'); + const project = await loadProject(host, invocation); + const target = await publicationTarget(host, project); + const state = await readUpload(target, uploadId); + return { + summary: `Upload ${state.uploadId} is ${state.status}`, + fields: [ + ['upload_id', state.uploadId], + ['status', state.status], + ['release', state.release], + ['sha256', state.sha256], + ['size', state.size], + ['expires_at', state.expiresAt], + ], + json: { + upload_id: state.uploadId, + status: state.status, + release: state.release, + abi: state.abi, + sha256: state.sha256, + size: state.size, + requires: state.requires, + expires_at: state.expiresAt, + }, + }; +} + +async function dispatch(host: CliHost, invocation: Invocation): Promise { + switch (invocation.command) { + case 'init': + return await runInit(host, invocation); + case 'check': + return await runCheck(host, invocation); + case 'publish': + return await runPublish(host, invocation); + case 'activate': + case 'rollback': + return await runActivate(host, invocation); + case 'release': + return await runRelease(host, invocation); + case 'upload': + return await runUpload(host, invocation); + default: + throw usageError(`Unknown command: ${invocation.command}`); + } +} + +function renderText(result: CommandResult): string { + const lines = [result.summary]; + for (const [key, value] of result.fields) { + lines.push(` ${key}: ${Array.isArray(value) ? `[${value.join(', ')}]` : String(value)}`); + } + return `${lines.join('\n')}\n`; +} + +function renderFailure(error: CliError, json: boolean): string { + if (json) { + return `${JSON.stringify({ + ok: false, + kind: error.kind, + exit_code: error.exitCode, + message: error.message, + ...(error.remedy === undefined ? {} : { remedy: error.remedy }), + })}\n`; + } + const remedy = error.remedy === undefined ? '' : `\n ${error.remedy}`; + return `miakapp: ${error.kind}: ${error.message}${remedy}\n`; +} + +/** + * Runs one invocation and returns its exit code. + * + * Nothing throws out of this function: an unexpected error becomes + * `unknown_outcome`, because a CLI that crashed mid-publication cannot claim + * the control plane was left untouched. + */ +export async function run(argv: readonly string[], host: CliHost): Promise { + let json = false; + try { + const invocation = parseArguments(argv); + json = invocation.flags.has('json'); + if (invocation.command === 'help') { + host.write(json ? `${JSON.stringify({ ok: true, usage: USAGE })}\n` : USAGE); + return EXIT_CODE.success; + } + if (invocation.command === 'version') { + host.write(json ? `${JSON.stringify({ ok: true, version: CLI_VERSION })}\n` : `${CLI_VERSION}\n`); + return EXIT_CODE.success; + } + const result = await dispatch(host, invocation); + host.write( + json + ? `${JSON.stringify({ ok: true, command: invocation.command, ...result.json })}\n` + : renderText(result), + ); + return EXIT_CODE.success; + } catch (error) { + if (error instanceof CliError) { + host.writeError(renderFailure(error, json)); + return error.exitCode; + } + const message = error instanceof Error ? error.message : 'Unrecognized failure'; + host.writeError(renderFailure( + new CliError( + 'unknown_outcome', + `The command ended in an unhandled failure: ${message}`, + 'Reconcile with miakapp release or miakapp upload before publishing again.', + ), + json, + )); + return EXIT_CODE.unknown_outcome; + } +} diff --git a/packages/cli/src/project.ts b/packages/cli/src/project.ts new file mode 100644 index 0000000..a43be6d --- /dev/null +++ b/packages/cli/src/project.ts @@ -0,0 +1,119 @@ +import { isAbsolute, join, normalize, resolve, sep } from 'node:path'; +import { projectError } from './errors.js'; +import { canonicalHttpsUrl } from './internal/http.js'; +import { isHomeId, isRelease, type Requirements } from './internal/names.js'; +import { canonicalRequirements } from './internal/requirements.js'; +import { parseYaml, type YamlMapping, type YamlValue } from './internal/yaml.js'; + +export const PROJECT_FILE = 'miakapp.yaml'; +export const PROJECT_SCHEMA = 'miakapp.project/1'; + +export interface Project { + readonly root: string; + readonly homeId: string; + readonly issuer: string; + readonly artifactPath: string; + readonly release: string; + readonly requires: Requirements; + readonly coordinatorEntry: string | undefined; +} + +function mapping(value: YamlValue | undefined, label: string): YamlMapping { + if (value === null || value === undefined || Array.isArray(value) || typeof value !== 'object') { + throw projectError(`${label} must be a mapping`); + } + return value; +} + +function exactKeys(source: YamlMapping, allowed: readonly string[], label: string): void { + const unknown = Object.keys(source).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + throw projectError(`${label} has unknown keys: ${unknown.join(', ')}`); + } +} + +/** + * Resolves a declared path inside the project root. An absolute path or one that + * escapes the root is rejected so a published artifact always comes from the + * repository the agent is working in. + */ +export function resolveProjectPath(root: string, declared: unknown, label: string): string { + if (typeof declared !== 'string' || declared === '') { + throw projectError(`${label} must be a relative path inside the project`); + } + if (isAbsolute(declared) || declared.includes('\0')) { + throw projectError(`${label} must be relative to the project root`); + } + const normalized = normalize(declared); + if (normalized === '..' || normalized.startsWith(`..${sep}`)) { + throw projectError(`${label} must not leave the project root`); + } + return join(root, normalized); +} + +export function parseProject(root: string, source: string): Project { + const document = parseYaml(source); + exactKeys(document, ['schema', 'home', 'control_plane', 'component', 'coordinator'], PROJECT_FILE); + if (document.schema !== PROJECT_SCHEMA) { + throw projectError( + `${PROJECT_FILE} must declare schema: ${PROJECT_SCHEMA}`, + 'Run miakapp init to generate a current project file.', + ); + } + if (!isHomeId(document.home)) { + throw projectError( + 'home must be a Miakapp home ID of 3..63 bytes matching [a-z][a-z0-9-]*[a-z0-9]', + ); + } + const issuer = canonicalHttpsUrl(document.control_plane, 'control_plane'); + if (issuer.endsWith('/')) throw projectError('control_plane must not have a trailing slash'); + + const component = mapping(document.component, 'component'); + exactKeys(component, ['artifact', 'release', 'requires'], 'component'); + if (!isRelease(component.release)) { + throw projectError('component.release must be 1..64 UTF-8 bytes without control characters'); + } + const requires = canonicalRequirements( + component.requires === undefined || component.requires === null ? {} : component.requires, + ); + + let coordinatorEntry: string | undefined; + if (document.coordinator !== undefined && document.coordinator !== null) { + const coordinator = mapping(document.coordinator, 'coordinator'); + exactKeys(coordinator, ['entry'], 'coordinator'); + coordinatorEntry = resolveProjectPath(root, coordinator.entry, 'coordinator.entry'); + } + + return Object.freeze({ + root, + homeId: document.home, + issuer, + artifactPath: resolveProjectPath(root, component.artifact, 'component.artifact'), + release: component.release, + requires, + coordinatorEntry, + }); +} + +/** + * Walks up from `start` to find the project root. An agent may run the CLI from + * any subdirectory of the repository it is editing. + */ +export async function findProjectFile( + start: string, + exists: (path: string) => Promise, +): Promise { + let directory = resolve(start); + while (true) { + const candidate = join(directory, PROJECT_FILE); + if (await exists(candidate)) return candidate; + const parent = resolve(directory, '..'); + if (parent === directory) { + throw projectError( + `No ${PROJECT_FILE} found in ${start} or any parent directory`, + 'Run miakapp init to create one.', + ); + } + directory = parent; + } +} diff --git a/packages/cli/src/publication.ts b/packages/cli/src/publication.ts new file mode 100644 index 0000000..7b50b45 --- /dev/null +++ b/packages/cli/src/publication.ts @@ -0,0 +1,578 @@ +import { + artifactError, + authorizationError, + conflictError, + contractError, + unknownOutcomeError, + type CliError, +} from './errors.js'; +import { + MAXIMUM_RESPONSE_BYTES, + cancelBody, + jsonRequestInit, + readBoundedBody, + readJsonBody, + requestBody, + type FetchLike, +} from './internal/http.js'; +import { + boundedString, + exactRecord, + instant, + parseJson, + positiveInteger, +} from './internal/json.js'; +import { + MAXIMUM_ARTIFACT_BYTES, + isDigest, + isGeneration, + isHomeId, + isRandomId, + isRelease, + isUploadToken, + REQUIREMENT_KINDS, + type Requirements, +} from './internal/names.js'; +import { canonicalRequirements, sameRequirements } from './internal/requirements.js'; +import type { Artifact } from './artifact.js'; + +export const COMPONENT_ABI = 'miakapp.component/1'; + +/** RFC 0004 §13.2 expires an upload capability within fifteen minutes. */ +const MAXIMUM_CAPABILITY_LIFETIME_MS = 900_000; + +export type UploadStatus = 'awaiting_upload' | 'delivered' | 'finalized'; + +export interface UploadCapability { + readonly uploadId: string; + readonly uploadUrl: string; + readonly uploadToken: string; + readonly expiresAt: string; +} + +export interface UploadState { + readonly uploadId: string; + readonly status: UploadStatus; + readonly release: string; + readonly abi: string; + readonly sha256: string; + readonly size: number; + readonly requires: Requirements; + readonly expiresAt: string; +} + +export interface ComponentRelease { + readonly release: string; + readonly abi: string; + readonly sha256: string; + readonly size: number; + readonly requires: Requirements; + readonly finalizedAt: string; +} + +export interface ComponentPointer { + readonly homeId: string; + readonly generation: number; + readonly release: string; + readonly abi: string; + readonly url: string; + readonly sha256: string; + readonly size: number; + readonly requires: Requirements; +} + +export interface PublicationTarget { + /** Control-plane issuer, without a trailing slash. */ + readonly issuer: string; + readonly homeId: string; + /** Five-minute `components:publish` access token. */ + readonly token: string; + readonly fetch?: FetchLike; + readonly signal?: AbortSignal; +} + +export interface UploadRequest { + readonly release: string; + readonly requires: Requirements; +} + +interface ControlPlaneFailure { + readonly code: string; + readonly retryable: boolean; + readonly requestId: string; + readonly message: string; +} + +function client(target: PublicationTarget): { + fetcher: FetchLike; + signal: AbortSignal; + base: string; +} { + if (!isHomeId(target.homeId)) throw contractError('homeId is not a valid Miakapp home ID'); + const fetcher = target.fetch ?? globalThis.fetch; + return { + fetcher, + signal: target.signal ?? new AbortController().signal, + base: `${target.issuer}/v1/homes/${target.homeId}`, + }; +} + +async function readFailure(response: Response): Promise { + let parsed: unknown; + try { + parsed = parseJson(await readBoundedBody(response, MAXIMUM_RESPONSE_BYTES)); + } catch { + return { + code: 'unparseable', + retryable: false, + requestId: '', + message: `HTTP ${response.status} with no closed error body`, + }; + } + const envelope = exactRecord(parsed, ['error']); + const error = exactRecord(envelope.error, ['code', 'message', 'retryable', 'request_id']); + if (typeof error.retryable !== 'boolean') { + throw contractError('Error envelope has a non-boolean retryable field'); + } + return { + code: boundedString(error.code, 1, 64), + retryable: error.retryable, + requestId: boundedString(error.request_id, 0, 64), + message: boundedString(error.message, 1, 1_024), + }; +} + +/** + * Maps the RFC 0004 §16 closed failure table onto CLI outcomes. + * + * `temporarily_unavailable` after a mutating request is deliberately + * `unknown_outcome`: §17 states the effect may already have crossed its commit + * boundary, so the caller reconciles with a read instead of repeating it. + */ +function failure( + operation: string, + status: number, + error: ControlPlaneFailure, + mutating: boolean, +): CliError { + const detail = `${operation} failed with HTTP ${status} ${error.code}` + + (error.requestId === '' ? '' : ` (request ${error.requestId})`); + switch (error.code) { + case 'invalid_home_key': + case 'invalid_access_token': + case 'insufficient_scope': + case 'not_home_owner': + case 'publisher_mismatch': + case 'recent_authentication_required': + return authorizationError(detail, error.message); + case 'invalid_upload_capability': + return authorizationError( + detail, + 'The capability is absent, expired, replayed or bound to another tuple. ' + + 'Read the upload status before requesting a new one.', + ); + case 'generation_conflict': + return conflictError( + detail, + 'Another publication advanced the pointer. Read the active generation and retry the ' + + 'activation with the observed expected_generation.', + ); + case 'digest_quarantined': + return artifactError(detail, 'A quarantined digest cannot be activated, even by the owner.'); + case 'invalid_artifact': + return artifactError(detail, error.message); + case 'limit_exceeded': + return artifactError(detail, error.message); + case 'temporarily_unavailable': + return mutating + ? unknownOutcomeError( + detail, + 'The effect may already have committed. Reconcile with the upload-status or ' + + 'release read before acting again.', + ) + : contractError(detail, error.message); + default: + return contractError(detail, error.message); + } +} + +function decodeRequirements(value: unknown): Requirements { + const record = exactRecord(value, [...REQUIREMENT_KINDS]); + return canonicalRequirements(record); +} + +function decodeUploadCapability(value: unknown, uploadBase: string): UploadCapability { + const document = exactRecord(value, [ + 'schema', + 'upload_id', + 'upload_url', + 'upload_token', + 'expires_at', + ]); + if (document.schema !== 'miakapp.component-upload/1') { + throw contractError('Upload response has an unsupported schema'); + } + const uploadId = document.upload_id; + if (!isRandomId(uploadId)) throw contractError('upload_id is not a 22-character random ID'); + if (!isUploadToken(document.upload_token)) { + throw contractError('upload_token is not a 43-character capability secret'); + } + const uploadUrl = boundedString(document.upload_url, 1, 2_048); + let parsed: URL; + try { + parsed = new URL(uploadUrl); + } catch { + throw contractError('upload_url is not a URL'); + } + if (parsed.protocol !== 'https:' + || parsed.username !== '' + || parsed.password !== '' + || parsed.search !== '' + || parsed.hash !== '' + || parsed.href !== uploadUrl + || !uploadUrl.endsWith(`/${uploadId}`) + || !uploadUrl.startsWith(uploadBase)) { + throw contractError( + 'upload_url is not a credential-free HTTPS identifier for this home ending in its upload ID', + ); + } + const expiresAt = instant(document.expires_at); + const lifetime = Date.parse(expiresAt) - Date.now(); + if (lifetime <= 0 || lifetime > MAXIMUM_CAPABILITY_LIFETIME_MS) { + throw contractError('Upload capability is expired or exceeds the fifteen-minute ceiling'); + } + return Object.freeze({ + uploadId, + uploadUrl, + uploadToken: document.upload_token, + expiresAt, + }); +} + +function decodeUploadState(value: unknown): UploadState { + const document = exactRecord(value, [ + 'schema', + 'upload_id', + 'status', + 'release', + 'abi', + 'sha256', + 'size', + 'requires', + 'expires_at', + ]); + if (document.schema !== 'miakapp.component-upload-status/1') { + throw contractError('Upload-status response has an unsupported schema'); + } + const status = document.status; + if (status !== 'awaiting_upload' && status !== 'delivered' && status !== 'finalized') { + throw contractError('Upload status is outside the closed set'); + } + if (!isRandomId(document.upload_id)) throw contractError('upload_id is invalid'); + if (!isRelease(document.release)) throw contractError('release is invalid'); + if (!isDigest(document.sha256)) throw contractError('sha256 is not a base64url SHA-256 digest'); + if (document.abi !== COMPONENT_ABI) throw contractError('abi is not the supported ABI'); + return Object.freeze({ + uploadId: document.upload_id, + status, + release: document.release, + abi: COMPONENT_ABI, + sha256: document.sha256, + size: positiveInteger(document.size, MAXIMUM_ARTIFACT_BYTES), + requires: decodeRequirements(document.requires), + expiresAt: instant(document.expires_at), + }); +} + +function decodeRelease(value: unknown): ComponentRelease { + const document = exactRecord(value, [ + 'schema', + 'release', + 'abi', + 'sha256', + 'size', + 'requires', + 'finalized_at', + ]); + if (document.schema !== 'miakapp.component-release/1') { + throw contractError('Release response has an unsupported schema'); + } + if (!isRelease(document.release)) throw contractError('release is invalid'); + if (!isDigest(document.sha256)) throw contractError('sha256 is not a base64url SHA-256 digest'); + if (document.abi !== COMPONENT_ABI) throw contractError('abi is not the supported ABI'); + return Object.freeze({ + release: document.release, + abi: COMPONENT_ABI, + sha256: document.sha256, + size: positiveInteger(document.size, MAXIMUM_ARTIFACT_BYTES), + requires: decodeRequirements(document.requires), + finalizedAt: instant(document.finalized_at), + }); +} + +function decodePointer(value: unknown, target: PublicationTarget): ComponentPointer { + const document = exactRecord(value, [ + 'schema', + 'home_id', + 'generation', + 'release', + 'abi', + 'url', + 'sha256', + 'size', + 'requires', + ]); + if (document.schema !== 'miakapp.component-pointer/1') { + throw contractError('Pointer response has an unsupported schema'); + } + if (document.home_id !== target.homeId) { + throw contractError('Pointer names a different home'); + } + if (!isGeneration(document.generation)) { + throw contractError('Pointer generation is not a positive safe integer'); + } + if (!isRelease(document.release)) throw contractError('Pointer release is invalid'); + if (!isDigest(document.sha256)) throw contractError('Pointer sha256 is invalid'); + if (document.abi !== COMPONENT_ABI) throw contractError('Pointer abi is not the supported ABI'); + const url = boundedString(document.url, 1, 2_048); + if (url !== `${target.issuer}/v1/components/${document.sha256}.js`) { + throw contractError('Pointer url is not the token-free control-plane artifact resource'); + } + return Object.freeze({ + homeId: target.homeId, + generation: document.generation, + release: document.release, + abi: COMPONENT_ABI, + url, + sha256: document.sha256, + size: positiveInteger(document.size, MAXIMUM_ARTIFACT_BYTES), + requires: decodeRequirements(document.requires), + }); +} + +/** Step 1: request one capability bound to the complete publication tuple. */ +export async function requestUpload( + target: PublicationTarget, + artifact: Artifact, + request: UploadRequest, +): Promise { + const { fetcher, signal, base } = client(target); + if (!isRelease(request.release)) { + throw contractError('release must be 1..64 UTF-8 bytes without control characters'); + } + const body = requestBody({ + release: request.release, + abi: COMPONENT_ABI, + sha256: artifact.sha256, + size: artifact.size, + requires: request.requires, + }); + const response = await fetcher( + `${base}/component-uploads`, + jsonRequestInit('POST', target.token, body, signal), + ); + if (response.status !== 201) { + throw failure('Upload capability request', response.status, await readFailure(response), false); + } + return decodeUploadCapability(await readJsonBody(response), `${base}/component-uploads/`); +} + +/** + * Step 2: deliver the exact bytes once. + * + * A lost response is never retried with a new capability: the caller reconciles + * through {@link readUpload}, which distinguishes `awaiting_upload` from + * `delivered`. + */ +export async function deliverArtifact( + target: PublicationTarget, + capability: UploadCapability, + artifact: Artifact, +): Promise { + const { fetcher, signal } = client(target); + let response: Response; + try { + response = await fetcher(capability.uploadUrl, { + method: 'PUT', + headers: { + authorization: `Bearer ${capability.uploadToken}`, + 'content-type': 'application/javascript; charset=utf-8', + 'content-length': String(artifact.size), + }, + body: artifact.bytes, + redirect: 'error', + credentials: 'omit', + signal, + }); + } catch { + throw unknownOutcomeError( + 'Artifact delivery did not return a response', + 'Read the upload status before deciding whether to deliver again.', + ); + } + if (response.status === 204) { + cancelBody(response); + return; + } + throw failure('Artifact delivery', response.status, await readFailure(response), true); +} + +/** Step 3: finalize only the delivered, content-addressed object. */ +export async function finalizeUpload( + target: PublicationTarget, + uploadId: string, +): Promise { + const { fetcher, signal, base } = client(target); + if (!isRandomId(uploadId)) throw contractError('uploadId is not a 22-character random ID'); + const response = await fetcher( + `${base}/component-uploads/${uploadId}:finalize`, + jsonRequestInit('POST', target.token, requestBody({}), signal), + ); + if (response.status !== 200) { + throw failure('Finalization', response.status, await readFailure(response), true); + } + return decodeRelease(await readJsonBody(response)); +} + +/** Reconciliation read that tells a lost `PUT` from an undelivered upload. */ +export async function readUpload( + target: PublicationTarget, + uploadId: string, +): Promise { + const { fetcher, signal, base } = client(target); + if (!isRandomId(uploadId)) throw contractError('uploadId is not a 22-character random ID'); + const response = await fetcher( + `${base}/component-uploads/${uploadId}`, + jsonRequestInit('GET', target.token, undefined, signal), + ); + if (response.status !== 200) { + throw failure('Upload-status read', response.status, await readFailure(response), false); + } + return decodeUploadState(await readJsonBody(response)); +} + +/** Reconciliation read for a lost finalization response. */ +export async function readRelease( + target: PublicationTarget, + sha256: string, +): Promise { + const { fetcher, signal, base } = client(target); + if (!isDigest(sha256)) throw contractError('sha256 is not a base64url SHA-256 digest'); + const response = await fetcher( + `${base}/component-releases/${sha256}`, + jsonRequestInit('GET', target.token, undefined, signal), + ); + if (response.status === 422) { + const error = await readFailure(response); + if (error.code === 'invalid_artifact') return undefined; + throw failure('Release read', response.status, error, false); + } + if (response.status !== 200) { + throw failure('Release read', response.status, await readFailure(response), false); + } + return decodeRelease(await readJsonBody(response)); +} + +/** + * Step 4: compare-and-set the home pointer to a strictly greater generation. + * + * Activation is one transaction and is never blindly retried: a stale + * `expected_generation` fails with `generation_conflict` rather than + * last-write-wins. + */ +export async function activateRelease( + target: PublicationTarget, + activation: { + readonly sha256: string; + readonly expectedGeneration: number; + readonly generation: number; + }, +): Promise { + const { fetcher, signal, base } = client(target); + if (!isDigest(activation.sha256)) { + throw contractError('sha256 is not a base64url SHA-256 digest'); + } + if (!Number.isSafeInteger(activation.expectedGeneration) || activation.expectedGeneration < 0) { + throw contractError('expected_generation must be a non-negative safe integer'); + } + if (!isGeneration(activation.generation) + || activation.generation <= activation.expectedGeneration) { + throw contractError('generation must be a positive safe integer above expected_generation'); + } + const body = requestBody({ + sha256: activation.sha256, + expected_generation: activation.expectedGeneration, + generation: activation.generation, + }); + const response = await fetcher( + `${base}/component-releases:activate`, + jsonRequestInit('POST', target.token, body, signal), + ); + if (response.status !== 200) { + throw failure('Activation', response.status, await readFailure(response), true); + } + return decodePointer(await readJsonBody(response), target); +} + +/** + * Publishes one artifact: capability, delivery, finalization, activation. + * + * Every step is checked against the locally computed digest, size and + * requirements, so a control plane that echoes different metadata is rejected + * rather than trusted. + */ +export async function publish( + target: PublicationTarget, + artifact: Artifact, + request: UploadRequest & { + readonly expectedGeneration: number; + readonly generation: number; + }, +): Promise<{ readonly release: ComponentRelease; readonly pointer: ComponentPointer }> { + const requires = canonicalRequirements(request.requires); + const capability = await requestUpload(target, artifact, { release: request.release, requires }); + await deliverArtifact(target, capability, artifact); + const state = await readUpload(target, capability.uploadId); + if (state.sha256 !== artifact.sha256 + || state.size !== artifact.size + || state.release !== request.release + || !sameRequirements(state.requires, requires)) { + throw contractError('Upload state does not match the tuple the capability was bound to'); + } + if (state.status === 'awaiting_upload') { + throw unknownOutcomeError( + 'The control plane still reports the upload as awaiting delivery', + 'Request a new capability and deliver the bytes again; do not finalize this upload.', + ); + } + const release = state.status === 'finalized' + ? await requireRelease(target, artifact.sha256) + : await finalizeUpload(target, capability.uploadId); + if (release.sha256 !== artifact.sha256 || release.size !== artifact.size) { + throw contractError('Finalized release does not match the delivered artifact'); + } + const pointer = await activateRelease(target, { + sha256: artifact.sha256, + expectedGeneration: request.expectedGeneration, + generation: request.generation, + }); + if (pointer.sha256 !== artifact.sha256 || !sameRequirements(pointer.requires, requires)) { + throw contractError('Activated pointer does not match the published release'); + } + return { release, pointer }; +} + +async function requireRelease( + target: PublicationTarget, + sha256: string, +): Promise { + const release = await readRelease(target, sha256); + if (release === undefined) { + throw unknownOutcomeError( + 'The upload reports finalized but no release record is readable', + 'Re-read the release before publishing again; the control plane is mid-commit or ' + + 'has quarantined the digest.', + ); + } + return release; +} diff --git a/packages/cli/test/main.test.ts b/packages/cli/test/main.test.ts new file mode 100644 index 0000000..96266b5 --- /dev/null +++ b/packages/cli/test/main.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, test } from 'bun:test'; +import { EXIT_CODE } from '../src/errors.js'; +import { HOME_KEY_VARIABLE, parseArguments, run } from '../src/main.js'; +import { digestOf, fakeControlPlane, homeKey } from './support/control-plane.js'; +import { + ARTIFACT_SOURCE, + MemoryFiles, + PROJECT_ROOT, + standardProject, + testHost, +} from './support/host.js'; + +const HOME_ID = 'test-home'; +const ARTIFACT_DIGEST = digestOf(new TextEncoder().encode(ARTIFACT_SOURCE)); + +function publisherEnvironment(): Record { + return { [HOME_KEY_VARIABLE]: homeKey() }; +} + +describe('argument parsing', () => { + test('no argument prints usage rather than acting', () => { + expect(parseArguments([]).command).toBe('help'); + }); + + test('an option may use either separator', () => { + const spaced = parseArguments(['publish', '--expected-generation', '4']); + const joined = parseArguments(['publish', '--expected-generation=4']); + expect(spaced.options.get('expected-generation')).toBe('4'); + expect(joined.options.get('expected-generation')).toBe('4'); + }); + + test('an option outside the command set is a usage failure', () => { + expect(() => parseArguments(['check', '--sha256', 'x'])).toThrow(/Unknown option/); + }); + + test('a repeated option is a usage failure', () => { + expect(() => parseArguments(['publish', '--generation', '1', '--generation', '2'])) + .toThrow(/given twice/); + }); + + test('an option without a value is a usage failure', () => { + expect(() => parseArguments(['publish', '--generation'])).toThrow(/requires a value/); + }); +}); + +describe('offline commands', () => { + test('version prints the package version', async () => { + const host = testHost(); + expect(await run(['version'], host)).toBe(EXIT_CODE.success); + expect(host.stdout().trim()).toBe('4.0.0-alpha.0'); + }); + + test('an unknown command exits with the usage code', async () => { + const host = testHost(); + expect(await run(['deploy-everything'], host)).toBe(EXIT_CODE.usage); + expect(host.stderr()).toContain('usage:'); + }); + + test('check reports the digest the publication would bind', async () => { + const host = testHost({ files: standardProject() }); + expect(await run(['check'], host)).toBe(EXIT_CODE.success); + expect(host.stdout()).toContain(ARTIFACT_DIGEST); + expect(host.stdout()).toContain('requires.call: [lighting.set]'); + }); + + test('check --json emits one closed object', async () => { + const host = testHost({ files: standardProject() }); + expect(await run(['check', '--json'], host)).toBe(EXIT_CODE.success); + const report = host.json(); + expect(report['ok']).toBe(true); + expect(report['home_id']).toBe(HOME_ID); + expect(report['sha256']).toBe(ARTIFACT_DIGEST); + expect(report['requires']).toEqual({ + state_read: ['climate.living_room.temperature'], + event_subscribe: [], + event_publish: [], + call: ['lighting.set'], + presentation: [], + }); + }); + + test('check runs from a subdirectory of the project', async () => { + const host = testHost({ files: standardProject(), cwd: `${PROJECT_ROOT}/ui/src` }); + expect(await run(['check'], host)).toBe(EXIT_CODE.success); + }); + + test('a missing project file is a project failure', async () => { + const host = testHost({ files: new MemoryFiles() }); + expect(await run(['check'], host)).toBe(EXIT_CODE.project); + expect(host.stderr()).toContain('miakapp.yaml'); + }); + + test('a missing artifact is an artifact failure', async () => { + const files = standardProject(); + files.entries.delete(`${PROJECT_ROOT}/dist/component.js`); + const host = testHost({ files }); + expect(await run(['check'], host)).toBe(EXIT_CODE.artifact); + }); + + test('module syntax in the artifact is rejected before any upload', async () => { + const host = testHost({ files: standardProject("import { x } from './x.js';\n") }); + expect(await run(['check'], host)).toBe(EXIT_CODE.artifact); + expect(host.stderr()).toContain('module_syntax'); + }); + + test('a dynamic import in the artifact is rejected', async () => { + const host = testHost({ files: standardProject("const load = () => import('./x.js');\n") }); + expect(await run(['check'], host)).toBe(EXIT_CODE.artifact); + expect(host.stderr()).toContain('dynamic_import'); + }); + + test('init writes a project file that check accepts', async () => { + const files = new MemoryFiles(); + const host = testHost({ files }); + const code = await run([ + 'init', + '--home', HOME_ID, + '--control-plane', 'https://control.example.test/api', + ], host); + expect(code).toBe(EXIT_CODE.success); + + files.entries.set( + `${PROJECT_ROOT}/dist/component.js`, + new TextEncoder().encode(ARTIFACT_SOURCE), + ); + const second = testHost({ files }); + expect(await run(['check'], second)).toBe(EXIT_CODE.success); + }); + + test('init never overwrites an existing project file', async () => { + const host = testHost({ files: standardProject() }); + const code = await run([ + 'init', + '--home', HOME_ID, + '--control-plane', 'https://control.example.test/api', + ], host); + expect(code).toBe(EXIT_CODE.project); + expect(host.stderr()).toContain('already exists'); + }); +}); + +describe('publication', () => { + test('publish walks capability, delivery, finalization and activation', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const code = await run(['publish', '--expected-generation', '0', '--json'], host); + expect(code).toBe(EXIT_CODE.success); + + const pointer = host.json(); + expect(pointer['ok']).toBe(true); + expect(pointer['generation']).toBe(1); + expect(pointer['sha256']).toBe(ARTIFACT_DIGEST); + expect(pointer['release']).toBe('2026-09-13.1'); + expect(pointer['url']).toBe( + `https://control.example.test/api/v1/components/${ARTIFACT_DIGEST}.js`, + ); + expect(plane.generation).toBe(1); + + const methods = plane.requests.map((request) => request.split(' ')[0]); + expect(methods).toEqual(['GET', 'POST', 'POST', 'PUT', 'GET', 'POST', 'POST']); + }); + + test('publish without a Home Key is an authorization failure', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID }); + const host = testHost({ files: standardProject(), fetch: plane.fetch }); + expect(await run(['publish', '--expected-generation', '0'], host)) + .toBe(EXIT_CODE.authorization); + expect(host.stderr()).toContain(HOME_KEY_VARIABLE); + expect(plane.requests).toEqual([]); + }); + + test('the Home Key never appears in output', async () => { + const key = homeKey(); + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: { [HOME_KEY_VARIABLE]: key }, + }); + await run(['publish', '--expected-generation', '0'], host); + expect(host.stdout()).not.toContain(key); + expect(host.stderr()).not.toContain(key); + }); + + test('a stale expected generation exits with the conflict code', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 7 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + expect(await run(['publish', '--expected-generation', '0'], host)).toBe(EXIT_CODE.conflict); + expect(host.stderr()).toContain('generation_conflict'); + expect(plane.generation).toBe(7); + }); + + test('--generation must be strictly above --expected-generation', async () => { + const host = testHost({ files: standardProject(), env: publisherEnvironment() }); + const code = await run( + ['publish', '--expected-generation', '4', '--generation', '4'], + host, + ); + expect(code).toBe(EXIT_CODE.usage); + }); + + test('a lost upload response is reconciled instead of re-delivered', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + await run(['publish', '--expected-generation', '0'], host); + const uploadId = [...plane.uploads.keys()][0] as string; + + const reader = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + expect(await run(['upload', uploadId, '--json'], reader)).toBe(EXIT_CODE.success); + expect(reader.json()['status']).toBe('finalized'); + }); +}); + +describe('activation and rollback', () => { + async function publishOnce(plane: ReturnType): Promise { + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + expect(await run(['publish', '--expected-generation', '0'], host)).toBe(EXIT_CODE.success); + } + + test('rollback republishes a known digest at a greater generation', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + await publishOnce(plane); + + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const code = await run([ + 'rollback', + '--sha256', ARTIFACT_DIGEST, + '--expected-generation', '1', + '--json', + ], host); + expect(code).toBe(EXIT_CODE.success); + expect(host.json()['generation']).toBe(2); + expect(plane.generation).toBe(2); + }); + + test('activating an unpublished digest fails before the pointer is touched', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 3 }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const code = await run([ + 'activate', + '--sha256', digestOf(new TextEncoder().encode('never published')), + '--expected-generation', '3', + ], host); + expect(code).toBe(EXIT_CODE.artifact); + expect(plane.generation).toBe(3); + expect(plane.requests.some((request) => request.includes(':activate'))).toBe(false); + }); + + test('a malformed digest is caught as usage before any request', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID }); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + const code = await run([ + 'activate', + '--sha256', 'not-a-digest', + '--expected-generation', '0', + ], host); + expect(code).toBe(EXIT_CODE.usage); + expect(plane.requests).toEqual([]); + }); + + test('release reads back the finalized record', async () => { + const plane = fakeControlPlane({ homeId: HOME_ID, generation: 0 }); + await publishOnce(plane); + const host = testHost({ + files: standardProject(), + fetch: plane.fetch, + env: publisherEnvironment(), + }); + expect(await run(['release', ARTIFACT_DIGEST, '--json'], host)).toBe(EXIT_CODE.success); + expect(host.json()['release']).toBe('2026-09-13.1'); + }); +}); diff --git a/packages/cli/test/support/control-plane.ts b/packages/cli/test/support/control-plane.ts new file mode 100644 index 0000000..ffce18c --- /dev/null +++ b/packages/cli/test/support/control-plane.ts @@ -0,0 +1,238 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { FetchLike } from '../../src/internal/http.js'; +import { COMPONENT_ABI } from '../../src/publication.js'; + +export const ISSUER = 'https://control.example.test/api'; + +export function randomId(): string { + return randomBytes(16).toString('base64url'); +} + +export function randomSecret(): string { + return randomBytes(32).toString('base64url'); +} + +export function homeKey(): string { + return `mhk1_${randomId()}_${randomSecret()}`; +} + +export function digestOf(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('base64url'); +} + +interface Upload { + readonly uploadId: string; + readonly token: string; + readonly release: string; + readonly sha256: string; + readonly size: number; + readonly requires: unknown; + status: 'awaiting_upload' | 'delivered' | 'finalized'; +} + +export interface FakeControlPlaneOptions { + readonly homeId: string; + /** Generation the component pointer currently holds; 0 means never published. */ + readonly generation?: number; + /** Forces one response, by exact `${method} ${path}` key, for failure tests. */ + readonly fail?: ReadonlyMap; +} + +export interface FakeControlPlane { + readonly fetch: FetchLike; + readonly requests: string[]; + generation: number; + readonly uploads: Map; + readonly releases: Map; +} + +function json(status: number, body: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8', ...headers }, + }); +} + +function failure(status: number, code: string): Response { + return json(status, { + error: { code, message: `synthetic ${code}`, retryable: false, request_id: randomId() }, + }); +} + +/** + * An in-memory control plane that answers exactly the RFC 0004 §13.2 surface. + * + * It is deliberately strict: every response carries the closed schema and the + * exact field set the CLI decoders demand, so a test failure means the CLI + * changed, not that the double drifted. + */ +export function fakeControlPlane(options: FakeControlPlaneOptions): FakeControlPlane { + const base = `${ISSUER}/v1/homes/${options.homeId}`; + const plane: FakeControlPlane = { + fetch: async (input, init) => handle(input, init), + requests: [], + generation: options.generation ?? 0, + uploads: new Map(), + releases: new Map(), + }; + + async function handle(input: string, init: RequestInit): Promise { + const method = init.method ?? 'GET'; + const path = input.startsWith(ISSUER) ? input.slice(ISSUER.length) : input; + const key = `${method} ${path}`; + plane.requests.push(key); + const forced = options.fail?.get(key); + if (forced !== undefined) return failure(forced.status, forced.code); + + if (key === 'GET /.well-known/miakapp-control-plane') { + return json(200, { + schema: 'miakapp.control-plane-discovery/1', + issuer: ISSUER, + jwks_uri: `${ISSUER}/.well-known/jwks.json`, + exchange_endpoint: `${ISSUER}/v1/access-tokens:exchange`, + user_relay_exchange_endpoint: `${ISSUER}/v1/user-relay-tokens:exchange`, + push_audience: `${ISSUER}/v1/push`, + components_audience: `${ISSUER}/v1/components`, + }); + } + + if (key === 'POST /v1/access-tokens:exchange') { + const authorization = (init.headers as Record)['authorization'] ?? ''; + const keyId = /^Bearer mhk1_([A-Za-z0-9_-]{22})_/.exec(authorization)?.[1]; + if (keyId === undefined) return failure(401, 'invalid_home_key'); + return json(200, { + schema: 'miakapp.access-token/1', + access_token: 'header.payload.signature', + token_type: 'Bearer', + expires_at_ms: Date.now() + 300_000, + key: { id: keyId, label: 'test publisher' }, + }, { + 'cache-control': 'no-store', + pragma: 'no-cache', + 'referrer-policy': 'no-referrer', + }); + } + + const local = input.startsWith(base) ? input.slice(base.length) : undefined; + if (local === undefined) return failure(404, 'not_found'); + + if (method === 'POST' && local === '/component-uploads') { + const body = JSON.parse(String(init.body)) as { + release: string; + sha256: string; + size: number; + requires: unknown; + }; + const uploadId = randomId(); + plane.uploads.set(uploadId, { + uploadId, + token: randomSecret(), + release: body.release, + sha256: body.sha256, + size: body.size, + requires: body.requires, + status: 'awaiting_upload', + }); + const upload = plane.uploads.get(uploadId) as Upload; + return json(201, { + schema: 'miakapp.component-upload/1', + upload_id: uploadId, + upload_url: `${base}/component-uploads/${uploadId}`, + upload_token: upload.token, + expires_at: new Date(Date.now() + 600_000).toISOString(), + }); + } + + const finalizeMatch = /^\/component-uploads\/([A-Za-z0-9_-]{22}):finalize$/.exec(local); + if (method === 'POST' && finalizeMatch !== null) { + const upload = plane.uploads.get(finalizeMatch[1] as string); + if (upload === undefined) return failure(404, 'invalid_upload_capability'); + if (upload.status === 'awaiting_upload') return failure(409, 'invalid_artifact'); + upload.status = 'finalized'; + plane.releases.set(upload.sha256, upload); + return json(200, releaseBody(upload)); + } + + const uploadMatch = /^\/component-uploads\/([A-Za-z0-9_-]{22})$/.exec(local); + if (uploadMatch !== null) { + const upload = plane.uploads.get(uploadMatch[1] as string); + if (upload === undefined) return failure(404, 'invalid_upload_capability'); + if (method === 'PUT') { + if ((init.headers as Record)['authorization'] + !== `Bearer ${upload.token}`) { + return failure(403, 'invalid_upload_capability'); + } + const bytes = init.body as Uint8Array; + if (digestOf(bytes) !== upload.sha256 || bytes.byteLength !== upload.size) { + return failure(422, 'invalid_artifact'); + } + upload.status = 'delivered'; + return new Response(null, { status: 204 }); + } + return json(200, { + schema: 'miakapp.component-upload-status/1', + upload_id: upload.uploadId, + status: upload.status, + release: upload.release, + abi: COMPONENT_ABI, + sha256: upload.sha256, + size: upload.size, + requires: upload.requires, + expires_at: new Date(Date.now() + 600_000).toISOString(), + }); + } + + const releaseMatch = /^\/component-releases\/([A-Za-z0-9_-]{43})$/.exec(local); + if (method === 'GET' && releaseMatch !== null) { + const record = plane.releases.get(releaseMatch[1] as string); + if (record === undefined) return failure(422, 'invalid_artifact'); + return json(200, releaseBody(record)); + } + + if (method === 'POST' && local === '/component-releases:activate') { + const body = JSON.parse(String(init.body)) as { + sha256: string; + expected_generation: number; + generation: number; + }; + const record = plane.releases.get(body.sha256); + if (record === undefined) return failure(422, 'invalid_artifact'); + if (body.expected_generation !== plane.generation) { + return failure(409, 'generation_conflict'); + } + plane.generation = body.generation; + return json(200, { + schema: 'miakapp.component-pointer/1', + home_id: options.homeId, + generation: body.generation, + release: record.release, + abi: COMPONENT_ABI, + url: `${ISSUER}/v1/components/${record.sha256}.js`, + sha256: record.sha256, + size: record.size, + requires: record.requires, + }); + } + + return failure(404, 'not_found'); + } + + function releaseBody(record: { + release: string; + sha256: string; + size: number; + requires: unknown; + }): unknown { + return { + schema: 'miakapp.component-release/1', + release: record.release, + abi: COMPONENT_ABI, + sha256: record.sha256, + size: record.size, + requires: record.requires, + finalized_at: new Date().toISOString(), + }; + } + + return plane; +} diff --git a/packages/cli/test/support/host.ts b/packages/cli/test/support/host.ts new file mode 100644 index 0000000..ac79033 --- /dev/null +++ b/packages/cli/test/support/host.ts @@ -0,0 +1,94 @@ +import type { CliHost, FileSystem } from '../../src/main.js'; +import type { FetchLike } from '../../src/internal/http.js'; + +export const PROJECT_ROOT = '/home/mathieu/lumiere'; + +export const ARTIFACT_SOURCE = "self.addEventListener('fetch', function () {});\n"; + +export const PROJECT_YAML = `schema: miakapp.project/1 +home: test-home +control_plane: https://control.example.test/api + +component: + artifact: dist/component.js + release: 2026-09-13.1 + requires: + state_read: + - climate.living_room.temperature + event_subscribe: [] + event_publish: [] + call: + - lighting.set + presentation: [] +`; + +export class MemoryFiles implements FileSystem { + readonly entries: Map; + + constructor(entries: Record = {}) { + this.entries = new Map( + Object.entries(entries).map(([path, text]) => [path, new TextEncoder().encode(text)]), + ); + } + + async read(path: string): Promise { + const bytes = this.entries.get(path); + if (bytes === undefined) throw new Error(`ENOENT: ${path}`); + return bytes; + } + + async write(path: string, bytes: Uint8Array): Promise { + if (this.entries.has(path)) throw new Error(`EEXIST: ${path}`); + this.entries.set(path, bytes); + } + + async exists(path: string): Promise { + return this.entries.has(path); + } + + text(path: string): string { + const bytes = this.entries.get(path); + if (bytes === undefined) throw new Error(`ENOENT: ${path}`); + return new TextDecoder().decode(bytes); + } +} + +export interface TestHost extends CliHost { + readonly out: string[]; + readonly err: string[]; + stdout(): string; + stderr(): string; + json(): Record; +} + +export function testHost(options: { + files?: FileSystem; + fetch?: FetchLike; + env?: Record; + cwd?: string; +} = {}): TestHost { + const out: string[] = []; + const err: string[] = []; + const environment = options.env ?? {}; + return { + out, + err, + write: (text) => void out.push(text), + writeError: (text) => void err.push(text), + cwd: () => options.cwd ?? PROJECT_ROOT, + env: (name) => environment[name], + ...(options.files === undefined ? {} : { files: options.files }), + ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + stdout: () => out.join(''), + stderr: () => err.join(''), + json: () => JSON.parse(out.join('')) as Record, + }; +} + +/** A project laid out the way `miakapp init` leaves it, with a built artifact. */ +export function standardProject(artifact = ARTIFACT_SOURCE): MemoryFiles { + return new MemoryFiles({ + [`${PROJECT_ROOT}/miakapp.yaml`]: PROJECT_YAML, + [`${PROJECT_ROOT}/dist/component.js`]: artifact, + }); +} diff --git a/packages/cli/test/url.test.ts b/packages/cli/test/url.test.ts new file mode 100644 index 0000000..12cafc3 --- /dev/null +++ b/packages/cli/test/url.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test'; +import { canonicalHttpsUrl } from '../src/internal/http.js'; + +describe('canonicalHttpsUrl', () => { + test('accepts the bare origin RFC 0004 §3 uses as its example issuer', () => { + expect(canonicalHttpsUrl('https://control.example.test', 'issuer')) + .toBe('https://control.example.test'); + }); + + test('accepts an origin written with its trailing slash', () => { + expect(canonicalHttpsUrl('https://control.example.test/', 'issuer')) + .toBe('https://control.example.test/'); + }); + + test('accepts an exact path identifier', () => { + expect(canonicalHttpsUrl('https://control.example.test/api', 'issuer')) + .toBe('https://control.example.test/api'); + }); + + test('relaxing the empty path does not relax anything else', () => { + const rejected = [ + 'http://control.example.test', + 'https://control.example.test?a=1', + 'https://control.example.test#fragment', + 'https://user:secret@control.example.test', + 'https://control.example.test:443', + 'https://control.example.test/a/../b', + 'https://CONTROL.example.test', + 'wss://control.example.test', + 'control.example.test', + '', + ]; + for (const value of rejected) { + expect(() => canonicalHttpsUrl(value, 'issuer')).toThrow(); + } + }); + + test('a non-string is refused', () => { + expect(() => canonicalHttpsUrl(undefined, 'issuer')).toThrow(); + expect(() => canonicalHttpsUrl(42, 'issuer')).toThrow(); + }); +}); diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json new file mode 100644 index 0000000..6103c93 --- /dev/null +++ b/packages/cli/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "stripInternal": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..5a4e4e7 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ + "bun", + "node" + ], + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/packages/component/README.md b/packages/component/README.md new file mode 100644 index 0000000..f749ab4 --- /dev/null +++ b/packages/component/README.md @@ -0,0 +1,128 @@ +# @miakapp/component + +The guest SDK for a Miakapp home component. + +Your component runs inside a sandboxed Worker on a separate origin. Before your +first statement executes, the trusted prelude has already removed `fetch`, +WebSocket, `importScripts`, nested Workers, IndexedDB, Cache Storage, Beacon, +WebRTC and the rest. There is no DOM and no network. Everything the component +can see or do arrives through one message bridge, and everything it shows is a +semantic tree the host renders with its own trusted components. + +This package is that bridge, typed. + +```ts +import { defineComponent, ui } from '@miakapp/component'; + +defineComponent((home) => ({ + render: () => ui.screen({ title: 'Salon' }, [ + ui.toggle({ + id: 'lamp', + label: 'Lampe du salon', + value: home.state.get('zone.salon.light.on') === true, + onChange: (on) => void home.call('lighting.set', { on }), + }), + ]), +})); +``` + +## Building the artifact + +The runtime loads **one self-contained classic Worker program**: no module +syntax, no dynamic `import`, no source map. Bundle to an IIFE and let the CLI +check it before you publish. + +```bash +bun build src/component.ts --format=iife --minify --outfile dist/component.js +bunx @miakapp/cli check +``` + +`miakapp check` verifies those artifact rules offline, so a mistake costs a +second rather than an upload capability. + +## The `Home` object + +`setup` runs once, after `guest.boot` and the first authoritative state +snapshot. It receives the home and returns a component. + +| Member | Purpose | +| --- | --- | +| `home.state` | The granted state projection. `get`, `has`, `paths`, `entries`. | +| `home.state.stale` | True after a patch gap. **Read it.** See below. | +| `home.events.subscribe(name, fn)` | Subscribe to a granted topic; returns an unsubscribe. | +| `home.events.publish(name, data)` | Publish one at-most-once event. | +| `home.call(name, args, opts?)` | One granted RFC 0001 call. Resolves with the result. | +| `home.stream(name, args, opts?)` | The same, as an `AsyncIterable` with automatic credit. | +| `home.log(level, message)` | Bounded local development diagnostic. Not telemetry. | +| `home.invalidate()` | Request one coalesced re-render. | +| `home.grant` | The effective capability grant, for feature detection. | +| `home.staging` | True while the release is staged: renders work, actions do not. | + +State, events and interactions all re-render automatically. `invalidate()` is +for the rest — a timer, a resolved call, your own component state. + +## Three things that will bite you otherwise + +**Stale state is not current state.** A patch gap sets `home.state.stale` and +the values keep their last known contents. RFC 0002 requires the SDK to expose +that rather than pretend, so show it — a `status` node with state `stale` is one +line: + +```ts +ui.status({ id: 'sync', label: 'Synchronisation', state: home.state.stale ? 'stale' : 'applied' }) +``` + +**An unknown outcome is not a failure.** `home.call` can reject with +`CallOutcomeUnknownError`, which means the home may already have applied the +call. Retrying could act twice. Wait for the next snapshot instead. + +```ts +try { + await home.call('lighting.set', { on: true }); +} catch (error) { + if (error instanceof CallOutcomeUnknownError) { + // Do not retry. The next state snapshot is the authority. + } +} +``` + +**A staged release may render but not act.** During staging the broker answers +`event.publish` and `call.start` by terminating the instance, not by returning +an error. This SDK refuses those calls locally with a message that names the +state, so check `home.staging` and render a disabled control rather than trying. + +## The semantic tree + +`ui` builds the twelve ABI 1 node types: `screen`, `stack`, `grid`, `section`, +`text`, `status`, `button`, `toggle`, `input`, `select`, `progress` and `media`. +Tokens are closed enums in the type system, so an invalid `tone` or `gap` is a +compile error rather than a terminated instance. + +Every render commits a complete tree. That is intentional in ABI 1: it makes +validation and atomic rendering auditable. Commits are coalesced and spaced to +the thirty-per-second ABI rate for you, so `invalidate()` in a loop is safe. + +There is no URL property anywhere. Images and camera surfaces are named by exact +granted handles: + +```ts +ui.media({ id: 'door', label: 'Caméra d’entrée', handle: 'media.front_door' }) +``` + +Handlers may be callbacks or handler IDs. A callback is registered for the +render that created it, so an interaction against an older tree is ignored +rather than misrouted. + +## Limits worth knowing + +1,024 UI nodes, depth 32, 8,192 UTF-8 bytes per text, 262,144 aggregate, 100 +select options, 32 outstanding calls, 30 renders per second, 120 guest messages +per second. `LIMITS` exports the full set. The four whose violation is fatal — +node count, depth, text size and duplicate IDs — are checked locally before the +tree is sent, so you get a thrown error instead of a dead component. + +## Status + +Alpha, tracking Miakapp 4. The shapes here mirror +`component-runtime/src/runtime-broker.ts` exactly; that broker is the authority, +and any disagreement between the two is a bug in this package. diff --git a/packages/component/examples/salon.ts b/packages/component/examples/salon.ts new file mode 100644 index 0000000..51595ee --- /dev/null +++ b/packages/component/examples/salon.ts @@ -0,0 +1,90 @@ +/** + * A complete home component, small enough to read in one sitting. + * + * Bundle it to the single classic Worker program the runtime loads, then let + * the CLI check it before publishing: + * + * ```bash + * bun build examples/salon.ts --format=iife --minify --outfile dist/component.js + * bunx @miakapp/cli check + * ``` + * + * The component reads two granted state paths, calls one granted function and + * renders one semantic tree. It never touches the network: inside the Worker + * there is nothing to touch. + */ +import { defineComponent, ui, type StructuredValue } from '@miakapp/component'; + +function asBoolean(value: StructuredValue | undefined): boolean { + return value === true; +} + +function asNumber(value: StructuredValue | undefined, fallback: number): number { + return typeof value === 'number' ? value : fallback; +} + +defineComponent((home) => { + let pending = false; + let failure: string | undefined; + + async function setLight(on: boolean): Promise { + pending = true; + failure = undefined; + home.invalidate(); + try { + await home.call('lighting.set', { on }, { deadlineMs: 10_000 }); + } catch (error) { + // An unknown outcome is deliberately not retried: the home may already + // have applied it. The next state snapshot is the authority. + failure = error instanceof Error ? error.message : 'La commande a échoué'; + } finally { + pending = false; + home.invalidate(); + } + } + + return { + render: () => { + const on = asBoolean(home.state.get('zone.salon.light.on')); + const temperature = asNumber(home.state.get('climate.salon.temperature'), 0); + + return ui.screen({ title: 'Salon' }, [ + ui.section({ id: 'lights', heading: 'Lumières' }, [ + ui.toggle({ + id: 'salon-light', + label: 'Lampe du salon', + value: on, + pending, + onChange: (next) => void setLight(next), + }), + ui.status({ + id: 'light-status', + label: 'État', + state: home.state.stale + ? 'stale' + : failure !== undefined + ? 'failed' + : pending + ? 'pending' + : 'applied', + ...(failure === undefined ? {} : { detail: failure }), + }), + ]), + ui.section({ id: 'climate', heading: 'Climat' }, [ + ui.text({ + id: 'temperature', + text: `${temperature.toFixed(1)} °C`, + emphasis: 'strong', + }), + ui.text({ + id: 'temperature-note', + text: home.state.stale + ? 'Valeur peut-être périmée, en attente d’un instantané.' + : `Relevé à la révision ${home.state.revision}.`, + tone: home.state.stale ? 'warning' : 'muted', + }), + ]), + ]); + }, + }; +}); diff --git a/packages/component/package.json b/packages/component/package.json new file mode 100644 index 0000000..d5caa43 --- /dev/null +++ b/packages/component/package.json @@ -0,0 +1,52 @@ +{ + "name": "@miakapp/component", + "version": "4.0.0-alpha.0", + "description": "Guest SDK for Miakapp home components: semantic UI, granted state, calls and events inside the sandboxed Worker", + "type": "module", + "private": true, + "engines": { + "bun": ">=1.2.23", + "node": ">=22.9" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "LICENSE", + "README.md" + ], + "sideEffects": false, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "bun test test/", + "typecheck": "tsc --noEmit", + "check": "bun run build && bun run typecheck && bun run test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Miakapp/MiakAPI.git", + "directory": "packages/component" + }, + "publishConfig": { + "access": "public", + "tag": "next" + }, + "keywords": [ + "Miakapp", + "MiakAPI", + "component", + "smart-home", + "worker" + ], + "author": "Mathieu Colmon", + "license": "ISC", + "devDependencies": { + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "typescript": "7.0.2" + } +} diff --git a/packages/component/src/calls.ts b/packages/component/src/calls.ts new file mode 100644 index 0000000..b0a222f --- /dev/null +++ b/packages/component/src/calls.ts @@ -0,0 +1,237 @@ +/** + * Call correlation for the guest side of the bridge. + * + * RFC 0001 semantics are preserved rather than smoothed over: `call.accepted` + * is not success, cancellation is cooperative, and a call can end as + * `outcome_unknown`. That last state is why a call rejects with a distinct + * {@link CallOutcomeUnknownError} instead of a generic failure — a component + * that retried it could act twice on the home. + */ +import { + LIMITS, + type CallChunk, + type CallFailure, + type CallOutcomeUnknown, + type CallResult, + type GuestMessageKind, + type StructuredValue, +} from './protocol.js'; + +export class CallError extends Error { + readonly code: string; + readonly retryable: boolean; + readonly details: StructuredValue | undefined; + + constructor(failure: CallFailure) { + super(failure.message); + this.name = 'MiakappCallError'; + this.code = failure.code; + this.retryable = failure.retryable ?? false; + this.details = failure.details; + } +} + +/** The call may or may not have been applied. Reconcile through state; never retry blindly. */ +export class CallOutcomeUnknownError extends Error { + readonly operationId: number; + + constructor(operationId: number, message?: string) { + super(message ?? 'The call outcome is unknown'); + this.name = 'MiakappCallOutcomeUnknownError'; + this.operationId = operationId; + } +} + +export class CallCancelledError extends Error { + constructor() { + super('The call was cancelled'); + this.name = 'MiakappCallCancelledError'; + } +} + +export interface CallOptions { + readonly deadlineMs?: number; + readonly signal?: { addEventListener(type: 'abort', listener: () => void): void }; +} + +export interface CallStream extends AsyncIterable { + readonly operationId: number; + /** Terminal value, once the stream ends. */ + readonly result: Promise; + cancel(): void; +} + +type Send = (kind: GuestMessageKind, payload: unknown) => void; + +interface Pending { + accepted: boolean; + credit: number; + readonly chunks: StructuredValue[]; + readonly waiters: Array<(value: IteratorResult) => void>; + readonly streaming: boolean; + settled: boolean; + resolve(value: StructuredValue): void; + reject(error: Error): void; +} + +export class CallManager { + readonly #send: Send; + readonly #pending = new Map(); + #nextOperationId = 1; + + constructor(send: Send) { + this.#send = send; + } + + get outstanding(): number { + return this.#pending.size; + } + + call(name: string, args: StructuredValue, options: CallOptions = {}): Promise { + return this.#start(name, args, options, false).promise; + } + + stream(name: string, args: StructuredValue, options: CallOptions = {}): CallStream { + const started = this.#start(name, args, options, true); + const manager = this; + const operationId = started.operationId; + // Leaving a `for await` loop early cancels the call, which rejects the + // terminal promise. Marking it handled here keeps that ordinary control + // flow from surfacing as an unhandled rejection; `result` still rejects for + // a caller that awaits it. + void started.promise.catch(() => undefined); + return { + operationId, + result: started.promise, + cancel: () => manager.cancel(operationId), + [Symbol.asyncIterator](): AsyncIterator { + return { + async next(): Promise> { + return await manager.#next(operationId); + }, + async return(): Promise> { + manager.cancel(operationId); + return { done: true, value: undefined }; + }, + }; + }, + }; + } + + cancel(operationId: number): void { + const pending = this.#pending.get(operationId); + if (pending === undefined || pending.settled) return; + this.#send('call.cancel', { operation_id: operationId }); + this.#settle(operationId, (entry) => entry.reject(new CallCancelledError())); + } + + accept(operationId: number): void { + const pending = this.#pending.get(operationId); + if (pending === undefined) return; + pending.accepted = true; + if (pending.streaming) this.#grantCredit(operationId, pending); + } + + chunk(message: CallChunk): void { + const pending = this.#pending.get(message.operation_id); + if (pending === undefined) return; + pending.credit -= 1; + const waiter = pending.waiters.shift(); + if (waiter === undefined) pending.chunks.push(message.value); + else waiter({ done: false, value: message.value }); + this.#grantCredit(message.operation_id, pending); + } + + result(message: CallResult): void { + this.#settle(message.operation_id, (entry) => entry.resolve(message.value)); + } + + fail(message: CallFailure): void { + this.#settle(message.operation_id, (entry) => entry.reject(new CallError(message))); + } + + outcomeUnknown(message: CallOutcomeUnknown): void { + this.#settle(message.operation_id, (entry) => entry.reject( + new CallOutcomeUnknownError(message.operation_id, message.message), + )); + } + + /** Rejects everything in flight; used when the instance is disposed. */ + abortAll(error: Error): void { + for (const operationId of [...this.#pending.keys()]) { + this.#settle(operationId, (entry) => entry.reject(error)); + } + } + + #start( + name: string, + args: StructuredValue, + options: CallOptions, + streaming: boolean, + ): { promise: Promise; operationId: number } { + if (this.#pending.size >= LIMITS.outstandingCalls) { + throw new Error(`No more than ${LIMITS.outstandingCalls} calls may be in flight`); + } + if (options.deadlineMs !== undefined + && (!Number.isSafeInteger(options.deadlineMs) + || options.deadlineMs <= 0 + || options.deadlineMs > LIMITS.callDeadlineMs)) { + throw new Error(`deadlineMs must be a positive integer of at most ${LIMITS.callDeadlineMs}`); + } + const operationId = this.#nextOperationId; + this.#nextOperationId += 1; + + let resolve: (value: StructuredValue) => void = () => undefined; + let reject: (error: Error) => void = () => undefined; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + this.#pending.set(operationId, { + accepted: false, + credit: 0, + chunks: [], + waiters: [], + streaming, + settled: false, + resolve, + reject, + }); + options.signal?.addEventListener('abort', () => this.cancel(operationId)); + this.#send('call.start', { + operation_id: operationId, + name, + args, + ...(options.deadlineMs === undefined ? {} : { deadline_ms: options.deadlineMs }), + }); + return { promise, operationId }; + } + + async #next(operationId: number): Promise> { + const pending = this.#pending.get(operationId); + if (pending === undefined) return { done: true, value: undefined }; + const buffered = pending.chunks.shift(); + if (buffered !== undefined) return { done: false, value: buffered }; + return await new Promise>((resolve) => { + pending.waiters.push(resolve); + }); + } + + /** Keeps outstanding credit topped up to the ABI ceiling while a stream runs. */ + #grantCredit(operationId: number, pending: Pending): void { + if (!pending.accepted || pending.settled) return; + const missing = LIMITS.callCredit - pending.credit; + if (missing <= 0) return; + pending.credit += missing; + this.#send('call.credit', { operation_id: operationId, credit: missing }); + } + + #settle(operationId: number, finish: (pending: Pending) => void): void { + const pending = this.#pending.get(operationId); + if (pending === undefined || pending.settled) return; + pending.settled = true; + this.#pending.delete(operationId); + for (const waiter of pending.waiters.splice(0)) waiter({ done: true, value: undefined }); + finish(pending); + } +} diff --git a/packages/component/src/component.ts b/packages/component/src/component.ts new file mode 100644 index 0000000..0e5b697 --- /dev/null +++ b/packages/component/src/component.ts @@ -0,0 +1,328 @@ +/** + * The component entry point: handshake, render loop and lifecycle. + * + * ABI 1 commits a complete immutable tree per render, so this module owns the + * only two timing rules that can kill an otherwise correct component: the first + * render must reach the broker within three seconds of boot, and no more than + * thirty renders may be committed per rolling second. Commits are therefore + * coalesced rather than passed through. + */ +import { + CallManager, + type CallOptions, + type CallStream, +} from './calls.js'; +import { + COMPONENT_ABI, + LIMITS, + decodeBrokerMessage, + guestMessage, + workerTransport, + type CapabilityGrant, + type GuestBoot, + type GuestMessageKind, + type GuestTransport, + type LogLevel, + type StructuredValue, + type Theme, + type UiInteraction, +} from './protocol.js'; +import { StateStore, type StateView } from './state.js'; +import { checkTree, collectHandlers, type Handler, type UiNode } from './ui.js'; + +export interface EventsView { + subscribe(name: string, listener: (data: StructuredValue) => void): () => void; + publish(name: string, data: StructuredValue): void; +} + +export interface Home { + readonly homeId: string; + readonly generation: number; + readonly release: string; + readonly grant: CapabilityGrant; + /** True while the release is staged: rendering works, calls and events do not. */ + readonly staging: boolean; + readonly locale: string; + readonly theme: Theme; + readonly state: StateView; + readonly events: EventsView; + call(name: string, args: StructuredValue, options?: CallOptions): Promise; + stream(name: string, args: StructuredValue, options?: CallOptions): CallStream; + log(level: LogLevel, message: string): void; + /** Requests one coalesced re-render. */ + invalidate(): void; +} + +export interface Component { + render(): UiNode; + dispose?(): void; +} + +export type Setup = (home: Home) => Component; + +export interface DefineOptions { + /** Injected by tests; defaults to the ambient Worker scope. */ + readonly transport?: GuestTransport; + readonly now?: () => number; + readonly schedule?: (callback: () => void, delayMs: number) => void; +} + +export interface ComponentHandle { + /** Resolves once the first tree has been committed. */ + readonly ready: Promise; + readonly disposed: boolean; +} + +const MINIMUM_RENDER_INTERVAL_MS = Math.ceil(1_000 / LIMITS.rendersPerSecond); + +/** + * Starts one component. + * + * The call sends `guest.ready` immediately: the broker gives the Worker three + * seconds to boot, and the handshake must not wait on any guest work. `setup` + * runs later, once `guest.boot` and the first authoritative snapshot arrive, so + * a component never observes state before the grant that justifies it. + */ +export function defineComponent(setup: Setup, options: DefineOptions = {}): ComponentHandle { + const transport = options.transport ?? workerTransport(); + const now = options.now ?? (() => Date.now()); + const schedule = options.schedule + ?? ((callback, delayMs) => void setTimeout(callback, delayMs)); + + const state = new StateStore(); + const listeners = new Map void>>(); + /** Handlers from the last committed tree, keyed by node ID. */ + let handlers = new Map(); + + let boot: GuestBoot | undefined; + let component: Component | undefined; + let renderRevision = 0; + let lastRenderAt = Number.NEGATIVE_INFINITY; + let renderScheduled = false; + let suspended = false; + let disposed = false; + let active = false; + + let signalReady: () => void = () => undefined; + const ready = new Promise((resolve) => { + signalReady = resolve; + }); + + const send = (kind: GuestMessageKind, payload: unknown): void => { + if (disposed) return; + transport.post(guestMessage(kind, payload)); + }; + + const calls = new CallManager(send); + + const log = (level: LogLevel, message: string): void => { + send('log.write', { level, message: message.slice(0, LIMITS.logMessageBytes) }); + }; + + const home: Home = { + get homeId() { + return requireBoot().home_id; + }, + get generation() { + return requireBoot().generation; + }, + get release() { + return requireBoot().release; + }, + get grant() { + return requireBoot().grant; + }, + get staging() { + return requireBoot().staging; + }, + get locale() { + return requireBoot().locale; + }, + get theme() { + return requireBoot().theme; + }, + state, + events: { + subscribe(name, listener) { + const existing = listeners.get(name); + if (existing === undefined) { + listeners.set(name, new Set([listener])); + send('event.subscribe', { name }); + } else { + existing.add(listener); + } + return () => { + const current = listeners.get(name); + if (current === undefined) return; + current.delete(listener); + if (current.size > 0) return; + listeners.delete(name); + send('event.unsubscribe', { name }); + }; + }, + publish(name, data) { + requireOperable('publish an event'); + send('event.publish', { name, data }); + }, + }, + call: (name, args, callOptions) => { + requireOperable('start a call'); + return calls.call(name, args, callOptions); + }, + stream: (name, args, callOptions) => { + requireOperable('start a call'); + return calls.stream(name, args, callOptions); + }, + log, + invalidate: () => requestRender(), + }; + + function requireBoot(): GuestBoot { + if (boot === undefined) { + throw new Error('The component is not booted yet; use the Home passed to setup'); + } + return boot; + } + + /** + * Refuses an operation the broker would answer by terminating the instance. + * + * `event.publish`, `call.start`, `call.credit` and `call.cancel` are denied + * while the runtime is staged, suspended or inactive, and a denial in the + * broker is fatal rather than an error reply. Failing locally keeps the + * component alive and gives the author a message that names the state. + */ + function requireOperable(action: string): void { + if (disposed) throw new Error(`Cannot ${action}: the component instance was disposed`); + if (!active) throw new Error(`Cannot ${action}: the component is not active yet`); + if (suspended) throw new Error(`Cannot ${action}: the component is suspended`); + if (requireBoot().staging) { + throw new Error(`Cannot ${action}: a staged release may render but not act on the home`); + } + } + + function commit(): void { + const current = component; + if (disposed || suspended || current === undefined) return; + const collected = collectHandlers(() => current.render()); + const tree = checkTree(collected.result); + handlers = collected.handlers; + renderRevision += 1; + lastRenderAt = now(); + send('ui.render', { revision: renderRevision, tree }); + signalReady(); + } + + /** + * Coalesces renders and spaces them at the ABI rate. + * + * A component that calls `invalidate()` in a loop, or a burst of state + * patches, must not turn into thirty-one commits in a second: the broker + * would terminate the instance rather than drop the extra render. + */ + function requestRender(): void { + if (disposed || suspended || component === undefined || renderScheduled) return; + renderScheduled = true; + const wait = Math.max(0, MINIMUM_RENDER_INTERVAL_MS - (now() - lastRenderAt)); + schedule(() => { + renderScheduled = false; + commit(); + }, wait); + } + + function start(): void { + if (component !== undefined) return; + component = setup(home); + commit(); + } + + function dispatchInteraction(interaction: UiInteraction): void { + if (interaction.render_revision !== renderRevision) return; + const handler = handlers.get(interaction.node_id); + if (handler === undefined) return; + if (interaction.event === 'press') (handler as () => void)(); + else (handler as (value: boolean | string) => void)(interaction.value as boolean | string); + requestRender(); + } + + function teardown(): void { + if (disposed) return; + disposed = true; + calls.abortAll(new Error('The component instance was disposed')); + try { + component?.dispose?.(); + } finally { + component = undefined; + } + } + + transport.subscribe((data) => { + const message = decodeBrokerMessage(data); + if (message === undefined || disposed) return; + switch (message.kind) { + case 'guest.boot': + boot = message.payload; + active = true; + break; + case 'state.snapshot': + state.applySnapshot(message.payload); + if (component === undefined) start(); + else requestRender(); + break; + case 'state.patch': + if (state.applyPatch(message.payload)) requestRender(); + break; + case 'state.stale': + state.markStale(message.payload); + requestRender(); + break; + case 'event.message': { + const subscribers = listeners.get(message.payload.name); + if (subscribers === undefined) break; + for (const listener of [...subscribers]) listener(message.payload.data); + requestRender(); + break; + } + case 'call.accepted': + calls.accept(message.payload.operation_id); + break; + case 'call.chunk': + calls.chunk(message.payload); + break; + case 'call.result': + calls.result(message.payload); + break; + case 'call.error': + calls.fail(message.payload); + break; + case 'call.outcome_unknown': + calls.outcomeUnknown(message.payload); + break; + case 'ui.interaction': + dispatchInteraction(message.payload); + break; + case 'lifecycle.suspend': + suspended = true; + break; + case 'lifecycle.resume': + suspended = false; + active = message.payload.active; + requestRender(); + break; + case 'lifecycle.dispose': + teardown(); + break; + } + }); + + send('guest.ready', { abi: COMPONENT_ABI }); + + return { + ready, + get disposed() { + return disposed; + }, + }; +} + +export type { CallOptions, CallStream }; diff --git a/packages/component/src/index.ts b/packages/component/src/index.ts new file mode 100644 index 0000000..c47cd3f --- /dev/null +++ b/packages/component/src/index.ts @@ -0,0 +1,47 @@ +/** + * `@miakapp/component` — the guest SDK for a Miakapp home component. + * + * The component runs inside a sandboxed Worker with no network, no storage and + * no DOM. Everything it can do arrives through the bridge: a granted state + * projection, granted events, granted calls, and one complete semantic tree per + * render. This module is the whole public surface. + */ +export { + defineComponent, + type Component, + type ComponentHandle, + type DefineOptions, + type EventsView, + type Home, + type Setup, +} from './component.js'; + +export { + CallCancelledError, + CallError, + CallOutcomeUnknownError, + CallManager, + type CallOptions, + type CallStream, +} from './calls.js'; + +export { StateStore, type StateView } from './state.js'; + +export { + COMPONENT_ABI, + LIMITS, + decodeBrokerMessage, + guestMessage, + workerTransport, + type CapabilityGrant, + type GuestBoot, + type GuestTransport, + type LogLevel, + type RequirementKind, + type StructuredValue, + type Theme, + type UiInteraction, +} from './protocol.js'; + +export * as ui from './ui.js'; +export { UiError, checkTree, type UiNode, type UiNodeType } from './ui.js'; diff --git a/packages/component/src/protocol.ts b/packages/component/src/protocol.ts new file mode 100644 index 0000000..c06a02e --- /dev/null +++ b/packages/component/src/protocol.ts @@ -0,0 +1,239 @@ +/** + * The guest half of the ABI 1 bridge. + * + * Shapes here mirror `component-runtime/src/runtime-broker.ts` exactly. The + * broker is the authority: it validates every field, and one unknown key, one + * missing key or one out-of-range number terminates the instance. Nothing in + * this file is inferred from prose — each payload matches the record the broker + * builds or destructures. + */ + +export const COMPONENT_ABI = 'miakapp.component/1'; + +/** The guest envelope is `{ v, kind, payload }`; instance, epoch and seq are host/broker only. */ +export const BROKER_PROTOCOL = 1; + +export const LIMITS = Object.freeze({ + uiNodes: 1_024, + uiDepth: 32, + uiTextBytes: 262_144, + textBytes: 8_192, + inputBytes: 16_384, + selectOptions: 100, + outstandingCalls: 32, + callCredit: 32, + callDeadlineMs: 300_000, + guestMessagesPerSecond: 120, + rendersPerSecond: 30, + logMessageBytes: 2_048, +}); + +export type RequirementKind = + | 'state_read' + | 'event_subscribe' + | 'event_publish' + | 'call' + | 'presentation'; + +export type CapabilityGrant = { + readonly [Kind in RequirementKind]: readonly string[]; +}; + +export type Theme = 'light' | 'dark' | 'system'; + +export interface GuestBoot { + readonly home_id: string; + readonly generation: number; + readonly release: string; + readonly abi: string; + readonly grant: CapabilityGrant; + readonly staging: boolean; + readonly locale: string; + readonly theme: Theme; +} + +export type StructuredValue = + | null + | boolean + | number + | string + | Uint8Array + | readonly StructuredValue[] + | { readonly [key: string]: StructuredValue }; + +export interface StateSnapshot { + readonly revision: number; + readonly values: Readonly>; +} + +export type StateMutation = + | { readonly path: string; readonly op: 'set'; readonly value: StructuredValue } + | { readonly path: string; readonly op: 'delete' }; + +export interface StatePatch { + readonly base_revision: number; + readonly revision: number; + readonly mutations: readonly StateMutation[]; +} + +export interface StateStale { + readonly revision: number; + readonly reason: string; +} + +export interface EventMessage { + readonly name: string; + readonly data: StructuredValue; +} + +export interface CallAccepted { + readonly operation_id: number; +} + +export interface CallChunk { + readonly operation_id: number; + readonly value: StructuredValue; +} + +export interface CallResult { + readonly operation_id: number; + readonly value: StructuredValue; +} + +export interface CallFailure { + readonly operation_id: number; + readonly code: string; + readonly message: string; + readonly retryable?: boolean; + readonly details?: StructuredValue; +} + +export interface CallOutcomeUnknown { + readonly operation_id: number; + readonly message?: string; +} + +export type InteractionEvent = 'press' | 'change'; + +export interface UiInteraction { + readonly render_revision: number; + readonly node_id: string; + readonly handler: string; + readonly event: InteractionEvent; + readonly value?: boolean | string; +} + +export interface LifecycleResume { + readonly active: boolean; + readonly epoch: number; +} + +/** + * Broker-to-guest messages, as the closed set the broker actually sends. + * + * `runtime.probe` is absent on purpose: the trusted prelude answers the + * heartbeat with a captured native `postMessage` before guest code runs, and + * RFC 0002 §12.1 requires a guest SDK to ignore reserved runtime messages + * rather than reply to them. + */ +export type BrokerMessage = + | { readonly kind: 'guest.boot'; readonly payload: GuestBoot } + | { readonly kind: 'state.snapshot'; readonly payload: StateSnapshot } + | { readonly kind: 'state.patch'; readonly payload: StatePatch } + | { readonly kind: 'state.stale'; readonly payload: StateStale } + | { readonly kind: 'event.message'; readonly payload: EventMessage } + | { readonly kind: 'call.accepted'; readonly payload: CallAccepted } + | { readonly kind: 'call.chunk'; readonly payload: CallChunk } + | { readonly kind: 'call.result'; readonly payload: CallResult } + | { readonly kind: 'call.error'; readonly payload: CallFailure } + | { readonly kind: 'call.outcome_unknown'; readonly payload: CallOutcomeUnknown } + | { readonly kind: 'ui.interaction'; readonly payload: UiInteraction } + | { readonly kind: 'lifecycle.suspend'; readonly payload: Record } + | { readonly kind: 'lifecycle.resume'; readonly payload: LifecycleResume } + | { readonly kind: 'lifecycle.dispose'; readonly payload: Record }; + +const BROKER_KINDS = new Set([ + 'guest.boot', + 'state.snapshot', + 'state.patch', + 'state.stale', + 'event.message', + 'call.accepted', + 'call.chunk', + 'call.result', + 'call.error', + 'call.outcome_unknown', + 'ui.interaction', + 'lifecycle.suspend', + 'lifecycle.resume', + 'lifecycle.dispose', +]); + +export type GuestMessageKind = + | 'guest.ready' + | 'ui.render' + | 'event.subscribe' + | 'event.unsubscribe' + | 'event.publish' + | 'call.start' + | 'call.credit' + | 'call.cancel' + | 'log.write'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +/** + * Reads one broker message, or `undefined` for anything this SDK must ignore. + * + * Ignoring rather than throwing is deliberate. A reserved `runtime.*` control + * message and a kind added by a newer broker are both none of the guest's + * business, and a guest that threw on them would turn a forward-compatible + * platform change into a terminated component. + */ +export function decodeBrokerMessage(data: unknown): BrokerMessage | undefined { + if (data === null || typeof data !== 'object') return undefined; + const record = data as Record; + if (record['v'] !== BROKER_PROTOCOL) return undefined; + const kind = record['kind']; + if (typeof kind !== 'string' || !BROKER_KINDS.has(kind as BrokerMessage['kind'])) { + return undefined; + } + const payload = record['payload']; + if (payload === null || typeof payload !== 'object') return undefined; + return { kind, payload } as BrokerMessage; +} + +/** The transport the guest owns. Tests substitute it; the Worker default is below. */ +export interface GuestTransport { + post(message: unknown): void; + subscribe(handler: (data: unknown) => void): void; +} + +interface WorkerScope { + postMessage(message: unknown): void; + addEventListener(type: 'message', listener: (event: { data: unknown }) => void): void; +} + +/** + * The ambient Worker scope, after the trusted prelude has shadowed every + * network and storage entry point. `postMessage` and `addEventListener` are the + * only globals the guest is left with, and they are captured once here. + */ +export function workerTransport(): GuestTransport { + const scope = globalThis as unknown as Partial; + const post = scope.postMessage; + const listen = scope.addEventListener; + if (typeof post !== 'function' || typeof listen !== 'function') { + throw new Error('@miakapp/component must run inside the component runtime Worker'); + } + const boundPost = post.bind(scope); + const boundListen = listen.bind(scope); + return { + post: (message) => void boundPost(message), + subscribe: (handler) => void boundListen('message', (event) => handler(event.data)), + }; +} + +export function guestMessage(kind: GuestMessageKind, payload: unknown): unknown { + return { v: BROKER_PROTOCOL, kind, payload }; +} diff --git a/packages/component/src/state.ts b/packages/component/src/state.ts new file mode 100644 index 0000000..c50c7e8 --- /dev/null +++ b/packages/component/src/state.ts @@ -0,0 +1,98 @@ +/** + * The granted state projection, as the broker delivers it. + * + * RFC 0002 §12.2 is explicit that the SDK must expose staleness rather than + * pretend cached values are current, so {@link StateStore.stale} is part of the + * public surface and every read can be paired with it. + */ +import type { + StatePatch, + StateSnapshot, + StateStale, + StructuredValue, +} from './protocol.js'; + +export interface StateView { + /** Last authoritative revision this component saw. */ + readonly revision: number; + /** + * True once a patch gap was reported. Values keep their last known contents + * until a fresh snapshot arrives; they are not current. + */ + readonly stale: boolean; + readonly staleReason: string | undefined; + get(path: string): StructuredValue | undefined; + has(path: string): boolean; + paths(): readonly string[]; + entries(): Readonly>; +} + +export class StateStore implements StateView { + #values = new Map(); + #revision = 0; + #stale = false; + #staleReason: string | undefined; + + get revision(): number { + return this.#revision; + } + + get stale(): boolean { + return this.#stale; + } + + get staleReason(): string | undefined { + return this.#staleReason; + } + + get(path: string): StructuredValue | undefined { + return this.#values.get(path); + } + + has(path: string): boolean { + return this.#values.has(path); + } + + paths(): readonly string[] { + return [...this.#values.keys()].sort(); + } + + entries(): Readonly> { + return Object.fromEntries(this.#values); + } + + /** A snapshot is authoritative: it replaces the projection and clears staleness. */ + applySnapshot(snapshot: StateSnapshot): void { + this.#values = new Map(Object.entries(snapshot.values)); + this.#revision = snapshot.revision; + this.#stale = false; + this.#staleReason = undefined; + } + + /** + * Applies one contiguous patch. + * + * The broker already refuses to forward a gap, but a guest that applied a + * non-contiguous patch anyway would silently diverge from the home, so the + * check is repeated here and a mismatch marks the projection stale instead. + */ + applyPatch(patch: StatePatch): boolean { + if (patch.base_revision !== this.#revision || patch.revision !== patch.base_revision + 1) { + this.#stale = true; + this.#staleReason = 'revision_gap'; + return false; + } + for (const mutation of patch.mutations) { + if (mutation.op === 'set') this.#values.set(mutation.path, mutation.value); + else this.#values.delete(mutation.path); + } + this.#revision = patch.revision; + return true; + } + + markStale(stale: StateStale): void { + this.#stale = true; + this.#staleReason = stale.reason; + if (stale.revision > this.#revision) this.#revision = stale.revision; + } +} diff --git a/packages/component/src/ui.ts b/packages/component/src/ui.ts new file mode 100644 index 0000000..54b9249 --- /dev/null +++ b/packages/component/src/ui.ts @@ -0,0 +1,381 @@ +/** + * Typed builders for the ABI 1 semantic tree. + * + * The broker validates every node against a closed schema, so these builders + * exist to make the legal tree the easy one to write: exact property names, + * closed token enums in the type system, and no way to emit a key the broker + * would reject. Two rules are enforced here rather than left to the broker, + * because breaking them terminates the instance instead of returning an error: + * a property is omitted when it is `undefined` (the bridge forbids `undefined` + * as a structured value), and node IDs are checked for shape and uniqueness. + * + * Everything else is left to the broker on purpose. Duplicating its validation + * would mean two sources of truth that drift. + */ +import { LIMITS } from './protocol.js'; + +export type UiNodeType = + | 'screen' + | 'stack' + | 'grid' + | 'section' + | 'text' + | 'status' + | 'button' + | 'toggle' + | 'input' + | 'select' + | 'progress' + | 'media'; + +export interface UiNode { + readonly id: string; + readonly type: UiNodeType; + readonly props: Readonly>; + readonly children?: readonly UiNode[]; +} + +export type Gap = 'none' | 'small' | 'medium' | 'large'; +export type Direction = 'vertical' | 'horizontal'; +export type Align = 'start' | 'center' | 'end' | 'stretch'; +export type Tone = 'default' | 'muted' | 'positive' | 'warning' | 'critical'; +export type Emphasis = 'normal' | 'strong'; +export type ButtonVariant = 'primary' | 'secondary' | 'danger'; +export type InputType = 'text' | 'number' | 'email' | 'search'; +export type StatusState = + | 'idle' + | 'pending' + | 'accepted' + | 'applied' + | 'failed' + | 'stale' + | 'outcome_unknown'; + +export type PressHandler = () => void; +export type ToggleHandler = (value: boolean) => void; +export type TextHandler = (value: string) => void; +export type Handler = PressHandler | ToggleHandler | TextHandler; + +const NODE_ID = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/; +const UTF8 = new TextEncoder(); + +export class UiError extends Error { + constructor(message: string) { + super(message); + this.name = 'MiakappUiError'; + } +} + +/** + * Handlers registered by the render pass currently running. + * + * A builder is a pure function, so a callback passed to `ui.button` has to be + * recorded somewhere the dispatcher can find it. The scope is set for exactly + * the duration of one render and cleared afterwards, which is safe in a Worker: + * the guest is single-threaded and a render never awaits. + */ +let activeHandlers: Map | undefined; + +export function collectHandlers(render: () => T): { result: T; handlers: Map } { + if (activeHandlers !== undefined) { + throw new UiError('A render is already in progress; renders must not nest'); + } + const handlers = new Map(); + activeHandlers = handlers; + try { + return { result: render(), handlers }; + } finally { + activeHandlers = undefined; + } +} + +function nodeId(id: string): string { + if (!NODE_ID.test(id)) { + throw new UiError( + `Node ID ${JSON.stringify(id)} must match [A-Za-z][A-Za-z0-9._:-]{0,63}`, + ); + } + return id; +} + +function register(id: string, handler: string | Handler | undefined, label: string): string { + if (typeof handler === 'string') return nodeId(handler); + if (handler === undefined) { + throw new UiError(`${label} ${id} needs a handler callback or a handler ID`); + } + if (activeHandlers === undefined) { + throw new UiError( + `${label} ${id} passed a handler callback outside a render; ` + + 'build trees inside the component render function', + ); + } + activeHandlers.set(id, handler); + return id; +} + +/** Drops `undefined` entries, which the bridge forbids as structured values. */ +function props(entries: Record): Readonly> { + const result: Record = {}; + for (const [key, value] of Object.entries(entries)) { + if (value !== undefined) result[key] = value; + } + return result; +} + +function container( + id: string, + type: UiNodeType, + nodeProps: Readonly>, + children: readonly UiNode[], +): UiNode { + return { id: nodeId(id), type, props: nodeProps, children }; +} + +export interface ScreenProps { + readonly id?: string; + readonly title: string; +} + +export function screen(options: ScreenProps, children: readonly UiNode[] = []): UiNode { + return container(options.id ?? 'screen', 'screen', props({ title: options.title }), children); +} + +export interface StackProps { + readonly id: string; + readonly direction?: Direction; + readonly gap?: Gap; + readonly align?: Align; +} + +export function stack(options: StackProps, children: readonly UiNode[] = []): UiNode { + return container(options.id, 'stack', props({ + direction: options.direction, + gap: options.gap, + align: options.align, + }), children); +} + +export interface GridProps { + readonly id: string; + /** One to six columns; the broker rejects more. */ + readonly columns: number; + readonly gap?: Gap; +} + +export function grid(options: GridProps, children: readonly UiNode[] = []): UiNode { + return container(options.id, 'grid', props({ + columns: options.columns, + gap: options.gap, + }), children); +} + +export interface SectionProps { + readonly id: string; + readonly heading: string; + readonly description?: string; +} + +export function section(options: SectionProps, children: readonly UiNode[] = []): UiNode { + return container(options.id, 'section', props({ + heading: options.heading, + description: options.description, + }), children); +} + +function leaf( + id: string, + type: UiNodeType, + nodeProps: Readonly>, +): UiNode { + return { id: nodeId(id), type, props: nodeProps }; +} + +export interface TextProps { + readonly id: string; + readonly text: string; + readonly tone?: Tone; + readonly emphasis?: Emphasis; +} + +export function text(options: TextProps): UiNode { + return leaf(options.id, 'text', props({ + text: options.text, + tone: options.tone, + emphasis: options.emphasis, + })); +} + +export interface StatusProps { + readonly id: string; + readonly label: string; + readonly state: StatusState; + readonly detail?: string; +} + +export function status(options: StatusProps): UiNode { + return leaf(options.id, 'status', props({ + label: options.label, + state: options.state, + detail: options.detail, + })); +} + +export interface ButtonProps { + readonly id: string; + readonly label: string; + readonly onPress?: PressHandler; + readonly handler?: string; + readonly variant?: ButtonVariant; + readonly disabled?: boolean; + readonly pending?: boolean; +} + +export function button(options: ButtonProps): UiNode { + const id = nodeId(options.id); + return leaf(id, 'button', props({ + label: options.label, + handler: register(id, options.handler ?? options.onPress, 'button'), + variant: options.variant, + disabled: options.disabled, + pending: options.pending, + })); +} + +export interface ToggleProps { + readonly id: string; + readonly label: string; + readonly value: boolean; + readonly onChange?: ToggleHandler; + readonly handler?: string; + readonly disabled?: boolean; + readonly pending?: boolean; +} + +export function toggle(options: ToggleProps): UiNode { + const id = nodeId(options.id); + return leaf(id, 'toggle', props({ + label: options.label, + value: options.value, + handler: register(id, options.handler ?? options.onChange, 'toggle'), + disabled: options.disabled, + pending: options.pending, + })); +} + +export interface InputProps { + readonly id: string; + readonly label: string; + readonly value: string; + readonly onChange?: TextHandler; + readonly handler?: string; + readonly inputType?: InputType; + readonly maxLength?: number; + readonly disabled?: boolean; +} + +export function input(options: InputProps): UiNode { + const id = nodeId(options.id); + return leaf(id, 'input', props({ + label: options.label, + value: options.value, + handler: register(id, options.handler ?? options.onChange, 'input'), + input_type: options.inputType, + max_length: options.maxLength, + disabled: options.disabled, + })); +} + +export interface SelectOption { + readonly value: string; + readonly label: string; +} + +export interface SelectProps { + readonly id: string; + readonly label: string; + readonly value: string; + readonly options: readonly SelectOption[]; + readonly onChange?: TextHandler; + readonly handler?: string; + readonly disabled?: boolean; +} + +export function select(options: SelectProps): UiNode { + const id = nodeId(options.id); + return leaf(id, 'select', props({ + label: options.label, + value: options.value, + options: options.options.map((option) => ({ value: option.value, label: option.label })), + handler: register(id, options.handler ?? options.onChange, 'select'), + disabled: options.disabled, + })); +} + +export interface ProgressProps { + readonly id: string; + readonly label: string; + /** Between 0 and 1 inclusive. */ + readonly value: number; +} + +export function progress(options: ProgressProps): UiNode { + return leaf(options.id, 'progress', props({ + label: options.label, + value: options.value, + })); +} + +export interface MediaProps { + readonly id: string; + readonly label: string; + /** An exact granted `media.*` handle; ABI 1 has no URL property. */ + readonly handle: string; +} + +export function media(options: MediaProps): UiNode { + return leaf(options.id, 'media', props({ + label: options.label, + handle: options.handle, + })); +} + +/** + * Checks the tree-wide invariants a single builder cannot see. + * + * These four are the ones whose violation is fatal rather than reportable: the + * broker terminates the instance instead of answering, so catching them in the + * Worker turns a dead component into a thrown error the guest can handle. + */ +export function checkTree(root: UiNode): UiNode { + if (root.type !== 'screen') throw new UiError('The root node must be a screen'); + const seen = new Set(); + let nodes = 0; + let textBytes = 0; + const stack: Array<{ node: UiNode; depth: number }> = [{ node: root, depth: 1 }]; + while (stack.length > 0) { + const { node, depth } = stack.pop() as { node: UiNode; depth: number }; + if (depth > LIMITS.uiDepth) { + throw new UiError(`UI tree is deeper than the ABI 1 limit of ${LIMITS.uiDepth}`); + } + nodes += 1; + if (nodes > LIMITS.uiNodes) { + throw new UiError(`UI tree has more than the ABI 1 limit of ${LIMITS.uiNodes} nodes`); + } + if (seen.has(node.id)) throw new UiError(`Duplicate node ID: ${node.id}`); + seen.add(node.id); + for (const key of ['title', 'text', 'label', 'heading', 'description', 'detail']) { + const value = node.props[key]; + if (typeof value !== 'string') continue; + const bytes = UTF8.encode(value).byteLength; + if (bytes > LIMITS.textBytes) { + throw new UiError(`${node.id}.${key} exceeds ${LIMITS.textBytes} UTF-8 bytes`); + } + textBytes += bytes; + } + if (textBytes > LIMITS.uiTextBytes) { + throw new UiError(`Aggregate UI text exceeds ${LIMITS.uiTextBytes} UTF-8 bytes`); + } + for (const child of node.children ?? []) stack.push({ node: child, depth: depth + 1 }); + } + return root; +} diff --git a/packages/component/test/component.test.ts b/packages/component/test/component.test.ts new file mode 100644 index 0000000..74ee1f8 --- /dev/null +++ b/packages/component/test/component.test.ts @@ -0,0 +1,433 @@ +import { describe, expect, test } from 'bun:test'; +import { + CallError, + CallOutcomeUnknownError, + defineComponent, + ui, + type Home, + type UiNode, +} from '../src/index.js'; +import { FakeBroker } from './support/broker.js'; + +/** Renders synchronously so a test can assert on the committed tree immediately. */ +function mount( + setup: (home: Home) => { render: () => UiNode; dispose?: () => void }, +): { broker: FakeBroker; handle: ReturnType } { + const broker = new FakeBroker(); + const handle = defineComponent(setup, { + transport: broker, + now: () => 0, + schedule: (callback) => callback(), + }); + return { broker, handle }; +} + +function screenOf(node: unknown): UiNode { + return node as UiNode; +} + +describe('handshake', () => { + test('guest.ready is sent before any other work', () => { + const { broker } = mount(() => ({ render: () => ui.screen({ title: 'Salon' }) })); + expect(broker.kinds()).toEqual(['guest.ready']); + expect(broker.last('guest.ready').payload).toEqual({ abi: 'miakapp.component/1' }); + }); + + test('setup runs only after boot and the first snapshot', () => { + let setups = 0; + const { broker } = mount(() => { + setups += 1; + return { render: () => ui.screen({ title: 'Salon' }) }; + }); + expect(setups).toBe(0); + broker.boot(); + expect(setups).toBe(1); + }); + + test('the first render is revision one and its root is a screen', async () => { + const { broker, handle } = mount(() => ({ render: () => ui.screen({ title: 'Salon' }) })); + broker.boot(); + await handle.ready; + const render = broker.last('ui.render'); + expect(render.payload['revision']).toBe(1); + expect(screenOf(render.payload['tree']).type).toBe('screen'); + expect(screenOf(render.payload['tree']).props['title']).toBe('Salon'); + }); + + test('render revisions are contiguous', () => { + const { broker } = mount((home) => ({ + render: () => ui.screen({ title: String(home.state.get('climate.zone.temperature') ?? '') }), + })); + broker.boot({}, { 'climate.zone.temperature': 19 }); + broker.deliver('state.patch', { + base_revision: 1, + revision: 2, + mutations: [{ path: 'climate.zone.temperature', op: 'set', value: 20 }], + }); + broker.deliver('state.patch', { + base_revision: 2, + revision: 3, + mutations: [{ path: 'climate.zone.temperature', op: 'set', value: 21 }], + }); + expect(broker.of('ui.render').map((message) => message.payload['revision'])).toEqual([1, 2, 3]); + }); +}); + +describe('state', () => { + test('a snapshot is authoritative and a patch is applied in order', () => { + let seen: unknown; + const { broker } = mount((home) => ({ + render: () => { + seen = home.state.get('zone.alpha.light.on'); + return ui.screen({ title: 'Salon' }); + }, + })); + broker.boot({}, { 'zone.alpha.light.on': false }); + expect(seen).toBe(false); + broker.deliver('state.patch', { + base_revision: 1, + revision: 2, + mutations: [{ path: 'zone.alpha.light.on', op: 'set', value: true }], + }); + expect(seen).toBe(true); + }); + + test('a delete mutation removes the path', () => { + let present = true; + const { broker } = mount((home) => ({ + render: () => { + present = home.state.has('zone.alpha.light.on'); + return ui.screen({ title: 'Salon' }); + }, + })); + broker.boot({}, { 'zone.alpha.light.on': false }); + broker.deliver('state.patch', { + base_revision: 1, + revision: 2, + mutations: [{ path: 'zone.alpha.light.on', op: 'delete' }], + }); + expect(present).toBe(false); + }); + + test('staleness is exposed rather than hidden', () => { + let stale = false; + const { broker } = mount((home) => ({ + render: () => { + stale = home.state.stale; + return ui.screen({ title: 'Salon' }); + }, + })); + broker.boot({}, { 'climate.zone.temperature': 19 }); + expect(stale).toBe(false); + broker.deliver('state.stale', { revision: 1, reason: 'revision_gap' }); + expect(stale).toBe(true); + }); + + test('a non-contiguous patch marks the projection stale and commits no value', () => { + let temperature: unknown; + let stale = false; + const { broker } = mount((home) => ({ + render: () => { + temperature = home.state.get('climate.zone.temperature'); + stale = home.state.stale; + return ui.screen({ title: 'Salon' }); + }, + })); + broker.boot({}, { 'climate.zone.temperature': 19 }); + broker.deliver('state.patch', { + base_revision: 7, + revision: 8, + mutations: [{ path: 'climate.zone.temperature', op: 'set', value: 30 }], + }); + expect(temperature).toBe(19); + expect(stale).toBe(false); + broker.deliver('state.snapshot', { revision: 9, values: { 'climate.zone.temperature': 30 } }); + expect(temperature).toBe(30); + }); +}); + +describe('interaction', () => { + test('a toggle handler receives the boolean and the tree recommits', () => { + const received: boolean[] = []; + const { broker } = mount(() => ({ + render: () => ui.screen({ title: 'Salon' }, [ + ui.toggle({ + id: 'lamp', + label: 'Lampe', + value: false, + onChange: (value) => void received.push(value), + }), + ]), + })); + broker.boot(); + broker.deliver('ui.interaction', { + render_revision: 1, + node_id: 'lamp', + handler: 'lamp', + event: 'change', + value: true, + }); + expect(received).toEqual([true]); + expect(broker.of('ui.render')).toHaveLength(2); + }); + + test('a button handler is called without a value', () => { + let presses = 0; + const { broker } = mount(() => ({ + render: () => ui.screen({ title: 'Salon' }, [ + ui.button({ id: 'go', label: 'Allumer', onPress: () => void (presses += 1) }), + ]), + })); + broker.boot(); + broker.deliver('ui.interaction', { + render_revision: 1, + node_id: 'go', + handler: 'go', + event: 'press', + }); + expect(presses).toBe(1); + }); + + test('an interaction for a stale render is ignored', () => { + let presses = 0; + const { broker } = mount(() => ({ + render: () => ui.screen({ title: 'Salon' }, [ + ui.button({ id: 'go', label: 'Allumer', onPress: () => void (presses += 1) }), + ]), + })); + broker.boot(); + broker.deliver('ui.interaction', { + render_revision: 0, + node_id: 'go', + handler: 'go', + event: 'press', + }); + expect(presses).toBe(0); + }); +}); + +describe('calls', () => { + test('a call resolves on its correlated result', async () => { + let home: Home | undefined; + const { broker } = mount((instance) => { + home = instance; + return { render: () => ui.screen({ title: 'Salon' }) }; + }); + broker.boot(); + const pending = (home as Home).call('lighting.set', { on: true }); + const start = broker.last('call.start'); + expect(start.payload['name']).toBe('lighting.set'); + const operationId = start.payload['operation_id'] as number; + + broker.deliver('call.accepted', { operation_id: operationId }); + broker.deliver('call.result', { operation_id: operationId, value: { on: true } }); + expect(await pending).toEqual({ on: true }); + }); + + test('a call rejects with the control-plane failure code', async () => { + let home: Home | undefined; + const { broker } = mount((instance) => { + home = instance; + return { render: () => ui.screen({ title: 'Salon' }) }; + }); + broker.boot(); + const pending = (home as Home).call('lighting.set', { on: true }); + const operationId = broker.last('call.start').payload['operation_id'] as number; + broker.deliver('call.error', { + operation_id: operationId, + code: 'application_error', + message: 'the lamp is unreachable', + retryable: true, + }); + await expect(pending).rejects.toBeInstanceOf(CallError); + await pending.catch((error: CallError) => { + expect(error.code).toBe('application_error'); + expect(error.retryable).toBe(true); + }); + }); + + test('an unknown outcome rejects with its own error type', async () => { + let home: Home | undefined; + const { broker } = mount((instance) => { + home = instance; + return { render: () => ui.screen({ title: 'Salon' }) }; + }); + broker.boot(); + const pending = (home as Home).call('lighting.set', { on: true }); + const operationId = broker.last('call.start').payload['operation_id'] as number; + broker.deliver('call.outcome_unknown', { operation_id: operationId }); + await expect(pending).rejects.toBeInstanceOf(CallOutcomeUnknownError); + }); + + test('a stream grants credit once accepted and yields its chunks', async () => { + let home: Home | undefined; + const { broker } = mount((instance) => { + home = instance; + return { render: () => ui.screen({ title: 'Salon' }) }; + }); + broker.boot(); + const stream = (home as Home).stream('lighting.set', null); + const operationId = broker.last('call.start').payload['operation_id'] as number; + expect(broker.of('call.credit')).toHaveLength(0); + + broker.deliver('call.accepted', { operation_id: operationId }); + const credit = broker.last('call.credit'); + expect(credit.payload).toEqual({ operation_id: operationId, credit: 32 }); + + const collected: unknown[] = []; + const consume = (async () => { + for await (const chunk of stream) { + collected.push(chunk); + if (collected.length === 2) break; + } + })(); + broker.deliver('call.chunk', { operation_id: operationId, value: 1 }); + broker.deliver('call.chunk', { operation_id: operationId, value: 2 }); + await consume; + expect(collected).toEqual([1, 2]); + }); + + test('a staged release may render but not act on the home', () => { + let home: Home | undefined; + const { broker } = mount((instance) => { + home = instance; + return { render: () => ui.screen({ title: 'Salon' }) }; + }); + broker.boot({ staging: true }); + expect(broker.of('ui.render')).toHaveLength(1); + expect(() => (home as Home).call('lighting.set', null)).toThrow(/staged release/); + expect(() => (home as Home).events.publish('zone.alpha.pressed', null)).toThrow(/staged/); + expect(broker.of('call.start')).toHaveLength(0); + }); +}); + +describe('events', () => { + test('one subscription is sent per topic and withdrawn after the last listener', () => { + let home: Home | undefined; + const { broker } = mount((instance) => { + home = instance; + return { render: () => ui.screen({ title: 'Salon' }) }; + }); + broker.boot(); + const received: unknown[] = []; + const first = (home as Home).events.subscribe('zone.alpha.pressed', (data) => { + received.push(data); + }); + const second = (home as Home).events.subscribe('zone.alpha.pressed', () => undefined); + expect(broker.of('event.subscribe')).toHaveLength(1); + + broker.deliver('event.message', { name: 'zone.alpha.pressed', data: { count: 1 } }); + expect(received).toEqual([{ count: 1 }]); + + first(); + expect(broker.of('event.unsubscribe')).toHaveLength(0); + second(); + expect(broker.last('event.unsubscribe').payload).toEqual({ name: 'zone.alpha.pressed' }); + }); +}); + +describe('lifecycle', () => { + test('a suspended component commits no render and resumes with one', () => { + const { broker } = mount((home) => ({ + render: () => ui.screen({ title: String(home.state.revision) }), + })); + broker.boot(); + expect(broker.of('ui.render')).toHaveLength(1); + + broker.deliver('lifecycle.suspend', {}); + broker.deliver('state.patch', { base_revision: 1, revision: 2, mutations: [] }); + expect(broker.of('ui.render')).toHaveLength(1); + + broker.deliver('lifecycle.resume', { active: true, epoch: 1 }); + expect(broker.of('ui.render')).toHaveLength(2); + }); + + test('dispose rejects everything in flight and runs the component teardown', async () => { + let home: Home | undefined; + let disposed = false; + const { broker, handle } = mount((instance) => { + home = instance; + return { + render: () => ui.screen({ title: 'Salon' }), + dispose: () => void (disposed = true), + }; + }); + broker.boot(); + const pending = (home as Home).call('lighting.set', null); + broker.deliver('lifecycle.dispose', {}); + await expect(pending).rejects.toThrow(/disposed/); + expect(disposed).toBe(true); + expect(handle.disposed).toBe(true); + }); + + test('nothing is sent after dispose', () => { + let home: Home | undefined; + const { broker } = mount((instance) => { + home = instance; + return { render: () => ui.screen({ title: 'Salon' }) }; + }); + broker.boot(); + broker.deliver('lifecycle.dispose', {}); + const before = broker.sent.length; + (home as Home).log('info', 'ignored'); + expect(broker.sent).toHaveLength(before); + }); +}); + +describe('render rate', () => { + test('bursts are coalesced into one commit per ABI interval', () => { + const broker = new FakeBroker(); + const queue: Array<{ callback: () => void; delayMs: number }> = []; + let clock = 0; + let home: Home | undefined; + defineComponent((instance) => { + home = instance; + return { render: () => ui.screen({ title: String(clock) }) }; + }, { + transport: broker, + now: () => clock, + schedule: (callback, delayMs) => void queue.push({ callback, delayMs }), + }); + broker.boot(); + expect(broker.of('ui.render')).toHaveLength(1); + + (home as Home).invalidate(); + (home as Home).invalidate(); + (home as Home).invalidate(); + expect(queue).toHaveLength(1); + expect(queue[0]?.delayMs).toBe(34); + + clock = 34; + (queue.shift() as { callback: () => void }).callback(); + expect(broker.of('ui.render')).toHaveLength(2); + }); +}); + +describe('bridge hygiene', () => { + test('reserved runtime messages are ignored, never answered', () => { + const { broker } = mount(() => ({ render: () => ui.screen({ title: 'Salon' }) })); + broker.boot(); + const before = broker.sent.length; + broker.deliver('runtime.probe', { challenge: 1 }); + expect(broker.sent).toHaveLength(before); + }); + + test('an unknown broker kind is ignored rather than fatal', () => { + const { broker } = mount(() => ({ render: () => ui.screen({ title: 'Salon' }) })); + broker.boot(); + expect(() => broker.deliver('ui.something_new', { a: 1 })).not.toThrow(); + }); + + test('no message carries an undefined property', () => { + const { broker } = mount(() => ({ + render: () => ui.screen({ title: 'Salon' }, [ + ui.button({ id: 'go', label: 'Allumer', onPress: () => undefined }), + ui.text({ id: 'hint', text: 'Sans ton ni emphase' }), + ui.input({ id: 'name', label: 'Nom', value: '', onChange: () => undefined }), + ]), + })); + // FakeBroker.post throws on any undefined value in the payload graph. + broker.boot(); + expect(broker.of('ui.render')).toHaveLength(1); + }); +}); diff --git a/packages/component/test/support/broker.ts b/packages/component/test/support/broker.ts new file mode 100644 index 0000000..49b0a84 --- /dev/null +++ b/packages/component/test/support/broker.ts @@ -0,0 +1,120 @@ +import type { GuestBoot, GuestTransport } from '../../src/protocol.js'; + +export interface SentMessage { + readonly kind: string; + readonly payload: Record; +} + +/** + * A transport double plus the broker-side assertions that actually matter. + * + * The real broker in `component-runtime/src/runtime-broker.ts` terminates the + * instance on a malformed guest message rather than replying, so these checks + * mirror the ones whose violation would be fatal: the exact `{ v, kind, payload }` + * envelope, an allowed kind, and a payload free of `undefined`, which is not an + * allowed structured value. + */ +export class FakeBroker implements GuestTransport { + readonly sent: SentMessage[] = []; + #listener: ((data: unknown) => void) | undefined; + + static readonly GUEST_KINDS = new Set([ + 'guest.ready', + 'ui.render', + 'event.subscribe', + 'event.unsubscribe', + 'event.publish', + 'call.start', + 'call.credit', + 'call.cancel', + 'log.write', + ]); + + post(message: unknown): void { + if (message === null || typeof message !== 'object') { + throw new Error('guest message must be an object'); + } + const record = message as Record; + const keys = Object.keys(record).sort().join(','); + if (keys !== 'kind,payload,v') { + throw new Error(`guest envelope must be exactly {v, kind, payload}, received {${keys}}`); + } + if (record['v'] !== 1) throw new Error('guest protocol must be 1'); + const kind = record['kind']; + if (typeof kind !== 'string' || !FakeBroker.GUEST_KINDS.has(kind)) { + throw new Error(`guest kind is not allowed: ${String(kind)}`); + } + assertStructured(record['payload'], kind); + this.sent.push({ kind, payload: record['payload'] as Record }); + } + + subscribe(handler: (data: unknown) => void): void { + this.#listener = handler; + } + + /** Delivers one broker-to-guest message in the shape the broker posts. */ + deliver(kind: string, payload: unknown): void { + if (this.#listener === undefined) throw new Error('the guest did not subscribe'); + this.#listener({ v: 1, kind, payload }); + } + + kinds(): string[] { + return this.sent.map((message) => message.kind); + } + + of(kind: string): SentMessage[] { + return this.sent.filter((message) => message.kind === kind); + } + + last(kind: string): SentMessage { + const messages = this.of(kind); + const message = messages[messages.length - 1]; + if (message === undefined) throw new Error(`no ${kind} message was sent`); + return message; + } + + /** Boot, then the first authoritative snapshot, the way the broker sequences them. */ + boot(overrides: Partial = {}, values: Record = {}): void { + this.deliver('guest.boot', { + home_id: 'test-home', + generation: 1, + release: '2026-09-13.1', + abi: 'miakapp.component/1', + grant: { + state_read: ['climate.*', 'zone.*'], + event_subscribe: ['zone.*'], + event_publish: ['zone.*'], + call: ['lighting.set'], + presentation: [], + }, + staging: false, + locale: 'fr-FR', + theme: 'system', + ...overrides, + }); + this.deliver('state.snapshot', { revision: 1, values }); + } +} + +function assertStructured(value: unknown, label: string): void { + const stack: Array<{ value: unknown; path: string }> = [{ value, path: label }]; + while (stack.length > 0) { + const item = stack.pop() as { value: unknown; path: string }; + const current = item.value; + if (current === undefined) { + throw new Error(`${item.path} is undefined, which is not an allowed structured value`); + } + if (current === null || typeof current !== 'object') continue; + if (current instanceof Uint8Array) continue; + if (Array.isArray(current)) { + current.forEach((entry, index) => stack.push({ value: entry, path: `${item.path}[${index}]` })); + continue; + } + for (const [key, entry] of Object.entries(current)) { + if (key === '__proto__' || key === 'prototype' || key === 'constructor') { + throw new Error(`${item.path}.${key} is a forbidden key`); + } + stack.push({ value: entry, path: `${item.path}.${key}` }); + } + } +} diff --git a/packages/component/test/ui.test.ts b/packages/component/test/ui.test.ts new file mode 100644 index 0000000..cade5b9 --- /dev/null +++ b/packages/component/test/ui.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'bun:test'; +import { LIMITS, UiError, checkTree, ui, type UiNode } from '../src/index.js'; + +describe('node construction', () => { + test('an optional property is omitted rather than sent as undefined', () => { + expect(ui.text({ id: 'hint', text: 'Bonjour' }).props).toEqual({ text: 'Bonjour' }); + expect(ui.text({ id: 'hint', text: 'Bonjour', tone: 'muted' }).props) + .toEqual({ text: 'Bonjour', tone: 'muted' }); + }); + + test('a stack with no options carries no property at all', () => { + expect(ui.stack({ id: 'rows' }, []).props).toEqual({}); + }); + + test('select options are copied to exactly value and label', () => { + const node = ui.select({ + id: 'mode', + label: 'Mode', + value: 'eco', + options: [{ value: 'eco', label: 'Éco' }, { value: 'confort', label: 'Confort' }], + handler: 'mode', + }); + expect(node.props['options']).toEqual([ + { value: 'eco', label: 'Éco' }, + { value: 'confort', label: 'Confort' }, + ]); + }); + + test('a node ID outside the ABI charset is refused', () => { + expect(() => ui.text({ id: '1bad', text: 'x' })).toThrow(UiError); + expect(() => ui.text({ id: 'a b', text: 'x' })).toThrow(UiError); + expect(() => ui.text({ id: 'a'.repeat(65), text: 'x' })).toThrow(UiError); + expect(ui.text({ id: 'a.b:c-d_0', text: 'x' }).id).toBe('a.b:c-d_0'); + }); + + test('an interactive node needs a handler', () => { + expect(() => ui.button({ id: 'go', label: 'Allumer' })).toThrow(/handler/); + }); + + test('a handler callback outside a render is refused with a usable message', () => { + expect(() => ui.button({ id: 'go', label: 'Allumer', onPress: () => undefined })) + .toThrow(/outside a render/); + }); + + test('a handler ID works without a render scope', () => { + expect(ui.button({ id: 'go', label: 'Allumer', handler: 'go' }).props['handler']).toBe('go'); + }); +}); + +describe('tree invariants', () => { + function nested(depth: number): UiNode { + let node = ui.text({ id: 'leaf', text: 'x' }); + for (let level = depth; level > 0; level -= 1) { + node = ui.stack({ id: `level${level}` }, [node]); + } + return ui.screen({ title: 'Salon' }, [node]); + } + + test('the root must be a screen', () => { + expect(() => checkTree(ui.text({ id: 'lonely', text: 'x' }))).toThrow(/root node must be a screen/); + }); + + test('duplicate node IDs are caught before the broker terminates the instance', () => { + const tree = ui.screen({ title: 'Salon' }, [ + ui.text({ id: 'same', text: 'un' }), + ui.text({ id: 'same', text: 'deux' }), + ]); + expect(() => checkTree(tree)).toThrow(/Duplicate node ID: same/); + }); + + test('a tree at the depth limit is accepted and one past it is not', () => { + expect(() => checkTree(nested(LIMITS.uiDepth - 2))).not.toThrow(); + expect(() => checkTree(nested(LIMITS.uiDepth + 1))).toThrow(/deeper than/); + }); + + test('too many nodes is refused', () => { + const children = Array.from( + { length: LIMITS.uiNodes }, + (_unused, index) => ui.text({ id: `t${index}`, text: 'x' }), + ); + expect(() => checkTree(ui.screen({ title: 'Salon' }, children))).toThrow(/more than/); + }); + + test('one oversized text is refused', () => { + const tree = ui.screen({ title: 'Salon' }, [ + ui.text({ id: 'long', text: 'é'.repeat(LIMITS.textBytes) }), + ]); + expect(() => checkTree(tree)).toThrow(/exceeds 8192 UTF-8 bytes/); + }); + + test('aggregate text above the budget is refused', () => { + const block = 'a'.repeat(LIMITS.textBytes); + const children = Array.from( + { length: Math.ceil(LIMITS.uiTextBytes / LIMITS.textBytes) }, + (_unused, index) => ui.text({ id: `t${index}`, text: block }), + ); + expect(() => checkTree(ui.screen({ title: 'Salon' }, children))) + .toThrow(/Aggregate UI text/); + }); + + test('a valid tree is returned unchanged', () => { + const tree = ui.screen({ title: 'Salon' }, [ + ui.section({ id: 'lights', heading: 'Lumières' }, [ + ui.toggle({ id: 'lamp', label: 'Lampe', value: true, handler: 'lamp' }), + ui.progress({ id: 'dim', label: 'Intensité', value: 0.5 }), + ]), + ]); + expect(checkTree(tree)).toBe(tree); + }); +}); diff --git a/packages/component/tsconfig.build.json b/packages/component/tsconfig.build.json new file mode 100644 index 0000000..6103c93 --- /dev/null +++ b/packages/component/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "stripInternal": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/component/tsconfig.json b/packages/component/tsconfig.json new file mode 100644 index 0000000..80bb12f --- /dev/null +++ b/packages/component/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ + "bun", + "node" + ], + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "examples/**/*.ts" + ] +} diff --git a/templates/home/README.md b/templates/home/README.md new file mode 100644 index 0000000..eb4fa31 --- /dev/null +++ b/templates/home/README.md @@ -0,0 +1,100 @@ +# A Miakapp home + +A complete, working home: a coordinator that owns the logic, a component that is +the interface, and a project file that ties them to a control plane. It controls +one lamp and reports one temperature. Replace those with yours. + +``` +coordinator/home.ts what this home is, as data — testable without a network +coordinator/main.ts the only file that touches the outside world +component/main.ts the interface, running in a sandboxed Worker +miakapp.yaml home, control plane, artifact, capability requirements +test/home.test.ts the coordinator's authorization, tested without a relay +``` + +## Copying this out of the MiakAPI repository + +The three Miakapp dependencies use `file:` paths so the template stays verified +inside the repository. Replace them with published versions: + +```bash +bun remove miakapi @miakapp/component @miakapp/cli +bun add miakapi @miakapp/component +bun add -d @miakapp/cli +``` + +Nothing else in the template refers to the repository. + +## Running it + +```bash +bun install +bun run check # typecheck, bundle, test, then validate the artifact offline +``` + +`check` is the command to run before every publication and in CI. It costs +nothing and catches the artifact rules the runtime would reject anyway. + +To run the coordinator against a real home: + +```bash +export MIAKAPP_COORDINATOR_NAME=salon +export MIAKAPP_CONTROL_PLANE_EXCHANGE_ENDPOINT=https://control.miakapp.app/v1/access-tokens:exchange +export MIAKAPP_OWNER_USER_ID= +export MIAKAPP_HOME_KEY="$(your-secret-manager read miakapp/home-key)" +bun run dev +``` + +The Home Key is read from the environment and never written to a file in this +project. Publishing reads the same variable: + +```bash +bun run publish:home # bundles, then publishes at generation 1 +``` + +`--expected-generation` is the generation you believe the home's component +pointer currently holds. It is `0` for a home that has never published. + +## The one rule that ties the three files together + +A component receives the **intersection** of what it asks for and what the +coordinator grants. So every name in `miakapp.yaml` under `requires` must also +be covered by `coordinator/home.ts`: + +| `miakapp.yaml` | `coordinator/home.ts` | +| --- | --- | +| `requires.state_read` | `stateAccess[].patterns` | +| `requires.event_subscribe` | `eventAccess[].subscribe` | +| `requires.event_publish` | `eventAccess[].publish` | +| `requires.call` | `functions` | + +Asking for more than the coordinator grants does not fail loudly at publication. +The component simply never receives that path, and the interface renders a hole. +`test/home.test.ts` checks the correspondence so a mismatch fails in CI instead +of in someone's living room. + +## Where the decisions live + +**The coordinator authorizes everything.** The relay proves who is calling and +attaches non-spoofable caller metadata; deciding whether that person may act is +the coordinator's job, and `lighting.set` does it on its first line. Removing +that check does not produce an error — it produces a home anyone enrolled can +drive. + +**The component trusts nothing it has not been told.** It has no network, no +storage and no DOM. It cannot request a capability that is not in the pointer. + +**State first, event second.** `onLightChanged` writes the state before +publishing the event, so a subscriber that reacts to the event and immediately +reads the state never sees the old value. + +**An unknown outcome is not a failure.** `component/main.ts` catches a failed +call and shows it, but never retries: the call may already have reached the +lamp. The next state snapshot is the authority. + +## Next steps + +- Replace `driveLamp` in `coordinator/main.ts` with your hardware. +- Add state paths in `coordinator/home.ts`, grant them in `stateAccess`, request + them in `miakapp.yaml`, read them in `component/main.ts`. +- Keep `bun run check` green. diff --git a/templates/home/bun.lock b/templates/home/bun.lock new file mode 100644 index 0000000..768924f --- /dev/null +++ b/templates/home/bun.lock @@ -0,0 +1,91 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "miakapp-home-template", + "dependencies": { + "@miakapp/component": "file:../../packages/component", + "miakapi": "file:../..", + }, + "devDependencies": { + "@miakapp/cli": "file:../../packages/cli", + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "typescript": "7.0.2", + }, + }, + }, + "packages": { + "@miakapp/cli": ["@miakapp/cli@file:../../packages/cli", { "devDependencies": { "@types/bun": "1.2.23", "@types/node": "22.20.1", "typescript": "7.0.2" }, "bin": { "miakapp": "./bin/miakapp.js" } }], + + "@miakapp/component": ["@miakapp/component@file:../../packages/component", { "devDependencies": { "@types/bun": "1.2.23", "@types/node": "22.20.1", "typescript": "7.0.2" } }], + + "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], + + "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + + "@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "miakapi": ["miakapi@file:../..", { "dependencies": { "@msgpack/msgpack": "3.1.3", "ws": "8.21.3" }, "devDependencies": { "@types/bun": "1.2.23", "@types/node": "22.20.1", "@types/ws": "8.18.1", "playwright": "1.62.1", "typescript": "7.0.2" } }], + + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + } +} diff --git a/templates/home/component/main.ts b/templates/home/component/main.ts new file mode 100644 index 0000000..b79734c --- /dev/null +++ b/templates/home/component/main.ts @@ -0,0 +1,106 @@ +/** + * The interface your household sees. + * + * It runs in a sandboxed Worker with no network and no DOM. It reads the state + * the coordinator granted, calls the function the coordinator declared, and + * renders a semantic tree the trusted host draws with its own components. + * + * Every path and name used here must also appear in `miakapp.yaml`. + */ +import { defineComponent, ui, type StructuredValue } from '@miakapp/component'; + +const LIGHT_ON = 'zone.salon.light.on'; +const TEMPERATURE = 'climate.salon.temperature'; +const HEALTH = 'service.coordinator.health'; +const LIGHT_CHANGED = 'zone.salon.light.changed'; + +function asBoolean(value: StructuredValue | undefined): boolean { + return value === true; +} + +function asNumber(value: StructuredValue | undefined): number | undefined { + return typeof value === 'number' ? value : undefined; +} + +defineComponent((home) => { + let pending = false; + let failure: string | undefined; + + home.events.subscribe(LIGHT_CHANGED, () => { + // The state snapshot is the authority; the event only tells us to look. + failure = undefined; + }); + + async function setLight(on: boolean): Promise { + pending = true; + failure = undefined; + home.invalidate(); + try { + await home.call('lighting.set', { on }, { deadlineMs: 10_000 }); + } catch (error) { + // Deliberately not retried. The call may already have reached the lamp, + // and the next state snapshot settles the question. + failure = error instanceof Error ? error.message : 'La commande a échoué'; + } finally { + pending = false; + home.invalidate(); + } + } + + function lightState(): 'stale' | 'failed' | 'pending' | 'applied' { + if (home.state.stale) return 'stale'; + if (failure !== undefined) return 'failed'; + return pending ? 'pending' : 'applied'; + } + + return { + render: () => { + const temperature = asNumber(home.state.get(TEMPERATURE)); + const healthy = home.state.get(HEALTH) === 'healthy'; + + return ui.screen({ title: 'Salon' }, [ + ui.section({ id: 'lights', heading: 'Lumières' }, [ + ui.toggle({ + id: 'salon-light', + label: 'Lampe du salon', + value: asBoolean(home.state.get(LIGHT_ON)), + disabled: home.staging || !healthy, + pending, + onChange: (next) => void setLight(next), + }), + ui.status({ + id: 'light-status', + label: 'État', + state: lightState(), + ...(failure === undefined ? {} : { detail: failure }), + }), + ]), + + ui.section({ id: 'climate', heading: 'Climat' }, [ + temperature === undefined + ? ui.text({ id: 'temperature', text: 'Température indisponible', tone: 'muted' }) + : ui.text({ + id: 'temperature', + text: `${temperature.toFixed(1)} °C`, + emphasis: 'strong', + }), + ui.text({ + id: 'temperature-note', + text: home.state.stale + ? 'Valeur peut-être périmée, en attente d’un instantané.' + : `Relevé à la révision ${home.state.revision}.`, + tone: home.state.stale ? 'warning' : 'muted', + }), + ]), + + ...(home.staging + ? [ui.text({ + id: 'staging-note', + text: 'Version en pré-activation : l’affichage fonctionne, les commandes non.', + tone: 'warning', + })] + : []), + ]); + }, + }; +}); diff --git a/templates/home/coordinator/home.ts b/templates/home/coordinator/home.ts new file mode 100644 index 0000000..bfb77b2 --- /dev/null +++ b/templates/home/coordinator/home.ts @@ -0,0 +1,82 @@ +/** + * What this home is, as data. + * + * The configuration is a pure function of its options so it can be tested + * without a relay, a control plane or a network. `coordinator/main.ts` is the + * only file that touches the outside world. + */ +import { + ApplicationCallError, + EventDirection, + type CoordinatorConfiguration, + type ProtocolValue, +} from 'miakapi'; + +export const STATE = { + lightOn: 'zone.salon.light.on', + temperature: 'climate.salon.temperature', + health: 'service.coordinator.health', +} as const; + +export const EVENT_LIGHT_CHANGED = 'zone.salon.light.changed'; + +export interface HomeOptions { + /** Firebase UID of the person who may see and drive this home. */ + readonly ownerUserId: string; + /** Drives the real lamp. Replace the stub in main.ts with your hardware. */ + readonly setLight: (on: boolean) => Promise; + /** Called after a successful change so the coordinator can publish it. */ + readonly onLightChanged: (on: boolean) => Promise; +} + +function booleanArgument(value: ProtocolValue, name: string): boolean { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new ApplicationCallError(2001, `${name} expects an object argument`); + } + const on = (value as Record)['on']; + if (typeof on !== 'boolean') { + throw new ApplicationCallError(2002, `${name} expects a boolean "on"`); + } + return on; +} + +export function createHomeConfiguration(options: HomeOptions): CoordinatorConfiguration { + return { + state: { + [STATE.lightOn]: false, + [STATE.temperature]: 19.5, + [STATE.health]: 'healthy', + }, + + // The user sees exactly these paths and nothing else. Widen deliberately: + // this list is the disclosure boundary, and the component's requirements in + // miakapp.yaml can never exceed it. + stateAccess: [{ + userId: options.ownerUserId, + patterns: ['climate.salon.*', 'service.coordinator.health', 'zone.salon.*'], + }], + + events: [{ topic: EVENT_LIGHT_CHANGED, directions: EventDirection.publishToUsers }], + eventAccess: [{ + userId: options.ownerUserId, + publish: [], + subscribe: [EVENT_LIGHT_CHANGED], + }], + + functions: { + async 'lighting.set'(call) { + const on = booleanArgument(call.arguments, 'lighting.set'); + + // The coordinator authorizes every application operation. The relay + // proved who is calling; deciding whether they may is this line's job. + if (call.source.kind !== 'user' || call.source.id !== options.ownerUserId) { + throw new ApplicationCallError(2003, 'Only the owner may drive the lights'); + } + + await options.setLight(on); + await options.onLightChanged(on); + return { on }; + }, + }, + }; +} diff --git a/templates/home/coordinator/main.ts b/templates/home/coordinator/main.ts new file mode 100644 index 0000000..ad16598 --- /dev/null +++ b/templates/home/coordinator/main.ts @@ -0,0 +1,66 @@ +/** + * The only file in this template that touches the outside world. + * + * It reads its credentials from the environment, wires the home configuration + * to real hardware, and runs until the process is asked to stop. + */ +import { + createCoordinator, + createHomeKeyAccessTokenProvider, + type Coordinator, +} from 'miakapi'; +import { EVENT_LIGHT_CHANGED, STATE, createHomeConfiguration } from './home.js'; + +function required(name: string): string { + const value = process.env[name]; + if (value === undefined || value.trim() === '') throw new Error(`${name} is required`); + return value; +} + +/** + * Replace this with your hardware: a GPIO write, an MQTT publish, a Zigbee + * command, an HTTP call to an existing hub. Everything above it stays the same. + */ +async function driveLamp(on: boolean): Promise { + console.log(`[lamp] ${on ? 'on' : 'off'}`); +} + +export function createHomeCoordinator(): Coordinator { + const coordinator = createCoordinator({ + name: required('MIAKAPP_COORDINATOR_NAME'), + accessTokenProvider: createHomeKeyAccessTokenProvider({ + exchangeEndpoint: required('MIAKAPP_CONTROL_PLANE_EXCHANGE_ENDPOINT'), + homeKey: required('MIAKAPP_HOME_KEY'), + }), + }); + + coordinator.configure(createHomeConfiguration({ + ownerUserId: required('MIAKAPP_OWNER_USER_ID'), + setLight: driveLamp, + onLightChanged: async (on) => { + // State first, then the event. A subscriber that reacts to the event and + // immediately reads the state must not see the old value. + await coordinator.state.set([{ path: STATE.lightOn, value: on }]); + await coordinator.events.publish(EVENT_LIGHT_CHANGED, { on }); + }, + })); + + return coordinator; +} + +async function main(): Promise { + const coordinator = createHomeCoordinator(); + const stop = (): void => void coordinator.stop({ deadlineMs: 2_000 }); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + + const session = await coordinator.start(); + console.log(`[coordinator] session ${session.sessionId}, generation ${session.generation}`); +} + +if (import.meta.main) { + main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/templates/home/miakapp.yaml b/templates/home/miakapp.yaml new file mode 100644 index 0000000..b7400cb --- /dev/null +++ b/templates/home/miakapp.yaml @@ -0,0 +1,24 @@ +schema: miakapp.project/1 +home: my-home +control_plane: https://control.miakapp.app + +component: + artifact: dist/component.js + release: 0.1.0 + # These lists are the component's request, not a grant. The effective grant is + # the intersection with what the coordinator declared in coordinator/home.ts, + # so every name here must also be covered there. + requires: + state_read: + - climate.salon.temperature + - service.coordinator.health + - zone.salon.light.on + event_subscribe: + - zone.salon.light.changed + event_publish: [] + call: + - lighting.set + presentation: [] + +coordinator: + entry: coordinator/main.ts diff --git a/templates/home/package.json b/templates/home/package.json new file mode 100644 index 0000000..063b5a9 --- /dev/null +++ b/templates/home/package.json @@ -0,0 +1,30 @@ +{ + "name": "miakapp-home-template", + "version": "0.1.0", + "description": "Starting point for a Miakapp home: a coordinator, a component and a project file", + "type": "module", + "private": true, + "engines": { + "bun": ">=1.2.23", + "node": ">=22.9" + }, + "scripts": { + "build": "bun build component/main.ts --format=iife --minify --outfile dist/component.js", + "check": "bun run typecheck && bun run build && bun run test && bun run check:artifact", + "check:artifact": "miakapp check", + "dev": "bun run coordinator/main.ts", + "publish:home": "bun run build && miakapp publish --expected-generation 0", + "test": "bun test test/", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@miakapp/component": "file:../../packages/component", + "miakapi": "file:../.." + }, + "devDependencies": { + "@miakapp/cli": "file:../../packages/cli", + "@types/bun": "1.2.23", + "@types/node": "22.20.1", + "typescript": "7.0.2" + } +} diff --git a/templates/home/test/home.test.ts b/templates/home/test/home.test.ts new file mode 100644 index 0000000..f8dc2f9 --- /dev/null +++ b/templates/home/test/home.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'bun:test'; +import { ApplicationCallError, type IncomingCall, type ProtocolValue } from 'miakapi'; +import { EVENT_LIGHT_CHANGED, STATE, createHomeConfiguration } from '../coordinator/home.js'; + +const OWNER = 'owner-uid'; + +function call(source: Partial, args: ProtocolValue): IncomingCall { + return { + source: { + kind: 'user', + id: OWNER, + sessionId: 1, + coordinatorName: null, + verifiedEmail: null, + ...source, + }, + arguments: args, + idempotencyKey: null, + signal: new AbortController().signal, + emit: async () => undefined, + }; +} + +function home(overrides: { setLight?: (on: boolean) => Promise } = {}): { + configuration: ReturnType; + lamp: boolean[]; + published: boolean[]; +} { + const lamp: boolean[] = []; + const published: boolean[] = []; + const configuration = createHomeConfiguration({ + ownerUserId: OWNER, + setLight: overrides.setLight ?? (async (on) => void lamp.push(on)), + onLightChanged: async (on) => void published.push(on), + }); + return { configuration, lamp, published }; +} + +describe('declarations', () => { + test('the component requirements stay inside what the coordinator grants', () => { + const { configuration } = home(); + const granted = configuration.stateAccess[0]?.patterns ?? []; + + // Mirrors miakapp.yaml requires.state_read. If one moves, this test fails + // before the relay silently withholds a path the interface expects. + const required = [ + 'climate.salon.temperature', + 'service.coordinator.health', + 'zone.salon.light.on', + ]; + for (const path of required) { + const covered = granted.some((pattern) => pattern.endsWith('.*') + ? path.startsWith(pattern.slice(0, -1)) + : pattern === path); + expect(covered).toBe(true); + } + }); + + test('the declared state covers every path the interface reads', () => { + const { configuration } = home(); + expect(Object.keys(configuration.state).sort()).toEqual( + [STATE.health, STATE.lightOn, STATE.temperature].sort(), + ); + }); + + test('the light event is published to users and subscribed by the owner', () => { + const { configuration } = home(); + expect(configuration.events).toEqual([ + { topic: EVENT_LIGHT_CHANGED, directions: 0x02 }, + ]); + expect(configuration.eventAccess[0]?.subscribe).toEqual([EVENT_LIGHT_CHANGED]); + expect(configuration.eventAccess[0]?.publish).toEqual([]); + }); +}); + +describe('lighting.set', () => { + function handler(configuration: ReturnType) { + const fn = configuration.functions['lighting.set']; + if (fn === undefined) throw new Error('lighting.set is not declared'); + return fn; + } + + test('drives the lamp and reports the new value', async () => { + const { configuration, lamp, published } = home(); + const result = await handler(configuration)(call({}, { on: true })); + expect(result).toEqual({ on: true }); + expect(lamp).toEqual([true]); + expect(published).toEqual([true]); + }); + + test('refuses a caller who is not the owner', async () => { + const { configuration, lamp } = home(); + await expect(handler(configuration)(call({ id: 'someone-else' }, { on: true }))) + .rejects.toBeInstanceOf(ApplicationCallError); + expect(lamp).toEqual([]); + }); + + test('refuses a coordinator-originated call', async () => { + const { configuration, lamp } = home(); + await expect(handler(configuration)( + call({ kind: 'coordinator', coordinatorName: 'other' }, { on: true }), + )).rejects.toBeInstanceOf(ApplicationCallError); + expect(lamp).toEqual([]); + }); + + test('refuses a malformed argument before touching the hardware', async () => { + const { configuration, lamp } = home(); + await expect(handler(configuration)(call({}, { on: 'yes' }))) + .rejects.toBeInstanceOf(ApplicationCallError); + await expect(handler(configuration)(call({}, null))) + .rejects.toBeInstanceOf(ApplicationCallError); + expect(lamp).toEqual([]); + }); + + test('a hardware failure is not reported as success', async () => { + const { configuration, published } = home({ + setLight: async () => { + throw new Error('the lamp did not answer'); + }, + }); + await expect(handler(configuration)(call({}, { on: true }))).rejects.toThrow(/did not answer/); + expect(published).toEqual([]); + }); +}); diff --git a/templates/home/tsconfig.json b/templates/home/tsconfig.json new file mode 100644 index 0000000..301cc36 --- /dev/null +++ b/templates/home/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "exactOptionalPropertyTypes": true, + "forceConsistentCasingInFileNames": true, + "lib": [ + "ES2022" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": [ + "bun", + "node" + ], + "useUnknownInCatchVariables": true, + "verbatimModuleSyntax": true + }, + "include": [ + "coordinator/**/*.ts", + "component/**/*.ts", + "test/**/*.ts" + ] +} diff --git a/test/check-coverage.test.ts b/test/check-coverage.test.ts new file mode 100644 index 0000000..1c5278d --- /dev/null +++ b/test/check-coverage.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +// `templates/home` is a project in its own right: it resolves `miakapi` and +// `@miakapp/cli` through `file:` links that exist only after its own install. +// The root sweep once reached into it, and the test resolved `miakapi` by +// self-reference instead of through the link the template ships — green on a +// Bun that self-references, red on the one CI pins, and never a real exercise +// of the template either way. Hence the two halves pinned here: the root sweep +// runs exactly the files the root install can resolve, and everything it leaves +// out is checked by a script CI actually runs. +// +// The sweep is written as globs because `bun test foo/` is a substring filter, +// not a directory scope: `test/` also selects `templates/home/test/`. + +const ROOT = resolve(import.meta.dir, '..'); +const SKIP = new Set(['node_modules', 'dist', 'coverage', '.git', '.contract', '.contract-dist']); + +type Manifest = { + name?: string; + workspaces?: string[]; + scripts?: Record; +}; + +function manifest(directory: string): Manifest { + return JSON.parse(readFileSync(join(ROOT, directory, 'package.json'), 'utf8')) as Manifest; +} + +const root = manifest('.'); + +/** The directory holding `path`, or `.` when it sits at the repository root. */ +function parentOf(path: string): string { + const cut = path.lastIndexOf('/'); + return cut === -1 ? '.' : path.slice(0, cut); +} + +function walk(directory: string, visit: (relativePath: string, entry: string) => void): void { + for (const entry of readdirSync(join(ROOT, directory === '.' ? '' : directory))) { + if (SKIP.has(entry)) continue; + const child = directory === '.' ? entry : `${directory}/${entry}`; + visit(child, entry); + if (statSync(join(ROOT, child)).isDirectory()) walk(child, visit); + } +} + +/** Every Bun test file in the working tree. */ +function testFiles(): string[] { + const found: string[] = []; + walk('.', (child, entry) => { + if (entry.endsWith('.test.ts') || entry.endsWith('.test.mjs')) found.push(child); + }); + return found.sort(); +} + +/** The directories the root `workspaces` globs resolve to. */ +function workspaceMembers(): string[] { + const members: string[] = []; + for (const pattern of root.workspaces ?? []) { + if (!pattern.endsWith('/*')) { + members.push(pattern); + continue; + } + const parent = pattern.slice(0, -2); + for (const entry of readdirSync(join(ROOT, parent))) { + if (SKIP.has(entry)) continue; + if (statSync(join(ROOT, parent, entry)).isDirectory()) members.push(`${parent}/${entry}`); + } + } + return members.sort(); +} + +/** + * Directories carrying their own manifest that the root install does not link: + * neither the root itself nor a workspace member. Their tests cannot run from + * the root sweep, so each one needs a check of its own. + */ +function standaloneProjects(): string[] { + const members = new Set(workspaceMembers()); + const projects: string[] = []; + walk('.', (child, entry) => { + if (entry !== 'package.json') return; + const directory = parentOf(child); + if (directory === '.' || members.has(directory)) return; + projects.push(directory); + }); + return projects.sort(); +} + +/** The files the root `test` script hands to `bun test`, glob-expanded as its shell would. */ +function sweptFiles(): string[] { + const script = root.scripts?.test ?? ''; + const match = /^bun test\b(.*)$/.exec(script.trim()); + if (match === null) throw new Error(`the root test script is not a bun test run: ${script}`); + const patterns = match[1]!.split(/\s+/).filter((word) => word !== '' && !word.startsWith('-')); + if (patterns.length === 0) throw new Error('the root test script sweeps the whole tree'); + + const files = new Set(); + for (const pattern of patterns) { + for (const file of new Bun.Glob(pattern).scanSync({ cwd: ROOT })) files.add(file); + } + return [...files].sort(); +} + +/** + * Follows a script through `bun run` references and reports every directory it + * ends up checking, either by entering it (`cd`) or by filtering to the + * workspace package that lives there (`bun --filter`). + */ +function directoriesCheckedBy(entry: string): string[] { + const byName = new Map(workspaceMembers().map((directory) => [manifest(directory).name, directory])); + const reached = new Set(); + const seen = new Set(); + + const follow = (name: string): void => { + if (seen.has(name)) return; + seen.add(name); + const script = root.scripts?.[name]; + if (script === undefined) throw new Error(`${name} is not a root script`); + for (const step of script.split('&&').map((part) => part.trim())) { + const run = /^bun run ([\w:-]+)$/.exec(step); + if (run !== null) { + follow(run[1]!); + continue; + } + const filtered = /^bun --filter (\S+)\b/.exec(step); + if (filtered !== null) { + const directory = byName.get(filtered[1]!); + if (directory === undefined) throw new Error(`${filtered[1]} is not a workspace package`); + reached.add(directory); + continue; + } + const entered = /^cd (\S+)$/.exec(step); + if (entered !== null) reached.add(entered[1]!.replace(/\/+$/, '')); + } + }; + + follow(entry); + return [...reached].sort(); +} + +/** Every package name in the tree that is published nowhere. */ +function privatePackages(): string[] { + const names: string[] = []; + walk('.', (child, entry) => { + if (entry !== 'package.json') return; + const parsed = JSON.parse(readFileSync(join(ROOT, child), 'utf8')) as Manifest & { private?: boolean }; + if (parsed.private === true && typeof parsed.name === 'string') names.push(parsed.name); + }); + return names.sort(); +} + +/** Every Bun lockfile in the tree, with the format version it declares. */ +function lockfileVersions(): Record { + // bun.lock is JSONC, so read the declaration rather than parsing the file. + const versions: Record = {}; + walk('.', (child, entry) => { + if (entry !== 'bun.lock') return; + const declared = /"lockfileVersion"\s*:\s*(\d+)/.exec(readFileSync(join(ROOT, child), 'utf8')); + versions[child] = declared === null ? 'undeclared' : declared[1]!; + }); + return versions; +} + +/** The root scripts the CI workflow runs. */ +function scriptsRunByCi(): string[] { + const workflow = readFileSync(join(ROOT, '.github/workflows/ci.yml'), 'utf8'); + return [...workflow.matchAll(/^\s*- run: bun run (\S+)\s*$/gm)].map((match) => match[1]!); +} + +function isInside(path: string, directory: string): boolean { + return path === directory || path.startsWith(`${directory}/`); +} + +describe('the checks CI runs cover every test on disk', () => { + // Without these the rest could pass by discovering nothing at all. + test('discovery finds what this repository is known to contain', () => { + expect(testFiles()).toContain('test/socket.test.ts'); + expect(testFiles()).toContain('templates/home/test/home.test.ts'); + expect(workspaceMembers()).toContain('packages/cli'); + expect(standaloneProjects()).toEqual(['templates/home']); + expect(sweptFiles().length).toBeGreaterThan(10); + }); + + test('the sweep runs no test from a project the root install does not link', () => { + const projects = standaloneProjects(); + const trespassing = sweptFiles() + .filter((file) => projects.some((project) => isInside(file, project))); + expect(trespassing).toEqual([]); + }); + + test('the sweep runs every other test file', () => { + const projects = standaloneProjects(); + const expected = testFiles() + .filter((file) => !projects.some((project) => isInside(file, project))); + expect(sweptFiles()).toEqual(expected); + }); + + test('each unlinked project is checked by a script of its own', () => { + const checked = directoriesCheckedBy('check:packages'); + for (const project of standaloneProjects()) { + expect({ project, checked: checked.includes(project) }).toEqual({ project, checked: true }); + } + }); + + test('CI runs both the root check and the per-project checks', () => { + const run = scriptsRunByCi(); + expect(run).toContain('check'); + expect(run).toContain('check:packages'); + }); + + test('the root check is what runs the sweep', () => { + expect(root.scripts?.check ?? '').toContain('bun run test'); + }); + + // `bunx @miakapp/cli` reached the registry on the Bun CI pins and got a 404: + // the package is private, so it exists nowhere to fetch. A project that + // depends on it must call the binary its own install links. + test('no project runs a private package through bunx', () => { + const unpublished = privatePackages(); + for (const project of ['.', ...standaloneProjects()]) { + const scripts = manifest(project).scripts ?? {}; + for (const [name, script] of Object.entries(scripts)) { + const fetched = [...script.matchAll(/\bbunx\s+(?:--\S+\s+)*(\S+)/g)] + .map((match) => match[1]!) + .filter((packageName) => unpublished.includes(packageName)); + expect({ project, script: name, fetched }).toEqual({ project, script: name, fetched: [] }); + } + } + }); + + // The template shipped a lockfile written by a newer Bun than the one this + // repository pins. CI could not parse it, ignored it and silently resolved + // afresh — the opposite of what a committed lockfile is for. + test('every lockfile is in the format the pinned Bun writes', () => { + const versions = lockfileVersions(); + const reference = versions['bun.lock']; + if (reference === undefined) throw new Error('the root lockfile is missing'); + expect(Object.keys(versions)).toContain('templates/home/bun.lock'); + for (const [file, version] of Object.entries(versions)) { + expect({ file, version }).toEqual({ file, version: reference }); + } + }); + + // A lockfile CI is free to rewrite proves nothing about what it installed. + test('every install CI runs is frozen against its lockfile', () => { + const scripts = root.scripts ?? {}; + for (const [name, script] of Object.entries(scripts)) { + for (const step of script.split('&&').map((part) => part.trim())) { + if (!/^bun install\b/.test(step)) continue; + expect({ script: name, step, frozen: step.includes('--frozen-lockfile') }) + .toEqual({ script: name, step, frozen: true }); + } + } + }); +});