@remit/auth-service 0.0.9 → 0.0.10

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.9",
3
+ "version": "0.0.10",
4
4
  "description": "better-auth identity service for the Postgres-parity stack (pg-parity)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -4,7 +4,6 @@ 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";
8
7
  import * as authSchema from "./schema/auth-schema.js";
9
8
  import * as authSchemaSqlite from "./schema/auth-schema-sqlite.js";
10
9
 
@@ -81,10 +80,6 @@ export const createAuth = async (
81
80
  config: AuthConfig,
82
81
  ): Promise<BetterAuthInstance> => {
83
82
  const database = await buildAuthAdapter(config);
84
- const instanceOwnerStore = await createInstanceOwnerStore({
85
- provider: config.provider === "sqlite" ? "sqlite" : "pg",
86
- connectionString: config.connectionString,
87
- });
88
83
 
89
84
  return betterAuth({
90
85
  secret: config.secret,
@@ -124,19 +119,6 @@ export const createAuth = async (
124
119
  session: { modelName: "auth_session" },
125
120
  account: { modelName: "auth_account" },
126
121
  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
122
  plugins: [
141
123
  jwt({
142
124
  schema: { jwks: { modelName: "auth_jwks" } },
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,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
- };