diff --git a/README.md b/README.md index 4949cc2b..49e97111 100644 --- a/README.md +++ b/README.md @@ -1074,6 +1074,8 @@ export const { GET, POST, PUT, PATCH, DELETE } = createEmulateHandler({ }) ``` +GitHub App seeds may omit `private_key`. Retain the handler to call server-only `generatedSecrets()`; explicit keys are excluded. Persisted snapshots contain generated keys, so keep the backend private. + ### Auth.js / NextAuth configuration Point your provider at the emulator paths on the same origin: @@ -1143,7 +1145,7 @@ import { filePersistence } from '@emulators/core' persistence: filePersistence('.emulate/state.json'), ``` -The persistence adapter is called on cold start (load) and after every mutating request (save). Saves are serialized via an internal queue to prevent race conditions. +The persistence adapter loads on cold start and saves after mutations. Generated identities also require atomic create-or-read `initialize`; see `@emulators/core`. ## Nuxt Integration @@ -1186,6 +1188,8 @@ export default defineEventHandler(createEmulateHandler({ })) ``` +GitHub App seeds may omit `private_key`. Retain the handler to call server-only `generatedSecrets()`; explicit keys are excluded. Persisted snapshots contain generated keys, so keep the backend private. + ### Nuxt config Emulator UI pages use bundled fonts. Wrap your Nuxt config so Nitro traces the core package assets into production builds: @@ -1236,7 +1240,7 @@ export default defineEventHandler(createEmulateHandler({ })) ``` -The persistence adapter is called on cold start (load) and after every mutating request (save). Saves are serialized via an internal queue to prevent race conditions. +The persistence adapter loads on cold start and saves after mutations. Generated identities also require atomic create-or-read `initialize`; see `@emulators/core`. ## Architecture diff --git a/apps/web/app/docs/github/page.mdx b/apps/web/app/docs/github/page.mdx index 36f6aff3..4e4fea02 100644 --- a/apps/web/app/docs/github/page.mdx +++ b/apps/web/app/docs/github/page.mdx @@ -29,6 +29,8 @@ const privateKey = github.generatedSecrets.find( The generated RSA-2048 PKCS#1 key remains stable across `reset()` calls. Explicit keys are never included in `generatedSecrets`. +The Next.js and Nuxt adapters also generate omitted keys. Their returned server handlers expose `generatedSecrets()`, and persistence restores the same identity across cold starts. Keep persisted snapshots private because they contain the signing key. Custom persistence backends must implement atomic `initialize()` semantics when generated identities are used. + The CLI can generate omitted keys when you request a private delivery file: ```bash diff --git a/apps/web/app/docs/nextjs/page.mdx b/apps/web/app/docs/nextjs/page.mdx index eedad42b..bbff8f08 100644 --- a/apps/web/app/docs/nextjs/page.mdx +++ b/apps/web/app/docs/nextjs/page.mdx @@ -44,6 +44,10 @@ This creates the following routes: - `/emulate/github/**` serves the GitHub emulator - `/emulate/google/**` serves the Google emulator +## GitHub App Private Keys + +GitHub App seeds may omit `private_key`; retain the handler and call server-only `generatedSecrets()`. Keep persisted generated keys private and implement atomic `initialize`. + ## Auth.js / NextAuth Configuration Point your provider at the emulator paths on the same origin: @@ -117,6 +121,8 @@ import { filePersistence } from '@emulators/core' persistence: filePersistence('.emulate/state.json'), ``` +Generated identities require atomic create-or-read `initialize`; see `@emulators/core`. + ### How It Works - **Cold start**: The adapter loads state from the persistence adapter. If found, it restores the full Store and token map (skipping seed). If not found, it seeds from config and saves the initial state. @@ -134,4 +140,4 @@ persistence: filePersistence('.emulate/state.json'), ## Limitations - Requires the Node.js runtime (not Edge) since emulators use `crypto.randomBytes` -- Concurrent serverless instances writing to the same persistence adapter use last-write-wins semantics (acceptable for dev/preview traffic) +- Concurrent mutations use last-write-wins semantics. Generated identities use `initialize` to select the initial snapshot atomically across cold starts. diff --git a/apps/web/app/docs/nuxt/page.mdx b/apps/web/app/docs/nuxt/page.mdx index 95403788..ad030ab6 100644 --- a/apps/web/app/docs/nuxt/page.mdx +++ b/apps/web/app/docs/nuxt/page.mdx @@ -44,6 +44,10 @@ This creates these routes: - `/emulate/github/**` serves the GitHub emulator - `/emulate/google/**` serves the Google emulator +## GitHub App Private Keys + +GitHub App seeds may omit `private_key`; retain the handler and call server-only `generatedSecrets()`. Keep persisted generated keys private and implement atomic `initialize`. + ## Nuxt Config Emulator UI pages use bundled fonts. Wrap your Nuxt config so Nitro traces the core package assets into production builds: @@ -106,6 +110,8 @@ import { filePersistence } from '@emulators/core' persistence: filePersistence('.emulate/state.json'), ``` +Generated identities require atomic create-or-read `initialize`; see `@emulators/core`. + ### How It Works - **Cold start**: The adapter loads state from the persistence adapter. If found, it restores the full Store and token map. If not found, it seeds from config and saves the initial state. @@ -137,4 +143,4 @@ export default defineEventHandler(createEmulateHandler(config, { routePrefix: '/ ## Limitations - Requires a Node-compatible Nuxt server runtime since emulators use Node APIs -- Concurrent serverless instances writing to the same persistence adapter use last write wins semantics, which is acceptable for dev and preview traffic +- Concurrent mutations use last-write-wins semantics. Generated identities use `initialize` to select the initial snapshot atomically across cold starts. diff --git a/packages/@emulators/adapter-next/README.md b/packages/@emulators/adapter-next/README.md index 96d7fadc..988bb68a 100644 --- a/packages/@emulators/adapter-next/README.md +++ b/packages/@emulators/adapter-next/README.md @@ -26,7 +26,7 @@ import { createEmulateHandler } from '@emulators/adapter-next' import * as github from '@emulators/github' import * as google from '@emulators/google' -export const { GET, POST, PUT, PATCH, DELETE } = createEmulateHandler({ +export const emulator = createEmulateHandler({ services: { github: { emulator: github, @@ -43,8 +43,12 @@ export const { GET, POST, PUT, PATCH, DELETE } = createEmulateHandler({ }, }, }) + +export const { GET, POST, PUT, PATCH, DELETE } = emulator ``` +GitHub App seeds may omit `private_key`. Read generated keys with the handler's server-only `generatedSecrets()` method. Explicit keys are excluded; persisted snapshots contain generated keys and require a private backend with atomic `initialize`. + ## Auth.js / NextAuth configuration Point your provider at the emulator paths on the same origin: diff --git a/packages/@emulators/adapter-next/package.json b/packages/@emulators/adapter-next/package.json index c551b88a..8b9f4251 100644 --- a/packages/@emulators/adapter-next/package.json +++ b/packages/@emulators/adapter-next/package.json @@ -31,6 +31,7 @@ "build": "tsup --clean", "dev": "tsup --watch", "clean": "rm -rf dist .turbo", + "test": "vitest run", "type-check": "tsc --noEmit", "lint": "eslint src" }, @@ -38,7 +39,9 @@ "@emulators/core": "workspace:*" }, "devDependencies": { + "@emulators/github": "workspace:*", "tsup": "^8", - "typescript": "^5.7" + "typescript": "^5.7", + "vitest": "^4.1.0" } } diff --git a/packages/@emulators/adapter-next/src/__tests__/github-app-identity.test.ts b/packages/@emulators/adapter-next/src/__tests__/github-app-identity.test.ts new file mode 100644 index 00000000..cfab1f0a --- /dev/null +++ b/packages/@emulators/adapter-next/src/__tests__/github-app-identity.test.ts @@ -0,0 +1,31 @@ +import * as github from "@emulators/github"; +import { githubAppIdentityContract, type TestPersistence } from "../../../../../tests/contracts/github-app-identity.js"; +import { createEmulateHandler, type EmulateHandlerConfig } from "../index.js"; +function config(persistence?: TestPersistence, privateKey?: string): EmulateHandlerConfig { + return { + services: { + github: { + emulator: github, + seed: { apps: [{ app_id: 123, slug: "embedded", name: "Embedded", private_key: privateKey }] }, + }, + }, + persistence, + }; +} +githubAppIdentityContract>({ + createEmulateHandler, + config, + async createExplicitPrivateKey() { + const prepared = await github.materializeGitHubSeedConfig({ apps: [{ app_id: 123, slug: "key", name: "Key" }] }); + return prepared.config.apps![0]!.private_key!; + }, + requestApp(handler, authorization, method = "GET") { + return handler[method]( + new Request("http://localhost/emulate/github/app", { + method, + headers: authorization ? { Authorization: authorization } : undefined, + }), + { params: Promise.resolve({ path: ["github", "app"] }) }, + ); + }, +}); diff --git a/packages/@emulators/adapter-next/src/index.ts b/packages/@emulators/adapter-next/src/index.ts index 771e8d0a..d4c4d6d6 100644 --- a/packages/@emulators/adapter-next/src/index.ts +++ b/packages/@emulators/adapter-next/src/index.ts @@ -1,319 +1,46 @@ import { - createServer, - debug, - serializeTokenMap, - restoreTokenMap, - type ServicePlugin, - type Store, - type TokenMap, - type TokenEntry, - type StoreSnapshot, - type PersistenceAdapter, - type AppKeyResolver, - type WebhookDispatcher, + createAdapterRuntime, + type AdapterEmulatorEntry, + type AdapterEmulatorModule, + type AdapterHandlerConfig, + type GeneratedSecret, + type PreparedServiceSeed, } from "@emulators/core"; - export type { PersistenceAdapter } from "@emulators/core"; - -export interface EmulatorModule { - plugin?: ServicePlugin; - default?: ServicePlugin; - seedFromConfig?(store: Store, baseUrl: string, config: unknown, webhooks?: WebhookDispatcher): void; - createAppKeyResolver?(store: Store): AppKeyResolver; -} - -interface EmulatorEntry { - emulator: EmulatorModule; - seed?: Record; -} - -export interface EmulateHandlerConfig { - services: Record; - persistence?: PersistenceAdapter; -} - -interface Fetchable { - fetch(request: Request, ...rest: unknown[]): Response | Promise; -} - -interface ServiceApp { - app: Fetchable; - store: Store; - tokenMap: TokenMap; - plugin: ServicePlugin; - webhooks: WebhookDispatcher; -} - -interface FullSnapshot { - store: StoreSnapshot; - tokens: Record; -} - -type NextRequest = Request; -type NextResponse = Response; -type RouteHandler = (req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) => Promise; - -const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); - -function resolvePlugin(mod: EmulatorModule): ServicePlugin { - const plugin = mod.plugin ?? mod.default; - if (!plugin) { - throw new Error("Emulator module must export `plugin` or a default export implementing ServicePlugin"); - } - return plugin; -} - -function takeSnapshot(apps: Map): FullSnapshot { - const mergedStore: StoreSnapshot = { collections: {}, data: {} }; - const tokens: Record = {}; - - for (const [name, sa] of apps) { - const snap = sa.store.snapshot(); - for (const [colName, colSnap] of Object.entries(snap.collections)) { - mergedStore.collections[`${name}:${colName}`] = colSnap; - } - for (const [key, val] of Object.entries(snap.data)) { - mergedStore.data[`${name}:${key}`] = val; - } - tokens[name] = serializeTokenMap(sa.tokenMap); - } - - return { store: mergedStore, tokens }; -} - -function restoreFromSnapshot(apps: Map, snapshot: FullSnapshot): void { - const storesByName = new Map(); - for (const [qualifiedName, colSnap] of Object.entries(snapshot.store.collections)) { - const sepIdx = qualifiedName.indexOf(":"); - const name = qualifiedName.slice(0, sepIdx); - const colName = qualifiedName.slice(sepIdx + 1); - if (!storesByName.has(name)) { - storesByName.set(name, { collections: {}, data: {} }); - } - storesByName.get(name)!.collections[colName] = colSnap; - } - for (const [qualifiedKey, val] of Object.entries(snapshot.store.data)) { - const sepIdx = qualifiedKey.indexOf(":"); - const name = qualifiedKey.slice(0, sepIdx); - const dataKey = qualifiedKey.slice(sepIdx + 1); - if (!storesByName.has(name)) { - storesByName.set(name, { collections: {}, data: {} }); - } - storesByName.get(name)!.data[dataKey] = val; - } - - for (const [name, sa] of apps) { - const snap = storesByName.get(name); - if (snap) { - sa.store.restore(snap); - } - restoreTokenMap(sa.tokenMap, snapshot.tokens[name] ?? []); - } -} - +export type EmulatorModule = AdapterEmulatorModule; +export type EmulatorEntry = AdapterEmulatorEntry; +export type EmulateHandlerConfig = AdapterHandlerConfig; +export type { GeneratedSecret, PreparedServiceSeed }; +type RouteHandler = (req: Request, ctx: { params: Promise<{ path: string[] }> }) => Promise; function detectPrefix(url: string, pathSegments: string[]): string { - const parsed = new URL(url); - const fullPath = parsed.pathname; - const restPath = "/" + pathSegments.join("/"); - const idx = fullPath.lastIndexOf(restPath); - if (idx > 0) { - return fullPath.slice(0, idx); - } + const pathname = new URL(url).pathname; + const index = pathname.lastIndexOf("/" + pathSegments.join("/")); + if (index > 0) return pathname.slice(0, index); throw new Error(`Could not detect mount path from URL: ${url}`); } - -async function rewriteResponse(response: Response, servicePrefix: string): Promise { - const contentType = response.headers.get("Content-Type") ?? ""; - const location = response.headers.get("Location"); - const isHtml = contentType.includes("text/html"); - const locationChanged = location != null && location.startsWith("/"); - - if (!isHtml) { - if (!locationChanged) return response; - const headers = new Headers(response.headers); - headers.set("Location", servicePrefix + location); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); - } - - let html = await response.text(); - - // Skip paths already carrying the service prefix to avoid double-prefixing - // (e.g., redirects that already went through rewriting). - html = html.replace(/(action|href)="(\/[^"]*?)"/g, (_match, attr, path) => { - if (path.startsWith(servicePrefix)) return `${attr}="${path}"`; - return `${attr}="${servicePrefix}${path}"`; - }); - - html = html.replace(/url\('(\/[^']*?)'\)/g, (_match, path) => { - if (path.startsWith(servicePrefix)) return `url('${path}')`; - return `url('${servicePrefix}${path}')`; - }); - - const headers = new Headers(response.headers); - if (locationChanged) { - headers.set("Location", servicePrefix + location); - } - headers.delete("Content-Length"); - - return new Response(html, { - status: response.status, - statusText: response.statusText, - headers, - }); -} - export function createEmulateHandler(config: EmulateHandlerConfig) { - const { services: serviceEntries, persistence } = config; - - let apps: Map | null = null; + const runtime = createAdapterRuntime(config, (mountPath, service) => `${mountPath}/${service}`); let mountPath: string | null = null; - let initPromise: Promise | null = null; - let pendingSave: Promise = Promise.resolve(); - - function enqueueSave(): void { - if (!persistence || !apps) return; - pendingSave = pendingSave.then(async () => { - if (!apps) return; - const snapshot = takeSnapshot(apps); - const json = JSON.stringify(snapshot); - try { - await persistence.save(json); - } catch (err) { - debug("persistence", "save failed: %o", err); - } - }); - } - - async function initApps(origin: string, mountPath: string): Promise> { - const serviceApps = new Map(); - - for (const [name, entry] of Object.entries(serviceEntries)) { - const plugin = resolvePlugin(entry.emulator); - const servicePrefix = `${mountPath}/${name}`; - const baseUrl = `${origin}${servicePrefix}`; - - let appKeyResolver: AppKeyResolver | undefined; - const { app, store, tokenMap, webhooks } = createServer(plugin, { - baseUrl, - appKeyResolver: entry.emulator.createAppKeyResolver ? (appId) => appKeyResolver!(appId) : undefined, - }); - - if (entry.emulator.createAppKeyResolver) { - appKeyResolver = entry.emulator.createAppKeyResolver(store); - } - - serviceApps.set(name, { app, store, tokenMap, plugin, webhooks }); - } - - let restored = false; - if (persistence) { - const raw = await persistence.load(); - if (raw) { - try { - const snapshot = JSON.parse(raw) as FullSnapshot; - restoreFromSnapshot(serviceApps, snapshot); - restored = true; - } catch { - // Corrupted data, fall through to seeding - } - } - } - - if (!restored) { - for (const [name, entry] of Object.entries(serviceEntries)) { - const sa = serviceApps.get(name)!; - const servicePrefix = `${mountPath}/${name}`; - const baseUrl = `${origin}${servicePrefix}`; - sa.plugin.seed?.(sa.store, baseUrl); - if (entry.seed && entry.emulator.seedFromConfig) { - entry.emulator.seedFromConfig(sa.store, baseUrl, entry.seed, sa.webhooks); - } - } - if (persistence) { - enqueueSave(); - } - } - - return serviceApps; - } - - async function ensureInit(req: Request, pathSegments: string[]): Promise> { - if (apps) return apps; - if (!initPromise) { - const url = new URL(req.url); - const origin = url.origin; - mountPath = detectPrefix(req.url, pathSegments); - initPromise = initApps(origin, mountPath).then((result) => { - apps = result; - }); - } - await initPromise; - return apps!; - } - - async function handleRequest(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }): Promise { - const { path: pathSegments } = await ctx.params; - const serviceApps = await ensureInit(req, pathSegments); - - if (pathSegments.length === 0) { - return new Response("Not found", { status: 404 }); - } - - const serviceName = pathSegments[0]; - const sa = serviceApps.get(serviceName); - if (!sa) { - return new Response(`Unknown service: ${serviceName}`, { status: 404 }); - } - - const restPath = "/" + pathSegments.slice(1).join("/"); - const url = new URL(req.url); - const strippedUrl = new URL(restPath + url.search, url.origin); - - const strippedReq = new Request(strippedUrl.toString(), { - method: req.method, - headers: req.headers, - body: req.body, - duplex: "half", - } as RequestInit & { duplex: string }); - - let response = await sa.app.fetch(strippedReq); - - const servicePrefix = `${mountPath!}/${serviceName}`; - response = await rewriteResponse(response, servicePrefix); - - if (persistence && MUTATING_METHODS.has(req.method)) { - enqueueSave(); - } - - return response; - } - - const handler: RouteHandler = handleRequest; - + const handler: RouteHandler = async (request, context) => { + const { path } = await context.params; + mountPath ??= detectPrefix(request.url, path); + return runtime.handle(request, path, mountPath); + }; return { GET: handler, POST: handler, PUT: handler, PATCH: handler, DELETE: handler, + generatedSecrets: runtime.generatedSecrets, }; } - export function withEmulate(nextConfig: T, options?: { routePrefix?: string }): T { const config = nextConfig as Record; - const prefix = options?.routePrefix ?? "/emulate"; - const routePattern = `${prefix}/**`; + const routePattern = `${options?.routePrefix ?? "/emulate"}/**`; const fontGlob = "./node_modules/@emulators/core/dist/fonts/**"; - - const topLevel = { ...((config.outputFileTracingIncludes as Record | undefined) ?? {}) }; - const existing = topLevel[routePattern] ?? []; - if (!existing.includes(fontGlob)) { - topLevel[routePattern] = [...existing, fontGlob]; - } - - return { ...config, outputFileTracingIncludes: topLevel } as T; + const includes = { ...((config.outputFileTracingIncludes as Record | undefined) ?? {}) }; + const existing = includes[routePattern] ?? []; + if (!existing.includes(fontGlob)) includes[routePattern] = [...existing, fontGlob]; + return { ...config, outputFileTracingIncludes: includes } as T; } diff --git a/packages/@emulators/adapter-next/tsconfig.json b/packages/@emulators/adapter-next/tsconfig.json index c8c92cbd..a6b65447 100644 --- a/packages/@emulators/adapter-next/tsconfig.json +++ b/packages/@emulators/adapter-next/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "../../.." }, "include": ["src/**/*"] } diff --git a/packages/@emulators/adapter-nuxt/README.md b/packages/@emulators/adapter-nuxt/README.md index 34bf48b8..6237ff54 100644 --- a/packages/@emulators/adapter-nuxt/README.md +++ b/packages/@emulators/adapter-nuxt/README.md @@ -24,7 +24,7 @@ import { createEmulateHandler } from '@emulators/adapter-nuxt' import * as github from '@emulators/github' import * as google from '@emulators/google' -export default defineEventHandler(createEmulateHandler({ +export const emulator = createEmulateHandler({ services: { github: { emulator: github, @@ -40,7 +40,9 @@ export default defineEventHandler(createEmulateHandler({ }, }, }, -})) +}) + +export default defineEventHandler(emulator) ``` This creates these routes: @@ -48,6 +50,8 @@ This creates these routes: - `/emulate/github/**` serves the GitHub emulator - `/emulate/google/**` serves the Google emulator +GitHub App seeds may omit `private_key`. Read generated keys with the handler's server-only `generatedSecrets()` method. Explicit keys are excluded; persisted snapshots contain generated keys and require a private backend with atomic `initialize`. + ## Nuxt Config Emulator UI pages use bundled fonts. Wrap your Nuxt config so Nitro traces the core package assets into production builds: diff --git a/packages/@emulators/adapter-nuxt/package.json b/packages/@emulators/adapter-nuxt/package.json index 8de22e98..e3b821b9 100644 --- a/packages/@emulators/adapter-nuxt/package.json +++ b/packages/@emulators/adapter-nuxt/package.json @@ -31,6 +31,7 @@ "build": "tsup --clean", "dev": "tsup --watch", "clean": "rm -rf dist .turbo", + "test": "vitest run", "type-check": "tsc --noEmit", "lint": "eslint src" }, @@ -38,7 +39,9 @@ "@emulators/core": "workspace:*" }, "devDependencies": { + "@emulators/github": "workspace:*", "tsup": "^8", - "typescript": "^5.7" + "typescript": "^5.7", + "vitest": "^4.1.0" } } diff --git a/packages/@emulators/adapter-nuxt/src/__tests__/github-app-identity.test.ts b/packages/@emulators/adapter-nuxt/src/__tests__/github-app-identity.test.ts new file mode 100644 index 00000000..3403df46 --- /dev/null +++ b/packages/@emulators/adapter-nuxt/src/__tests__/github-app-identity.test.ts @@ -0,0 +1,31 @@ +import * as github from "@emulators/github"; +import { githubAppIdentityContract, type TestPersistence } from "../../../../../tests/contracts/github-app-identity.js"; +import { createEmulateHandler, type EmulateHandlerConfig } from "../index.js"; +function config(persistence?: TestPersistence, privateKey?: string): EmulateHandlerConfig { + return { + services: { + github: { + emulator: github, + seed: { apps: [{ app_id: 123, slug: "embedded", name: "Embedded", private_key: privateKey }] }, + }, + }, + persistence, + }; +} +githubAppIdentityContract>({ + createEmulateHandler, + config, + async createExplicitPrivateKey() { + const prepared = await github.materializeGitHubSeedConfig({ apps: [{ app_id: 123, slug: "key", name: "Key" }] }); + return prepared.config.apps![0]!.private_key!; + }, + requestApp(handler, authorization, method = "GET") { + return handler({ + req: new Request("http://localhost/emulate/github/app", { + method, + headers: authorization ? { Authorization: authorization } : undefined, + }), + context: { params: { path: "github/app" } }, + }); + }, +}); diff --git a/packages/@emulators/adapter-nuxt/src/index.ts b/packages/@emulators/adapter-nuxt/src/index.ts index 2fd963d2..d2a88f34 100644 --- a/packages/@emulators/adapter-nuxt/src/index.ts +++ b/packages/@emulators/adapter-nuxt/src/index.ts @@ -1,461 +1,136 @@ -import { createRequire } from "node:module"; import { cpSync } from "node:fs"; +import { createRequire } from "node:module"; import { dirname, join } from "node:path"; import { - createServer, + createAdapterRuntime, debug, - serializeTokenMap, - restoreTokenMap, - type ServicePlugin, - type Store, - type TokenMap, - type TokenEntry, - type StoreSnapshot, - type PersistenceAdapter, - type AppKeyResolver, - type WebhookDispatcher, + type AdapterEmulatorEntry, + type AdapterEmulatorModule, + type AdapterHandlerConfig, + type GeneratedSecret, + type PreparedServiceSeed, } from "@emulators/core"; - export type { PersistenceAdapter } from "@emulators/core"; - -export interface EmulatorModule { - plugin?: ServicePlugin; - default?: ServicePlugin; - seedFromConfig?(store: Store, baseUrl: string, config: unknown, webhooks?: WebhookDispatcher): void; - createAppKeyResolver?(store: Store): AppKeyResolver; -} - -interface EmulatorEntry { - emulator: EmulatorModule; - seed?: Record; -} - -export interface EmulateHandlerConfig { - services: Record; - persistence?: PersistenceAdapter; -} - +export type EmulatorModule = AdapterEmulatorModule; +export type EmulatorEntry = AdapterEmulatorEntry; +export type EmulateHandlerConfig = AdapterHandlerConfig; +export type { GeneratedSecret, PreparedServiceSeed }; export interface NuxtAdapterOptions { - /** - * Named catch-all route param from `server/routes/emulate/[...path].ts`. - */ param?: string; - /** - * Explicit mount path. Usually detected from the request URL. - */ routePrefix?: string; } - type HeaderValue = string | string[] | number | undefined; - interface NodeRequestLike { method?: string; url?: string; headers?: Record; } - export interface NuxtEventLike { req?: Request; - node?: { - req?: NodeRequestLike; - }; - context?: { - params?: Record; - path?: string; - }; -} - -interface Fetchable { - fetch(request: Request, ...rest: unknown[]): Response | Promise; + node?: { req?: NodeRequestLike }; + context?: { params?: Record; path?: string }; } - -interface ServiceApp { - app: Fetchable; - store: Store; - tokenMap: TokenMap; - plugin: ServicePlugin; - webhooks: WebhookDispatcher; +function normalizePrefix(prefix: string): string { + return (prefix.startsWith("/") ? prefix : `/${prefix}`).replace(/\/+$/, "") || "/"; } - -interface FullSnapshot { - store: StoreSnapshot; - tokens: Record; -} - -const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); - -function resolvePlugin(mod: EmulatorModule): ServicePlugin { - const plugin = mod.plugin ?? mod.default; - if (!plugin) { - throw new Error("Emulator module must export `plugin` or a default export implementing ServicePlugin"); - } - return plugin; -} - -function takeSnapshot(apps: Map): FullSnapshot { - const mergedStore: StoreSnapshot = { collections: {}, data: {} }; - const tokens: Record = {}; - - for (const [name, sa] of apps) { - const snap = sa.store.snapshot(); - for (const [colName, colSnap] of Object.entries(snap.collections)) { - mergedStore.collections[`${name}:${colName}`] = colSnap; - } - for (const [key, val] of Object.entries(snap.data)) { - mergedStore.data[`${name}:${key}`] = val; - } - tokens[name] = serializeTokenMap(sa.tokenMap); - } - - return { store: mergedStore, tokens }; -} - -function restoreFromSnapshot(apps: Map, snapshot: FullSnapshot): void { - const storesByName = new Map(); - for (const [qualifiedName, colSnap] of Object.entries(snapshot.store.collections)) { - const sepIdx = qualifiedName.indexOf(":"); - const name = qualifiedName.slice(0, sepIdx); - const colName = qualifiedName.slice(sepIdx + 1); - if (!storesByName.has(name)) { - storesByName.set(name, { collections: {}, data: {} }); - } - storesByName.get(name)!.collections[colName] = colSnap; - } - for (const [qualifiedKey, val] of Object.entries(snapshot.store.data)) { - const sepIdx = qualifiedKey.indexOf(":"); - const name = qualifiedKey.slice(0, sepIdx); - const dataKey = qualifiedKey.slice(sepIdx + 1); - if (!storesByName.has(name)) { - storesByName.set(name, { collections: {}, data: {} }); - } - storesByName.get(name)!.data[dataKey] = val; - } - - for (const [name, sa] of apps) { - const snap = storesByName.get(name); - if (snap) { - sa.store.restore(snap); - } - restoreTokenMap(sa.tokenMap, snapshot.tokens[name] ?? []); - } -} - -function normalizeRoutePrefix(prefix: string): string { - const withLeadingSlash = prefix.startsWith("/") ? prefix : `/${prefix}`; - return withLeadingSlash.replace(/\/+$/, "") || "/"; -} - function joinPath(base: string, segment: string): string { return base === "/" ? `/${segment}` : `${base}/${segment}`; } - -function detectPrefix(url: string, pathSegments: string[]): string { - const parsed = new URL(url); - const fullPath = parsed.pathname; - const restPath = "/" + pathSegments.join("/"); - const idx = fullPath.lastIndexOf(restPath); - if (idx >= 0) { - return fullPath.slice(0, idx) || "/"; - } - throw new Error(`Could not detect mount path from URL: ${url}`); -} - function splitPath(path: string): string[] { return path.split("/").filter(Boolean); } - -function getPathSegments(event: NuxtEventLike, paramName: string, routePrefix?: string): string[] { +function getPathSegments(event: NuxtEventLike, param: string, routePrefix?: string): string[] { const params = event.context?.params ?? {}; - const value = params[paramName] ?? params._ ?? params.path; - - if (Array.isArray(value)) { - return value.flatMap((part) => splitPath(String(part))); - } - if (typeof value === "string" && value.length > 0) { - return splitPath(value); - } - + const value = params[param] ?? params._ ?? params.path; + if (Array.isArray(value)) return value.flatMap((part) => splitPath(String(part))); + if (typeof value === "string" && value) return splitPath(value); if (routePrefix && event.context?.path) { - const prefix = normalizeRoutePrefix(routePrefix); - const path = event.context.path.startsWith(prefix) ? event.context.path.slice(prefix.length) : event.context.path; - return splitPath(path); + const path = event.context.path; + return splitPath(path.startsWith(routePrefix) ? path.slice(routePrefix.length) : path); } - return []; } - function firstHeader(value: HeaderValue): string | undefined { const raw = Array.isArray(value) ? value[0] : value; - if (raw === undefined) return undefined; - return String(raw).split(",")[0]?.trim(); + return raw === undefined ? undefined : String(raw).split(",")[0]?.trim(); } - -function headersFromNode(headers: Record | undefined): Headers { - const result = new Headers(); - for (const [key, value] of Object.entries(headers ?? {})) { - if (value === undefined) continue; - if (Array.isArray(value)) { - for (const item of value) { - result.append(key, item); - } - continue; - } - result.set(key, String(value)); - } - return result; -} - -function isWebRequest(value: unknown): value is Request { - if (typeof value !== "object" || value === null) return false; - const candidate = value as Request; - return ( - typeof candidate.url === "string" && - typeof candidate.method === "string" && - typeof candidate.headers?.get === "function" - ); -} - function requestFromEvent(event: NuxtEventLike): Request { - if (isWebRequest(event.req)) { + if ( + event.req && + typeof event.req.url === "string" && + typeof event.req.method === "string" && + typeof event.req.headers?.get === "function" + ) { return event.req; } - - const nodeReq = event.node?.req; - if (!nodeReq) { - throw new Error("Nuxt event must provide `req` or `node.req`"); - } - - const headers = nodeReq.headers ?? {}; - const proto = firstHeader(headers["x-forwarded-proto"]) ?? "http"; - const host = firstHeader(headers["x-forwarded-host"]) ?? firstHeader(headers.host) ?? "localhost"; - const url = new URL(nodeReq.url ?? "/", `${proto}://${host}`); - const method = nodeReq.method ?? "GET"; - const init: RequestInit & { duplex?: string } = { - method, - headers: headersFromNode(headers), - }; - + const nodeRequest = event.node?.req; + if (!nodeRequest) throw new Error("Nuxt event must provide `req` or `node.req`"); + const sourceHeaders = nodeRequest.headers ?? {}; + const protocol = firstHeader(sourceHeaders["x-forwarded-proto"]) ?? "http"; + const host = firstHeader(sourceHeaders["x-forwarded-host"]) ?? firstHeader(sourceHeaders.host) ?? "localhost"; + const headers = new Headers(); + for (const [name, value] of Object.entries(sourceHeaders)) { + if (Array.isArray(value)) value.forEach((item) => headers.append(name, item)); + else if (value !== undefined) headers.set(name, String(value)); + } + const method = nodeRequest.method ?? "GET"; + const init: RequestInit & { duplex?: string } = { method, headers }; if (method !== "GET" && method !== "HEAD") { - init.body = nodeReq as unknown as RequestInit["body"]; + init.body = nodeRequest as unknown as RequestInit["body"]; init.duplex = "half"; } - - return new Request(url, init); + return new Request(new URL(nodeRequest.url ?? "/", `${protocol}://${host}`), init); } - -async function rewriteResponse(response: Response, servicePrefix: string): Promise { - const contentType = response.headers.get("Content-Type") ?? ""; - const location = response.headers.get("Location"); - const isHtml = contentType.includes("text/html"); - const locationChanged = location != null && location.startsWith("/"); - - if (!isHtml) { - if (!locationChanged) return response; - const headers = new Headers(response.headers); - headers.set("Location", servicePrefix + location); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); - } - - let html = await response.text(); - - html = html.replace(/(action|href)="(\/[^"]*?)"/g, (_match, attr, path) => { - if (path.startsWith(servicePrefix)) return `${attr}="${path}"`; - return `${attr}="${servicePrefix}${path}"`; - }); - - html = html.replace(/url\('(\/[^']*?)'\)/g, (_match, path) => { - if (path.startsWith(servicePrefix)) return `url('${path}')`; - return `url('${servicePrefix}${path}')`; - }); - - const headers = new Headers(response.headers); - if (locationChanged) { - headers.set("Location", servicePrefix + location); - } - headers.delete("Content-Length"); - - return new Response(html, { - status: response.status, - statusText: response.statusText, - headers, - }); +function detectPrefix(url: string, pathSegments: string[]): string { + const pathname = new URL(url).pathname; + const index = pathname.lastIndexOf("/" + pathSegments.join("/")); + if (index >= 0) return pathname.slice(0, index) || "/"; + throw new Error(`Could not detect mount path from URL: ${url}`); } - export function createEmulateHandler(config: EmulateHandlerConfig, options: NuxtAdapterOptions = {}) { - const { services: serviceEntries, persistence } = config; - const paramName = options.param ?? "path"; - const configuredMountPath = options.routePrefix ? normalizeRoutePrefix(options.routePrefix) : null; - - let apps: Map | null = null; - let mountPath: string | null = configuredMountPath; - let initPromise: Promise | null = null; - let pendingSave: Promise = Promise.resolve(); - - function enqueueSave(targetApps: Map | null = apps): void { - if (!persistence || !targetApps) return; - pendingSave = pendingSave.then(async () => { - const snapshot = takeSnapshot(targetApps); - const json = JSON.stringify(snapshot); - try { - await persistence.save(json); - } catch (err) { - debug("persistence", "save failed: %o", err); - } - }); - } - - async function initApps(origin: string, mountPath: string): Promise> { - const serviceApps = new Map(); - - for (const [name, entry] of Object.entries(serviceEntries)) { - const plugin = resolvePlugin(entry.emulator); - const servicePrefix = joinPath(mountPath, name); - const baseUrl = `${origin}${servicePrefix}`; - - let appKeyResolver: AppKeyResolver | undefined; - const { app, store, tokenMap, webhooks } = createServer(plugin, { - baseUrl, - appKeyResolver: entry.emulator.createAppKeyResolver ? (appId) => appKeyResolver!(appId) : undefined, - }); - - if (entry.emulator.createAppKeyResolver) { - appKeyResolver = entry.emulator.createAppKeyResolver(store); - } - - serviceApps.set(name, { app, store, tokenMap, plugin, webhooks }); - } - - let restored = false; - if (persistence) { - const raw = await persistence.load(); - if (raw) { - try { - const snapshot = JSON.parse(raw) as FullSnapshot; - restoreFromSnapshot(serviceApps, snapshot); - restored = true; - } catch { - // Corrupted data, fall through to seeding. - } - } - } - - if (!restored) { - for (const [name, entry] of Object.entries(serviceEntries)) { - const sa = serviceApps.get(name)!; - const servicePrefix = joinPath(mountPath, name); - const baseUrl = `${origin}${servicePrefix}`; - sa.plugin.seed?.(sa.store, baseUrl); - if (entry.seed && entry.emulator.seedFromConfig) { - entry.emulator.seedFromConfig(sa.store, baseUrl, entry.seed, sa.webhooks); - } - } - if (persistence) { - enqueueSave(serviceApps); - } - } - - return serviceApps; - } - - async function ensureInit(req: Request, pathSegments: string[]): Promise> { - if (apps) return apps; - if (!initPromise) { - const url = new URL(req.url); - const origin = url.origin; - mountPath = configuredMountPath ?? detectPrefix(req.url, pathSegments); - initPromise = initApps(origin, mountPath).then((result) => { - apps = result; - }); - } - await initPromise; - return apps!; - } - - return async function emulateNuxtHandler(event: NuxtEventLike): Promise { - const req = requestFromEvent(event); - const pathSegments = getPathSegments(event, paramName, configuredMountPath ?? undefined); - - if (pathSegments.length === 0) { - return new Response("Not found", { status: 404 }); - } - - const serviceApps = await ensureInit(req, pathSegments); - - const serviceName = pathSegments[0]; - const sa = serviceApps.get(serviceName); - if (!sa) { - return new Response(`Unknown service: ${serviceName}`, { status: 404 }); - } - - const restPath = "/" + pathSegments.slice(1).join("/"); - const url = new URL(req.url); - const strippedUrl = new URL(restPath + url.search, url.origin); - - const strippedReq = new Request(strippedUrl.toString(), { - method: req.method, - headers: req.headers, - body: req.body, - duplex: "half", - } as RequestInit & { duplex: string }); - - let response = await sa.app.fetch(strippedReq); - - const servicePrefix = joinPath(mountPath!, serviceName); - response = await rewriteResponse(response, servicePrefix); - - if (persistence && MUTATING_METHODS.has(req.method)) { - enqueueSave(); - } - - return response; + const param = options.param ?? "path"; + const configuredMountPath = options.routePrefix ? normalizePrefix(options.routePrefix) : null; + const runtime = createAdapterRuntime(config, joinPath); + let mountPath = configuredMountPath; + const handler = async (event: NuxtEventLike): Promise => { + const request = requestFromEvent(event); + const path = getPathSegments(event, param, configuredMountPath ?? undefined); + if (path.length === 0) return new Response("Not found", { status: 404 }); + mountPath ??= detectPrefix(request.url, path); + return runtime.handle(request, path, mountPath); }; + handler.generatedSecrets = runtime.generatedSecrets; + return handler; } - interface NitroInstanceLike { options?: { output?: { serverDir?: string } }; } - -/** - * Copy the `@emulators/core` UI fonts into the built server output. - * - * The emulator UI reads its fonts with `readFileSync(join(__dirname, "fonts", …))`. - * In a Nitro build `__dirname` resolves to the server output root, so the font - * files must exist at `/fonts/`. Nitro's tracing does not place them - * there, so we copy them explicitly once the build is written. - */ function copyCoreFonts(nitro: NitroInstanceLike): void { const serverDir = nitro.options?.output?.serverDir; if (!serverDir) return; try { - const require = createRequire(import.meta.url); - const corePkg = require.resolve("@emulators/core/package.json"); - const fontsDir = join(dirname(corePkg), "dist", "fonts"); - cpSync(fontsDir, join(serverDir, "fonts"), { recursive: true }); - } catch (err) { - debug("nuxt", "font copy failed: %o", err); + const corePackage = createRequire(import.meta.url).resolve("@emulators/core/package.json"); + cpSync(join(dirname(corePackage), "dist", "fonts"), join(serverDir, "fonts"), { recursive: true }); + } catch (error) { + debug("nuxt", "font copy failed: %o", error); } } - export function withEmulate(nuxtConfig: T): T { const config = nuxtConfig as Record; const nitro = { ...((config.nitro as Record | undefined) ?? {}) }; - const existingHooks = (nitro.hooks as Record | undefined) ?? {}; - const prevCompiled = existingHooks.compiled as ((nitro: NitroInstanceLike) => unknown) | undefined; - - const compiled = async (nitroInstance: NitroInstanceLike) => { - if (prevCompiled) await prevCompiled(nitroInstance); - copyCoreFonts(nitroInstance); - }; - + const hooks = (nitro.hooks as Record | undefined) ?? {}; + const previous = hooks.compiled as ((nitro: NitroInstanceLike) => unknown) | undefined; return { ...config, nitro: { ...nitro, - hooks: { ...existingHooks, compiled }, + hooks: { + ...hooks, + async compiled(instance: NitroInstanceLike) { + if (previous) await previous(instance); + copyCoreFonts(instance); + }, + }, }, } as T; } diff --git a/packages/@emulators/adapter-nuxt/tsconfig.json b/packages/@emulators/adapter-nuxt/tsconfig.json index c8c92cbd..a6b65447 100644 --- a/packages/@emulators/adapter-nuxt/tsconfig.json +++ b/packages/@emulators/adapter-nuxt/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "../../.." }, "include": ["src/**/*"] } diff --git a/packages/@emulators/core/README.md b/packages/@emulators/core/README.md index fcc27b4c..1b7b8b5d 100644 --- a/packages/@emulators/core/README.md +++ b/packages/@emulators/core/README.md @@ -34,12 +34,16 @@ persistence: filePersistence('.emulate/state.json') ### Custom adapter -Any object with `load` and `save` methods works: +Any object with `load` and `save` methods works. Add `initialize` when a service generates durable identity: ```typescript const kvAdapter = { async load() { return await kv.get('emulate-state') }, async save(data: string) { await kv.set('emulate-state', data) }, + async initialize(data: string) { // atomic create-or-read + await kv.set('emulate-state', data, { nx: true }) + return (await kv.get('emulate-state'))! + }, } ``` diff --git a/packages/@emulators/core/src/__tests__/persistence.test.ts b/packages/@emulators/core/src/__tests__/persistence.test.ts index cf03c33d..cedaf2a6 100644 --- a/packages/@emulators/core/src/__tests__/persistence.test.ts +++ b/packages/@emulators/core/src/__tests__/persistence.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { existsSync, rmSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, rmSync, readFileSync, statSync, readdirSync, mkdirSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { Collection, Store, type Entity, serializeValue, deserializeValue } from "../store.js"; import { filePersistence } from "../persistence.js"; @@ -215,11 +215,7 @@ describe("filePersistence", () => { const tmpPath = join(tmpdir(), `emulate-test-${Date.now()}.json`); afterEach(() => { - try { - rmSync(tmpPath); - } catch { - /* noop */ - } + rmSync(tmpPath, { recursive: true, force: true }); }); it("save writes and load reads a JSON file", async () => { @@ -229,6 +225,7 @@ describe("filePersistence", () => { await adapter.save(data); expect(existsSync(tmpPath)).toBe(true); expect(readFileSync(tmpPath, "utf-8")).toBe(data); + expect(statSync(tmpPath).mode & 0o777).toBe(0o600); const loaded = await adapter.load(); expect(loaded).toBe(data); @@ -247,4 +244,29 @@ describe("filePersistence", () => { expect(existsSync(nested)).toBe(true); rmSync(dir, { recursive: true, force: true }); }); + + it("initialize atomically selects one value and leaves no temporary files", async () => { + const adapter = filePersistence(tmpPath); + const [first, second] = await Promise.all([adapter.initialize!("first"), adapter.initialize!("second")]); + + expect(new Set([first, second])).toEqual(new Set([readFileSync(tmpPath, "utf-8")])); + expect(statSync(tmpPath).mode & 0o777).toBe(0o600); + expect(readdirSync(dirname(tmpPath)).filter((name) => name.startsWith(`${basename(tmpPath)}.`))).toEqual([]); + }); + + it("initialize removes its temporary file when publication fails", async () => { + const adapter = filePersistence(tmpPath); + mkdirSync(tmpPath); + + await expect(adapter.initialize!("data")).rejects.toThrow(); + expect(readdirSync(dirname(tmpPath)).filter((name) => name.startsWith(`${basename(tmpPath)}.`))).toEqual([]); + }); + + it("save removes its temporary file when publication fails", async () => { + const adapter = filePersistence(tmpPath); + mkdirSync(tmpPath); + + await expect(adapter.save("data")).rejects.toThrow(); + expect(readdirSync(dirname(tmpPath)).filter((name) => name.startsWith(`${basename(tmpPath)}.`))).toEqual([]); + }); }); diff --git a/packages/@emulators/core/src/adapter-runtime.ts b/packages/@emulators/core/src/adapter-runtime.ts new file mode 100644 index 00000000..a3752343 --- /dev/null +++ b/packages/@emulators/core/src/adapter-runtime.ts @@ -0,0 +1,347 @@ +import { debug } from "./debug.js"; +import { + restoreTokenMap, + serializeTokenMap, + type AppKeyResolver, + type TokenEntry, + type TokenMap, +} from "./middleware/auth.js"; +import type { ServicePlugin } from "./plugin.js"; +import { createServer } from "./server.js"; +import type { Store, StoreSnapshot } from "./store.js"; +import type { WebhookDispatcher } from "./webhooks.js"; +import type { PersistenceAdapter } from "./persistence.js"; +export interface AdapterEmulatorModule { + plugin?: ServicePlugin; + default?: ServicePlugin; + seedFromConfig?(store: Store, baseUrl: string, config: unknown, webhooks?: WebhookDispatcher): void; + createAppKeyResolver?(store: Store): AppKeyResolver; + prepareSeed?( + config: Record, + generatedSecrets?: Array>, + ): Promise; + needsGeneratedSecrets?(config: Record): boolean; +} +export interface PreparedServiceSeed { + config: Record; + generatedSecrets: Array>; +} +export interface GeneratedSecret { + readonly service: string; + readonly kind: string; + readonly id: string; + readonly label: string; + readonly value: string; +} +export interface AdapterEmulatorEntry { + emulator: AdapterEmulatorModule; + seed?: Record; +} +export interface AdapterHandlerConfig { + services: Record; + persistence?: PersistenceAdapter; +} +interface Fetchable { + fetch(request: Request, ...rest: unknown[]): Response | Promise; +} +interface ServiceApp { + app: Fetchable; + store: Store; + tokenMap: TokenMap; + plugin: ServicePlugin; + webhooks: WebhookDispatcher; +} +interface FullSnapshot { + store: StoreSnapshot; + tokens: Record; + generatedSecrets?: GeneratedSecret[]; + seeded?: boolean; +} +interface PreparedState { + snapshot: FullSnapshot | null; + seeds: Map | undefined>; + generatedSecrets: readonly GeneratedSecret[]; +} +const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.length > 0; +const isGeneratedSecret = (value: unknown): value is GeneratedSecret => + isRecord(value) && [value.service, value.kind, value.id, value.label, value.value].every(isNonEmptyString); +const secretIdentity = ({ service, kind, id, value }: GeneratedSecret) => JSON.stringify([service, kind, id, value]); +function isFullSnapshot(value: unknown): value is FullSnapshot { + if (!isRecord(value) || !isRecord(value.store)) return false; + return ( + isRecord(value.store.collections) && + isRecord(value.store.data) && + isRecord(value.tokens) && + Object.values(value.tokens).every(Array.isArray) && + (value.seeded === undefined || typeof value.seeded === "boolean") && + (value.generatedSecrets === undefined || + (Array.isArray(value.generatedSecrets) && value.generatedSecrets.every(isGeneratedSecret))) + ); +} +function parseSnapshot(raw: string, message = "invalid snapshot"): FullSnapshot { + const snapshot = JSON.parse(raw) as unknown; + if (!isFullSnapshot(snapshot)) throw new Error(message); + return snapshot; +} +function freezeSecrets(snapshot: FullSnapshot): readonly GeneratedSecret[] { + return Object.freeze((snapshot.generatedSecrets ?? []).map((secret) => Object.freeze({ ...secret }))); +} +function resolvePlugin(mod: AdapterEmulatorModule): ServicePlugin { + const plugin = mod.plugin ?? mod.default; + if (!plugin) throw new Error("Emulator module must export `plugin` or a default export implementing ServicePlugin"); + return plugin; +} +function takeSnapshot(apps: Map, generatedSecrets: readonly GeneratedSecret[]): FullSnapshot { + const mergedStore: StoreSnapshot = { collections: {}, data: {} }; + const tokens: Record = {}; + for (const [name, service] of apps) { + const snapshot = service.store.snapshot(); + for (const [collection, value] of Object.entries(snapshot.collections)) { + mergedStore.collections[`${name}:${collection}`] = value; + } + for (const [key, value] of Object.entries(snapshot.data)) mergedStore.data[`${name}:${key}`] = value; + tokens[name] = serializeTokenMap(service.tokenMap); + } + return { store: mergedStore, tokens, generatedSecrets: [...generatedSecrets], seeded: true }; +} +function restoreFromSnapshot(apps: Map, snapshot: FullSnapshot): void { + const stores = new Map(); + for (const [qualifiedName, value] of Object.entries(snapshot.store.collections)) { + const separator = qualifiedName.indexOf(":"); + const name = qualifiedName.slice(0, separator); + const collection = qualifiedName.slice(separator + 1); + if (!stores.has(name)) stores.set(name, { collections: {}, data: {} }); + stores.get(name)!.collections[collection] = value; + } + for (const [qualifiedKey, value] of Object.entries(snapshot.store.data)) { + const separator = qualifiedKey.indexOf(":"); + const name = qualifiedKey.slice(0, separator); + const key = qualifiedKey.slice(separator + 1); + if (!stores.has(name)) stores.set(name, { collections: {}, data: {} }); + stores.get(name)!.data[key] = value; + } + for (const [name, service] of apps) { + const storeSnapshot = stores.get(name); + if (storeSnapshot) service.store.restore(storeSnapshot); + restoreTokenMap(service.tokenMap, snapshot.tokens[name] ?? []); + } +} +async function rewriteResponse(response: Response, servicePrefix: string): Promise { + const contentType = response.headers.get("Content-Type") ?? ""; + const location = response.headers.get("Location"); + const locationChanged = location != null && location.startsWith("/"); + if (!contentType.includes("text/html")) { + if (!locationChanged) return response; + const headers = new Headers(response.headers); + headers.set("Location", servicePrefix + location); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + } + let html = await response.text(); + html = html.replace(/(action|href)="(\/[^\"]*?)"/g, (_match, attr, path) => + path.startsWith(servicePrefix) ? `${attr}="${path}"` : `${attr}="${servicePrefix}${path}"`, + ); + html = html.replace(/url\('(\/[^']*?)'\)/g, (_match, path) => + path.startsWith(servicePrefix) ? `url('${path}')` : `url('${servicePrefix}${path}')`, + ); + const headers = new Headers(response.headers); + if (locationChanged) headers.set("Location", servicePrefix + location); + headers.delete("Content-Length"); + return new Response(html, { status: response.status, statusText: response.statusText, headers }); +} +export function createAdapterRuntime( + config: AdapterHandlerConfig, + servicePath: (mountPath: string, service: string) => string, +) { + const { services: serviceEntries, persistence } = config; + let apps: Map | null = null; + let initPromise: Promise | null = null; + let preparationPromise: Promise | null = null; + let pendingSave: Promise = Promise.resolve(); + const needsDurableGeneratedIdentity = Object.values(serviceEntries).some( + (entry) => entry.seed && entry.emulator.needsGeneratedSecrets?.(entry.seed), + ); + async function prepareFreshState(restoredSecrets: readonly GeneratedSecret[] = [], strict = false) { + const seeds = new Map | undefined>(); + const generatedSecrets: GeneratedSecret[] = []; + for (const [name, entry] of Object.entries(serviceEntries)) { + if (entry.seed && entry.emulator.prepareSeed) { + const serviceSecrets = restoredSecrets + .filter((secret) => secret.service === name) + .map(({ service: _service, ...secret }) => secret); + const prepared = await entry.emulator.prepareSeed(entry.seed, serviceSecrets); + seeds.set(name, prepared.config); + generatedSecrets.push(...prepared.generatedSecrets.map((secret) => ({ service: name, ...secret }))); + } else seeds.set(name, entry.seed); + } + const restoredIdentities = new Set(restoredSecrets.map(secretIdentity)); + if (strict && generatedSecrets.some((secret) => !restoredIdentities.has(secretIdentity(secret)))) { + throw new Error("Cannot initialize emulator state without replacing generated identities"); + } + return { + snapshot: null, + seeds, + generatedSecrets: Object.freeze(generatedSecrets.map((secret) => Object.freeze(secret))), + }; + } + function getPreparation(): Promise { + if (!preparationPromise) { + const preparation = (async () => { + if (persistence) { + const raw = await persistence.load(); + if (raw) { + try { + const snapshot = parseSnapshot(raw); + const generatedSecrets = freezeSecrets(snapshot); + if (snapshot.seeded === false) return { ...(await prepareFreshState(generatedSecrets, true)), snapshot }; + return { snapshot, seeds: new Map(), generatedSecrets }; + } catch { + if (needsDurableGeneratedIdentity) { + throw new Error("Cannot restore persisted emulator state without replacing generated identities"); + } + } + } + } + const fresh = await prepareFreshState(); + if (fresh.generatedSecrets.length === 0 || !persistence) return fresh; + if (!persistence.initialize) { + throw new Error("Persistence adapter must implement initialize() for generated identities"); + } + const canonical = parseSnapshot( + await persistence.initialize( + JSON.stringify({ + store: { collections: {}, data: {} }, + tokens: {}, + generatedSecrets: [...fresh.generatedSecrets], + seeded: false, + } satisfies FullSnapshot), + ), + "Persistence initialize() returned an invalid snapshot", + ); + const generatedSecrets = freezeSecrets(canonical); + if (canonical.seeded !== false) return { snapshot: canonical, seeds: new Map(), generatedSecrets }; + return { ...(await prepareFreshState(generatedSecrets, true)), snapshot: canonical }; + })(); + preparationPromise = preparation; + void preparation.catch(() => { + if (preparationPromise === preparation) preparationPromise = null; + }); + } + return preparationPromise; + } + function enqueueSave( + targetApps: Map | null = apps, + generatedSecrets: readonly GeneratedSecret[] = [], + failClosed = false, + ): Promise { + if (!persistence || !targetApps) return pendingSave; + pendingSave = pendingSave + .catch(() => {}) + .then(async () => { + try { + await persistence.save(JSON.stringify(takeSnapshot(targetApps, generatedSecrets))); + } catch (error) { + if (failClosed) throw error; + debug("persistence", "save failed: %o", error); + } + }); + return pendingSave; + } + async function initApps(origin: string, mountPath: string): Promise> { + let prepared = await getPreparation(); + if (persistence && prepared.snapshot?.seeded === false) { + const raw = await persistence.load(); + if (raw) { + try { + const snapshot = parseSnapshot(raw); + if (snapshot.seeded !== false) { + prepared = { snapshot, seeds: new Map(), generatedSecrets: freezeSecrets(snapshot) }; + preparationPromise = Promise.resolve(prepared); + } + } catch { + throw new Error("Cannot restore persisted emulator state without replacing generated identities"); + } + } + } + const serviceApps = new Map(); + for (const [name, entry] of Object.entries(serviceEntries)) { + const plugin = resolvePlugin(entry.emulator); + const baseUrl = `${origin}${servicePath(mountPath, name)}`; + let appKeyResolver: AppKeyResolver | undefined; + const server = createServer(plugin, { + baseUrl, + appKeyResolver: entry.emulator.createAppKeyResolver ? (appId) => appKeyResolver!(appId) : undefined, + }); + if (entry.emulator.createAppKeyResolver) appKeyResolver = entry.emulator.createAppKeyResolver(server.store); + serviceApps.set(name, { ...server, plugin }); + } + let restored = prepared.snapshot !== null && prepared.snapshot.seeded !== false; + if (restored && prepared.snapshot) { + try { + restoreFromSnapshot(serviceApps, prepared.snapshot); + } catch { + if (needsDurableGeneratedIdentity || prepared.generatedSecrets.length > 0) { + throw new Error("Cannot restore persisted emulator state without replacing generated identities"); + } + restored = false; + prepared = await prepareFreshState(); + } + } + if (!restored) { + for (const [name, entry] of Object.entries(serviceEntries)) { + const service = serviceApps.get(name)!; + const baseUrl = `${origin}${servicePath(mountPath, name)}`; + service.plugin.seed?.(service.store, baseUrl); + const seed = prepared.seeds.get(name); + if (seed && entry.emulator.seedFromConfig) { + entry.emulator.seedFromConfig(service.store, baseUrl, seed, service.webhooks); + } + } + if (persistence) await enqueueSave(serviceApps, prepared.generatedSecrets, prepared.generatedSecrets.length > 0); + } + return serviceApps; + } + async function ensureInit(origin: string, mountPath: string): Promise> { + if (apps) return apps; + if (!initPromise) { + initPromise = initApps(origin, mountPath) + .then((result) => { + apps = result; + }) + .catch((error) => { + initPromise = null; + throw error; + }); + } + await initPromise; + return apps!; + } + async function handle(req: Request, pathSegments: string[], mountPath: string): Promise { + const serviceApps = await ensureInit(new URL(req.url).origin, mountPath); + if (pathSegments.length === 0) return new Response("Not found", { status: 404 }); + const serviceName = pathSegments[0]; + const service = serviceApps.get(serviceName); + if (!service) return new Response(`Unknown service: ${serviceName}`, { status: 404 }); + const url = new URL(req.url); + const strippedUrl = new URL("/" + pathSegments.slice(1).join("/") + url.search, url.origin); + const strippedReq = new Request(strippedUrl, { + method: req.method, + headers: req.headers, + body: req.body, + duplex: "half", + } as RequestInit & { duplex: string }); + const response = await rewriteResponse(await service.app.fetch(strippedReq), servicePath(mountPath, serviceName)); + if (persistence && MUTATING_METHODS.has(req.method)) { + enqueueSave(serviceApps, (await getPreparation()).generatedSecrets); + } + return response; + } + return { + handle, + async generatedSecrets(): Promise { + return (await getPreparation()).generatedSecrets; + }, + }; +} diff --git a/packages/@emulators/core/src/index.ts b/packages/@emulators/core/src/index.ts index 83c9c72a..5fa65f1a 100644 --- a/packages/@emulators/core/src/index.ts +++ b/packages/@emulators/core/src/index.ts @@ -82,3 +82,11 @@ export { registerFontRoutes } from "./fonts.js"; export { normalizeUri, matchesRedirectUri, constantTimeSecretEqual, bodyStr, parseCookies } from "./oauth-helpers.js"; export { debug } from "./debug.js"; export { type PersistenceAdapter, filePersistence } from "./persistence.js"; +export { + createAdapterRuntime, + type AdapterEmulatorModule, + type AdapterEmulatorEntry, + type AdapterHandlerConfig, + type PreparedServiceSeed, + type GeneratedSecret, +} from "./adapter-runtime.js"; diff --git a/packages/@emulators/core/src/persistence.ts b/packages/@emulators/core/src/persistence.ts index b3a681eb..51e12fe7 100644 --- a/packages/@emulators/core/src/persistence.ts +++ b/packages/@emulators/core/src/persistence.ts @@ -1,9 +1,11 @@ -import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { readFile, mkdir, open, link, rename, unlink } from "node:fs/promises"; import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; export interface PersistenceAdapter { load(): Promise; save(data: string): Promise; + initialize?(data: string): Promise; } export function filePersistence(path: string): PersistenceAdapter { @@ -17,7 +19,45 @@ export function filePersistence(path: string): PersistenceAdapter { }, async save(data: string) { await mkdir(dirname(path), { recursive: true }); - await writeFile(path, data, "utf-8"); + const temporaryPath = `${path}.${randomUUID()}.tmp`; + try { + const file = await open(temporaryPath, "wx", 0o600); + try { + await file.chmod(0o600); + await file.writeFile(data, "utf-8"); + await file.sync(); + } finally { + await file.close(); + } + await rename(temporaryPath, path); + } finally { + await unlink(temporaryPath).catch(() => {}); + } + }, + async initialize(data: string) { + await mkdir(dirname(path), { recursive: true }); + const temporaryPath = `${path}.${randomUUID()}.tmp`; + try { + const file = await open(temporaryPath, "wx", 0o600); + try { + await file.chmod(0o600); + await file.writeFile(data, "utf-8"); + await file.sync(); + } finally { + await file.close(); + } + try { + await link(temporaryPath, path); + return data; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + return await readFile(path, "utf-8"); + } + } catch (error) { + throw error; + } finally { + await unlink(temporaryPath).catch(() => {}); + } }, }; } diff --git a/packages/@emulators/github/README.md b/packages/@emulators/github/README.md index 1b58001e..b03a35aa 100644 --- a/packages/@emulators/github/README.md +++ b/packages/@emulators/github/README.md @@ -197,6 +197,8 @@ const privateKey = materialized.generatedPrivateKeys[0]?.private_key The `emulate` package performs this materialization automatically in `createEmulator` and exposes generated keys through `generatedSecrets`. The CLI can do the same when a private delivery file is requested: +The Next.js and Nuxt adapters also materialize omitted keys. Their returned server handlers expose `generatedSecrets()`, and persistence restores the same identity across cold starts. Keep persisted snapshots private because they contain the signing key. A custom persistence backend must implement atomic `initialize()` semantics when generated identities are used. + ```bash npx emulate start --service github --seed emulate.config.yaml \ --generated-secrets-file .emulate-secrets.json diff --git a/packages/@emulators/github/src/__tests__/webhook-installation.test.ts b/packages/@emulators/github/src/__tests__/webhook-installation.test.ts index 77665371..5b0f2f7d 100644 --- a/packages/@emulators/github/src/__tests__/webhook-installation.test.ts +++ b/packages/@emulators/github/src/__tests__/webhook-installation.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Hono } from "@emulators/core"; import { Store, WebhookDispatcher } from "@emulators/core"; import { authMiddleware, createApiErrorHandler, createErrorHandler, type TokenMap } from "@emulators/core"; -import { githubPlugin, seedFromConfig, getGitHubStore, materializeGitHubSeedConfig } from "../index.js"; +import { githubPlugin, seedFromConfig, getGitHubStore, materializeGitHubSeedConfig, prepareSeed } from "../index.js"; const base = "http://localhost:4000"; @@ -56,6 +56,31 @@ function authHeaders(): Record { } describe("GitHub App seed materialization", () => { + it("keeps restored keys and reports newly generated App keys", async () => { + const restored = await materializeGitHubSeedConfig({ apps: [{ app_id: 9, slug: "restored", name: "Restored" }] }); + const restoredSecret = { + kind: "github.app_private_key" as const, + id: "9", + label: "Restored", + value: restored.config.apps![0]!.private_key!, + }; + + const prepared = await prepareSeed( + { + apps: [ + { app_id: 9, slug: "restored", name: "Restored" }, + { app_id: 10, slug: "new", name: "New" }, + ], + }, + [restoredSecret], + ); + + expect(prepared.generatedSecrets).toEqual([ + restoredSecret, + expect.objectContaining({ kind: "github.app_private_key", id: "10", label: "New" }), + ]); + }); + it("generates omitted keys without changing explicit keys", async () => { const explicitKey = "explicit-key-bytes"; const seed = { diff --git a/packages/@emulators/github/src/index.ts b/packages/@emulators/github/src/index.ts index 392cd9a4..6ea5aac0 100644 --- a/packages/@emulators/github/src/index.ts +++ b/packages/@emulators/github/src/index.ts @@ -1,6 +1,14 @@ import { createHmac, generateKeyPair } from "crypto"; import type { Hono } from "@emulators/core"; -import type { ServicePlugin, Store, WebhookDispatcher, TokenMap, AppEnv, RouteContext } from "@emulators/core"; +import type { + ServicePlugin, + Store, + WebhookDispatcher, + TokenMap, + AppEnv, + RouteContext, + AppKeyResolver, +} from "@emulators/core"; import { getGitHubStore } from "./store.js"; import type { GitHubStore } from "./store.js"; import type { GitHubAppInstallation } from "./entities.js"; @@ -99,6 +107,16 @@ export interface MaterializedGitHubSeedConfig { generatedPrivateKeys: GeneratedGitHubAppPrivateKey[]; } +export interface PreparedGitHubSeedConfig { + config: Record; + generatedSecrets: Array<{ + kind: string; + id: string; + label: string; + value: string; + }>; +} + function generateAppPrivateKey(): Promise { return new Promise((resolve, reject) => { generateKeyPair( @@ -157,6 +175,62 @@ export async function materializeGitHubSeedConfig(config: GitHubSeedConfig): Pro }; } +export async function prepareSeed( + config: Record, + generatedSecrets: PreparedGitHubSeedConfig["generatedSecrets"] = [], +): Promise { + const restoredKeys = new Map( + generatedSecrets + .filter((secret) => secret.kind === "github.app_private_key") + .map((secret) => [secret.id, secret.value]), + ); + const restoredConfig: GitHubSeedConfig = { + ...config, + apps: (config.apps as GitHubSeedConfig["apps"] | undefined)?.map((app) => { + if (app.private_key !== undefined) return app; + const privateKey = restoredKeys.get(String(app.app_id)); + if (!privateKey) return app; + return { ...app, private_key: privateKey }; + }), + }; + const materialized = await materializeGitHubSeedConfig(restoredConfig); + const nextGeneratedSecrets = generatedSecrets.map((secret) => ({ ...secret })); + const generatedIds = new Set( + nextGeneratedSecrets.filter((secret) => secret.kind === "github.app_private_key").map((secret) => secret.id), + ); + for (const key of materialized.generatedPrivateKeys) { + const id = String(key.app_id); + if (generatedIds.has(id)) continue; + nextGeneratedSecrets.push({ + kind: "github.app_private_key", + id, + label: key.name, + value: key.private_key, + }); + } + return { + config: materialized.config as Record, + generatedSecrets: nextGeneratedSecrets, + }; +} + +export function needsGeneratedSecrets(config: Record): boolean { + return ((config.apps as GitHubSeedConfig["apps"] | undefined) ?? []).some((app) => app.private_key === undefined); +} + +export function createAppKeyResolver(store: Store): AppKeyResolver { + return (appId: number) => { + try { + const gh = getGitHubStore(store); + const ghApp = gh.apps.all().find((app) => app.app_id === appId); + if (!ghApp) return null; + return { privateKey: ghApp.private_key, slug: ghApp.slug, name: ghApp.name }; + } catch { + return null; + } + }; +} + function seedDefaults(store: Store, baseUrl: string): void { const gh = getGitHubStore(store); diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index 2b242074..69f18771 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -82,30 +82,8 @@ export const SERVICE_REGISTRY: Record = { return { plugin: mod.githubPlugin, seedFromConfig: mod.seedFromConfig, - async prepareSeed(config) { - const materialized = await mod.materializeGitHubSeedConfig(config); - return { - config: materialized.config as Record, - generatedSecrets: materialized.generatedPrivateKeys.map((key) => ({ - kind: "github.app_private_key", - id: String(key.app_id), - label: key.name, - value: key.private_key, - })), - }; - }, - createAppKeyResolver(store: Store): AppKeyResolver { - return (appId: number) => { - try { - const gh = mod.getGitHubStore(store); - const ghApp = gh.apps.all().find((a) => a.app_id === appId); - if (!ghApp) return null; - return { privateKey: ghApp.private_key, slug: ghApp.slug, name: ghApp.name }; - } catch { - return null; - } - }; - }, + prepareSeed: mod.prepareSeed, + createAppKeyResolver: mod.createAppKeyResolver, }; }, defaultFallback(cfg) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3017a110..104f6068 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,12 +536,18 @@ importers: specifier: workspace:* version: link:../core devDependencies: + '@emulators/github': + specifier: workspace:* + version: link:../github tsup: specifier: ^8 version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.7 version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(vite@8.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) packages/@emulators/adapter-nuxt: dependencies: @@ -549,12 +555,18 @@ importers: specifier: workspace:* version: link:../core devDependencies: + '@emulators/github': + specifier: workspace:* + version: link:../github tsup: specifier: ^8 version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.7 version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(vite@8.0.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) packages/@emulators/apple: dependencies: diff --git a/skills/emulate/SKILL.md b/skills/emulate/SKILL.md index 81a5e999..79053831 100644 --- a/skills/emulate/SKILL.md +++ b/skills/emulate/SKILL.md @@ -386,7 +386,7 @@ const kvAdapter: PersistenceAdapter = { } ``` -State is loaded on cold start and saved after every mutating request (POST, PUT, PATCH, DELETE). Saves are serialized to prevent race conditions. +State is loaded on cold start and saved after every mutating request (POST, PUT, PATCH, DELETE). Saves are serialized to prevent race conditions. Generated GitHub App identities require `initialize` to atomically create the initial value or return the value another instance created first. ## Architecture diff --git a/skills/github/SKILL.md b/skills/github/SKILL.md index 6a9322a6..9df4dcf0 100644 --- a/skills/github/SKILL.md +++ b/skills/github/SKILL.md @@ -53,6 +53,8 @@ const privateKey = github.generatedSecrets.find( The key remains stable across `github.reset()`. Explicit keys are not included in `generatedSecrets`. +The Next.js and Nuxt adapters also generate omitted keys. Retain the returned handler and call its server-only `generatedSecrets()` method. With persistence configured, the identity survives cold starts. Keep snapshots private because they contain the signing key, and require custom persistence backends to implement atomic `initialize()` semantics. + For the CLI, omit `private_key` only when requesting a private delivery file: ```bash diff --git a/skills/next/SKILL.md b/skills/next/SKILL.md index ce6ac745..b5e0b7ef 100644 --- a/skills/next/SKILL.md +++ b/skills/next/SKILL.md @@ -123,6 +123,8 @@ import { filePersistence } from '@emulators/core' persistence: filePersistence('.emulate/state.json'), ``` +GitHub App seeds may omit `private_key`. Retain the handler and call server-only `generatedSecrets()`; explicit keys are excluded. Keep persisted snapshots private and implement `initialize` atomically. + ### How Persistence Works - **Cold start**: The adapter loads state from the persistence adapter. If found, it restores the full Store and token map (skipping seed). If not found, it seeds from config and saves the initial state. @@ -140,7 +142,7 @@ persistence: filePersistence('.emulate/state.json'), ## Limitations - Requires the Node.js runtime (not Edge) since emulators use `crypto.randomBytes` -- Concurrent serverless instances writing to the same persistence adapter use last-write-wins semantics (acceptable for dev/preview traffic) +- Concurrent mutations use last-write-wins semantics. Generated identities require `initialize` to select the initial snapshot atomically across cold starts. ## Config Reference @@ -172,7 +174,8 @@ Wraps a Next.js config to include emulator font files in the serverless output t interface PersistenceAdapter { load(): Promise save(data: string): Promise + initialize?(data: string): Promise } ``` -The built-in `filePersistence(path)` from `@emulators/core` provides a file-based adapter for local development. +`initialize` must atomically create the initial value or return the value another instance created first. Implement it with compare-and-set semantics such as Redis `SET NX`. The built-in `filePersistence(path)` from `@emulators/core` provides this behavior for local development. diff --git a/skills/nuxt/SKILL.md b/skills/nuxt/SKILL.md index 6bea976f..4531ec33 100644 --- a/skills/nuxt/SKILL.md +++ b/skills/nuxt/SKILL.md @@ -112,6 +112,8 @@ import { filePersistence } from '@emulators/core' persistence: filePersistence('.emulate/state.json'), ``` +GitHub App seeds may omit `private_key`. Retain the handler and call server-only `generatedSecrets()`; explicit keys are excluded. Keep persisted snapshots private and implement `initialize` atomically. + ### How Persistence Works - **Cold start**: The adapter loads state from the persistence adapter. If found, it restores the full Store and token map. If not found, it seeds from config and saves the initial state. @@ -129,7 +131,7 @@ persistence: filePersistence('.emulate/state.json'), ## Limitations - Requires a Node-compatible Nuxt server runtime since emulators use Node APIs -- Concurrent serverless instances writing to the same persistence adapter use last write wins semantics, which is acceptable for dev and preview traffic +- Concurrent mutations use last-write-wins semantics. Generated identities require `initialize` to select the initial snapshot atomically across cold starts. ## Config Reference @@ -164,5 +166,8 @@ Wraps a Nuxt config to include `@emulators/core` assets in Nitro's production tr interface PersistenceAdapter { load(): Promise save(data: string): Promise + initialize?(data: string): Promise } ``` + +`initialize` must atomically create the initial value or return the value another instance created first. Implement it with compare-and-set semantics such as Redis `SET NX`. The built-in `filePersistence(path)` from `@emulators/core` provides this behavior for local development. diff --git a/tests/contracts/github-app-identity.d.ts b/tests/contracts/github-app-identity.d.ts new file mode 100644 index 00000000..d231a6f0 --- /dev/null +++ b/tests/contracts/github-app-identity.d.ts @@ -0,0 +1,17 @@ +export interface TestPersistence { + load(): Promise; + save(data: string): Promise; + initialize(data: string): Promise; +} +interface Handler { + generatedSecrets(): Promise; +} +interface Harness { + createEmulateHandler(config: Config): AdapterHandler; + config(persistence?: TestPersistence, privateKey?: string): Config; + createExplicitPrivateKey(): Promise; + requestApp(handler: AdapterHandler, authorization?: string, method?: "GET" | "POST"): Promise; +} +export function githubAppIdentityContract( + harness: Harness, +): void; diff --git a/tests/contracts/github-app-identity.js b/tests/contracts/github-app-identity.js new file mode 100644 index 00000000..299c2b57 --- /dev/null +++ b/tests/contracts/github-app-identity.js @@ -0,0 +1,217 @@ +import { sign } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +function jwt(privateKey) { + const now = Math.floor(Date.now() / 1000); + const unsigned = `${Buffer.from('{"alg":"RS256","typ":"JWT"}').toString("base64url")}.${Buffer.from( + JSON.stringify({ iat: now - 60, exp: now + 540, iss: "123" }), + ).toString("base64url")}`; + return `${unsigned}.${sign("RSA-SHA256", Buffer.from(unsigned), privateKey).toString("base64url")}`; +} +function memory(initial = null) { + let value = initial; + return { + async load() { + return value; + }, + async save(data) { + value = data; + }, + async initialize(data) { + if (value === null) value = data; + return value; + }, + read: () => value, + write(data) { + value = data; + }, + }; +} +export function githubAppIdentityContract(h) { + const create = (persistence, privateKey) => h.createEmulateHandler(h.config(persistence, privateKey)); + const key = async (handler) => (await handler.generatedSecrets())[0].value; + const auth = (handler, privateKey, method) => h.requestApp(handler, `Bearer ${jwt(privateKey)}`, method); + describe("embedded GitHub App identity", () => { + it("generates, persists, restores, and authenticates the same identity", async () => { + const persistence = memory(); + const first = create(persistence); + const generated = (await first.generatedSecrets())[0]; + expect(generated).toMatchObject({ + service: "github", + kind: "github.app_private_key", + id: "123", + label: "Embedded", + }); + expect(generated.value).toMatch(/^-----BEGIN RSA PRIVATE KEY-----/); + expect((await auth(first, generated.value)).status).toBe(200); + await vi.waitFor(() => expect(persistence.read()).not.toBeNull()); + expect(persistence.read()).toContain('"generatedSecrets"'); + const second = create(persistence); + expect(await key(second)).toBe(generated.value); + expect((await auth(second, generated.value)).status).toBe(200); + }); + it("authenticates explicit keys without reporting them as generated", async () => { + const privateKey = await h.createExplicitPrivateKey(); + const handler = create(undefined, privateKey); + expect(await handler.generatedSecrets()).toEqual([]); + expect((await auth(handler, privateKey)).status).toBe(200); + }); + it("loads snapshots without generated-secret metadata", async () => { + const source = memory(); + const first = create(source); + const privateKey = await key(first); + expect((await auth(first, privateKey)).status).toBe(200); + await vi.waitFor(() => expect(source.read()).not.toBeNull()); + const snapshot = JSON.parse(source.read()); + delete snapshot.generatedSecrets; + const restored = create(memory(JSON.stringify(snapshot))); + expect(await restored.generatedSecrets()).toEqual([]); + expect((await auth(restored, privateKey)).status).toBe(200); + }); + it("refuses corrupt persistence instead of rotating a generated identity", async () => { + await expect(create(memory("not json")).generatedSecrets()).rejects.toThrow( + "Cannot restore persisted emulator state without replacing generated identities", + ); + }); + it("fails initialization when a generated identity cannot be persisted", async () => { + const persistence = memory(); + let attempts = 0; + const handler = create({ + ...persistence, + async save(data) { + if (++attempts === 1) throw new Error("storage unavailable"); + await persistence.save(data); + }, + }); + const privateKey = await key(handler); + await expect(h.requestApp(handler)).rejects.toThrow("storage unavailable"); + expect((await auth(handler, privateKey)).status).toBe(200); + expect(await key(handler)).toBe(privateKey); + expect(attempts).toBe(2); + }); + it("retries generated identity preparation after initialize fails", async () => { + const persistence = memory(); + let attempts = 0; + const handler = create({ + ...persistence, + async initialize(data) { + if (++attempts === 1) throw new Error("storage unavailable"); + return persistence.initialize(data); + }, + }); + await expect(handler.generatedSecrets()).rejects.toThrow("storage unavailable"); + expect((await auth(handler, await key(handler))).status).toBe(200); + expect(attempts).toBe(2); + }); + it("recovers when initialize publishes identity before losing its response", async () => { + const persistence = memory(); + let attempts = 0; + const handler = create({ + ...persistence, + async initialize(data) { + const canonical = await persistence.initialize(data); + if (++attempts === 1) throw new Error("response lost"); + return canonical; + }, + }); + await expect(handler.generatedSecrets()).rejects.toThrow("response lost"); + const publishedKey = JSON.parse(persistence.read()).generatedSecrets[0].value; + expect(await key(handler)).toBe(publishedKey); + expect((await auth(handler, publishedKey)).status).toBe(200); + }); + it("recovers when save publishes seeded state before losing its response", async () => { + const persistence = memory(); + let attempts = 0; + const handler = create({ + ...persistence, + async save(data) { + await persistence.save(data); + if (++attempts === 1) throw new Error("response lost"); + }, + }); + const privateKey = await key(handler); + await expect(h.requestApp(handler)).rejects.toThrow("response lost"); + expect((await auth(handler, privateKey)).status).toBe(200); + expect(await key(handler)).toBe(privateKey); + }); + it("recovers the save queue after a later mutating save fails", async () => { + const persistence = memory(); + let attempts = 0; + const handler = create({ + ...persistence, + async save(data) { + if (++attempts === 2) throw new Error("storage unavailable"); + await persistence.save(data); + }, + }); + const privateKey = await key(handler); + expect((await auth(handler, privateKey)).status).toBe(200); + await auth(handler, privateKey, "POST"); + await vi.waitFor(() => expect(attempts).toBe(2)); + await auth(handler, privateKey, "POST"); + await vi.waitFor(() => expect(attempts).toBe(3)); + expect(await key(handler)).toBe(privateKey); + }); + it("retries after a transient persistence load failure", async () => { + const persistence = memory(); + let attempts = 0; + const handler = create({ + ...persistence, + async load() { + if (++attempts === 1) throw new Error("storage unavailable"); + return persistence.load(); + }, + }); + await expect(handler.generatedSecrets()).rejects.toThrow("storage unavailable"); + expect((await auth(handler, await key(handler))).status).toBe(200); + }); + it("rejects a malformed canonical identity snapshot", async () => { + const persistence = memory(); + persistence.initialize = async () => '{"store":{"collections":[],"data":{}},"tokens":{}}'; + await expect(create(persistence).generatedSecrets()).rejects.toThrow(); + }); + it("refuses a persisted canonical snapshot that omits the configured identity", async () => { + const persistence = memory(); + const initialize = persistence.initialize; + persistence.initialize = async (data) => { + const canonical = JSON.parse(data); + canonical.generatedSecrets[0].service = "other"; + return initialize(JSON.stringify(canonical)); + }; + for (let attempt = 0; attempt < 2; attempt++) + await expect(create(persistence).generatedSecrets()).rejects.toThrow(/generated identities/); + }); + it("re-reads a seeded snapshot after generatedSecrets prepares identity", async () => { + const persistence = memory(); + const stale = create(persistence); + await stale.generatedSecrets(); + const winner = create(persistence); + const privateKey = await key(winner); + expect((await auth(winner, privateKey)).status).toBe(200); + const canonical = JSON.parse(persistence.read()); + canonical.generatedSecrets[0].label = "Canonical"; + persistence.write(JSON.stringify(canonical)); + expect((await auth(stale, privateKey)).status).toBe(200); + await auth(stale, privateKey, "POST"); + expect(JSON.parse(persistence.read()).generatedSecrets[0].label).toBe("Canonical"); + }); + it("selects one identity across concurrent cold starts", async () => { + const persistence = memory(); + const handlers = Array.from({ length: 8 }, () => create(persistence)); + const privateKeys = await Promise.all(handlers.map(key)); + expect(new Set(privateKeys)).toEqual(new Set([privateKeys[0]])); + expect(await Promise.all(handlers.map(async (handler) => (await auth(handler, privateKeys[0])).status))).toEqual( + Array(8).fill(200), + ); + }); + it("never exposes private keys through GitHub HTTP responses", async () => { + const handler = create(); + const privateKey = await key(handler); + const response = await auth(handler, privateKey); + const serialized = `${JSON.stringify(Object.fromEntries(response.headers))}\n${await response.text()}`; + expect(response.status).toBe(200); + expect(serialized).not.toContain("BEGIN RSA PRIVATE KEY"); + expect(serialized).not.toContain(privateKey); + expect(serialized).not.toContain(Buffer.from(privateKey).toString("base64")); + }); + }); +}