Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/verify-fastify-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"seamless-cli": minor
---

`seamless verify` now exercises the Fastify starter. The scaffold has offered a Fastify API since the
templates bump, but the conformance harness only ever drove the Express adapter, so a green run said
nothing about whether a Fastify-scaffolded project actually worked.

The stack gains a second adopter backend (`verify/adapter-fastify-app`, on port 3001) built on
`@seamless-auth/fastify`, a twin of the Express one: same routes, same env contract, same capture
transport. The existing adapter specs run against both without being duplicated, since the two
Playwright projects share a test directory and differ only in which backend they point at. The
conformance grid gains an `adapter-fastify` column, so a failure in one framework is attributable to
that framework.

`--api-only` and `--no-react` are unchanged, and `--local` builds and packs `@seamless-auth/fastify`
from source alongside core and express.
25 changes: 17 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,21 @@ The entry point is [src/index.ts](src/index.ts), which dispatches to a command m
prints a flow x layer pass/fail grid (plus JUnit and HTML reports).

- [verify/docker-compose.verify.yml](verify/docker-compose.verify.yml): postgres, the auth API, and
the adapter, plus the React starter behind the `react` compose profile. The mock OIDC provider runs
in-process in `global-setup` (it is not a container).
- [verify/adapter-app](verify/adapter-app): a minimal `@seamless-auth/express` adopter backend with a
capture transport, so the harness can read OTP / magic-link codes the adapter would otherwise strip.
- [verify/harness](verify/harness): the Playwright projects (`api`, `adapter`, `react`), `lib/`
helpers, `mock-oidc.ts`, `global-setup.ts`, and `lib/matrixReporter.ts` (the printed grid). It has
its own `node_modules` and browsers.
both adapters, plus the React starter behind the `react` compose profile. The mock OIDC provider
runs in-process in `global-setup` (it is not a container).
- [verify/adapter-app](verify/adapter-app) (port 3000) and
[verify/adapter-fastify-app](verify/adapter-fastify-app) (port 3001): minimal adopter backends on
`@seamless-auth/express` and `@seamless-auth/fastify`, each with a capture transport so the harness
can read OTP / magic-link codes the adapter would otherwise strip. They are deliberately twins: the
same routes on the same env contract, so a spec cannot tell which one answered and any difference
in behaviour is a real one. Keep them in step when either changes.
- [verify/harness](verify/harness): the Playwright projects (`api`, `adapter`, `adapter-fastify`,
`react`), `lib/` helpers, `mock-oidc.ts`, `global-setup.ts`, and `lib/matrixReporter.ts` (the
printed grid). It has its own `node_modules` and browsers.
- The two adapter projects run the *same* specs from `./adapter`; only the `adapterUrl` project
option differs (`lib/fixtures.ts`). Adding an adopter framework is a project entry plus a compose
service, never a copy of the suite. Because they share a directory, `matrixReporter` takes the
layer from the Playwright project name, not the spec's path.

Modes and sibling repos:

Expand Down Expand Up @@ -196,7 +204,8 @@ Templates are not in this repo — they live in the `seamless-templates` monorep
- **Adapter OTP limiter**: the adapter funnels all OTP through one client IP, so the API's per-IP OTP
limiter (10 per 15 minutes, hardcoded) bounds adapter / react OTP traffic. Keep specs off it where
possible (for example, magic-link login instead of a second email-OTP round trip).
- **Version pins**: [verify/adapter-app](verify/adapter-app) pins `@seamless-auth/express` and the
- **Version pins**: [verify/adapter-app](verify/adapter-app) pins `@seamless-auth/express`,
[verify/adapter-fastify-app](verify/adapter-fastify-app) pins `@seamless-auth/fastify`, and the
`react-vite` template pins `@seamless-auth/react`. Bump these when new versions publish.
- **Templates ref**: the CLI scaffolds from `seamless-templates` at `SEAMLESS_TEMPLATES_REF`
([src/core/images.ts](src/core/images.ts)); bump it when a new templates release publishes.
67 changes: 61 additions & 6 deletions src/commands/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,20 +96,38 @@ describe("runVerify — published (default) mode", () => {
it("cleans vendor, builds the base stack, and runs API + web layers", async () => {
await runVerify([]);

// Stale tarballs are removed from both vendor dirs; non-tgz files are left.
expect(fs.rmSync).toHaveBeenCalledTimes(2);
// Stale tarballs are removed from every vendor dir; non-tgz files are left.
expect(fs.rmSync).toHaveBeenCalledTimes(3);

const tails = dockerTails();
expect(tails).toContainEqual(["--profile", "react", "down", "-v"]); // initial clean
expect(tails).toContainEqual(["up", "-d", "--build", "postgres", "auth-api", "adapter"]);
expect(tails).toContainEqual([
"up",
"-d",
"--build",
"postgres",
"auth-api",
"adapter",
"adapter-fastify",
]);
expect(tails).toContainEqual(["--profile", "react", "up", "-d", "--build", "react"]);
expect(tails).toContainEqual(["--profile", "react", "rm", "-sf", "react"]);

// No --local ⇒ no pnpm/build packing.
expect(callsFor("pnpm")).toHaveLength(0);

const npmTests = callsFor("npm").filter((a) => a[0] === "test");
expect(npmTests).toContainEqual(["test", "--", "--project", "api", "--project", "adapter"]);
// Both adopter frameworks run the same adapter suite.
expect(npmTests).toContainEqual([
"test",
"--",
"--project",
"api",
"--project",
"adapter",
"--project",
"adapter-fastify",
]);
// The web template declares verify.flows ["oauth"] ⇒ Playwright grep "@oauth".
expect(npmTests).toContainEqual(["test", "--", "--project", "react", "--grep", "@oauth"]);

Expand Down Expand Up @@ -167,10 +185,27 @@ describe("runVerify — flag parsing", () => {
await runVerify(["--no-react"]);

const tails = dockerTails();
expect(tails).toContainEqual(["up", "-d", "--build", "postgres", "auth-api", "adapter"]);
expect(tails).toContainEqual([
"up",
"-d",
"--build",
"postgres",
"auth-api",
"adapter",
"adapter-fastify",
]);
expect(callsFor("npx")).toHaveLength(0);
const npmTests = callsFor("npm").filter((a) => a[0] === "test");
expect(npmTests).toContainEqual(["test", "--", "--project", "api", "--project", "adapter"]);
expect(npmTests).toContainEqual([
"test",
"--",
"--project",
"api",
"--project",
"adapter",
"--project",
"adapter-fastify",
]);
// No react project test runs.
expect(npmTests.some((t) => t.includes("react"))).toBe(false);
});
Expand All @@ -195,6 +230,8 @@ describe("runVerify — flag parsing", () => {
"api",
"--project",
"adapter",
"--project",
"adapter-fastify",
"--grep",
"@login",
]);
Expand All @@ -216,8 +253,26 @@ describe("runVerify — local mode", () => {
const pnpm = callsFor("pnpm");
expect(pnpm).toContainEqual(["--filter", "@seamless-auth/core", "build"]);
expect(pnpm).toContainEqual(["--filter", "@seamless-auth/express", "build"]);
expect(pnpm).toContainEqual(["--filter", "@seamless-auth/fastify", "build"]);
expect(pnpm.some((a) => a.includes("pack"))).toBe(true);

// Each adapter image installs core alongside its own framework package, so
// core is packed into both vendor dirs and neither adapter into the other's.
const packDest = (pkg: string) =>
pnpm
.filter((a) => a[1] === pkg && a.includes("pack"))
.map((a) => a[a.length - 1]);
expect(packDest("@seamless-auth/core")).toEqual([
expect.stringContaining("adapter-app"),
expect.stringContaining("adapter-fastify-app"),
]);
expect(packDest("@seamless-auth/express")).toEqual([
expect.stringContaining("adapter-app"),
]);
expect(packDest("@seamless-auth/fastify")).toEqual([
expect.stringContaining("adapter-fastify-app"),
]);

// The react SDK is built and packed with npm.
const npm = callsFor("npm");
expect(npm).toContainEqual(["run", "build"]);
Expand Down
53 changes: 40 additions & 13 deletions src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ function flowsToGrep(flows?: string[]): string | undefined {
}

const VENDOR_DIR = path.join(VERIFY_DIR, "adapter-app", "vendor");
const FASTIFY_VENDOR_DIR = path.join(VERIFY_DIR, "adapter-fastify-app", "vendor");
const REACT_VENDOR_DIR = path.join(VERIFY_DIR, "react-vendor");

// The React client SDK (@seamless-auth/react). Defaults to a sibling checkout;
Expand Down Expand Up @@ -169,7 +170,7 @@ function resolveServerDir(): string {
}

function cleanVendor(): void {
for (const dir of [VENDOR_DIR, REACT_VENDOR_DIR]) {
for (const dir of [VENDOR_DIR, FASTIFY_VENDOR_DIR, REACT_VENDOR_DIR]) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith(".tgz")) fs.rmSync(path.join(dir, f));
}
Expand All @@ -185,18 +186,32 @@ async function packLocalReactSdk(env: NodeJS.ProcessEnv): Promise<void> {
await runCommand("npm", ["pack", "--pack-destination", REACT_VENDOR_DIR], sdkDir, env);
}

// Each adapter image installs core plus its own framework package, so the
// tarballs are packed into that image's vendor dir and nothing else.
const ADAPTER_SDKS: Array<{ pkg: string; vendorDir: string }> = [
{ pkg: "@seamless-auth/express", vendorDir: VENDOR_DIR },
{ pkg: "@seamless-auth/fastify", vendorDir: FASTIFY_VENDOR_DIR },
];

async function packLocalSdks(env: NodeJS.ProcessEnv): Promise<void> {
const serverDir = resolveServerDir();
console.log(kleur.cyan("→ Building & packing local @seamless-auth/* (core, express)…"));
console.log(
kleur.cyan("→ Building & packing local @seamless-auth/* (core, express, fastify)…"),
);
await runCommand("pnpm", ["--filter", "@seamless-auth/core", "build"], serverDir, env);
await runCommand("pnpm", ["--filter", "@seamless-auth/express", "build"], serverDir, env);
for (const pkg of ["@seamless-auth/core", "@seamless-auth/express"]) {
await runCommand(
"pnpm",
["--filter", pkg, "pack", "--pack-destination", VENDOR_DIR],
serverDir,
env,
);
for (const { pkg } of ADAPTER_SDKS) {
await runCommand("pnpm", ["--filter", pkg, "build"], serverDir, env);
}
for (const { pkg, vendorDir } of ADAPTER_SDKS) {
// core goes into both, since each image installs it alongside its adapter.
for (const target of ["@seamless-auth/core", pkg]) {
await runCommand(
"pnpm",
["--filter", target, "pack", "--pack-destination", vendorDir],
serverDir,
env,
);
}
}
}

Expand Down Expand Up @@ -269,6 +284,7 @@ function collectPackageVersions(
const serverDir = resolveServerDir();
push("@seamless-auth/core", readPkgVersion(path.join(serverDir, "packages", "core", "package.json")));
push("@seamless-auth/express", readPkgVersion(path.join(serverDir, "packages", "express", "package.json")));
push("@seamless-auth/fastify", readPkgVersion(path.join(serverDir, "packages", "fastify", "package.json")));
} catch {
// Server checkout unavailable; leave the SDK lines out rather than fail.
}
Expand All @@ -285,6 +301,13 @@ function collectPackageVersions(
"@seamless-auth/express",
readDepVersion(path.join(VERIFY_DIR, "adapter-app", "package.json"), "@seamless-auth/express"),
);
push(
"@seamless-auth/fastify",
readDepVersion(
path.join(VERIFY_DIR, "adapter-fastify-app", "package.json"),
"@seamless-auth/fastify",
),
);
const reactPins = new Set<string>();
for (const tmpl of webTemplates) {
const pin = readDepVersion(path.join(tmpl.dir, "package.json"), "@seamless-auth/react");
Expand Down Expand Up @@ -411,11 +434,12 @@ export async function runVerify(args: string[] = []): Promise<void> {
SEAMLESS_OWNER_EMAIL: ownerEmail,
SEAMLESS_API_URL: "http://localhost:5312",
SEAMLESS_ADAPTER_URL: "http://localhost:3000",
SEAMLESS_FASTIFY_ADAPTER_URL: "http://localhost:3001",
};

// The base stack (no browser layer). The react service is added per template below.
const baseServices = ["postgres", "auth-api"];
if (!opts.apiOnly) baseServices.push("adapter");
if (!opts.apiOnly) baseServices.push("adapter", "adapter-fastify");

let failed = false;
let setupError: Error | undefined;
Expand Down Expand Up @@ -446,10 +470,13 @@ export async function runVerify(args: string[] = []): Promise<void> {
// API and adapter layers are template-independent, so they run once.
const apiEnv: NodeJS.ProcessEnv = {
...baseEnv,
...(opts.apiOnly ? {} : { SEAMLESS_VERIFY_ADAPTER: "1" }),
...(opts.apiOnly
? {}
: { SEAMLESS_VERIFY_ADAPTER: "1", SEAMLESS_VERIFY_ADAPTER_FASTIFY: "1" }),
};
const apiProjects = ["api"];
if (!opts.apiOnly) apiProjects.push("adapter");
// The adapter suite runs once per adopter framework, against the same specs.
if (!opts.apiOnly) apiProjects.push("adapter", "adapter-fastify");
const apiLabel = opts.apiOnly ? "API" : "API / adapter";
console.log(kleur.cyan("→ Running the API / adapter conformance…\n"));
if (!(await runLayer(results, apiLabel, () => runProjects(apiEnv, apiProjects, opts.grep)))) {
Expand Down
14 changes: 14 additions & 0 deletions verify/adapter-fastify-app/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM node:24-alpine
WORKDIR /app
RUN apk add --no-cache curl
COPY package.json ./
RUN npm install
# --local mode: install locally-built @seamless-auth/* tarballs over the registry
# versions. vendor/ is empty in --released mode, so this is a no-op there.
COPY vendor/ ./vendor/
RUN if ls ./vendor/*.tgz >/dev/null 2>&1; then \
npm install ./vendor/seamless-auth-core-*.tgz ./vendor/seamless-auth-fastify-*.tgz; \
fi
COPY server.mjs ./
EXPOSE 3001
CMD ["node", "server.mjs"]
12 changes: 12 additions & 0 deletions verify/adapter-fastify-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "seamless-verify-adapter-fastify",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Minimal adopter backend for the conformance harness — real @seamless-auth/fastify with a capture transport.",
"dependencies": {
"@fastify/cors": "^11.0.1",
"@seamless-auth/fastify": "^0.3.1",
"fastify": "^5.2.0"
}
}
64 changes: 64 additions & 0 deletions verify/adapter-fastify-app/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import cors from "@fastify/cors";
import Fastify from "fastify";
import seamlessAuth from "@seamless-auth/fastify";

// The Fastify twin of adapter-app/server.mjs. Same routes, same env contract,
// same capture transport: the point of this service is that a spec cannot tell
// which adapter answered it, so any difference in behaviour is a real one.
//
// The adapter strips OTP/magic-link secrets before responding to the browser, so
// the conformance harness can't read codes from responses. These handlers receive
// the raw delivery payloads and stash them for the harness to read via /__captured.
const captured = new Map();
const ok = (channel) => ({ accepted: true, provider: "capture", channel });

const handlers = {
async sendOtpEmail({ to, token }) {
captured.set(to, { token: String(token) });
return ok("email");
},
async sendOtpSms({ to, token }) {
captured.set(to, { token: String(token) });
return ok("sms");
},
async sendMagicLinkEmail({ to, token, magicLinkUrl }) {
captured.set(to, { token, magicLinkUrl });
return ok("email");
},
async sendBootstrapInviteEmail({ to, token, inviteUrl }) {
captured.set(to, { token, inviteUrl });
return ok("email");
},
};

const app = Fastify();

// The React app (browser) calls the adapter cross-origin with credentials, so
// CORS must echo its origin and allow cookies. WEB_ORIGIN is the React app host.
await app.register(cors, {
origin: process.env.WEB_ORIGIN ?? "http://localhost:5173",
credentials: true,
});

app.get("/", async () => ({ ok: true }));
app.get("/__captured/:email", async (req) => captured.get(req.params.email) ?? null);

// Registered under a prefix, which is how the plugin scopes its cookie and origin
// hooks. @fastify/cookie comes with the plugin, so it is not registered here.
// There is no `issuer` option on this adapter (the Express one takes one); the
// audience is what both check the API's tokens against.
await app.register(seamlessAuth, {
prefix: "/auth",
authServerUrl: process.env.AUTH_SERVER_URL,
cookieSecret: process.env.COOKIE_SIGNING_KEY,
serviceSecret: process.env.API_SERVICE_TOKEN,
audience: process.env.AUTH_SERVER_URL,
jwksKid: process.env.JWKS_KID,
messaging: { handlers, defaults: { appName: "Seamless Verify" } },
});

const port = Number(process.env.PORT ?? 3001);
// 0.0.0.0, not the Fastify default of localhost: the port is published out of the
// container, and a listener bound to loopback inside it is unreachable from the host.
await app.listen({ port, host: "0.0.0.0" });
console.log(`verify fastify adapter listening on :${port}`);
3 changes: 3 additions & 0 deletions verify/adapter-fastify-app/vendor/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.tgz
!.gitkeep
!.gitignore
Empty file.
Loading
Loading