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
20 changes: 20 additions & 0 deletions .changeset/docs-and-comments-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"seamless-cli": patch
---

Correct the documentation that had drifted from the rest of the ecosystem.

- `seamless check` and `seamless verify` now have README sections. Verify is half of
what the CLI does and had none.
- The auth server's local-development steps named a script that does not exist
(`db:migrate`); the auth API spells it `migrate:up`, and the Docker path runs it for
you at container start.
- `init --profile` was documented as selecting a profile. It has been accepted and
ignored since managed connect moved to the portal session, so the help now says so.
- The generated project layout listed a `README.md` the CLI does not write, and omitted
`seamless.config.json`, `admin/`, and which pieces a managed project skips.
- "No redirects or third-party auth providers" predated OAuth sign-in, which the CLI has
configured since the provider prompts shipped.
- The included-projects list now names all four repositories the CLI scaffolds from and
conformance-tests against, including `seamless-auth-server` and `seamless-auth-react`.
- `config set` help was missing `session_idle_ttl` from the string-typed keys.
10 changes: 10 additions & 0 deletions .changeset/remove-dead-auth-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"seamless-cli": patch
---

Stop writing `AUTH_MODE` into scaffolded projects.

The scaffold set `AUTH_MODE=server` in the auth server env and on the admin console
container, but nothing reads it: not the auth API, not the admin dashboard (its
entrypoint takes only `API_URL`), and not the web or api templates. It was config that
looked meaningful and meant nothing.
28 changes: 22 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,15 @@ Modes and sibling repos:

- `--local` builds the `@seamless-auth/*` packages from source (pre-publish contract testing); the
default uses the published packages.
- The browser layer runs once per web template, not once. `verify` reads the templates registry
and drives every `kind: web` entry that is not `coming-soon`, each served at :5173 in turn and
scoped to the flow tags its `template.json` declares in `verify.flows` (the whole suite when it
declares none). So `react-oauth` runs only `@oauth`, and `react-vite` runs everything.
- The sibling repos are resolved relative to this repo, overridable with `SEAMLESS_API_DIR`,
`SEAMLESS_SERVER_DIR`, `SEAMLESS_REACT_SDK_DIR` (the React SDK), and `SEAMLESS_REACT_DIR` (the
`react-vite` web template, defaulting to `../seamless-templates/templates/web/react-vite`).
`SEAMLESS_SERVER_DIR`, `SEAMLESS_REACT_SDK_DIR` (the React SDK), and `SEAMLESS_TEMPLATES_DIR`
(the templates checkout the web templates come from, defaulting to `../seamless-templates`).
`SEAMLESS_REACT_DIR` is the narrower override: it names a single template directory and runs
that one instead of the registry's set.
- Useful flags: `--api-only`, `--no-react`, `--filter=<flow>` (the `=` form; a space-separated `--filter <flow>` is not parsed), `--keep-up`.

## Important Folders
Expand Down Expand Up @@ -222,7 +228,17 @@ since the generated compose pins both the ports and the container names.
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`,
[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.
[verify/adapter-fastify-app](verify/adapter-fastify-app) pins `@seamless-auth/fastify`, and each
web template pins `@seamless-auth/react`. Bump these when new versions publish.
- **The four pins in [src/core/images.ts](src/core/images.ts)** are what a scaffold gets, and each
drifts on its own: `SEAMLESS_AUTH_API_VERSION` (the auth server image), the admin dashboard image
and ref, and `SEAMLESS_TEMPLATES_REF`. Check them against the sibling repos' latest tags before a
release; nothing fails when they lag, the scaffold just quietly ships an older stack.
- **`--auth=local` is not pinned**: it `git clone`s `seamless-auth-api` at its default branch
([src/generators/auth/auth.ts](src/generators/auth/auth.ts)) while `--auth=docker` runs the pinned
image, so the two auth modes can scaffold different servers from the same CLI version.
- **Config keys ahead of the API**: `WRITABLE_KEYS` in
[src/core/systemConfig.ts](src/core/systemConfig.ts) mirrors the instance's strict patch schema.
`magic_link_redirect_uris` is currently ahead of it (defined in `@seamless-auth/types`, not yet
released there, and not yet read by the auth API), so an instance rejects that key today. Adding a
key here before the API accepts it makes `config set` fail against every live instance.
94 changes: 73 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,12 @@ Depending on your selections, the CLI generates a project like this:

```text
my-app/
├─ auth/ # Seamless Auth server (optional)
├─ web/ # React web application (optional)
├─ api/ # Express or Fastify API server (optional)
├─ docker-compose.yml (optional)
└─ README.md
├─ auth/ # Seamless Auth server (local auth mode only)
├─ web/ # React web application (optional)
├─ api/ # Express or Fastify API server (optional)
├─ admin/ # Admin console source (--admin=source only)
├─ docker-compose.yml # not written for a managed project
└─ seamless.config.json
```

All services are preconfigured to work together.
Expand All @@ -228,14 +229,13 @@ If you choose Docker during setup:
docker compose up
```

This starts:

- PostgreSQL
- Auth server
- API server
- Web app
This starts PostgreSQL, the auth server, the API server, and the web app, plus a
standalone admin console container under `--admin=image` or `--admin=source`.

All services are configured to communicate correctly inside the container network.
Ports are published on `127.0.0.1` only: the auth server returns OTP codes in the
response for local login, which would be an authentication bypass for anyone else
on the network.

---

Expand All @@ -256,11 +256,14 @@ cd auth
npm install

npm run db:create
npm run db:migrate
npm run migrate:up

npm run dev
```

The Docker path runs both of these for you at container start, so they are only
needed when the auth server runs on the host.

---

#### 3. Start the API
Expand All @@ -283,6 +286,54 @@ npm run dev

---

## Checking and verifying

Two commands answer two different questions: is *this project* healthy, and does the
*whole auth surface* still behave.

### `seamless check`

Health-checks the project in the current directory, reading `seamless.config.json` to
decide what applies. A local project gets the Docker and Compose checks; a managed one
skips them and validates the remote instance instead. Every check runs, so one failure
never hides the rest.

```bash
seamless check # always exits 0, whatever it reported
seamless check --strict # exit 1 if any check failed, for a CI gate
```

The exit status is 0 without `--strict` because this output has been parsed by scripts
since before the flag existed.

### `seamless verify`

The cross-package conformance harness. It stands up the ecosystem with Docker Compose
(Postgres, the auth API, an Express adapter, a Fastify adapter, and the web starter),
runs a Playwright matrix over it, and prints a flow x layer pass/fail grid plus JUnit and
HTML reports. It ships with the package, so it needs no checkout of its own, but it does
need Docker and a sibling `seamless-auth-api` source tree to build the auth server from.

```bash
seamless verify # everything, against the published SDKs
seamless verify --api-only # fast pass: the API layer alone
seamless verify --no-react # skip the browser layer, keep the adapters
seamless verify --local # build @seamless-auth/* from source first
seamless verify --filter=passkey # one flow (the = form only)
seamless verify --keep-up # leave the stack running afterwards
```

`--local` is the pre-publish check: it builds and packs the local SDK source rather than
installing from npm, so an SDK regression surfaces before a release rather than after.
The browser layer runs once per web template in the registry, each scoped to the flows
its `template.json` declares.

Sibling repositories are resolved next to this one and can be pointed elsewhere with
`SEAMLESS_API_DIR`, `SEAMLESS_SERVER_DIR`, `SEAMLESS_REACT_SDK_DIR`, and
`SEAMLESS_TEMPLATES_DIR`.

---

## Creating the first admin

`init` asks for your email and writes it to the auth server as `OWNER_EMAIL`. The auth server grants
Expand Down Expand Up @@ -518,17 +569,18 @@ Everything is aligned across services so the system works immediately after setu

## Included projects

Seamless CLI pulls from the following repositories:
Seamless CLI scaffolds from, and conformance-tests against, these repositories:

- Seamless Auth API
[https://github.com/fells-code/seamless-auth-api](https://github.com/fells-code/seamless-auth-api)

- Seamless Templates (the frontend and API starters)
[https://github.com/fells-code/seamless-templates](https://github.com/fells-code/seamless-templates)
| Repository | What it provides | How the CLI uses it |
| --- | --- | --- |
| [seamless-auth-api](https://github.com/fells-code/seamless-auth-api) | The auth server | Run as a pinned image (`--auth=docker`) or cloned into `auth/` (`--auth=local`) |
| [seamless-templates](https://github.com/fells-code/seamless-templates) | The web and API starters | Scaffolded from its registry at a pinned ref |
| [seamless-auth-server](https://github.com/fells-code/seamless-auth-server) | `@seamless-auth/core`, `/express`, `/fastify` | The adapters the scaffolded `api/` runs on |
| [seamless-auth-react](https://github.com/fells-code/seamless-auth-react) | `@seamless-auth/react` | The client SDK the scaffolded `web/` runs on |

The starters live in the templates monorepo and are listed in its registry, so the set of
frameworks the CLI offers grows there. Each project can be used independently, but the CLI connects
them into a working system.
them into a working system, and `seamless verify` checks that connection holds across all four.

---

Expand All @@ -544,8 +596,8 @@ Full documentation is available at:

Seamless Auth is built around a few principles:

- Passwordless authentication only
- No redirects or third-party auth providers
- Passwordless authentication only (passkeys, magic links, email and phone OTP)
- Optional OIDC sign-in, configured explicitly and self-hosted like everything else
- Self-hosted by default
- Production-shaped local development
- Explicit configuration over hidden behavior
Expand Down
8 changes: 4 additions & 4 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions src/commands/helpTopics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ With a template flag (e.g. --oauth, --react-oauth, --fastify):
• Run seamless templates list to see every id, alias, and flag

--profile <name>
• Use that profile instead of the active one
• Accepted and ignored, with a warning. Managed connect uses your portal
session (seamless login), not an instance profile

--app <id>
• Connect the project to that managed application (needs a portal
Expand Down Expand Up @@ -315,8 +316,8 @@ config get [key] [--json]

config set <key> <value>
• Update one writable key. String-typed keys (app_name, rpid,
access_token_ttl, refresh_token_ttl) take the value verbatim; every other
key parses it as JSON, falling back to a string
access_token_ttl, session_idle_ttl, refresh_token_ttl) take the value
verbatim; every other key parses it as JSON, falling back to a string
(for example: config set app_name 123 sets the string "123",
config set login_methods '["email_otp","passkey"]')

Expand Down
30 changes: 13 additions & 17 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ export interface InitOptions {
email?: string;
auth?: string;
admin?: string;
// --yes: answer every remaining question with the recommended option rather
// than prompting. It never stands in for a destructive confirmation; those
// take --force.
// The split every prompt below is written against: --yes answers the ordinary
// questions with the recommended option, --force answers the destructive ones
// (overwriting a non-empty directory, rotating a live service token), and
// neither answers managed-or-local, which --app and --local settle outright.
yes?: boolean;
force?: boolean;
}
Expand Down Expand Up @@ -269,9 +270,7 @@ async function scaffold(
}

// Writing starter files over a directory someone already has work in is the one
// destructive step in a scaffold, so --yes is deliberately not enough to reach
// it. --force is, and it says nothing about the integrate-or-scaffold question,
// which --app answers instead.
// destructive step in a scaffold, so this takes --force rather than --yes.
async function resolveExistingDirectoryAction(
canConnect: boolean,
opts: InitOptions,
Expand All @@ -296,8 +295,8 @@ async function resolveExistingDirectoryAction(
return "scaffold";
}

// Managed or local decides where the project's auth lives for good, so --yes
// alone will not pick: --app <id> means managed and --local means self-hosted.
// Where the project's auth lives is settled for good here, so --yes will not pick:
// --app <id> means managed, --local means self-hosted.
async function resolveScaffoldTarget(
appCount: number,
opts: InitOptions,
Expand All @@ -318,6 +317,10 @@ async function resolveScaffoldTarget(
// The bundled database as a connection string with placeholder credentials, or
// an empty string when the control plane has not provisioned one yet. Never
// requests ?reveal=true, so no live credential reaches this machine.
//
// Both callers run this before rotating the service token: a database that is not
// provisioned yet is a warning rather than a failure, and finding that out after
// the rotation would mean reporting it against a half-wired project.
async function resolveDatabaseUrl(
client: AuthClient,
app: PortalApp,
Expand Down Expand Up @@ -400,9 +403,6 @@ async function scaffoldManaged(
await source.copyInto(entry, dir);
}

// Read before rotating: a database that is not provisioned yet is a warning,
// not a failure, and finding that out after the token is rotated would mean
// reporting it against a half-wired project.
const databaseUrl = await resolveDatabaseUrl(client, app);

const serviceToken = await issueServiceToken(client, app, opts);
Expand Down Expand Up @@ -595,9 +595,6 @@ async function integrateExistingProject(
) {
const app = await selectApplication(apps, opts.appId);

// Read before rotating: a database that is not provisioned yet is a warning,
// not a failure, and finding that out after the token is rotated would mean
// reporting it against a half-wired project.
const databaseUrl = await resolveDatabaseUrl(client, app);

const serviceToken = await issueServiceToken(client, app, opts);
Expand Down Expand Up @@ -686,9 +683,8 @@ async function issueServiceToken(
opts: InitOptions,
): Promise<string> {
if (app.hasServiceToken) {
// Rotation breaks whatever is running on the old token, so it is a
// destructive confirmation like the overwrite one: --yes does not answer it,
// --force does.
// Rotation breaks whatever is running on the old token, so it confirms like
// the overwrite step does.
if (opts.yes) {
if (!opts.force) {
throw new Error(
Expand Down
5 changes: 3 additions & 2 deletions src/commands/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,9 @@ function readTemplateFlows(dir: string): string[] | undefined {
}

// The web templates to build, serve, and drive. Each is served at :5173 in turn and
// pointed at the adapter. Resolution order: an explicit SEAMLESS_REACT_DIR (a single
// template path, used by CI), otherwise every runnable web template in the registry.
// pointed at the adapter. SEAMLESS_REACT_DIR narrows the run to one template directory;
// otherwise every runnable web template in the registry is driven, which is what CI does
// (it sets SEAMLESS_TEMPLATES_DIR and leaves the set alone).
function resolveWebTemplates(): WebTemplate[] {
const override = process.env.SEAMLESS_REACT_DIR;
if (override) {
Expand Down
16 changes: 7 additions & 9 deletions src/core/loginFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,22 +120,20 @@ async function startLogin(
);
}

// `/login` no longer answers 401. An identifier with no usable account, which used to
// mean unknown, unverified, or with no permitted method, now gets a 200 and a decoy
// pre-auth token so the response cannot be used to test whether an account exists. The
// branches that read 401 as "no such user" and "not verified yet" were removed with it:
// there is no longer an answer for them to read. Such a login fails at the code step
// instead, which is what `completeLogin` reports.
// `/login` never answers 401. An identifier with no usable account (unknown,
// unverified, or with no permitted method) gets a 200 and a decoy pre-auth token, so
// no answer here can be used to test whether an account exists. Nothing to read means
// nothing to report: such a login fails at the code step, which `completeLogin` names.
// Do not add a branch that reads a status as "no such user"; there is not one.
if (!res.ok) {
if (res.status === 400) {
throw new LoginError(
`"${identifier}" is not a valid email or phone number.`,
);
}
if (res.status === 423) {
// The one remaining answer that does imply an account, and the one worth naming:
// it needs prior failed attempts against this identifier, and the developer can
// act on it by waiting.
// The one answer that does imply an account, and the one worth naming: it needs
// prior failed attempts against this identifier, and waiting resolves it.
throw new LoginError(lockedMessage(res.data, identifier));
}
if (res.status === 403) {
Expand Down
Loading
Loading