@remit/auth-service 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@remit/auth-service",
3
- "version": "0.0.9",
4
- "description": "better-auth identity service for the Postgres-parity stack (pg-parity)",
3
+ "version": "0.0.11",
4
+ "description": "better-auth identity service for the self-host stack",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "types": "src/index.ts",
@@ -23,14 +23,9 @@
23
23
  "test:typecheck": "tsgo --noEmit -p tsconfig.json",
24
24
  "test:run": "node --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=94 --test 'src/**/*.test.ts'",
25
25
  "test": "npm run test:typecheck && npm run test:run",
26
- "auth:schema:generate": "npx @better-auth/cli generate --config src/auth.gen.ts --output src/schema/auth-schema.ts -y",
27
- "auth:schema:push": "npx drizzle-kit push",
28
26
  "fix": "biome check --write src"
29
27
  },
30
28
  "devDependencies": {
31
- "@better-auth/cli": "^1.4.21",
32
- "drizzle-kit": "^0.31.1",
33
- "embedded-postgres": "^18.4.0-beta.17",
34
29
  "tsx": "*"
35
30
  },
36
31
  "dependencies": {
@@ -38,7 +33,6 @@
38
33
  "better-sqlite3": "^12.11.1",
39
34
  "drizzle-orm": "^0.45.2",
40
35
  "jose": "^6.2.3",
41
- "pg": "^8.0.0",
42
36
  "@types/better-sqlite3": "^7.6.13"
43
37
  },
44
38
  "license": "MIT",
package/src/auth.ts CHANGED
@@ -2,20 +2,14 @@ import type { Auth as BetterAuthInstance } from "better-auth";
2
2
  import { betterAuth } from "better-auth";
3
3
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
4
  import { jwt } from "better-auth/plugins";
5
- import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
6
- import pg from "pg";
7
- import { createInstanceOwnerStore } from "./instance-owner.js";
8
- import * as authSchema from "./schema/auth-schema.js";
9
5
  import * as authSchemaSqlite from "./schema/auth-schema-sqlite.js";
10
6
 
11
7
  export interface AuthConfig {
12
8
  /**
13
- * The relational backend better-auth runs on, off `DATA_BACKEND` (RFC 036).
14
- * `pg` (the default) reads `connectionString` as a Postgres URL; `sqlite`
15
- * reads it as the path to the shared database file (D3), and drives the
16
- * SQLite identity schema through the drizzle adapter's `sqlite` provider.
9
+ * Path to the shared database file (RFC 036 D3). The identity tables live
10
+ * beside the app tables and are driven through the drizzle adapter's
11
+ * `sqlite` provider.
17
12
  */
18
- provider?: "pg" | "sqlite";
19
13
  connectionString: string;
20
14
  secret: string;
21
15
  baseURL: string;
@@ -28,31 +22,22 @@ export interface AuthConfig {
28
22
  selfSignUpEnabled: boolean;
29
23
  }
30
24
 
31
- // The drizzle adapter better-auth runs against, chosen by backend. better-sqlite3
32
- // and its drizzle driver are imported dynamically so the Postgres path never
33
- // loads the native binding, and the module stays out of any bundle that never
34
- // runs on SQLite. On SQLite the auth connection sets the same WAL/busy_timeout
25
+ // The drizzle adapter better-auth runs against. better-sqlite3 and its drizzle
26
+ // driver are imported dynamically so the module stays out of any bundle that
27
+ // never opens the database. The auth connection sets the same WAL/busy_timeout
35
28
  // coordination every writer uses (RFC 036 D3) — it shares the file with the app
36
29
  // tables through its own connection.
37
30
  const buildAuthAdapter = async (config: AuthConfig) => {
38
- if (config.provider === "sqlite") {
39
- const { default: Database } = await import("better-sqlite3");
40
- const { drizzle } = await import("drizzle-orm/better-sqlite3");
41
- const sqlite = new Database(config.connectionString);
42
- sqlite.pragma("journal_mode = WAL");
43
- sqlite.pragma("busy_timeout = 5000");
44
- sqlite.pragma("synchronous = NORMAL");
45
- sqlite.pragma("foreign_keys = ON");
46
- return drizzleAdapter(drizzle(sqlite, { schema: authSchemaSqlite }), {
47
- provider: "sqlite",
48
- schema: authSchemaSqlite,
49
- });
50
- }
51
-
52
- const pool = new pg.Pool({ connectionString: config.connectionString });
53
- return drizzleAdapter(drizzlePg(pool, { schema: authSchema }), {
54
- provider: "pg",
55
- schema: authSchema,
31
+ const { default: Database } = await import("better-sqlite3");
32
+ const { drizzle } = await import("drizzle-orm/better-sqlite3");
33
+ const sqlite = new Database(config.connectionString);
34
+ sqlite.pragma("journal_mode = WAL");
35
+ sqlite.pragma("busy_timeout = 5000");
36
+ sqlite.pragma("synchronous = NORMAL");
37
+ sqlite.pragma("foreign_keys = ON");
38
+ return drizzleAdapter(drizzle(sqlite, { schema: authSchemaSqlite }), {
39
+ provider: "sqlite",
40
+ schema: authSchemaSqlite,
56
41
  });
57
42
  };
58
43
 
@@ -64,7 +49,7 @@ const intEnv = (name: string, fallback: number): number => {
64
49
  };
65
50
 
66
51
  /**
67
- * Build a better-auth instance bound to the pg-parity Postgres.
52
+ * Build a better-auth instance bound to the self-host database file.
68
53
  *
69
54
  * RS256 is deliberate: the edge tier (APISIX, or the dev-server verifier)
70
55
  * follows JWKS key rotation offline, so signing must be asymmetric. The JWKS is
@@ -81,10 +66,6 @@ export const createAuth = async (
81
66
  config: AuthConfig,
82
67
  ): Promise<BetterAuthInstance> => {
83
68
  const database = await buildAuthAdapter(config);
84
- const instanceOwnerStore = await createInstanceOwnerStore({
85
- provider: config.provider === "sqlite" ? "sqlite" : "pg",
86
- connectionString: config.connectionString,
87
- });
88
69
 
89
70
  return betterAuth({
90
71
  secret: config.secret,
@@ -124,19 +105,6 @@ export const createAuth = async (
124
105
  session: { modelName: "auth_session" },
125
106
  account: { modelName: "auth_account" },
126
107
  verification: { modelName: "auth_verification" },
127
- // RFC 037 D8: the instance owner is whoever registers first. Every
128
- // successful user creation (self-signup or OAuth) attempts the claim;
129
- // the singleton row's primary key is the conditional write, so only the
130
- // first one to land keeps it.
131
- databaseHooks: {
132
- user: {
133
- create: {
134
- after: async (user) => {
135
- await instanceOwnerStore.claimIfUnclaimed(user.id);
136
- },
137
- },
138
- },
139
- },
140
108
  plugins: [
141
109
  jwt({
142
110
  schema: { jwks: { modelName: "auth_jwks" } },
package/src/config.ts CHANGED
@@ -29,29 +29,22 @@ export const resolveSelfSignUpEnabled = (
29
29
  };
30
30
 
31
31
  export interface DataConnectionConfig {
32
- provider: "pg" | "sqlite";
33
32
  connectionString: string;
34
33
  }
35
34
 
36
35
  /**
37
- * Resolve which relational backend identity data lives on, from the same env
38
- * vars the rest of the self-host stack reads (RFC 036 D5). On
39
- * `DATA_BACKEND=sqlite` the identity tables share the app database file, so
40
- * the locator is `SQLITE_DB_PATH`; otherwise it is the Postgres URL.
36
+ * Resolve where identity data lives, from the same env vars the rest of the
37
+ * self-host stack reads (RFC 036 D5). The identity tables share the app
38
+ * database file, so the locator is `SQLITE_DB_PATH`.
41
39
  */
42
- export const resolveDataConnectionConfig = (): DataConnectionConfig => {
43
- const provider = process.env.DATA_BACKEND === "sqlite" ? "sqlite" : "pg";
44
- const connectionString =
45
- provider === "sqlite"
46
- ? required("SQLITE_DB_PATH", process.env.SQLITE_DB_PATH)
47
- : required("PG_CONNECTION_URL", process.env.PG_CONNECTION_URL);
48
- return { provider, connectionString };
49
- };
40
+ export const resolveDataConnectionConfig = (): DataConnectionConfig => ({
41
+ connectionString: required("SQLITE_DB_PATH", process.env.SQLITE_DB_PATH),
42
+ });
50
43
 
51
44
  /**
52
45
  * Resolve the better-auth config from the environment. Callers reach this only
53
- * on the self-host relational backends (Postgres or SQLite); the Cognito/AWS
54
- * path never touches better-auth.
46
+ * on the self-host relational backend; the Cognito/AWS path never touches
47
+ * better-auth.
55
48
  */
56
49
  export const resolveAuthConfig = (): AuthConfig => {
57
50
  const baseURL = required("BETTER_AUTH_URL", process.env.BETTER_AUTH_URL);
package/src/index.ts CHANGED
@@ -11,11 +11,6 @@ export {
11
11
  resolveVerifierConfig,
12
12
  type VerifierConfig,
13
13
  } from "./config.js";
14
- export {
15
- createInstanceOwnerStore,
16
- type InstanceOwnerStore,
17
- isInstanceOwner,
18
- } from "./instance-owner.js";
19
14
  export {
20
15
  createJwtVerifier,
21
16
  extractBearerToken,
@@ -1,9 +1,15 @@
1
- // SQLite twin of auth-schema.ts (RFC 036 D5). Same five better-auth identity
2
- // tables in the SQLite dialect — the shape better-auth's drizzle adapter emits
3
- // with `provider: "sqlite"`: `sqliteTable`, `text` unchanged, `boolean` and
4
- // `timestamp` mapped to `integer` (mode boolean / timestamp). Used only to
5
- // generate the committed sqlite migrations (deploy/vps/migrations-sqlite/auth);
6
- // the Postgres schema stays the source for the pg migrations.
1
+ // The five better-auth identity tables, in the shape better-auth's drizzle
2
+ // adapter emits with `provider: "sqlite"`. Used to generate the committed
3
+ // sqlite migrations (deploy/vps/migrations-sqlite/auth).
4
+ //
5
+ // Hand-maintained, deliberately. The `@better-auth/cli generate` command no
6
+ // longer emits this shape: it writes `mode: "timestamp_ms"` where these columns
7
+ // store seconds, and a SQL `DEFAULT (cast(unixepoch('subsecond') * 1000 as
8
+ // integer))` where these use a JS-side `$defaultFn` and the committed migration
9
+ // has no DEFAULT clause. Taking its output would reinterpret every timestamp on
10
+ // every deployed instance and rewrite all five tables. Match better-auth's
11
+ // runtime expectations by hand when a version bump changes them, and let
12
+ // vps-migrations-drift.sqlite.test.ts catch the migration side.
7
13
  import { relations } from "drizzle-orm";
8
14
  import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
9
15
 
@@ -1,7 +1,5 @@
1
- // SQLite twin of meta-schema.ts. Same shape in the SQLite dialect — used only
2
- // to generate the committed SQLite migrations
3
- // (deploy/vps/migrations-sqlite/meta); the Postgres schema stays the source
4
- // for the pg migrations.
1
+ // The instance-owner singleton, used to generate the committed SQLite
2
+ // migrations (deploy/vps/migrations-sqlite/meta).
5
3
  import { sql } from "drizzle-orm";
6
4
  import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
7
5
 
@@ -29,7 +29,7 @@ describe("resolveSelfSignUpEnabled", () => {
29
29
  });
30
30
 
31
31
  const baseConfig = {
32
- connectionString: "postgresql://remit:remit@localhost:5432/remit_test",
32
+ connectionString: ":memory:",
33
33
  secret: "signup-test-secret-value-32chars-minimum",
34
34
  baseURL: "http://localhost:3000",
35
35
  };
@@ -45,9 +45,8 @@ describe("createAuth self-signup gate", () => {
45
45
  assert.equal(closed.options.emailAndPassword?.disableSignUp, true);
46
46
  });
47
47
 
48
- it("selects the sqlite drizzle provider when configured", async () => {
48
+ it("drives the sqlite drizzle provider", async () => {
49
49
  const auth = await createAuth({
50
- provider: "sqlite",
51
50
  connectionString: ":memory:",
52
51
  secret: baseConfig.secret,
53
52
  baseURL: baseConfig.baseURL,
package/src/verifier.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // JWT-verification-only slice of this package's barrel. The full barrel
2
- // (`index.ts`) re-exports `createAuth` from `./auth.js`, which constructs a
3
- // `pg.Pool` + `drizzle-orm/node-postgres` at module scope, and `toNodeHandler`
4
- // from `better-auth/node` — neither needed to verify a token, both dragging a
5
- // Postgres client into any Lambda bundle that only checks a bearer token (see
6
- // #1242's FAQ, #1244/#1247). `config.ts`'s `AuthConfig` import from `./auth.js`
7
- // is type-only and erases at build, so this stays free of that chain.
2
+ // (`index.ts`) re-exports `createAuth` from `./auth.js` and `toNodeHandler`
3
+ // from `better-auth/node` — neither needed to verify a token, both dragging the
4
+ // whole better-auth server graph into any Lambda bundle that only checks a
5
+ // bearer token (see #1242's FAQ, #1244/#1247). `config.ts`'s `AuthConfig`
6
+ // import from `./auth.js` is type-only and erases at build, so this stays free
7
+ // of that chain.
8
8
 
9
9
  export {
10
10
  AUTH_BASE_PATH,
package/drizzle.config.ts DELETED
@@ -1,24 +0,0 @@
1
- const url =
2
- process.env.DATABASE_URL ??
3
- process.env.PG_CONNECTION_URL ??
4
- "postgresql://remit:remit@localhost:5432/remit_dev";
5
-
6
- /**
7
- * The better-auth identity tables share the pg-parity database with the entity
8
- * schema (remit-drizzle-service). `tablesFilter` scopes drizzle-kit to only the
9
- * auth tables so a push here never proposes dropping the entity tables it does
10
- * not know about.
11
- */
12
- export default {
13
- dialect: "postgresql",
14
- schema: "./src/schema/auth-schema.ts",
15
- out: "./.drizzle",
16
- dbCredentials: { url },
17
- tablesFilter: [
18
- "auth_user",
19
- "auth_session",
20
- "auth_account",
21
- "auth_verification",
22
- "auth_jwks",
23
- ],
24
- };
package/src/auth.gen.ts DELETED
@@ -1,35 +0,0 @@
1
- import type { Auth as BetterAuthInstance } from "better-auth";
2
- import { betterAuth } from "better-auth";
3
- import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
- import { jwt } from "better-auth/plugins";
5
- import { drizzle } from "drizzle-orm/node-postgres";
6
- import pg from "pg";
7
-
8
- /**
9
- * Schema-generation-only config for `@better-auth/cli generate`. It mirrors the
10
- * plugin set of the real `createAuth` so the emitted Drizzle schema matches what
11
- * runtime expects, but omits the schema import to break the generate-time
12
- * chicken-and-egg (the schema file is this command's output).
13
- */
14
- const pool = new pg.Pool({
15
- connectionString:
16
- process.env.PG_CONNECTION_URL ??
17
- "postgresql://remit:remit@localhost:5432/remit_dev",
18
- });
19
-
20
- export const auth = betterAuth({
21
- secret: "schema-generation-only",
22
- baseURL: "http://localhost",
23
- database: drizzleAdapter(drizzle(pool), { provider: "pg" }),
24
- emailAndPassword: { enabled: true },
25
- user: { modelName: "auth_user" },
26
- session: { modelName: "auth_session" },
27
- account: { modelName: "auth_account" },
28
- verification: { modelName: "auth_verification" },
29
- plugins: [
30
- jwt({
31
- schema: { jwks: { modelName: "auth_jwks" } },
32
- jwks: { keyPairConfig: { alg: "RS256", modulusLength: 2048 } },
33
- }),
34
- ],
35
- }) as unknown as BetterAuthInstance;
@@ -1,151 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { rmSync } from "node:fs";
3
- import { after, before, describe, it } from "node:test";
4
- import { pushSchema } from "drizzle-kit/api";
5
- import { drizzle } from "drizzle-orm/node-postgres";
6
- import EmbeddedPostgres from "embedded-postgres";
7
- import { Pool } from "pg";
8
- import { createAuth } from "./auth.js";
9
- import {
10
- createInstanceOwnerStore,
11
- type InstanceOwnerStore,
12
- } from "./instance-owner.js";
13
- import * as authSchema from "./schema/auth-schema.js";
14
- import * as metaSchema from "./schema/meta-schema.js";
15
-
16
- /**
17
- * Real Postgres coverage for RFC 037 D8, using a self-contained embedded
18
- * server (the same tool `drizzle-service`'s pg repo tests use, `before`/
19
- * `after`-scoped the same way) rather than a container — no external service
20
- * required, so this runs in CI unattended. Proves the earlier failure —
21
- * nothing ever migrated `instance_owner` on Postgres, so every registration
22
- * 500s — is closed: this pushes the exact `auth-schema.ts` + `meta-schema.ts`
23
- * tables (the same shape `deploy/vps/migrations/meta` migrates to) and drives
24
- * real signups through `createAuth`.
25
- */
26
- describe("createAuth claims ownership on registration — postgres (RFC 037 D8)", () => {
27
- const combinedSchema = { ...authSchema, ...metaSchema };
28
- const port = 55600 + Math.floor(Math.random() * 400);
29
- const databaseDir = `/tmp/auth-service-test-pg-${port}-${Date.now()}`;
30
- const pg = new EmbeddedPostgres({
31
- databaseDir,
32
- user: "test",
33
- password: "test",
34
- port,
35
- persistent: false,
36
- });
37
- const connectionString = `postgresql://test:test@localhost:${port}/postgres`;
38
- let store: InstanceOwnerStore;
39
- let testPool: Pool;
40
-
41
- before(async () => {
42
- await pg.initialise();
43
- await pg.start();
44
-
45
- const db = drizzle(connectionString, { schema: combinedSchema });
46
- const write = process.stdout.write.bind(process.stdout);
47
- process.stdout.write = (() => true) as typeof process.stdout.write;
48
- try {
49
- const { apply } = await pushSchema(
50
- combinedSchema,
51
- db as unknown as Parameters<typeof pushSchema>[1],
52
- );
53
- await apply();
54
- } finally {
55
- process.stdout.write = write;
56
- }
57
-
58
- store = await createInstanceOwnerStore({
59
- provider: "pg",
60
- connectionString,
61
- });
62
- testPool = new Pool({ connectionString });
63
- });
64
-
65
- after(async () => {
66
- await testPool.end();
67
- await store.close();
68
- await pg.stop();
69
- try {
70
- rmSync(databaseDir, { recursive: true, force: true });
71
- } catch {
72
- // best-effort cleanup
73
- }
74
- });
75
-
76
- const signUp = (
77
- auth: Awaited<ReturnType<typeof createAuth>>,
78
- email: string,
79
- ) =>
80
- auth.handler(
81
- new Request("http://localhost:3000/api/auth/sign-up/email", {
82
- method: "POST",
83
- headers: { "content-type": "application/json" },
84
- body: JSON.stringify({
85
- email,
86
- password: "a-sufficiently-long-password",
87
- name: email,
88
- }),
89
- }),
90
- );
91
-
92
- const resetTables = async (): Promise<void> => {
93
- await testPool.query("DELETE FROM instance_owner");
94
- await testPool.query("DELETE FROM auth_user");
95
- };
96
-
97
- const insertUser = (id: string, email: string, createdAt: Date) =>
98
- testPool.query(
99
- "INSERT INTO auth_user (id, name, email, email_verified, created_at, updated_at) VALUES ($1, $2, $3, false, $4, $4)",
100
- [id, email, email, createdAt],
101
- );
102
-
103
- it("the first real Postgres signup succeeds and claims ownership; the second does not", async () => {
104
- const auth = await createAuth({
105
- provider: "pg",
106
- connectionString,
107
- secret: "instance-owner-pg-test-secret-value-32chars-minimum",
108
- baseURL: "http://localhost:3000",
109
- selfSignUpEnabled: true,
110
- });
111
-
112
- const first = await signUp(auth, "first@example.com");
113
- assert.equal(first.status, 200);
114
- const firstBody = (await first.json()) as { user: { id: string } };
115
-
116
- const second = await signUp(auth, "second@example.com");
117
- assert.equal(second.status, 200);
118
- const secondBody = (await second.json()) as { user: { id: string } };
119
-
120
- assert.equal(await store.isOwner(firstBody.user.id), true);
121
- assert.equal(await store.isOwner(secondBody.user.id), false);
122
- });
123
-
124
- // The two cases below exercise `CLAIM_EARLIEST_USER_SQL_PG` directly — the
125
- // pg twin of the sqlite gate tests — so a typo in the pg `WHERE` cannot ship
126
- // silently and degrade the pg path to a bare conditional insert.
127
- it("a hook resolving out of order does not override the true first registrant", async () => {
128
- await resetTables();
129
- await insertUser("first-registered", "first@example.com", new Date(1000));
130
- await insertUser("second-registered", "second@example.com", new Date(2000));
131
-
132
- // second-registered's hook lands first — simulating it winning a race —
133
- // but it registered after first-registered, so it must not become owner.
134
- await store.claimIfUnclaimed("second-registered");
135
- await store.claimIfUnclaimed("first-registered");
136
-
137
- assert.equal(await store.isOwner("first-registered"), true);
138
- assert.equal(await store.isOwner("second-registered"), false);
139
- });
140
-
141
- it("a claim for a user that was never committed (a rolled-back registration) does not land", async () => {
142
- await resetTables();
143
-
144
- await store.claimIfUnclaimed("rolled-back-user");
145
- assert.equal(await store.isOwner("rolled-back-user"), false);
146
-
147
- await insertUser("real-user", "real@example.com", new Date(1000));
148
- await store.claimIfUnclaimed("real-user");
149
- assert.equal(await store.isOwner("real-user"), true);
150
- });
151
- });
@@ -1,335 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { after, afterEach, describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
- import Database from "better-sqlite3";
7
- import { pushSQLiteSchema } from "drizzle-kit/api";
8
- import { drizzle } from "drizzle-orm/better-sqlite3";
9
- import { createAuth } from "./auth.js";
10
- import {
11
- _setInstanceOwnerStoreForTest,
12
- createInstanceOwnerStore,
13
- type InstanceOwnerStore,
14
- isInstanceOwner,
15
- } from "./instance-owner.js";
16
- import * as authSchemaSqlite from "./schema/auth-schema-sqlite.js";
17
- import * as metaSchemaSqlite from "./schema/meta-schema-sqlite.js";
18
-
19
- const combinedSchema = { ...authSchemaSqlite, ...metaSchemaSqlite };
20
-
21
- const tmpRoot = join(
22
- fileURLToPath(new URL(".", import.meta.url)),
23
- "..",
24
- ".tmp",
25
- "instance-owner",
26
- );
27
-
28
- after(() => {
29
- rmSync(tmpRoot, { recursive: true, force: true });
30
- });
31
-
32
- const makeDbPath = (): string => {
33
- mkdirSync(tmpRoot, { recursive: true });
34
- return join(mkdtempSync(join(tmpRoot, "db-")), "app.db");
35
- };
36
-
37
- /**
38
- * Provision a fresh sqlite file with the auth + meta tables so a test can
39
- * exercise the real dialect a self-host deployment runs — the pushed shape
40
- * derives from the same drizzle table objects the committed migrations do
41
- * (see the drift guard in drizzle-service).
42
- */
43
- const provisionSchema = async (dbPath: string): Promise<void> => {
44
- const sqlite = new Database(dbPath);
45
- const db = drizzle(sqlite, { schema: combinedSchema });
46
- const write = process.stdout.write.bind(process.stdout);
47
- process.stdout.write = (() => true) as typeof process.stdout.write;
48
- let statementsToExecute: string[];
49
- try {
50
- ({ statementsToExecute } = await pushSQLiteSchema(
51
- combinedSchema,
52
- db as unknown as Parameters<typeof pushSQLiteSchema>[1],
53
- ));
54
- } finally {
55
- process.stdout.write = write;
56
- }
57
- for (const statement of statementsToExecute) sqlite.exec(statement);
58
- sqlite.close();
59
- };
60
-
61
- const insertUser = (
62
- dbPath: string,
63
- id: string,
64
- email: string,
65
- createdAtMs: number = Date.now(),
66
- ): void => {
67
- const sqlite = new Database(dbPath);
68
- sqlite
69
- .prepare(
70
- "INSERT INTO auth_user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)",
71
- )
72
- .run(id, email, email, createdAtMs, createdAtMs);
73
- sqlite.close();
74
- };
75
-
76
- const userIdByEmail = (dbPath: string, email: string): string => {
77
- const sqlite = new Database(dbPath);
78
- const row = sqlite
79
- .prepare("SELECT id FROM auth_user WHERE email = ?")
80
- .get(email) as { id: string } | undefined;
81
- sqlite.close();
82
- if (!row) throw new Error(`no such user: ${email}`);
83
- return row.id;
84
- };
85
-
86
- describe("createInstanceOwnerStore (sqlite)", () => {
87
- it("claims ownership for the first-registered user and ignores later claims", async () => {
88
- const dbPath = makeDbPath();
89
- await provisionSchema(dbPath);
90
- insertUser(dbPath, "user-1", "first@example.com", 1000);
91
- insertUser(dbPath, "user-2", "second@example.com", 2000);
92
- const store = await createInstanceOwnerStore({
93
- provider: "sqlite",
94
- connectionString: dbPath,
95
- });
96
-
97
- await store.claimIfUnclaimed("user-1");
98
- await store.claimIfUnclaimed("user-2");
99
-
100
- assert.equal(await store.isOwner("user-1"), true);
101
- assert.equal(await store.isOwner("user-2"), false);
102
- await store.close();
103
- });
104
-
105
- it("produces exactly one owner under concurrent first claims", async () => {
106
- const dbPath = makeDbPath();
107
- await provisionSchema(dbPath);
108
- insertUser(dbPath, "user-a", "a@example.com", 1000);
109
- insertUser(dbPath, "user-b", "b@example.com", 1000);
110
- const store = await createInstanceOwnerStore({
111
- provider: "sqlite",
112
- connectionString: dbPath,
113
- });
114
-
115
- await Promise.all([
116
- store.claimIfUnclaimed("user-a"),
117
- store.claimIfUnclaimed("user-b"),
118
- ]);
119
-
120
- const aIsOwner = await store.isOwner("user-a");
121
- const bIsOwner = await store.isOwner("user-b");
122
- assert.notEqual(aIsOwner, bIsOwner);
123
- await store.close();
124
- });
125
-
126
- it("a hook resolving out of order does not override the true first registrant", async () => {
127
- const dbPath = makeDbPath();
128
- await provisionSchema(dbPath);
129
- insertUser(dbPath, "first-registered", "first@example.com", 1000);
130
- insertUser(dbPath, "second-registered", "second@example.com", 2000);
131
- const store = await createInstanceOwnerStore({
132
- provider: "sqlite",
133
- connectionString: dbPath,
134
- });
135
-
136
- // second-registered's hook lands first — simulating it winning a race —
137
- // but it registered after first-registered, so it must not become owner.
138
- await store.claimIfUnclaimed("second-registered");
139
- await store.claimIfUnclaimed("first-registered");
140
-
141
- assert.equal(await store.isOwner("first-registered"), true);
142
- assert.equal(await store.isOwner("second-registered"), false);
143
- await store.close();
144
- });
145
-
146
- it("a claim for a user that was never committed (a rolled-back registration) does not land", async () => {
147
- const dbPath = makeDbPath();
148
- await provisionSchema(dbPath);
149
- const store = await createInstanceOwnerStore({
150
- provider: "sqlite",
151
- connectionString: dbPath,
152
- });
153
-
154
- await store.claimIfUnclaimed("rolled-back-user");
155
- assert.equal(await store.isOwner("rolled-back-user"), false);
156
-
157
- insertUser(dbPath, "real-user", "real@example.com", 1000);
158
- await store.claimIfUnclaimed("real-user");
159
- assert.equal(await store.isOwner("real-user"), true);
160
- await store.close();
161
- });
162
-
163
- it("claiming again for the already-claimed owner is a harmless no-op", async () => {
164
- const dbPath = makeDbPath();
165
- await provisionSchema(dbPath);
166
- insertUser(dbPath, "user-1", "first@example.com", 1000);
167
- const store = await createInstanceOwnerStore({
168
- provider: "sqlite",
169
- connectionString: dbPath,
170
- });
171
-
172
- await store.claimIfUnclaimed("user-1");
173
- await store.claimIfUnclaimed("user-1");
174
- await store.claimIfUnclaimed("user-1");
175
-
176
- assert.equal(await store.isOwner("user-1"), true);
177
- await store.close();
178
- });
179
-
180
- it("REMIT_OWNER_EMAIL resolves to a user and ignores the stored claim", async () => {
181
- const dbPath = makeDbPath();
182
- await provisionSchema(dbPath);
183
- insertUser(dbPath, "user-1", "first@example.com", 1000);
184
- insertUser(dbPath, "user-2", "second@example.com", 2000);
185
- const store = await createInstanceOwnerStore({
186
- provider: "sqlite",
187
- connectionString: dbPath,
188
- });
189
- await store.claimIfUnclaimed("user-1");
190
- assert.equal(await store.isOwner("user-1"), true);
191
-
192
- assert.equal(await store.isOwner("user-1", "second@example.com"), false);
193
- assert.equal(await store.isOwner("user-2", "second@example.com"), true);
194
- await store.close();
195
- });
196
-
197
- it("REMIT_OWNER_EMAIL naming no account makes every caller a non-owner", async () => {
198
- const dbPath = makeDbPath();
199
- await provisionSchema(dbPath);
200
- insertUser(dbPath, "user-1", "first@example.com", 1000);
201
- const store = await createInstanceOwnerStore({
202
- provider: "sqlite",
203
- connectionString: dbPath,
204
- });
205
- await store.claimIfUnclaimed("user-1");
206
- assert.equal(await store.isOwner("user-1"), true);
207
-
208
- assert.equal(await store.isOwner("user-1", "nobody@example.com"), false);
209
- await store.close();
210
- });
211
- });
212
-
213
- describe("isInstanceOwner", () => {
214
- afterEach(() => {
215
- _setInstanceOwnerStoreForTest(null);
216
- delete process.env.REMIT_OWNER_EMAIL;
217
- });
218
-
219
- it("passes REMIT_OWNER_EMAIL through to the store when set", async () => {
220
- const calls: Array<string | undefined> = [];
221
- const stub: InstanceOwnerStore = {
222
- claimIfUnclaimed: async () => {},
223
- isOwner: async (_userId, ownerEmail) => {
224
- calls.push(ownerEmail);
225
- return ownerEmail === "owner@example.com";
226
- },
227
- close: async () => {},
228
- };
229
- _setInstanceOwnerStoreForTest(stub);
230
- process.env.REMIT_OWNER_EMAIL = "owner@example.com";
231
-
232
- assert.equal(await isInstanceOwner("user-1"), true);
233
- assert.deepEqual(calls, ["owner@example.com"]);
234
- });
235
-
236
- it("omits ownerEmail when REMIT_OWNER_EMAIL is unset", async () => {
237
- let received: string | undefined = "unset";
238
- const stub: InstanceOwnerStore = {
239
- claimIfUnclaimed: async () => {},
240
- isOwner: async (_userId, ownerEmail) => {
241
- received = ownerEmail;
242
- return false;
243
- },
244
- close: async () => {},
245
- };
246
- _setInstanceOwnerStoreForTest(stub);
247
-
248
- await isInstanceOwner("user-1");
249
-
250
- assert.equal(received, undefined);
251
- });
252
- });
253
-
254
- describe("createAuth claims ownership on registration (RFC 037 D8)", () => {
255
- const baseConfig = {
256
- secret: "instance-owner-test-secret-value-32chars-minimum",
257
- baseURL: "http://localhost:3000",
258
- selfSignUpEnabled: true,
259
- };
260
-
261
- const signUp = (
262
- auth: Awaited<ReturnType<typeof createAuth>>,
263
- email: string,
264
- ) =>
265
- auth.handler(
266
- new Request("http://localhost:3000/api/auth/sign-up/email", {
267
- method: "POST",
268
- headers: { "content-type": "application/json" },
269
- body: JSON.stringify({
270
- email,
271
- password: "a-sufficiently-long-password",
272
- name: email,
273
- }),
274
- }),
275
- );
276
-
277
- it("the first successful registration claims ownership; the second does not", async () => {
278
- const dbPath = makeDbPath();
279
- await provisionSchema(dbPath);
280
- const auth = await createAuth({
281
- ...baseConfig,
282
- provider: "sqlite",
283
- connectionString: dbPath,
284
- });
285
-
286
- const first = await signUp(auth, "first@example.com");
287
- assert.equal(first.status, 200);
288
- const second = await signUp(auth, "second@example.com");
289
- assert.equal(second.status, 200);
290
-
291
- const store = await createInstanceOwnerStore({
292
- provider: "sqlite",
293
- connectionString: dbPath,
294
- });
295
- assert.equal(
296
- await store.isOwner(userIdByEmail(dbPath, "first@example.com")),
297
- true,
298
- );
299
- assert.equal(
300
- await store.isOwner(userIdByEmail(dbPath, "second@example.com")),
301
- false,
302
- );
303
- await store.close();
304
- });
305
-
306
- it("concurrent first registrations produce exactly one owner", async () => {
307
- const dbPath = makeDbPath();
308
- await provisionSchema(dbPath);
309
- const auth = await createAuth({
310
- ...baseConfig,
311
- provider: "sqlite",
312
- connectionString: dbPath,
313
- });
314
-
315
- const [first, second] = await Promise.all([
316
- signUp(auth, "racer-a@example.com"),
317
- signUp(auth, "racer-b@example.com"),
318
- ]);
319
- assert.equal(first.status, 200);
320
- assert.equal(second.status, 200);
321
-
322
- const store = await createInstanceOwnerStore({
323
- provider: "sqlite",
324
- connectionString: dbPath,
325
- });
326
- const aIsOwner = await store.isOwner(
327
- userIdByEmail(dbPath, "racer-a@example.com"),
328
- );
329
- const bIsOwner = await store.isOwner(
330
- userIdByEmail(dbPath, "racer-b@example.com"),
331
- );
332
- assert.notEqual(aIsOwner, bIsOwner);
333
- await store.close();
334
- });
335
- });
@@ -1,150 +0,0 @@
1
- import { eq } from "drizzle-orm";
2
- import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
3
- import pg from "pg";
4
- import type { DataConnectionConfig } from "./config.js";
5
- import { resolveDataConnectionConfig } from "./config.js";
6
- import { auth_user as auth_user_pg } from "./schema/auth-schema.js";
7
- import { auth_user as auth_user_sqlite } from "./schema/auth-schema-sqlite.js";
8
- import { instance_owner as instance_owner_pg } from "./schema/meta-schema.js";
9
- import { instance_owner as instance_owner_sqlite } from "./schema/meta-schema-sqlite.js";
10
-
11
- const OWNER_ROW_ID = 1;
12
-
13
- /**
14
- * Gated on `userId` being `auth_user`'s own earliest row right now, not
15
- * merely "no owner claimed yet" — hook-completion order is not write order,
16
- * so a later registrant's hook can otherwise settle first. It also covers
17
- * better-auth queuing `create.after` to run even when the surrounding
18
- * transaction rolled back (the OAuth create-user-and-account path shares one
19
- * transaction): a rolled-back id matches no committed row, so the claim
20
- * resolves to nothing. The primary key stays as a backstop for a tie on
21
- * `created_at`, resolved identically on both sides by the `id ASC` sort.
22
- */
23
- const CLAIM_EARLIEST_USER_SQL_PG = `
24
- INSERT INTO instance_owner (id, user_id, claimed_at)
25
- SELECT ${OWNER_ROW_ID}, $1, $2
26
- WHERE $1 = (SELECT id FROM auth_user ORDER BY created_at ASC, id ASC LIMIT 1)
27
- ON CONFLICT (id) DO NOTHING
28
- `;
29
-
30
- const CLAIM_EARLIEST_USER_SQL_SQLITE = `
31
- INSERT INTO instance_owner (id, user_id, claimed_at)
32
- SELECT ${OWNER_ROW_ID}, ?, ?
33
- WHERE ? = (SELECT id FROM auth_user ORDER BY created_at ASC, id ASC LIMIT 1)
34
- ON CONFLICT (id) DO NOTHING
35
- `;
36
-
37
- export interface InstanceOwnerStore {
38
- /**
39
- * Attempt to claim ownership for `userId`. A no-op unless `userId` is
40
- * genuinely the earliest-created row in `auth_user` and no owner is
41
- * claimed yet — see `CLAIM_EARLIEST_USER_SQL_PG` above for why that is
42
- * stricter than "no owner claimed yet" alone.
43
- */
44
- claimIfUnclaimed(userId: string): Promise<void>;
45
- /**
46
- * `ownerEmail`, when given, is `REMIT_OWNER_EMAIL` resolved to a user and
47
- * compared directly — the stored claim is never consulted, so an email
48
- * with no matching account makes every caller a non-owner.
49
- */
50
- isOwner(userId: string, ownerEmail?: string): Promise<boolean>;
51
- close(): Promise<void>;
52
- }
53
-
54
- const buildPgStore = (connectionString: string): InstanceOwnerStore => {
55
- const pool = new pg.Pool({ connectionString });
56
- const db = drizzlePg(pool);
57
-
58
- return {
59
- async claimIfUnclaimed(userId) {
60
- await pool.query(CLAIM_EARLIEST_USER_SQL_PG, [userId, new Date()]);
61
- },
62
- async isOwner(userId, ownerEmail) {
63
- if (ownerEmail) {
64
- const [user] = await db
65
- .select({ id: auth_user_pg.id })
66
- .from(auth_user_pg)
67
- .where(eq(auth_user_pg.email, ownerEmail.toLowerCase()));
68
- return user?.id === userId;
69
- }
70
- const [row] = await db
71
- .select({ userId: instance_owner_pg.userId })
72
- .from(instance_owner_pg)
73
- .where(eq(instance_owner_pg.id, OWNER_ROW_ID));
74
- return row?.userId === userId;
75
- },
76
- async close() {
77
- await pool.end();
78
- },
79
- };
80
- };
81
-
82
- const buildSqliteStore = async (
83
- connectionString: string,
84
- ): Promise<InstanceOwnerStore> => {
85
- const { default: Database } = await import("better-sqlite3");
86
- const { drizzle } = await import("drizzle-orm/better-sqlite3");
87
- const sqlite = new Database(connectionString);
88
- sqlite.pragma("journal_mode = WAL");
89
- sqlite.pragma("busy_timeout = 5000");
90
- const db = drizzle(sqlite);
91
-
92
- return {
93
- async claimIfUnclaimed(userId) {
94
- sqlite
95
- .prepare(CLAIM_EARLIEST_USER_SQL_SQLITE)
96
- .run(userId, Date.now(), userId);
97
- },
98
- async isOwner(userId, ownerEmail) {
99
- if (ownerEmail) {
100
- const [user] = await db
101
- .select({ id: auth_user_sqlite.id })
102
- .from(auth_user_sqlite)
103
- .where(eq(auth_user_sqlite.email, ownerEmail.toLowerCase()));
104
- return user?.id === userId;
105
- }
106
- const [row] = await db
107
- .select({ userId: instance_owner_sqlite.userId })
108
- .from(instance_owner_sqlite)
109
- .where(eq(instance_owner_sqlite.id, OWNER_ROW_ID));
110
- return row?.userId === userId;
111
- },
112
- async close() {
113
- sqlite.close();
114
- },
115
- };
116
- };
117
-
118
- export const createInstanceOwnerStore = (
119
- config: DataConnectionConfig,
120
- ): Promise<InstanceOwnerStore> =>
121
- config.provider === "sqlite"
122
- ? buildSqliteStore(config.connectionString)
123
- : Promise.resolve(buildPgStore(config.connectionString));
124
-
125
- let defaultStore: Promise<InstanceOwnerStore> | null = null;
126
-
127
- const getDefaultStore = (): Promise<InstanceOwnerStore> => {
128
- if (!defaultStore) {
129
- defaultStore = createInstanceOwnerStore(resolveDataConnectionConfig());
130
- }
131
- return defaultStore;
132
- };
133
-
134
- /** Test-only override for the memoized default store. Pass null to reset. */
135
- export const _setInstanceOwnerStoreForTest = (
136
- store: InstanceOwnerStore | null,
137
- ): void => {
138
- defaultStore = store ? Promise.resolve(store) : null;
139
- };
140
-
141
- /**
142
- * Whether `userId` may trigger a standalone self-update (RFC 037 D8). Set
143
- * `REMIT_OWNER_EMAIL` overrides the stored claim entirely, including when it
144
- * names an account other than the one that claimed ownership.
145
- */
146
- export const isInstanceOwner = async (userId: string): Promise<boolean> => {
147
- const store = await getDefaultStore();
148
- const ownerEmail = process.env.REMIT_OWNER_EMAIL?.trim();
149
- return store.isOwner(userId, ownerEmail || undefined);
150
- };
@@ -1,101 +0,0 @@
1
- import { relations } from "drizzle-orm";
2
- import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
3
-
4
- export const auth_user = pgTable("auth_user", {
5
- id: text("id").primaryKey(),
6
- name: text("name").notNull(),
7
- email: text("email").notNull().unique(),
8
- emailVerified: boolean("email_verified").default(false).notNull(),
9
- image: text("image"),
10
- createdAt: timestamp("created_at").defaultNow().notNull(),
11
- updatedAt: timestamp("updated_at")
12
- .defaultNow()
13
- .$onUpdate(() => /* @__PURE__ */ new Date())
14
- .notNull(),
15
- });
16
-
17
- export const auth_session = pgTable(
18
- "auth_session",
19
- {
20
- id: text("id").primaryKey(),
21
- expiresAt: timestamp("expires_at").notNull(),
22
- token: text("token").notNull().unique(),
23
- createdAt: timestamp("created_at").defaultNow().notNull(),
24
- updatedAt: timestamp("updated_at")
25
- .$onUpdate(() => /* @__PURE__ */ new Date())
26
- .notNull(),
27
- ipAddress: text("ip_address"),
28
- userAgent: text("user_agent"),
29
- userId: text("user_id")
30
- .notNull()
31
- .references(() => auth_user.id, { onDelete: "cascade" }),
32
- },
33
- (table) => [index("auth_session_userId_idx").on(table.userId)],
34
- );
35
-
36
- export const auth_account = pgTable(
37
- "auth_account",
38
- {
39
- id: text("id").primaryKey(),
40
- accountId: text("account_id").notNull(),
41
- providerId: text("provider_id").notNull(),
42
- userId: text("user_id")
43
- .notNull()
44
- .references(() => auth_user.id, { onDelete: "cascade" }),
45
- accessToken: text("access_token"),
46
- refreshToken: text("refresh_token"),
47
- idToken: text("id_token"),
48
- accessTokenExpiresAt: timestamp("access_token_expires_at"),
49
- refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
50
- scope: text("scope"),
51
- password: text("password"),
52
- createdAt: timestamp("created_at").defaultNow().notNull(),
53
- updatedAt: timestamp("updated_at")
54
- .$onUpdate(() => /* @__PURE__ */ new Date())
55
- .notNull(),
56
- },
57
- (table) => [index("auth_account_userId_idx").on(table.userId)],
58
- );
59
-
60
- export const auth_verification = pgTable(
61
- "auth_verification",
62
- {
63
- id: text("id").primaryKey(),
64
- identifier: text("identifier").notNull(),
65
- value: text("value").notNull(),
66
- expiresAt: timestamp("expires_at").notNull(),
67
- createdAt: timestamp("created_at").defaultNow().notNull(),
68
- updatedAt: timestamp("updated_at")
69
- .defaultNow()
70
- .$onUpdate(() => /* @__PURE__ */ new Date())
71
- .notNull(),
72
- },
73
- (table) => [index("auth_verification_identifier_idx").on(table.identifier)],
74
- );
75
-
76
- export const auth_jwks = pgTable("auth_jwks", {
77
- id: text("id").primaryKey(),
78
- publicKey: text("public_key").notNull(),
79
- privateKey: text("private_key").notNull(),
80
- createdAt: timestamp("created_at").notNull(),
81
- expiresAt: timestamp("expires_at"),
82
- });
83
-
84
- export const auth_userRelations = relations(auth_user, ({ many }) => ({
85
- auth_sessions: many(auth_session),
86
- auth_accounts: many(auth_account),
87
- }));
88
-
89
- export const auth_sessionRelations = relations(auth_session, ({ one }) => ({
90
- auth_user: one(auth_user, {
91
- fields: [auth_session.userId],
92
- references: [auth_user.id],
93
- }),
94
- }));
95
-
96
- export const auth_accountRelations = relations(auth_account, ({ one }) => ({
97
- auth_user: one(auth_user, {
98
- fields: [auth_account.userId],
99
- references: [auth_user.id],
100
- }),
101
- }));
@@ -1,12 +0,0 @@
1
- import { sql } from "drizzle-orm";
2
- import { check, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core";
3
-
4
- export const instance_owner = pgTable(
5
- "instance_owner",
6
- {
7
- id: integer("id").primaryKey().default(1),
8
- userId: text("user_id").notNull(),
9
- claimedAt: timestamp("claimed_at").defaultNow().notNull(),
10
- },
11
- (table) => [check("instance_owner_singleton", sql`${table.id} = 1`)],
12
- );