@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 +2 -8
- package/src/auth.ts +17 -49
- package/src/config.ts +8 -15
- package/src/index.ts +0 -5
- package/src/schema/auth-schema-sqlite.ts +12 -6
- package/src/schema/meta-schema-sqlite.ts +2 -4
- package/src/signup.test.ts +2 -3
- package/src/verifier.ts +6 -6
- package/drizzle.config.ts +0 -24
- package/src/auth.gen.ts +0 -35
- package/src/instance-owner.pg.test.ts +0 -151
- package/src/instance-owner.test.ts +0 -335
- package/src/instance-owner.ts +0 -150
- package/src/schema/auth-schema.ts +0 -101
- package/src/schema/meta-schema.ts +0 -12
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/auth-service",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "better-auth identity service for the
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
54
|
-
*
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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
|
|
package/src/signup.test.ts
CHANGED
|
@@ -29,7 +29,7 @@ describe("resolveSelfSignUpEnabled", () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
const baseConfig = {
|
|
32
|
-
connectionString: "
|
|
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("
|
|
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
});
|
package/src/instance-owner.ts
DELETED
|
@@ -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
|
-
);
|