678 lines
26 KiB
TypeScript
678 lines
26 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import crypto, { type KeyObject } from "node:crypto";
|
|
import http, { type Server } from "node:http";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "node:test";
|
|
|
|
import {
|
|
canonicalRuntimeJson,
|
|
createRuntimeRequestPayload,
|
|
sha256Base64Url,
|
|
type LocalRuntimeModuleDescriptor,
|
|
} from "../src/local-runtime-contract.js";
|
|
import { createLocalRuntimeApp, type LocalRuntimeModule } from "../src/local-runtime.js";
|
|
import { createDreaminaHttpModule } from "../src/modules/dreamina-http.js";
|
|
import {
|
|
LocalRuntimeSessionManager,
|
|
type RuntimeBrowserRegistration,
|
|
} from "../src/local-runtime-session.js";
|
|
|
|
const origin = "http://127.0.0.1:3001";
|
|
const hostileOrigin = "https://hostile.example";
|
|
const authority = "127.0.0.1:41742";
|
|
const endpoint = `http://${authority}`;
|
|
const runtimeInstanceId = "runtime-http-fixture";
|
|
const now = Date.parse("2026-08-10T00:00:00.000Z");
|
|
|
|
test("runtime info has exact Host and route-scoped CORS/PNA without exposing modules", async () => {
|
|
await withRuntime(async ({ server }) => {
|
|
const wrongHost = await request(server, {
|
|
path: "/runtime/info",
|
|
headers: { Host: "localhost:41742", Origin: hostileOrigin },
|
|
});
|
|
assert.equal(wrongHost.status, 421);
|
|
assert.equal(wrongHost.headers["access-control-allow-origin"], undefined);
|
|
|
|
const info = await request(server, {
|
|
path: "/runtime/info",
|
|
headers: { Host: authority, Origin: hostileOrigin },
|
|
});
|
|
assert.equal(info.status, 200);
|
|
assert.equal(info.headers["access-control-allow-origin"], hostileOrigin);
|
|
assert.equal(info.headers["access-control-allow-credentials"], undefined);
|
|
assert.equal(info.headers["cache-control"], "no-store, max-age=0");
|
|
assert.deepEqual(JSON.parse(info.body), {
|
|
runtime: "framefield-local-runtime",
|
|
apiVersion: 2,
|
|
protocolVersion: "framefield-runtime-session-v1",
|
|
runtimeInstanceId,
|
|
originTrusted: false,
|
|
});
|
|
assert.equal(info.body.includes("module"), false);
|
|
assert.equal(info.body.includes("token"), false);
|
|
|
|
const preflight = await request(server, {
|
|
method: "OPTIONS",
|
|
path: "/runtime/info",
|
|
headers: {
|
|
Host: authority,
|
|
Origin: hostileOrigin,
|
|
"Access-Control-Request-Method": "GET",
|
|
"Access-Control-Request-Private-Network": "true",
|
|
},
|
|
});
|
|
assert.equal(preflight.status, 204);
|
|
assert.equal(preflight.headers["access-control-allow-private-network"], "true");
|
|
|
|
const forbidden = await request(server, {
|
|
method: "POST",
|
|
path: "/runtime/session/challenge",
|
|
headers: jsonHeaders(hostileOrigin),
|
|
body: "{}",
|
|
});
|
|
assert.equal(forbidden.status, 403);
|
|
assert.equal(forbidden.headers["access-control-allow-origin"], undefined);
|
|
});
|
|
});
|
|
|
|
test("route-scoped CORS keeps every method registered on the same path", async () => {
|
|
await withRuntime(async ({ server }) => {
|
|
const preflight = await request(server, {
|
|
method: "OPTIONS",
|
|
path: "/dreamina/tasks",
|
|
headers: {
|
|
Host: authority,
|
|
Origin: origin,
|
|
"Access-Control-Request-Method": "POST",
|
|
"Access-Control-Request-Headers": "content-type,x-framefield-runtime-session,x-framefield-runtime-timestamp,x-framefield-runtime-nonce,x-framefield-runtime-proof",
|
|
},
|
|
});
|
|
assert.equal(preflight.status, 204);
|
|
assert.equal(preflight.headers["access-control-allow-methods"], "GET,POST");
|
|
assert.equal(preflight.headers["access-control-allow-origin"], origin);
|
|
assert.equal(preflight.headers["access-control-allow-headers"], "x-framefield-runtime-session,x-framefield-runtime-timestamp,x-framefield-runtime-nonce,x-framefield-runtime-proof,content-type");
|
|
});
|
|
});
|
|
|
|
test("only an exact trusted Origin can silently establish a signed browser session", async () => {
|
|
await withRuntime(async ({ server }) => {
|
|
const key = browserKey();
|
|
const challenge = await challengeRequest(server, key.publicJwk);
|
|
assert.equal(challenge.state, "challenge");
|
|
assert.equal(JSON.stringify(challenge).includes("pair"), false);
|
|
const session = await exchangeRequest(server, key.privateKey, challenge);
|
|
assert.equal(session.keyId, challenge.keyId);
|
|
|
|
for (const rejectedOrigin of [undefined, "null", hostileOrigin]) {
|
|
const rejected = await request(server, {
|
|
method: "POST",
|
|
path: "/runtime/session/challenge",
|
|
headers: {
|
|
Host: authority,
|
|
...(rejectedOrigin ? { Origin: rejectedOrigin } : {}),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ publicKeyJwk: browserKey().publicJwk }),
|
|
});
|
|
assert.equal(rejected.status, 403);
|
|
assert.equal(rejected.headers.location, undefined);
|
|
}
|
|
|
|
for (const method of ["GET", "POST"]) {
|
|
const removed = await request(server, { method, path: `/runtime/pair/${challenge.challengeId}`, headers: { Host: authority } });
|
|
assert.equal(removed.status, 404);
|
|
}
|
|
});
|
|
});
|
|
|
|
test("signed protected route maps its own scope and rejects replay or substituted paths before module invocation", async () => {
|
|
let calls = 0;
|
|
await withRuntime(async ({ server, manager }) => {
|
|
const key = browserKey();
|
|
const challenge = await challengeRequest(server, key.publicJwk);
|
|
const session = await exchangeRequest(server, key.privateKey, challenge);
|
|
const signed = signedHeaders(key.privateKey, session, "GET", "/dreamina/status", Buffer.alloc(0));
|
|
|
|
const accepted = await request(server, {
|
|
path: "/dreamina/status",
|
|
headers: { Host: authority, Origin: origin, ...signed },
|
|
});
|
|
assert.equal(accepted.status, 200);
|
|
assert.equal(calls, 1);
|
|
|
|
const replay = await request(server, {
|
|
path: "/dreamina/status",
|
|
headers: { Host: authority, Origin: origin, ...signed },
|
|
});
|
|
assert.equal(replay.status, 409);
|
|
assert.equal(calls, 1);
|
|
|
|
const freshDreaminaProof = signedHeaders(
|
|
key.privateKey,
|
|
session,
|
|
"GET",
|
|
"/dreamina/status",
|
|
Buffer.alloc(0),
|
|
);
|
|
const substituted = await request(server, {
|
|
path: "/runtime/status",
|
|
headers: { Host: authority, Origin: origin, ...freshDreaminaProof },
|
|
});
|
|
assert.equal(substituted.status, 401);
|
|
assert.equal(calls, 1);
|
|
}, () => {
|
|
calls += 1;
|
|
});
|
|
});
|
|
|
|
test("Dreamina effect routes require a signed dreamina:generate request before invocation", async () => {
|
|
let calls = 0;
|
|
await withRuntime(async ({ server }) => {
|
|
const key = browserKey();
|
|
const challenge = await challengeRequest(server, key.publicJwk);
|
|
const session = await exchangeRequest(server, key.privateKey, challenge);
|
|
const path = "/dreamina/generate/effects/claim";
|
|
const body = Buffer.from(JSON.stringify({
|
|
consumerId: "web-generation-materializer",
|
|
taskId: "dreamina:signed-effect-task-0001",
|
|
effectKey: "materialize:dreamina:signed-effect-task-0001:0",
|
|
}));
|
|
|
|
const unsigned = await request(server, {
|
|
method: "POST",
|
|
path,
|
|
headers: jsonHeaders(origin),
|
|
body: body.toString("utf8"),
|
|
});
|
|
assert.equal(unsigned.status, 401);
|
|
assert.equal(calls, 0);
|
|
|
|
const substitutedBody = Buffer.from(JSON.stringify({ consumerId: "substituted" }));
|
|
const substituted = await request(server, {
|
|
method: "POST",
|
|
path,
|
|
headers: {
|
|
...jsonHeaders(origin),
|
|
...signedHeaders(key.privateKey, session, "POST", path, body),
|
|
},
|
|
body: substitutedBody.toString("utf8"),
|
|
});
|
|
assert.equal(substituted.status, 401);
|
|
assert.equal(calls, 0);
|
|
|
|
const accepted = await request(server, {
|
|
method: "POST",
|
|
path,
|
|
headers: {
|
|
...jsonHeaders(origin),
|
|
...signedHeaders(key.privateKey, session, "POST", path, body),
|
|
},
|
|
body: body.toString("utf8"),
|
|
});
|
|
assert.equal(accepted.status, 200);
|
|
assert.equal(calls, 1);
|
|
}, undefined, () => {
|
|
calls += 1;
|
|
});
|
|
});
|
|
|
|
test("production Dreamina effect claim composes signing scope and strict JSON in the real runtime app", async () => {
|
|
await withProductionDreaminaRuntime(async ({ server }) => {
|
|
const key = browserKey();
|
|
const challenge = await challengeRequest(server, key.publicJwk);
|
|
const session = await exchangeRequest(server, key.privateKey, challenge);
|
|
const route = "/dreamina/generate/effects/claim";
|
|
const body = Buffer.from(JSON.stringify({
|
|
consumerId: "web-generation-materializer",
|
|
taskId: "dreamina:dreamina-http-effect-task-0001",
|
|
effectKey: "materialize:dreamina:dreamina-http-effect-task-0001:0",
|
|
}));
|
|
|
|
const substitutedBody = Buffer.from(JSON.stringify({
|
|
consumerId: "web-generation-materializer",
|
|
taskId: "dreamina:dreamina-http-effect-task-0001",
|
|
effectKey: "materialize:dreamina:dreamina-http-effect-task-0001:0",
|
|
unexpected: true,
|
|
}));
|
|
const substituted = await request(server, {
|
|
method: "POST",
|
|
path: route,
|
|
headers: {
|
|
...jsonHeaders(origin),
|
|
...signedHeaders(key.privateKey, session, "POST", route, body),
|
|
},
|
|
body: substitutedBody.toString("utf8"),
|
|
});
|
|
assert.equal(substituted.status, 401);
|
|
|
|
const unexpected = await request(server, {
|
|
method: "POST",
|
|
path: route,
|
|
headers: {
|
|
...jsonHeaders(origin),
|
|
...signedHeaders(key.privateKey, session, "POST", route, substitutedBody),
|
|
},
|
|
body: substitutedBody.toString("utf8"),
|
|
});
|
|
assert.equal(unexpected.status, 400);
|
|
assert.equal((JSON.parse(unexpected.body) as { code?: string }).code, "dreamina_request_invalid");
|
|
|
|
const accepted = await request(server, {
|
|
method: "POST",
|
|
path: route,
|
|
headers: {
|
|
...jsonHeaders(origin),
|
|
...signedHeaders(key.privateKey, session, "POST", route, body),
|
|
},
|
|
body: body.toString("utf8"),
|
|
});
|
|
assert.equal(accepted.status, 200);
|
|
assert.equal((JSON.parse(accepted.body) as { result?: { status?: string } }).result?.status, "claimed");
|
|
});
|
|
|
|
await withProductionDreaminaRuntime(async ({ server }) => {
|
|
const key = browserKey();
|
|
const challenge = await challengeRequest(server, key.publicJwk);
|
|
const session = await exchangeRequest(server, key.privateKey, challenge);
|
|
assert.equal(session.scopes.includes("dreamina:generate"), false);
|
|
const route = "/dreamina/generate/effects/claim";
|
|
const body = Buffer.from(JSON.stringify({
|
|
consumerId: "web-generation-materializer",
|
|
taskId: "dreamina:dreamina-http-effect-task-0001",
|
|
effectKey: "materialize:dreamina:dreamina-http-effect-task-0001:0",
|
|
}));
|
|
const denied = await request(server, {
|
|
method: "POST",
|
|
path: route,
|
|
headers: {
|
|
...jsonHeaders(origin),
|
|
...signedHeaders(key.privateKey, session, "POST", route, body),
|
|
},
|
|
body: body.toString("utf8"),
|
|
});
|
|
assert.equal(denied.status, 403);
|
|
assert.equal((JSON.parse(denied.body) as { code?: string }).code, "scope_denied");
|
|
}, ["dreamina:status"]);
|
|
});
|
|
|
|
test("production Dreamina task list accepts the signed canonical pagination query", async () => {
|
|
await withProductionDreaminaRuntime(async ({ server }) => {
|
|
const key = browserKey();
|
|
const challenge = await challengeRequest(server, key.publicJwk);
|
|
const session = await exchangeRequest(server, key.privateKey, challenge);
|
|
const route = "/dreamina/generate/tasks?activeOnly=true&limit=100";
|
|
const response = await request(server, {
|
|
path: route,
|
|
headers: {
|
|
Host: authority,
|
|
Origin: origin,
|
|
...signedHeaders(key.privateKey, session, "GET", route, Buffer.alloc(0)),
|
|
},
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
const body = JSON.parse(response.body) as { ok?: boolean; result?: { tasks?: unknown[] } };
|
|
assert.equal(body.ok, true);
|
|
assert.equal(Array.isArray(body.result?.tasks), true);
|
|
});
|
|
});
|
|
|
|
test("signed session and registration revoke invalidate the intended authorization", async () => {
|
|
await withRuntime(async ({ server, manager }) => {
|
|
const key = browserKey();
|
|
const challenge = await challengeRequest(server, key.publicJwk);
|
|
const firstSession = await exchangeRequest(server, key.privateKey, challenge);
|
|
const emptyBody = Buffer.from("{}");
|
|
|
|
const revokeSession = await request(server, {
|
|
method: "POST",
|
|
path: "/runtime/session/revoke",
|
|
headers: {
|
|
...jsonHeaders(origin),
|
|
...signedHeaders(key.privateKey, firstSession, "POST", "/runtime/session/revoke", emptyBody),
|
|
},
|
|
body: emptyBody.toString("utf8"),
|
|
});
|
|
assert.equal(revokeSession.status, 200);
|
|
assert.deepEqual(JSON.parse(revokeSession.body), { ok: true });
|
|
assert.equal(revokeSession.headers["access-control-allow-origin"], origin);
|
|
|
|
const rejectedSession = await request(server, {
|
|
path: "/runtime/status",
|
|
headers: {
|
|
Host: authority,
|
|
Origin: origin,
|
|
...signedHeaders(key.privateKey, firstSession, "GET", "/runtime/status", Buffer.alloc(0)),
|
|
},
|
|
});
|
|
assert.equal(rejectedSession.status, 401);
|
|
|
|
const secondChallenge = await challengeRequest(server, undefined, firstSession.keyId);
|
|
const secondSession = await exchangeRequest(server, key.privateKey, secondChallenge);
|
|
const thirdChallenge = await challengeRequest(server, undefined, firstSession.keyId);
|
|
const thirdSession = await exchangeRequest(server, key.privateKey, thirdChallenge);
|
|
const revokeRegistration = await request(server, {
|
|
method: "POST",
|
|
path: "/runtime/session/registration/revoke",
|
|
headers: {
|
|
...jsonHeaders(origin),
|
|
...signedHeaders(key.privateKey, secondSession, "POST", "/runtime/session/registration/revoke", emptyBody),
|
|
},
|
|
body: emptyBody.toString("utf8"),
|
|
});
|
|
assert.equal(revokeRegistration.status, 200);
|
|
assert.deepEqual(JSON.parse(revokeRegistration.body), { ok: true });
|
|
|
|
const rejectedSiblingSession = await request(server, {
|
|
path: "/runtime/status",
|
|
headers: {
|
|
Host: authority,
|
|
Origin: origin,
|
|
...signedHeaders(key.privateKey, thirdSession, "GET", "/runtime/status", Buffer.alloc(0)),
|
|
},
|
|
});
|
|
assert.equal(rejectedSiblingSession.status, 401);
|
|
|
|
const rejectedRegistration = await request(server, {
|
|
method: "POST",
|
|
path: "/runtime/session/challenge",
|
|
headers: jsonHeaders(origin),
|
|
body: JSON.stringify({ keyId: firstSession.keyId }),
|
|
});
|
|
assert.equal(rejectedRegistration.status, 404);
|
|
assert.equal(JSON.parse(rejectedRegistration.body).code, "registration_not_found");
|
|
});
|
|
});
|
|
|
|
async function withProductionDreaminaRuntime(
|
|
run: (fixture: { server: Server; manager: LocalRuntimeSessionManager }) => Promise<void>,
|
|
scopes?: ConstructorParameters<typeof LocalRuntimeSessionManager>[0]["scopes"],
|
|
) {
|
|
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "dreamina-production-http-"));
|
|
const idempotencyKey = "dreamina-http-effect-task-0001";
|
|
await fs.writeFile(path.join(configDir, "dreamina-runtime-state.json"), JSON.stringify({
|
|
version: 1,
|
|
records: [{
|
|
ownerId: "owner-production-http-0001",
|
|
idempotencyKey,
|
|
requestHash: "a".repeat(64),
|
|
state: "accepted",
|
|
updatedAt: "2026-08-13T00:00:00.000Z",
|
|
submitId: "opaque-fixture-id",
|
|
taskVersion: 1,
|
|
operation: "text2video",
|
|
mode: "video",
|
|
model: "seedance2.0mini",
|
|
createdAt: "2026-08-13T00:00:00.000Z",
|
|
}],
|
|
}));
|
|
const registrations: RuntimeBrowserRegistration[] = [];
|
|
const manager = new LocalRuntimeSessionManager({
|
|
endpoint,
|
|
runtimeInstanceId,
|
|
trustedOrigins: [origin],
|
|
registrations,
|
|
now: () => now,
|
|
scopes,
|
|
});
|
|
const task = (id: string) => ({
|
|
id,
|
|
provider: "dreamina-cli" as const,
|
|
mode: "video" as const,
|
|
operation: "text2video" as const,
|
|
model: "seedance2.0mini",
|
|
status: "running" as const,
|
|
stage: "submitted" as const,
|
|
progress: 10,
|
|
receiptRecorded: true,
|
|
createdAt: "2026-08-13T00:00:00.000Z",
|
|
updatedAt: "2026-08-13T00:00:00.000Z",
|
|
});
|
|
const module = createDreaminaHttpModule({
|
|
ownerId: "owner-production-http-0001",
|
|
configDir,
|
|
dreaminaRuntime: {
|
|
run: async () => ({ state: "accepted" as const, submitId: "opaque-fixture-id" }),
|
|
generateToResult: async () => ({ mode: "video" as const, video: { dataUrl: "data:video/mp4;base64,AA==", mimeType: "video/mp4", bytes: 1 } }),
|
|
resumeToResult: async () => ({ mode: "video" as const, video: { dataUrl: "data:video/mp4;base64,AA==", mimeType: "video/mp4", bytes: 1 } }),
|
|
getTask: async (id: string) => task(id),
|
|
waitForTask: async () => ({ mode: "video" as const, video: { dataUrl: "data:video/mp4;base64,AA==", mimeType: "video/mp4", bytes: 1 } }),
|
|
refreshTask: async (id: string) => task(id),
|
|
listTasks: async () => [task(idempotencyKey)],
|
|
cancelTask: async (id: string) => ({ ...task(id), status: "cancelled" as const, stage: "cancelled" as const }),
|
|
deleteTask: async () => ({ deleted: true as const }),
|
|
enqueue: async () => task(idempotencyKey),
|
|
},
|
|
});
|
|
const app = createLocalRuntimeApp({
|
|
authority,
|
|
endpoint,
|
|
version: "0.1.0",
|
|
sessionManager: manager,
|
|
modules: [module],
|
|
});
|
|
const server = app.listen(0, "127.0.0.1");
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("listening", resolve);
|
|
server.once("error", reject);
|
|
});
|
|
try {
|
|
await run({ server, manager });
|
|
} finally {
|
|
manager.dispose();
|
|
await module.dispose?.();
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
await fs.rm(configDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function withRuntime(
|
|
run: (fixture: { server: Server; manager: LocalRuntimeSessionManager }) => Promise<void>,
|
|
onDreaminaStatus: (() => void) | undefined = () => undefined,
|
|
onDreaminaEffect: () => void = () => undefined,
|
|
) {
|
|
const registrations: RuntimeBrowserRegistration[] = [];
|
|
const manager = new LocalRuntimeSessionManager({
|
|
endpoint,
|
|
runtimeInstanceId,
|
|
trustedOrigins: [origin],
|
|
registrations,
|
|
now: () => now,
|
|
});
|
|
const descriptor: LocalRuntimeModuleDescriptor = {
|
|
id: "dreamina",
|
|
displayName: "Dreamina CLI",
|
|
apiVersion: 1,
|
|
scopes: ["dreamina:status", "dreamina:generate"],
|
|
};
|
|
const module: LocalRuntimeModule = {
|
|
descriptor,
|
|
routes: [
|
|
{
|
|
method: "GET",
|
|
path: "/dreamina/status",
|
|
scope: "dreamina:status",
|
|
handler: (_req, res) => {
|
|
onDreaminaStatus?.();
|
|
res.json({ ok: true });
|
|
},
|
|
},
|
|
{
|
|
method: "GET",
|
|
path: "/dreamina/tasks",
|
|
scope: "dreamina:status",
|
|
handler: (_req, res) => res.json({ ok: true }),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/dreamina/tasks",
|
|
scope: "dreamina:generate",
|
|
handler: (_req, res) => res.json({ ok: true }),
|
|
},
|
|
{
|
|
method: "POST",
|
|
path: "/dreamina/generate/effects/claim",
|
|
scope: "dreamina:generate",
|
|
handler: (_req, res) => {
|
|
onDreaminaEffect();
|
|
res.json({ ok: true });
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const app = createLocalRuntimeApp({
|
|
authority,
|
|
endpoint,
|
|
version: "0.1.0",
|
|
sessionManager: manager,
|
|
modules: [module],
|
|
});
|
|
const server = app.listen(0, "127.0.0.1");
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once("listening", resolve);
|
|
server.once("error", reject);
|
|
});
|
|
try {
|
|
await run({ server, manager });
|
|
} finally {
|
|
manager.dispose();
|
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
}
|
|
}
|
|
|
|
async function challengeRequest(server: Server, publicKeyJwk?: JsonWebKey, keyId?: string) {
|
|
const response = await request(server, {
|
|
method: "POST",
|
|
path: "/runtime/session/challenge",
|
|
headers: jsonHeaders(origin),
|
|
body: JSON.stringify(publicKeyJwk ? { publicKeyJwk } : { keyId }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
return JSON.parse(response.body) as {
|
|
state: "challenge";
|
|
challengeId: string;
|
|
nonce: string;
|
|
runtimeInstanceId: string;
|
|
expiresAt: string;
|
|
keyId: string;
|
|
};
|
|
}
|
|
|
|
async function exchangeRequest(
|
|
server: Server,
|
|
privateKey: KeyObject,
|
|
challenge: Awaited<ReturnType<typeof challengeRequest>>,
|
|
) {
|
|
const signature = crypto.sign("sha256", Buffer.from(canonicalRuntimeJson({
|
|
protocol: "framefield-runtime-session-v1",
|
|
challengeId: challenge.challengeId,
|
|
nonce: challenge.nonce,
|
|
origin,
|
|
endpoint,
|
|
runtimeInstanceId,
|
|
expiresAt: challenge.expiresAt,
|
|
})), { key: privateKey, dsaEncoding: "ieee-p1363" }).toString("base64url");
|
|
const response = await request(server, {
|
|
method: "POST",
|
|
path: "/runtime/session/exchange",
|
|
headers: jsonHeaders(origin),
|
|
body: JSON.stringify({ challengeId: challenge.challengeId, signature }),
|
|
});
|
|
assert.equal(response.status, 200);
|
|
return JSON.parse(response.body) as {
|
|
sessionId: string;
|
|
keyId: string;
|
|
scopes: string[];
|
|
expiresAt: string;
|
|
};
|
|
}
|
|
|
|
function signedHeaders(
|
|
privateKey: KeyObject,
|
|
session: { sessionId: string; keyId: string; expiresAt: string },
|
|
method: string,
|
|
pathAndQuery: string,
|
|
body: Buffer,
|
|
) {
|
|
const requestNonce = crypto.randomBytes(16).toString("base64url");
|
|
const payload = createRuntimeRequestPayload({
|
|
sessionId: session.sessionId,
|
|
keyId: session.keyId,
|
|
method,
|
|
pathAndQuery,
|
|
bodySha256: sha256Base64Url(body),
|
|
lastEventId: null,
|
|
origin,
|
|
endpoint,
|
|
runtimeInstanceId,
|
|
requestNonce,
|
|
timestamp: now,
|
|
sessionExpiresAt: session.expiresAt,
|
|
});
|
|
return {
|
|
"X-Framefield-Runtime-Session": session.sessionId,
|
|
"X-Framefield-Runtime-Timestamp": String(now),
|
|
"X-Framefield-Runtime-Nonce": requestNonce,
|
|
"X-Framefield-Runtime-Proof": crypto.sign(
|
|
"sha256",
|
|
Buffer.from(canonicalRuntimeJson(payload)),
|
|
{ key: privateKey, dsaEncoding: "ieee-p1363" },
|
|
).toString("base64url"),
|
|
};
|
|
}
|
|
|
|
function browserKey() {
|
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" });
|
|
const exported = publicKey.export({ format: "jwk" });
|
|
return {
|
|
privateKey,
|
|
publicJwk: {
|
|
kty: exported.kty,
|
|
crv: exported.crv,
|
|
x: exported.x,
|
|
y: exported.y,
|
|
ext: true,
|
|
key_ops: ["verify"],
|
|
} satisfies JsonWebKey,
|
|
};
|
|
}
|
|
|
|
function jsonHeaders(requestOrigin: string) {
|
|
return { Host: authority, Origin: requestOrigin, "Content-Type": "application/json" };
|
|
}
|
|
|
|
function request(
|
|
server: Server,
|
|
options: {
|
|
method?: string;
|
|
path: string;
|
|
headers?: Record<string, string>;
|
|
body?: string;
|
|
},
|
|
) {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") throw new Error("fixture server is not listening");
|
|
return new Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }>((resolve, reject) => {
|
|
const req = http.request({
|
|
hostname: "127.0.0.1",
|
|
port: address.port,
|
|
method: options.method ?? "GET",
|
|
path: options.path,
|
|
headers: options.headers,
|
|
}, (res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
res.on("end", () => resolve({
|
|
status: res.statusCode ?? 0,
|
|
headers: res.headers,
|
|
body: Buffer.concat(chunks).toString("utf8"),
|
|
}));
|
|
});
|
|
req.once("error", reject);
|
|
if (options.body) req.write(options.body);
|
|
req.end();
|
|
});
|
|
}
|