-
Notifications
You must be signed in to change notification settings - Fork 210
fix(auth): migrate Better Auth to 1.7.1 #656
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
610bf29
f8437d7
ad8a0ac
17816f2
d69e425
0f86b24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { describe, expect, mock, test } from "bun:test"; | ||
|
|
||
| const ratelimit = mock(async () => ({ | ||
| limit: 3, | ||
| remaining: 2, | ||
| reset: Date.now() + 60_000, | ||
| success: true, | ||
| })); | ||
|
|
||
| mock.module("@databuddy/redis", () => ({ ratelimit })); | ||
|
|
||
| const { createAuthRateLimitStorage } = await import("./rate-limit-storage"); | ||
|
|
||
| describe("createAuthRateLimitStorage", () => { | ||
| test("atomically delegates the request rule and allows successful requests", async () => { | ||
| resetRateLimit(); | ||
| const storage = createAuthRateLimitStorage(); | ||
|
|
||
| await expect( | ||
| storage.consume("auth:sign-in", { max: 3, window: 60 }) | ||
| ).resolves.toEqual({ allowed: true, retryAfter: null }); | ||
| expect(ratelimit).toHaveBeenCalledWith("auth:sign-in", 3, 60); | ||
| }); | ||
|
|
||
| test("maps blocked requests to a positive retry delay", async () => { | ||
| resetRateLimit({ | ||
| success: false, | ||
| reset: Date.now() + 2_000, | ||
| }); | ||
| const storage = createAuthRateLimitStorage(); | ||
|
|
||
| await expect( | ||
| storage.consume("auth:sign-in", { max: 3, window: 60 }) | ||
| ).resolves.toEqual({ allowed: false, retryAfter: 2 }); | ||
| }); | ||
|
|
||
| test("never returns a zero-second retry delay", async () => { | ||
| resetRateLimit({ success: false, reset: Date.now() }); | ||
| const storage = createAuthRateLimitStorage(); | ||
|
|
||
| await expect( | ||
| storage.consume("auth:sign-in", { max: 3, window: 60 }) | ||
| ).resolves.toEqual({ allowed: false, retryAfter: 1 }); | ||
| }); | ||
|
Comment on lines
+14
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win Add coverage for degraded Redis responses.
🤖 Prompt for AI Agents |
||
| }); | ||
|
|
||
| function resetRateLimit( | ||
| override: Partial<Awaited<ReturnType<typeof ratelimit>>> = {} | ||
| ) { | ||
| ratelimit.mockReset(); | ||
| ratelimit.mockResolvedValue({ | ||
| limit: 3, | ||
| remaining: 2, | ||
| reset: Date.now() + 60_000, | ||
| success: true, | ||
| ...override, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,20 @@ | ||||||||||||||||||
| import { ratelimit } from "@databuddy/redis"; | ||||||||||||||||||
|
|
||||||||||||||||||
| export interface AuthRateLimitRule { | ||||||||||||||||||
| max: number; | ||||||||||||||||||
| window: number; | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+3
to
+6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Use Replace the interface with an equivalent type alias. As per coding guidelines, Proposed fix-export interface AuthRateLimitRule {
+export type AuthRateLimitRule = {
max: number;
window: number;
-}
+};📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Coding guidelines |
||||||||||||||||||
|
|
||||||||||||||||||
| export function createAuthRateLimitStorage() { | ||||||||||||||||||
| return { | ||||||||||||||||||
| consume: async (key: string, rule: AuthRateLimitRule) => { | ||||||||||||||||||
| const result = await ratelimit(key, rule.max, rule.window); | ||||||||||||||||||
| return { | ||||||||||||||||||
| allowed: result.success, | ||||||||||||||||||
| retryAfter: result.success | ||||||||||||||||||
| ? null | ||||||||||||||||||
| : Math.max(1, Math.ceil((result.reset - Date.now()) / 1000)), | ||||||||||||||||||
| }; | ||||||||||||||||||
| }, | ||||||||||||||||||
| }; | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { getTableConfig } from "drizzle-orm/pg-core"; | ||
| import { account, twoFactor } from "./auth"; | ||
|
|
||
| describe("Better Auth 1.7 account identity schema", () => { | ||
| test("requires a trusted issuer on every account row", () => { | ||
| const issuer = getTableConfig(account).columns.find( | ||
| (column) => column.name === "issuer" | ||
| ); | ||
|
|
||
| expect(issuer?.notNull).toBe(true); | ||
| expect(issuer?.dataType).toBe("string"); | ||
| }); | ||
|
|
||
| test("uniquely scopes provider account IDs by issuer", () => { | ||
| const identityIndex = getTableConfig(account).indexes.find( | ||
| (index) => index.config.name === "accounts_issuer_account_unique" | ||
| ); | ||
|
|
||
| expect(identityIndex?.config.unique).toBe(true); | ||
| expect(identityIndex?.config.columns.map((column) => column.name)).toEqual([ | ||
| "issuer", | ||
| "account_id", | ||
| ]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("Better Auth two-factor schema", () => { | ||
| test("supports verified enrollment and account lockout state", () => { | ||
| const columns = new Map( | ||
| getTableConfig(twoFactor).columns.map((column) => [column.name, column]) | ||
| ); | ||
|
|
||
| expect(columns.get("verified")?.notNull).toBe(true); | ||
| expect(columns.get("verified")?.default).toBe(true); | ||
| expect(columns.get("failed_verification_count")?.notNull).toBe(true); | ||
| expect(columns.get("failed_verification_count")?.default).toBe(0); | ||
| expect(columns.get("locked_until")?.notNull).toBe(false); | ||
| expect(columns.get("locked_until")?.dataType).toBe("object date"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import { | ||
| boolean, | ||
| foreignKey, | ||
| integer, | ||
| index, | ||
| jsonb, | ||
| pgEnum, | ||
|
|
@@ -128,6 +129,7 @@ export const account = pgTable( | |
| id: text().primaryKey().notNull(), | ||
| accountId: text("account_id").notNull(), | ||
| providerId: text("provider_id").notNull(), | ||
| issuer: text().notNull(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
fd -a '^package\.json$' packages/auth -x rg -n -C2 '"better-auth"' {}
ast-grep outline packages/db/src/drizzle/schema/auth.ts --items all
rg -n -C4 'twoFactor|verified|failedVerificationCount|lockedUntil' \
packages/db/src/drizzle/schema/auth.ts \
packages/db/src/drizzle/schema/auth.test.tsRepository: databuddy-analytics/Databuddy Length of output: 4156 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- account and two_factor schema ---'
sed -n '125,180p' packages/db/src/drizzle/schema/auth.ts
sed -n '320,350p' packages/db/src/drizzle/schema/auth.ts
printf '%s\n' '--- migration files and schema references ---'
git ls-files | rg '(^|/)(drizzle|migrations?)(/|$)|auth.*(migration|schema)|schema.*auth'
rg -n -C3 'two_factor|twoFactor|failed_verification|locked_until|verified' \
packages/db . \
-g '!node_modules' -g '!dist' -g '!build' \
| head -n 300
printf '%s\n' '--- Better Auth package metadata and local type/source references ---'
rg -n -C3 'better-auth|failedVerificationCount|lockedUntil|twoFactor' \
package.json bun.lockb bun.lock packages/*/package.json packages/*/src \
-g '!node_modules' 2>/dev/null | head -n 300 || trueRepository: databuddy-analytics/Databuddy Length of output: 49330 🌐 Web query:
💡 Result: In Better Auth, the twoFactor table schema was recently updated to include Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
python3 - "$tmpdir" <<'PY'
import json
import pathlib
import sys
import tarfile
import urllib.request
out = pathlib.Path(sys.argv[1])
metadata = json.load(urllib.request.urlopen("https://registry.npmjs.org/better-auth/1.7.1"))
tarball = out / "better-auth-1.7.1.tgz"
urllib.request.urlretrieve(metadata["dist"]["tarball"], tarball)
with tarfile.open(tarball) as archive:
names = archive.getnames()
matches = [
name for name in names
if any(term in name.lower() for term in ("two-factor", "twofactor", "2fa", "schema"))
]
print("--- matching package paths ---")
print("\n".join(matches[:200]))
print("--- matching source lines ---")
for name in names:
if not name.endswith((".js", ".mjs", ".cjs", ".d.ts", ".json")):
continue
data = archive.extractfile(name).read().decode("utf-8", "replace")
lines = data.splitlines()
hits = [
f"{name}:{i + 1}:{line}"
for i, line in enumerate(lines)
if any(term in line for term in (
"failedVerificationCount",
"lockedUntil",
"twoFactor",
"verified",
))
]
if hits:
print("\n".join(hits[:100]))
PYRepository: databuddy-analytics/Databuddy Length of output: 2453 🏁 Script executed: #!/bin/bash
set -euo pipefail
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSLk https://registry.npmjs.org/better-auth/1.7.1 -o "$tmpdir/metadata.json"
tarball="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["dist"]["tarball"])' "$tmpdir/metadata.json")"
curl -fsSLk "$tarball" -o "$tmpdir/package.tgz"
printf '%s\n' '--- package paths relevant to two-factor support ---'
tar -tzf "$tmpdir/package.tgz" | rg -i 'two.?factor|2fa|schema' | head -n 200
printf '%s\n' '--- exact field references in Better Auth 1.7.1 ---'
tar -xzf "$tmpdir/package.tgz" -C "$tmpdir"
rg -n -C2 'failedVerificationCount|lockedUntil|twoFactor.*verified|verified.*twoFactor' \
"$tmpdir/package" | head -n 300Repository: databuddy-analytics/Databuddy Length of output: 11017 🏁 Script executed: #!/bin/bash
set -euo pipefail
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl -fsSLk https://registry.npmjs.org/better-auth/1.7.1 \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["dist"]["tarball"])' \
| xargs -I{} curl -fsSLk {} -o "$tmpdir/package.tgz"
tar -xzf "$tmpdir/package.tgz" -C "$tmpdir"
printf '%s\n' '--- Better Auth 1.7.1 two-factor schema ---'
cat "$tmpdir/package/dist/plugins/two-factor/schema.mjs"
printf '%s\n' '--- Better Auth 1.7.1 two-factor type ---'
sed -n '95,112p' "$tmpdir/package/dist/plugins/two-factor/types.d.mts"
printf '%s\n' '--- Better Auth 1.7.1 two-factor schema declaration ---'
sed -n '650,700p' "$tmpdir/package/dist/plugins/two-factor/index.d.mts"Repository: databuddy-analytics/Databuddy Length of output: 2700 Add the Better Auth two-factor fields and migration. Add 🤖 Prompt for AI Agents |
||
| userId: text("user_id").notNull(), | ||
| accessToken: text("access_token"), | ||
| refreshToken: text("refresh_token"), | ||
|
|
@@ -165,6 +167,11 @@ export const account = pgTable( | |
| table.providerId.asc().nullsLast().op("text_ops"), | ||
| table.accountId.asc().nullsLast().op("text_ops") | ||
| ), | ||
| uniqueIndex("accounts_issuer_account_unique").using( | ||
| "btree", | ||
| table.issuer.asc().nullsLast().op("text_ops"), | ||
| table.accountId.asc().nullsLast().op("text_ops") | ||
| ), | ||
| foreignKey({ | ||
| columns: [table.userId], | ||
| foreignColumns: [user.id], | ||
|
|
@@ -326,6 +333,14 @@ export const twoFactor = pgTable( | |
| secret: text().notNull(), | ||
| backupCodes: text("backup_codes").notNull(), | ||
| userId: text("user_id").notNull(), | ||
| verified: boolean().default(true).notNull(), | ||
| failedVerificationCount: integer("failed_verification_count") | ||
| .default(0) | ||
| .notNull(), | ||
| lockedUntil: timestamp("locked_until", { | ||
| precision: 3, | ||
| withTimezone: true, | ||
| }), | ||
| }, | ||
| (table) => [ | ||
| index("idx_two_factor_user_id").using( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: databuddy-analytics/Databuddy
Length of output: 14614
🏁 Script executed:
Repository: databuddy-analytics/Databuddy
Length of output: 4661
🏁 Script executed:
Repository: databuddy-analytics/Databuddy
Length of output: 8782
🏁 Script executed:
Repository: databuddy-analytics/Databuddy
Length of output: 4421
🏁 Script executed:
Repository: databuddy-analytics/Databuddy
Length of output: 312
Add
@databuddy/authto the test task.The root
testcommand runsturbo run test, butpackages/auth/package.jsonhas notestscript. Therefore,packages/auth/src/rate-limit-storage.test.tsis not included in the package test run. The full-module mock cannot contaminate other auth tests because this is the only auth test file.🤖 Prompt for AI Agents
Source: Coding guidelines