@remit/auth-service 0.0.4 → 0.0.6

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/auth-service",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "better-auth identity service for the Postgres-parity stack (pg-parity)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "scripts": {
23
23
  "test:typecheck": "tsgo --noEmit -p tsconfig.json",
24
- "test:run": "node --import tsx --test 'src/**/*.test.ts'",
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=50 --test 'src/**/*.test.ts'",
25
25
  "test": "npm run test:typecheck && npm run test:run",
26
26
  "auth:schema:generate": "npx @better-auth/cli generate --config src/auth.gen.ts --output src/schema/auth-schema.ts -y",
27
27
  "auth:schema:push": "npx drizzle-kit push",
@@ -30,6 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@better-auth/cli": "^1.4.21",
32
32
  "drizzle-kit": "^0.31.1",
33
+ "embedded-postgres": "^18.4.0-beta.17",
33
34
  "tsx": "*"
34
35
  },
35
36
  "dependencies": {
package/src/auth.ts CHANGED
@@ -4,6 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
4
  import { jwt } from "better-auth/plugins";
5
5
  import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
6
6
  import pg from "pg";
7
+ import { createInstanceOwnerStore } from "./instance-owner.js";
7
8
  import * as authSchema from "./schema/auth-schema.js";
8
9
  import * as authSchemaSqlite from "./schema/auth-schema-sqlite.js";
9
10
 
@@ -80,6 +81,10 @@ export const createAuth = async (
80
81
  config: AuthConfig,
81
82
  ): Promise<BetterAuthInstance> => {
82
83
  const database = await buildAuthAdapter(config);
84
+ const instanceOwnerStore = await createInstanceOwnerStore({
85
+ provider: config.provider === "sqlite" ? "sqlite" : "pg",
86
+ connectionString: config.connectionString,
87
+ });
83
88
 
84
89
  return betterAuth({
85
90
  secret: config.secret,
@@ -119,6 +124,19 @@ export const createAuth = async (
119
124
  session: { modelName: "auth_session" },
120
125
  account: { modelName: "auth_account" },
121
126
  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
+ },
122
140
  plugins: [
123
141
  jwt({
124
142
  schema: { jwks: { modelName: "auth_jwks" } },
package/src/config.ts CHANGED
@@ -28,23 +28,35 @@ export const resolveSelfSignUpEnabled = (
28
28
  return value !== "false" && value !== "0" && value !== "no";
29
29
  };
30
30
 
31
+ export interface DataConnectionConfig {
32
+ provider: "pg" | "sqlite";
33
+ connectionString: string;
34
+ }
35
+
31
36
  /**
32
- * Resolve the better-auth config from the environment. Callers reach this only
33
- * on the self-host relational backends (Postgres or SQLite); the Cognito/AWS
34
- * path never touches better-auth. On `DATA_BACKEND=sqlite` the identity tables
35
- * share the app database file (RFC 036 D3), so the locator is `SQLITE_DB_PATH`;
36
- * otherwise it is the Postgres URL.
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.
37
41
  */
38
- export const resolveAuthConfig = (): AuthConfig => {
39
- const baseURL = required("BETTER_AUTH_URL", process.env.BETTER_AUTH_URL);
42
+ export const resolveDataConnectionConfig = (): DataConnectionConfig => {
40
43
  const provider = process.env.DATA_BACKEND === "sqlite" ? "sqlite" : "pg";
41
44
  const connectionString =
42
45
  provider === "sqlite"
43
46
  ? required("SQLITE_DB_PATH", process.env.SQLITE_DB_PATH)
44
47
  : required("PG_CONNECTION_URL", process.env.PG_CONNECTION_URL);
48
+ return { provider, connectionString };
49
+ };
50
+
51
+ /**
52
+ * 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.
55
+ */
56
+ export const resolveAuthConfig = (): AuthConfig => {
57
+ const baseURL = required("BETTER_AUTH_URL", process.env.BETTER_AUTH_URL);
45
58
  return {
46
- provider,
47
- connectionString,
59
+ ...resolveDataConnectionConfig(),
48
60
  secret: required("BETTER_AUTH_SECRET", process.env.BETTER_AUTH_SECRET),
49
61
  baseURL,
50
62
  trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",")
package/src/index.ts CHANGED
@@ -4,11 +4,18 @@ export {
4
4
  AUTH_BASE_PATH,
5
5
  AUTH_JWKS_PATH,
6
6
  AUTH_TOKEN_PATH,
7
+ type DataConnectionConfig,
7
8
  resolveAuthConfig,
9
+ resolveDataConnectionConfig,
8
10
  resolveSelfSignUpEnabled,
9
11
  resolveVerifierConfig,
10
12
  type VerifierConfig,
11
13
  } from "./config.js";
14
+ export {
15
+ createInstanceOwnerStore,
16
+ type InstanceOwnerStore,
17
+ isInstanceOwner,
18
+ } from "./instance-owner.js";
12
19
  export {
13
20
  createJwtVerifier,
14
21
  extractBearerToken,
@@ -0,0 +1,151 @@
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
+ });
@@ -0,0 +1,335 @@
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
+ });
@@ -0,0 +1,150 @@
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
+ };
@@ -0,0 +1,18 @@
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.
5
+ import { sql } from "drizzle-orm";
6
+ import { check, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
7
+
8
+ export const instance_owner = sqliteTable(
9
+ "instance_owner",
10
+ {
11
+ id: integer("id").primaryKey().default(1),
12
+ userId: text("user_id").notNull(),
13
+ claimedAt: integer("claimed_at", { mode: "timestamp" })
14
+ .$defaultFn(() => /* @__PURE__ */ new Date())
15
+ .notNull(),
16
+ },
17
+ (table) => [check("instance_owner_singleton", sql`${table.id} = 1`)],
18
+ );
@@ -0,0 +1,12 @@
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
+ );