be70f56657
Wave A (Phase 1) implements the M1 foundations for REQ-038, REQ-039, REQ-040: Monorepo scaffold (pnpm workspaces, TS strict, NodeNext): - apps/control-plane (Next.js App Router scaffold — Wave B adds routes) - apps/relay-agent (Go module scaffold — Wave D adds WebSocket + install) - packages/db, packages/secrets, packages/runtime, packages/config packages/db (REQ-038, REQ-039): - 0001_init.sql: tenants, users, tenant_memberships, targets, byom_endpoints, invitations, audit_log (append-only hash-chain), runtime_health (not audited) - RLS policies on all tenant-scoped tables + FORCE ROW LEVEL SECURITY - withTenant(tenantId, fn): SET LOCAL app.tenant_id + transaction - audit.ts: appendAudit with sha256(prev_hash || canonical(payload)) chain; AuditWriteHaltError on write failure (Edge 7 — halts + rolls back) - BEFORE INSERT trigger enforces chain integrity (G-006: per-tenant concurrent-write serialization documented; M3 mitigation noted) - create-db.ts: PGlite (dev/test) + pg Pool (prod) behind DbClient interface - pen-test scaffold: cross-tenant isolation (PGlite RLS limitation documented; prod RLS test runs at M1 review) - 12 tests, 98% coverage packages/secrets (REQ-040, G-005, G-010): - SecretProvider interface: get/put/delete + SecretRef + SecretValue (toString returns [REDACTED]; unwrap is the only read path) - AwsSecretsManagerProvider (prod): coreci/<tenantId>/<name>, KMS-backed - LocalEncryptedProvider (dev): AES-256-GCM, PBKDF2 from SECRET_MASTER_KEY_DEV - relay-token.ts: JWT HS256 sign/verify/issue (G-005 contract — locked so Wave B + Wave D parallelize without blocking) - 24 tests (14 provider + 10 relay-token) packages/runtime (G-002, G-003): - Trigger.dev bootstrap (initRuntime) — pre-investment for M3 (G-003) - runtimeHealthCheck task: writes to runtime_health, NOT audit_log (G-002) - 6 tests packages/config (G-010): - Two-tier credential taxonomy: infra env vars (tier a) vs tenant creds (SecretProvider only, tier b). PO's 'no env vars' applies to tenant creds. Coverage: db 98%, secrets/runtime/config typecheck clean. All 45 tests passing. Go relay-agent builds. ---ci--- phase: 1 milestone: v0.1 status: verify ---/ci---
121 lines
4.9 KiB
TypeScript
121 lines
4.9 KiB
TypeScript
/**
|
|
* Relay registration token contract tests (G-005).
|
|
*
|
|
* Verifies: sign/verify round-trip, expired rejection, tampered signature
|
|
* rejection, tampered payload rejection, malformed token rejection.
|
|
*/
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
import {
|
|
signRelayToken,
|
|
verifyRelayToken,
|
|
issueRelayToken,
|
|
RelayTokenError,
|
|
type RelayTokenPayload,
|
|
} from "../src/index.js";
|
|
|
|
const TENANT = "00000000-0000-0000-0000-000000000001";
|
|
const SIGNING_KEY = "relay-signing-key-dev-only-not-a-tenant-secret";
|
|
|
|
describe("relay token (G-005)", () => {
|
|
it("sign then verify round-trips the claims", () => {
|
|
const token = issueRelayToken(TENANT, SIGNING_KEY);
|
|
const claims = verifyRelayToken(token, SIGNING_KEY);
|
|
expect(claims.tenantId).toBe(TENANT);
|
|
expect(claims.scope).toBe("relay.register");
|
|
expect(typeof claims.iat).toBe("number");
|
|
expect(typeof claims.exp).toBe("number");
|
|
expect(claims.exp).toBeGreaterThan(claims.iat);
|
|
expect(claims.exp - claims.iat).toBe(24 * 60 * 60);
|
|
});
|
|
|
|
it("respects a custom lifetime", () => {
|
|
const token = issueRelayToken(TENANT, SIGNING_KEY, 1);
|
|
const claims = verifyRelayToken(token, SIGNING_KEY);
|
|
expect(claims.exp - claims.iat).toBe(60 * 60);
|
|
});
|
|
|
|
it("signRelayToken builds the standard 3-segment shape", () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload: RelayTokenPayload = {
|
|
tenantId: TENANT,
|
|
scope: "relay.register",
|
|
iat: now,
|
|
exp: now + 60,
|
|
};
|
|
const token = signRelayToken(payload, SIGNING_KEY);
|
|
expect(token.split(".")).toHaveLength(3);
|
|
});
|
|
|
|
it("rejects an expired token", () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload: RelayTokenPayload = {
|
|
tenantId: TENANT,
|
|
scope: "relay.register",
|
|
iat: now - 120,
|
|
exp: now - 60,
|
|
};
|
|
const token = signRelayToken(payload, SIGNING_KEY);
|
|
expect(() => verifyRelayToken(token, SIGNING_KEY)).toThrow(RelayTokenError);
|
|
expect(() => verifyRelayToken(token, SIGNING_KEY)).toThrow(/expired/);
|
|
});
|
|
|
|
it("rejects a tampered signature", () => {
|
|
const token = issueRelayToken(TENANT, SIGNING_KEY);
|
|
const parts = token.split(".");
|
|
const tamperedSig = Buffer.from(parts[2]!, "base64url");
|
|
tamperedSig[0] = tamperedSig[0]! ^ 0xff;
|
|
const tampered = `${parts[0]}.${parts[1]}.${tamperedSig.toString("base64url")}`;
|
|
expect(() => verifyRelayToken(tampered, SIGNING_KEY)).toThrow(RelayTokenError);
|
|
expect(() => verifyRelayToken(tampered, SIGNING_KEY)).toThrow(/invalid signature/);
|
|
});
|
|
|
|
it("rejects a signature verified with the wrong key", () => {
|
|
const token = issueRelayToken(TENANT, SIGNING_KEY);
|
|
expect(() => verifyRelayToken(token, "wrong-key")).toThrow(RelayTokenError);
|
|
expect(() => verifyRelayToken(token, "wrong-key")).toThrow(/invalid signature/);
|
|
});
|
|
|
|
it("rejects a tampered payload (signature no longer matches)", () => {
|
|
const token = issueRelayToken(TENANT, SIGNING_KEY);
|
|
const parts = token.split(".");
|
|
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as RelayTokenPayload;
|
|
payload.tenantId = "00000000-0000-0000-0000-000000000002";
|
|
const tamperedPayload = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
const tampered = `${parts[0]}.${tamperedPayload}.${parts[2]}`;
|
|
expect(() => verifyRelayToken(tampered, SIGNING_KEY)).toThrow(RelayTokenError);
|
|
expect(() => verifyRelayToken(tampered, SIGNING_KEY)).toThrow(/invalid signature/);
|
|
});
|
|
|
|
it("rejects a token with an unexpected scope", () => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = {
|
|
tenantId: TENANT,
|
|
scope: "wrong.scope",
|
|
iat: now,
|
|
exp: now + 60,
|
|
} as unknown as RelayTokenPayload;
|
|
const token = signRelayToken(payload, SIGNING_KEY);
|
|
expect(() => verifyRelayToken(token, SIGNING_KEY)).toThrow(RelayTokenError);
|
|
expect(() => verifyRelayToken(token, SIGNING_KEY)).toThrow(/unexpected scope/);
|
|
});
|
|
|
|
it("rejects a malformed token (not 3 segments)", () => {
|
|
expect(() => verifyRelayToken("not.a.valid-shape-extra.part", SIGNING_KEY)).toThrow(RelayTokenError);
|
|
expect(() => verifyRelayToken("onlyone", SIGNING_KEY)).toThrow(RelayTokenError);
|
|
expect(() => verifyRelayToken("", SIGNING_KEY)).toThrow(RelayTokenError);
|
|
});
|
|
|
|
it("rejects a token with a non-HS256 alg", () => {
|
|
const headerB64 = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" }), "utf8").toString("base64url");
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payloadB64 = Buffer.from(
|
|
JSON.stringify({ tenantId: TENANT, scope: "relay.register", iat: now, exp: now + 60 }),
|
|
"utf8",
|
|
).toString("base64url");
|
|
const fakeSig = Buffer.from("fake").toString("base64url");
|
|
const token = `${headerB64}.${payloadB64}.${fakeSig}`;
|
|
expect(() => verifyRelayToken(token, SIGNING_KEY)).toThrow(RelayTokenError);
|
|
expect(() => verifyRelayToken(token, SIGNING_KEY)).toThrow(/unexpected alg/);
|
|
});
|
|
}); |