@amadeni/dev-contract
v0.2.0
Published
Standardized dev-start/dev-auth process contract for the Amadeni project fleet: ready means a verified login
Maintainers
Readme
@amadeni/dev-contract
Standardized dev-start/dev-auth process contract for the Amadeni project
fleet. One CLI replaces the per-repo shell scripts (dev-start.sh /
dev-auth.sh / dev-stop.sh) that every project used to copy — with one
core guarantee the scripts never gave:
ready = verified login.
dev-contract startonly reports ready after a dev login has DEMONSTRABLY worked: it mints a single-use token, consumes it at the magic link verify endpoint, and replays the issued cookies against an authenticated probe until the response proves a live session. The pipeline receives a ready-made authenticated state (cookies + Convex JWT), not just URLs — the "screenshot shows the login screen instead of the app" failure mode cannot pass the gate.
What start does
- Starts
convex dev(detached process group, pid + log files in.dev-contract/). Fresh checkouts getCONVEX_AGENT_MODE=anonymousso Convex picks a local anonymous deployment without prompting. Readiness means the backend answers andconvex devhas reportedConvex functions readyfor this start — on a fresh deployment the push lands seconds after the backend, and nothing (seed, token mint) calls a function before it did. - Guard (hard abort): provisioning only ever happens against a
dev:*oranonymous:*CONVEX_DEPLOYMENT(and only localCONVEX_SELF_HOSTED_URLhosts). Anything else exits non-zero before a single env var is written. - Provisions missing dev env vars on the Convex deployment:
AMADENI_DEV_AUTH_ENABLED=true, a generatedBETTER_AUTH_SECRET, andSITE_URL— reconciled on every start, so repaired environments heal. - Seed (optional): runs the
baseseed profile — after the backend is ready and provisioned, before the login gate — so the test user / base data exist before the login is verified. A failing seed aborts the start with a[seed]diagnosis; there is no "ready" on top of a broken seed. Other profiles (full) only run on request. See Seeding. - Starts the app dev server and waits for HTTP.
- Readiness gate: retries mint → verify → session-probe until the
login is verified (or the deadline passes — then it fails loudly with
the step that broke). A second, unused token becomes
auth.loginUrlfor browser consumers. - Emits the contract JSON as the last stdout line (all logging goes to stderr):
{
"ok": true,
"baseUrl": "http://localhost:3001",
"appUrl": "http://localhost:3001",
"convexUrl": "https://<deployment>.convex.cloud",
"convexSiteUrl": "https://<deployment>.convex.site",
"auth": {
"email": "[email protected]",
"cookie": "better-auth.session_token=...; better-auth.convex_jwt=...",
"cookies": {
"better-auth.session_token": "...",
"better-auth.convex_jwt": "..."
},
"convexJwt": "<decoded JWT for ConvexHttpClient.setAuth()>",
"loginUrl": "http://localhost:3001/api/auth/magic-link/verify?token=..."
},
"readyAt": "2026-01-02T03:04:05.000Z",
"pids": { "convex": 123, "app": 456 },
"stateDir": "/abs/path/.dev-contract"
}Failures never emit ok: true: the process exits non-zero with a
[step]-prefixed diagnosis on stderr (guard, convex-ready,
provision, seed, app-ready, mint-token, verify,
session-probe, login-ready, ...).
Commands
dev-contract start [--config path] [--email x] [--out file] [--root dir]
dev-contract auth # fresh verified session for a running environment
dev-contract seed [--profile <name>] # run one seed profile (default: base)
dev-contract stop # stop the process groups started by `start`auth emits { "ok": true, "loginUrl": ..., "baseUrl": ..., "auth": {...} };
seed emits { "ok": true, "profile": "full", "ran": ["command", "function"] };
stop emits { "ok": true, "stopped": [...] }.
Seeding (optional)
Projects that need base data (a test user, org fixtures, e2e profiles)
before the first login declare a seed block in the config. The
top-level command / function / args are the base profile;
further profiles live in seed.profiles:
{
"seed": {
"command": "pnpm run seed:dev",
"function": "testSupport/seed:ensureBaseData",
"args": { "profile": "e2e" },
"profiles": {
"full": {
"function": "testSupport/seed:ensureFixture",
"args": { "scenario": "review" }
}
}
},
"timeouts": { "seedMs": 300000 }
}commandis run as a shell command in the project root.functionis run vianpx convex run(typecheck/codegen disabled,auth.identityattached when configured — identity-gated seed functions work exactly like the token function).- Every profile needs at least one of the two; with both set,
commandruns first. - Profiles.
baseis whatstartruns (after backend readiness + provisioning, before the auth/login gate) and whatdev-contract seedruns without--profile.profiles.basemay replace the top-level block, but declaring both is a config error. Any other name only ever runs on request:dev-contract seed --profile <name>. A block with onlyprofiles(nobase) is fine —startthen seeds nothing. fullis the fleet convention for the complete test fixture (just dev-seed fullin the fleet contract): Mynd's executor runs it once afterdev-startand beforedev-auth, with a 5-minute budget, one attempt, failure = warning. Profile names are shell-safe ([A-Za-z0-9_-]).timeouts.seedMs(default 300 000 ms) is the budget for one seed profile — it applies tocommandand tofunctioneach. A timeout kills the process and fails the seed with[timeout]in the diagnosis.- An unknown profile fails with
[seed] unknown profile <name>on stderr and a non-zero exit — never a silent no-op. - Every profile MUST be idempotent (insert-only, or probe-then-insert
like the Hub's
ensure_seed): the contract re-runsbaseon everystartandfullon every review iteration. Wipe-and-recreate seeds do not belong here;fullshould be additive on top ofbase. - Any seed failure is a hard abort with a
[seed]-prefixed diagnosis — the environment is never reported ready on a broken seed. - The deployment guard applies: seeding (like everything that writes) is
only ever allowed against
dev:*/anonymous:*deployments.
Project integration
1. Config: devcontract.config.json in the repo root
See devcontract.config.example.json.
Minimal version:
{
"appUrl": "http://localhost:3001",
"auth": {
"createTokenFunction": "dev/auth:createDevToken",
"identity": { "issuer": "my-app-dev-auth", "subject": "dev-auth-cli" }
}
}Everything else has defaults (pnpm, convex dev, next dev -p <port>,
better-auth verify/get-session paths, 120s/120s/90s timeouts, 300s per
seed profile).
2. Convex-side fixture: createDevAuth from @amadeni/better-auth-kit
The token function referenced by auth.createTokenFunction lives in the
app's convex/ directory and is a thin wiring of the kit factory
(v0.3.0+). It writes a hashed magic-link verification row directly into
the Better Auth component — the login then runs through the app's regular
verify endpoint, with real sessions and cookies:
// convex/dev/auth.ts
import { v } from 'convex/values';
import {
createDevAuth,
requireDevAuthCliIdentity,
} from '@amadeni/better-auth-kit';
import { action } from '../_generated/server';
import { components, internal } from '../_generated/api';
const devAuth = createDevAuth({
createVerification: (ctx, input) =>
ctx.runMutation(components.betterAuth.adapter.create, { input }),
ensureUser: (ctx, { email, name }) =>
ctx.runMutation(internal.dev.auth.ensureDevUserInternal, { email, name }),
});
export const createDevToken = action({
args: { email: v.optional(v.string()) },
handler: async (ctx, args) => {
await requireDevAuthCliIdentity(ctx, {
issuer: 'my-app-dev-auth', // must match devcontract.config.json
subject: 'dev-auth-cli',
});
return await devAuth.issueToken(ctx, args);
},
});The kit enforces the hard gate: minting throws unless
AMADENI_DEV_AUTH_ENABLED === 'true', and always throws on
production-shaped deployments. Never set that variable on production.
Apps with existing dev-auth actions (e.g. the Hub's
dev/auth:createDevToken) work as-is — the contract only requires "takes
{ email? }, returns { token }".
3. Optional: keep the just recipes as thin wrappers
dev-start:
pnpm exec dev-contract start
dev-auth:
pnpm exec dev-contract auth
dev-seed profile='base':
pnpm exec dev-contract seed --profile {{profile}}
dev-stop:
pnpm exec dev-contract stopConsumer notes (Mynd / pipelines)
- Legacy compatibility: the previous shell contract emitted
{"baseUrl": ...}(dev-start) and{"loginUrl": ...}(dev-auth) as the last stdout line. The new output is a strict superset:baseUrlstays top-level instart,loginUrlstays top-level inauth. Existing parsers (parseDevStartOutput/parseDevAuthOutput) keep working unchanged. - The upgrade: consumers should switch from "open loginUrl and hope"
to injecting the delivered state directly — set
auth.cookieas theCookieheader (or seed the browser context's cookies) and/or useauth.convexJwtwithConvexHttpClient.setAuth().loginUrlremains for pure-browser flows; it carries a fresh unused single-use token. - Trust the exit code, not the log tail: exit 0 + last-line JSON with
ok: trueis the only ready signal; the JSON is only emitted after the verified-login gate passed. On failure the exit code is non-zero and stderr names the failing step. startis idempotent: running processes are reused, env state is re-reconciled, and the login is re-verified on every call — safe to call once per review iteration.- Test fixture:
dev-contract seed --profile full(=just dev-seed full) betweenstartandauth; the last stdout line is{ "ok": true, "profile": "full", "ran": [...] }. Treat a non-zero exit as a warning about the fixture, not as "environment not ready".
Programmatic use
import { loadConfig, runStart } from '@amadeni/dev-contract';
const config = await loadConfig(projectRoot);
const result = await runStart(config); // throws DevContractError with .stepSecurity posture
- Provisioning is hard-gated to
dev:*/anonymous:*deployments — the CLI refuses everything else before writing anything. - The dev login itself is additionally gated Convex-side by
@amadeni/better-auth-kit'sassertDevAuthEnabled(exact-match env flag + production-shape refusal). - Zero runtime dependencies; Node >= 20.
Development
pnpm install
pnpm run ci # prettier + eslint + tsc + cspell + vitest