Add the Better Auth issuer column so signup and sign-in work (#12396)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A self-hosted install in `authenticated` mode signs users in with
Better Auth, mounted at `/api/auth` over a hand-written Drizzle
`account` table in `packages/db`
> - Better Auth 1.7.0 added a required `issuer` field to that `account`
model, plus a unique index on `(issuer, accountId)`
> - The dependency bump in #11886 changed only `server/package.json` and
the lockfile, so the Drizzle table never grew the column
> - The Drizzle adapter checks the model against the schema on every
write, so `linkAccount` throws and sign-up answers 500 with an empty
body; a fresh install cannot create its first user, and an upgraded
install locks out every existing user
> - This pull request adds the `issuer` column and its unique index, and
migrates the column in with a backfill that covers every existing row
> - The benefit is that sign-up and sign-in work again, on a new install
and after an upgrade

## Linked Issues or Issue Description

No existing issue. Describing it inline, following
`.github/ISSUE_TEMPLATE/bug_report.yml`.

Refs #11886 (the dependency bump that introduced the required field).
Refs #12269 (an earlier attempt at this fix; its backfill covers only
`provider_id = 'credential'`).

**What happened?**

Sign-up fails on a self-hosted install. `POST /api/auth/sign-up/email`
answers HTTP 500 with a zero-byte body. The server log carries:

```
[Better Auth]: The field "issuer" does not exist in the "account" Drizzle schema.
# SERVER_ERROR: [BetterAuthError: The field "issuer" does not exist in the "account" Drizzle schema.]
```

The request writes the `user` row and then fails on the `account` row.
The address is stuck after that: a second sign-up answers 422
`USER_ALREADY_EXISTS`, sign-in answers 401, and password reset answers
400 `RESET_PASSWORD_DISABLED` because the account that would hold the
password does not exist.

An upgraded install is worse. `sign-in/email` matches the credential
account on `account.issuer === 'local:credential'`. Rows written before
the upgrade have no issuer, so every existing user is locked out.

**Expected behavior**

`POST /api/auth/sign-up/email` answers 2xx and writes both the `user`
row and its credential `account` row. `POST /api/auth/sign-in/email`
then answers 2xx and sets a session cookie. An install that upgrades
keeps its existing users.

**Steps to reproduce**

1. Start a server from `master` with
`PAPERCLIP_DEPLOYMENT_MODE=authenticated` against an empty database.
2. `curl -X POST http://127.0.0.1:<port>/api/auth/sign-up/email -H
'Content-Type: application/json' -H 'Origin: http://127.0.0.1:<port>'
--data
'{"name":"A","email":"a@example.com","password":"a-long-password"}'`
3. The response is HTTP 500 with an empty body.

**Paperclip version or commit**

`master` at 4436cf0. The defect starts at 69e8585 (#11886), which moved
Better Auth from 1.6.28 to 1.7.0.

**Deployment mode**

`authenticated`. `local_trusted` does not sign users in, so it is not
affected. Hosted tenants are not affected either: that path resolves the
actor from a trusted header and never reads `account`.

**Database mode**

Both. Embedded PostgreSQL and external PostgreSQL use the same Drizzle
schema.

**Relevant logs or output**

Reproduced in a test by reverting the schema change:

```
stderr | better-auth-credential-signup.integration.test.ts
[Better Auth]: The field "issuer" does not exist in the "account" Drizzle schema.
AssertionError: expected 500 to be 200
```

## What Changed

- `packages/db/src/schema/auth.ts`: adds `issuer` (text, NOT NULL) to
`authAccounts`, and the `(issuer, account_id)` unique index that mirrors
the index Better Auth declares on the model. The field name, type,
requiredness, and index all come from
`@better-auth/core/dist/db/get-tables.mjs` in 1.7.0.
- `packages/db/src/migrations/0230_better_auth_account_issuer.sql`: adds
the column, backfills every existing row, sets NOT NULL, and creates the
unique index.
- `packages/db/src/migrations/meta/0230_snapshot.json` and
`_journal.json`: regenerated with `pnpm --filter @paperclipai/db
generate`.
- `packages/db/src/better-auth-account-issuer-migration.test.ts`: new.
Asserts the schema shape, then rewinds the migration on a real database,
seeds pre-upgrade rows, and re-applies it.
-
`server/src/__tests__/better-auth-credential-signup.integration.test.ts`:
new. Real sign-up and sign-in through the Better Auth mount, against the
real Drizzle schema and a migrated PostgreSQL.
- `cli/src/__tests__/worktree.test.ts`: the worktree seed fixture writes
a credential `account` row, so it now writes `issuer` too.

`server/package.json` and `pnpm-lock.yaml` are untouched. The dependency
is correct; the schema was what was missing.

### The issuer values, and where they come from

Better Auth builds these itself, in
`@better-auth/core/src/db/schema/account.ts`:

```ts
export function createLocalAccountIssuer(providerId: string): string {
  return `local:${encodeURIComponent(providerId)}`;
}
export function createOAuthAccountIssuer(providerId: string): string {
  return `local:oauth:${encodeURIComponent(providerId)}`;
}
```

Sign-up and sign-in both call `createLocalAccountIssuer("credential")`,
so a credential account is `local:credential`. An OAuth account whose
provider declares no `accountIssuer` of its own is
`local:oauth:<providerId>` — no built-in social provider declares one.
The migration writes exactly those two forms:

```sql
ALTER TABLE "account" ADD COLUMN IF NOT EXISTS "issuer" text;
UPDATE "account"
SET "issuer" = CASE
  WHEN "provider_id" = 'credential' THEN 'local:credential'
  ELSE 'local:oauth:' || "provider_id"
END
WHERE "issuer" IS NULL;
ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "account_issuer_account_id_uq" ON "account" USING btree ("issuer","account_id");
```

Two limits are worth stating plainly. The OAuth branch reproduces
`createOAuthAccountIssuer` for provider ids that need no
percent-encoding, which covers every built-in provider id; a provider id
with a character `encodeURIComponent` would escape would get a slightly
different string. And a generic-OAuth provider that sets `accountIssuer`
explicitly (Okta, Auth0, Keycloak, Slack, Line) uses the real issuer
URL, which this migration cannot know. Neither case can arise on
Paperclip today: `createBetterAuthInstance` configures
`emailAndPassword` only and registers no social or generic-OAuth
provider, so every existing row is a credential row. The OAuth branch is
there so the backfill stays total rather than leaving a NULL that aborts
`SET NOT NULL`.

## Verification

- `pnpm --filter @paperclipai/db check:migrations` — passes.
- `pnpm --filter @paperclipai/db typecheck` — passes.
- `packages/db` suite: 30 files, 107 tests, all pass.
- `npx tsc --noEmit` in `server/` — no error in any changed file. (The
wrapped `pnpm typecheck` builds the runner vendor first, which needs
cargo; that toolchain was not available here, so the pre-existing
"cannot find module" errors from the unbuilt workspace packages remain
in the bare run.)
- `node --test scripts/__tests__/run-vitest-stable-shard.test.mjs` —
passes with the new server suite in the file list.
- The two new tests were confirmed to fail without the fix:
- Reverting `packages/db/src/schema/auth.ts` to its `master` content
makes the server test fail with the reported error and `expected 500 to
be 200`.
- Narrowing the backfill to `WHERE "issuer" IS NULL AND "provider_id" =
'credential'` makes the migration test fail with `column "issuer" of
relation "account" contains null values` — the failure mode of #12269.
- End to end against a server built from this branch, started with
`PAPERCLIP_DEPLOYMENT_MODE=authenticated` on embedded PostgreSQL:
  - `POST /api/auth/sign-up/email` → 200 with a user and token.
  - `POST /api/auth/sign-in/email` → 200 with a session cookie.
  - `GET /api/auth/get-session` → 200 with the session.
- The stored row is `issuer = 'local:credential'`, `provider_id =
'credential'`, `account_id = user_id`, and `pg_indexes` lists
`account_issuer_account_id_uq`.
- `scripts/docker-onboard-smoke.sh` was not used as proof: it installs
`paperclipai` from npm inside the container, so it exercises a published
release rather than this branch.

## Risks

- **Migration.** The migration backfills every existing row before `SET
NOT NULL`, so an install that upgrades keeps working and its users keep
signing in. `account` is one row per user per provider, so the
full-table `UPDATE` and the index build are cheap;
`packages/db/src/table-size-estimates.ts` already classes `account` as
small, and `check:migrations` passes with no new safety finding.
- **New unique index.** `(issuer, account_id)` is the key Better Auth
resolves accounts by, so a duplicate would already be a defect. Better
Auth writes one credential account per user keyed on the user id, so the
pair is unique by construction. An install that somehow holds a
duplicate would fail the index build rather than corrupt anything, and
the migration is a single transaction.
- **Orphaned users are not repaired.** An address that hit the broken
window has a `user` row and no `account` row. This migration does not
delete or repair those rows, so that address stays unusable after the
upgrade: sign-up says the user exists, and there is no credential
account to sign in as or reset. Only installs that ran a build
containing #11886 are affected, and the repair — deleting the orphaned
`user` rows — is a judgment call about live data that does not belong in
an automatic migration.
- **Not a behavior change anywhere else.** Only the `account` table
changes. Hosted tenants resolve their actor from a trusted header and
never read it.

## Model Used

Claude (Anthropic), Claude Opus, 1M context, extended thinking, agentic
tool use via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-08-27 22:31:35 -07:00 committed by GitHub
parent dc7a1a020a
commit 8316ceb0b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 41916 additions and 16 deletions

View File

@ -125,6 +125,8 @@ async function seedValidWorktreeSource(
if (options.includeCredentialAccount !== false) {
await db.insert(authAccounts).values({
id: "credential-existing",
// The issuer Better Auth stamps on an email/password account.
issuer: "local:credential",
accountId: "existing@paperclip.ing",
providerId: "credential",
userId,

View File

@ -0,0 +1,137 @@
/**
* The `account.issuer` backfill has to be total. The column lands NOT NULL, so
* a row the UPDATE misses aborts the whole migration and leaves an upgraded
* install with no working auth at all. This suite rewinds the migration,
* seeds the pre-upgrade rows an existing deployment would have, and re-applies
* it a backfill narrowed to one provider fails here.
*/
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs";
import { getTableConfig } from "drizzle-orm/pg-core";
import postgres from "postgres";
import { afterEach, describe, expect, it } from "vitest";
import { applyPendingMigrations } from "./client.js";
import { authAccounts } from "./schema/auth.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./test-embedded-postgres.js";
const MIGRATION_FILE = "0230_better_auth_account_issuer.sql";
const UNIQUE_INDEX = "account_issuer_account_id_uq";
// Better Auth's own issuer helpers: `createLocalAccountIssuer("credential")`
// for email/password and `createOAuthAccountIssuer(providerId)` for a social
// provider that declares no issuer of its own. Sign-in looks the credential
// account up by this exact value.
const CREDENTIAL_ISSUER = "local:credential";
const OAUTH_ISSUER_PREFIX = "local:oauth:";
const cleanups: Array<() => Promise<void>> = [];
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
async function migrationHash() {
const content = await fs.promises.readFile(
new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url),
"utf8",
);
return createHash("sha256").update(content).digest("hex");
}
describe("account issuer schema", () => {
it("declares the column Better Auth requires and the key it indexes on", () => {
const config = getTableConfig(authAccounts);
const issuer = config.columns.find((column) => column.name === "issuer");
expect(issuer).toBeDefined();
expect(issuer?.notNull).toBe(true);
// Better Auth declares `(issuer, accountId)` unique on the account model
// and resolves accounts by that pair.
const unique = config.indexes.find((index) => index.config.name === UNIQUE_INDEX);
expect(unique?.config.unique).toBe(true);
expect(unique?.config.columns.map((column) => (column as { name: string }).name)).toEqual([
"issuer",
"account_id",
]);
});
});
describeEmbeddedPostgres("account issuer migration", () => {
// Reverse registration order, one at a time. The raw `postgres` client is not
// registered with the module's client registry, so the cluster teardown does
// not close it. Stopping the cluster while that client is still draining kills
// the backend socket under a queued write and can crash the runner.
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) {
await cleanup();
}
});
it("backfills every pre-upgrade row before the column goes NOT NULL", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-account-issuer-");
cleanups.push(database.cleanup);
const sql = postgres(database.connectionString, { max: 1 });
cleanups.push(async () => sql.end());
// Rewind to the pre-upgrade shape: no issuer column, no unique index.
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
await sql`DROP INDEX IF EXISTS ${sql(UNIQUE_INDEX)}`;
await sql`ALTER TABLE "account" DROP COLUMN IF EXISTS "issuer"`;
const credentialUserId = `user-${randomUUID()}`;
const socialUserId = `user-${randomUUID()}`;
const credentialAccountId = `account-${randomUUID()}`;
const githubAccountId = `account-${randomUUID()}`;
const googleAccountId = `account-${randomUUID()}`;
await sql`
INSERT INTO "user" ("id", "name", "email", "created_at", "updated_at")
VALUES
(${credentialUserId}, 'Credential User', 'credential@example.com', now(), now()),
(${socialUserId}, 'Social User', 'social@example.com', now(), now())
`;
await sql`
INSERT INTO "account"
("id", "account_id", "provider_id", "user_id", "password", "created_at", "updated_at")
VALUES
(${credentialAccountId}, ${credentialUserId}, 'credential', ${credentialUserId}, 'hashed', now(), now()),
(${githubAccountId}, 'github-subject-1', 'github', ${socialUserId}, NULL, now(), now()),
(${googleAccountId}, 'google-subject-1', 'google', ${socialUserId}, NULL, now(), now())
`;
await applyPendingMigrations(database.connectionString);
const rows = await sql<{ id: string; issuer: string }[]>`
SELECT "id", "issuer" FROM "account" ORDER BY "id"
`;
const issuerById = new Map(rows.map((row) => [row.id, row.issuer]));
expect(issuerById.get(credentialAccountId)).toBe(CREDENTIAL_ISSUER);
// A backfill restricted to `provider_id = 'credential'` leaves these NULL,
// and SET NOT NULL then aborts the upgrade.
expect(issuerById.get(githubAccountId)).toBe(`${OAUTH_ISSUER_PREFIX}github`);
expect(issuerById.get(googleAccountId)).toBe(`${OAUTH_ISSUER_PREFIX}google`);
const [issuerColumn] = await sql<{ is_nullable: string }[]>`
SELECT "is_nullable" FROM "information_schema"."columns"
WHERE "table_name" = 'account' AND "column_name" = 'issuer'
`;
expect(issuerColumn?.is_nullable).toBe("NO");
const indexes = await sql<{ indexname: string }[]>`
SELECT "indexname" FROM "pg_indexes"
WHERE "tablename" = 'account' AND "indexname" = ${UNIQUE_INDEX}
`;
expect(indexes).toHaveLength(1);
// The restored index rejects a second account under the same key.
await expect(
sql`
INSERT INTO "account"
("id", "issuer", "account_id", "provider_id", "user_id", "created_at", "updated_at")
VALUES (${`account-${randomUUID()}`}, ${CREDENTIAL_ISSUER}, ${credentialUserId}, 'credential', ${credentialUserId}, now(), now())
`,
).rejects.toMatchObject({ code: "23505", constraint_name: UNIQUE_INDEX });
}, 30_000);
});

View File

@ -0,0 +1,23 @@
-- Better Auth 1.7 added a required "issuer" field to its `account` model: the
-- account namespace that pairs with "account_id" as the stable provider-side
-- key. Sign-up writes it, sign-in matches on it, and the Drizzle adapter
-- rejects the whole table when the column is missing.
--
-- The column has to land NOT NULL, but an upgraded deployment already has
-- rows, so backfill every one of them before the constraint goes on. The
-- values mirror Better Auth's own issuer helpers: `local:credential` for
-- email/password accounts (createLocalAccountIssuer) and
-- `local:oauth:<provider_id>` for social accounts that do not declare an
-- issuer of their own (createOAuthAccountIssuer). Paperclip only enables
-- email/password today, so in practice every existing row takes the first
-- branch; the second keeps the backfill total rather than leaving a NULL
-- behind that would abort the SET NOT NULL.
ALTER TABLE "account" ADD COLUMN IF NOT EXISTS "issuer" text;--> statement-breakpoint
UPDATE "account"
SET "issuer" = CASE
WHEN "provider_id" = 'credential' THEN 'local:credential'
ELSE 'local:oauth:' || "provider_id"
END
WHERE "issuer" IS NULL;--> statement-breakpoint
ALTER TABLE "account" ALTER COLUMN "issuer" SET NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "account_issuer_account_id_uq" ON "account" USING btree ("issuer","account_id");

File diff suppressed because it is too large Load Diff

View File

@ -1597,6 +1597,13 @@
"when": 1787854369379,
"tag": "0229_drop_company_brand_color_and_attachment_max_bytes",
"breakpoints": true
},
{
"idx": 230,
"version": "7",
"when": 1787880484952,
"tag": "0230_better_auth_account_issuer",
"breakpoints": true
}
]
}

View File

@ -1,4 +1,4 @@
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
import { pgTable, text, timestamp, boolean, uniqueIndex } from "drizzle-orm/pg-core";
export const authUsers = pgTable("user", {
id: text("id").primaryKey(),
@ -21,21 +21,42 @@ export const authSessions = pgTable("session", {
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
});
export const authAccounts = pgTable("account", {
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
});
/**
* Better Auth's `account` model. Every column here has to exist for the
* Drizzle adapter to accept the table at all: the adapter validates the
* model's fields against the schema on each write and throws
* `The field "<name>" does not exist in the "account" Drizzle schema`
* when one is missing, which surfaces as a 500 on sign-up.
*
* `issuer` is the account namespace Better Auth 1.7 added. It is
* `local:credential` for email/password accounts and
* `local:oauth:<providerId>` for social accounts that do not declare an
* issuer of their own, and it pairs with `account_id` as the stable
* provider-side key hence the unique index, which mirrors the
* `(issuer, accountId)` unique index Better Auth declares on the model.
*/
export const authAccounts = pgTable(
"account",
{
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
},
(table) => ({
issuerAccountIdUq: uniqueIndex("account_issuer_account_id_uq").on(table.issuer, table.accountId),
}),
);
export const authVerifications = pgTable("verification", {
id: text("id").primaryKey(),

View File

@ -0,0 +1,123 @@
/**
* End-to-end coverage for the credential sign-up and sign-in that a
* self-hosted install depends on, driven through the real Better Auth mount
* against the real Drizzle schema and a migrated Postgres.
*
* The other Better Auth suites mount either a stub handler or the in-memory
* adapter, so neither notices when Better Auth's `account` model grows a field
* the Drizzle table does not have. The Drizzle adapter validates the model
* against the schema on every write and throws
* `The field "<name>" does not exist in the "account" Drizzle schema`, which
* the mount turns into a 500 with an empty body a fresh install cannot
* create its first user at all. This suite is the one that fails when that
* happens.
*/
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { authAccounts, createDb } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { createBetterAuthHandler, createBetterAuthInstance } from "../auth/better-auth.js";
import type { Config } from "../config.js";
const ORIGIN = "http://127.0.0.1:41999";
const EMAIL = "founder@example.com";
const PASSWORD = "correct-horse-battery-staple";
// The issuer Better Auth stamps on an email/password account:
// `createLocalAccountIssuer("credential")`. Sign-in matches on it, so a row
// written with anything else is a row nobody can sign in as.
const CREDENTIAL_ISSUER = "local:credential";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
function testConfig(): Config {
// Only the auth-relevant fields are read by `createBetterAuthInstance`; the
// rest of `Config` describes storage, backups, and scheduling that the Better
// Auth instance never looks at.
return {
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "explicit",
authPublicBaseUrl: ORIGIN,
authDisableSignUp: false,
allowedHostnames: ["127.0.0.1"],
port: 41999,
} as unknown as Config;
}
function sessionCookies(response: request.Response): string[] {
const raw = response.headers["set-cookie"];
const cookies = Array.isArray(raw) ? raw : raw ? [raw] : [];
return cookies.filter((cookie) => cookie.includes("session_token"));
}
describeEmbeddedPostgres("Better Auth credential sign-up against the real schema", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db!: ReturnType<typeof createDb>;
let app!: express.Express;
const originalEnv = {
secret: process.env.BETTER_AUTH_SECRET,
rateLimit: process.env.PAPERCLIP_AUTH_RATE_LIMIT_ENABLED,
};
beforeAll(async () => {
process.env.BETTER_AUTH_SECRET = "better-auth-secret-for-credential-signup-tests";
// The rate limiter is on by default in `authenticated` mode and would score
// the sign-up and sign-in this suite issues back to back.
process.env.PAPERCLIP_AUTH_RATE_LIMIT_ENABLED = "false";
database = await startEmbeddedPostgresTestDatabase("paperclip-better-auth-signup-");
db = createDb(database.connectionString);
const auth = createBetterAuthInstance(db, testConfig(), [ORIGIN]);
app = express();
// Mounted exactly as `createApp` mounts it, and with no body parser in
// front of it: Better Auth reads the raw request body itself.
app.all("/api/auth/{*authPath}", createBetterAuthHandler(auth));
}, 30_000);
afterAll(async () => {
await database?.cleanup();
if (originalEnv.secret === undefined) delete process.env.BETTER_AUTH_SECRET;
else process.env.BETTER_AUTH_SECRET = originalEnv.secret;
if (originalEnv.rateLimit === undefined) delete process.env.PAPERCLIP_AUTH_RATE_LIMIT_ENABLED;
else process.env.PAPERCLIP_AUTH_RATE_LIMIT_ENABLED = originalEnv.rateLimit;
});
it("creates the user and its credential account, then signs that user in", async () => {
const signUp = await request(app)
.post("/api/auth/sign-up/email")
.set("origin", ORIGIN)
.send({ email: EMAIL, password: PASSWORD, name: "Founder" });
// A missing `account` column fails here with a 500 and an empty body.
expect(signUp.status).toBe(200);
expect(signUp.body?.user?.email).toBe(EMAIL);
// Sign-up has to leave a usable account row behind. A half-created user —
// a `user` row with no `account` — cannot sign in, cannot sign up again,
// and cannot reset its password.
const accounts = await db.select().from(authAccounts);
expect(accounts).toHaveLength(1);
expect(accounts[0]).toMatchObject({
providerId: "credential",
issuer: CREDENTIAL_ISSUER,
});
expect(accounts[0]?.password).toBeTruthy();
const signIn = await request(app)
.post("/api/auth/sign-in/email")
.set("origin", ORIGIN)
.send({ email: EMAIL, password: PASSWORD });
expect(signIn.status).toBe(200);
expect(signIn.body?.user?.email).toBe(EMAIL);
expect(sessionCookies(signIn).length).toBeGreaterThan(0);
});
});