Compare commits
7 Commits
61fdd3329f
...
94b6de5970
| Author | SHA1 | Date | |
|---|---|---|---|
|
94b6de5970
|
|||
|
6fa4da1abb
|
|||
|
92f7e1df09
|
|||
|
b2fba6e150
|
|||
|
ebc85af62c
|
|||
|
6b8dd27898
|
|||
|
848d9e9af1
|
8
.ast-grep/rules/no-countall-number.yml
Normal file
8
.ast-grep/rules/no-countall-number.yml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
id: no-countall-number
|
||||||
|
language: typescript
|
||||||
|
severity: error
|
||||||
|
message: "Don't use countAll<number>() - use countAll() instead. PostgreSQL COUNT returns bigint (string), so the type annotation is misleading."
|
||||||
|
note: "Use Number() to convert the result if you need a number type."
|
||||||
|
rule:
|
||||||
|
pattern: countAll<number>()
|
||||||
|
fix: countAll()
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# Claude Code Notes
|
# Claude Code Notes
|
||||||
|
|
||||||
|
## Database Scripts
|
||||||
|
|
||||||
|
Use the wrapper scripts instead of running dbmate directly:
|
||||||
|
- `./scripts/db-dump` - Dump schema without random `\restrict` tokens
|
||||||
|
- `./scripts/db-migrate` - Run migrations and dump clean schema
|
||||||
|
|
||||||
|
PostgreSQL 17.6+ adds random `\restrict`/`\unrestrict` lines to pg_dump output (CVE-2025-8714 fix), causing schema.sql to show as changed on every dump. These scripts strip those lines.
|
||||||
|
|
||||||
## Development Server
|
## Development Server
|
||||||
|
|
||||||
Before starting the dev server, check if it's already running:
|
Before starting the dev server, check if it's already running:
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ bun run dev
|
|||||||
| `bun run lint:fix` | Fix linting issues |
|
| `bun run lint:fix` | Fix linting issues |
|
||||||
| `bun run test` | Run tests |
|
| `bun run test` | Run tests |
|
||||||
| `bun run db:codegen` | Generate database types |
|
| `bun run db:codegen` | Generate database types |
|
||||||
|
| `./scripts/db-dump` | Dump database schema (strips `\restrict` lines) |
|
||||||
|
| `./scripts/db-migrate` | Run migrations (strips `\restrict` lines) |
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache",
|
"clean": "rm -rf dist .eslintcache",
|
||||||
"test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage",
|
"test": "bun test src/ --no-parallel"
|
||||||
"test:unit": "bun test src/__tests__/unit",
|
|
||||||
"test": "bun test --coverage src/utils"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-durationformat": "^0.9.2",
|
"@formatjs/intl-durationformat": "^0.9.2",
|
||||||
@@ -34,12 +32,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@reviq/test-helpers": "workspace:*",
|
||||||
"@reviq/virtual-authenticator": "workspace:*",
|
"@reviq/virtual-authenticator": "workspace:*",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"pg": "^8.16.3",
|
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
1947
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
1947
apps/api-server/src/__tests__/e2e/admin.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,14 +41,19 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
describeE2E,
|
||||||
|
getSharedDb,
|
||||||
|
initTestDb,
|
||||||
|
TEST_RP,
|
||||||
|
withTestTransaction,
|
||||||
|
} from "@reviq/test-helpers";
|
||||||
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
||||||
import { router } from "../../router.js";
|
import { router } from "../../router.js";
|
||||||
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
||||||
import { hashToken } from "../../utils/crypto.js";
|
import { hashToken } from "../../utils/crypto.js";
|
||||||
import { hashPassword } from "../../utils/password.js";
|
import { hashPassword } from "../../utils/password.js";
|
||||||
import { TEST_RP } from "../helpers/test-constants.js";
|
|
||||||
import { createTestUser, getSharedDb, initTestDb } from "../helpers/test-db.js";
|
|
||||||
import { withTestTransaction } from "../helpers/test-transaction.js";
|
|
||||||
|
|
||||||
/** Session expiry duration: 24 hours in milliseconds */
|
/** Session expiry duration: 24 hours in milliseconds */
|
||||||
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -263,6 +268,7 @@ async function createPasswordReset(
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describeE2E("auth", () => {
|
||||||
// Test setup
|
// Test setup
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initTestDb();
|
await initTestDb();
|
||||||
@@ -391,7 +397,9 @@ describe("auth.signup", () => {
|
|||||||
// nested transactions.
|
// nested transactions.
|
||||||
test("creates user with passkey", async () => {
|
test("creates user with passkey", async () => {
|
||||||
const db = getSharedDb();
|
const db = getSharedDb();
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
const ctx = createAPIContext(db);
|
const ctx = createAPIContext(db);
|
||||||
|
|
||||||
// Step 1: Create registration options
|
// Step 1: Create registration options
|
||||||
@@ -1111,7 +1119,9 @@ describe("auth.loginIfRequestIsCompleted", () => {
|
|||||||
|
|
||||||
test("returns pending for fake/non-existent token", async () => {
|
test("returns pending for fake/non-existent token", async () => {
|
||||||
await withTestTransaction(getSharedDb(), async (db) => {
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
const ctx = createAPIContext(db, { loginRequestToken: "fake-token-xyz" });
|
const ctx = createAPIContext(db, {
|
||||||
|
loginRequestToken: "fake-token-xyz",
|
||||||
|
});
|
||||||
const result = await call(
|
const result = await call(
|
||||||
router.auth.loginIfRequestIsCompleted,
|
router.auth.loginIfRequestIsCompleted,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1344,7 +1354,9 @@ describe("auth.resendVerificationEmail", () => {
|
|||||||
const ctx = createAPIContext(db); // No session
|
const ctx = createAPIContext(db); // No session
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
call(router.auth.resendVerificationEmail, undefined, { context: ctx }),
|
call(router.auth.resendVerificationEmail, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
}),
|
||||||
).rejects.toThrow();
|
).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1503,7 +1515,6 @@ describe("auth.resetPassword", () => {
|
|||||||
|
|
||||||
// Create some sessions
|
// Create some sessions
|
||||||
await createSession(db, user.id);
|
await createSession(db, user.id);
|
||||||
await createSession(db, user.id);
|
|
||||||
|
|
||||||
const token = await createPasswordReset(db, user.id);
|
const token = await createPasswordReset(db, user.id);
|
||||||
|
|
||||||
@@ -1875,7 +1886,10 @@ describe("End-to-end login scenarios", () => {
|
|||||||
const ctx2 = createAPIContext(db);
|
const ctx2 = createAPIContext(db);
|
||||||
await call(
|
await call(
|
||||||
router.auth.resetPassword,
|
router.auth.resetPassword,
|
||||||
{ token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" },
|
{
|
||||||
|
token: assertDefined(reset).token,
|
||||||
|
newPassword: "NewSecureP@ss123!",
|
||||||
|
},
|
||||||
{ context: ctx2 },
|
{ context: ctx2 },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1991,7 +2005,8 @@ describe("End-to-end login scenarios", () => {
|
|||||||
loginRequestToken: assertDefined(loginToken),
|
loginRequestToken: assertDefined(loginToken),
|
||||||
deviceFingerprint: fingerprint,
|
deviceFingerprint: fingerprint,
|
||||||
});
|
});
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } =
|
||||||
|
await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
{ context: ctx2 },
|
{ context: ctx2 },
|
||||||
@@ -2105,3 +2120,62 @@ describe("End-to-end login scenarios", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// loginRequestMiddleware tests (base.ts)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("loginRequestMiddleware", () => {
|
||||||
|
test("rejects request with no login request cookie", async () => {
|
||||||
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
// No login request token in context
|
||||||
|
const ctx = createAPIContext(db);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("No login request found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects request with invalid login request token", async () => {
|
||||||
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
// Invalid token that doesn't exist in DB
|
||||||
|
const ctx = createAPIContext(db, {
|
||||||
|
loginRequestToken: "invalid-login-request-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Login request expired or not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects request with expired login request", async () => {
|
||||||
|
await withTestTransaction(getSharedDb(), async (db) => {
|
||||||
|
const user = await createTestUser(db, {
|
||||||
|
email: "expiredloginreq@example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an expired login request
|
||||||
|
const { token: loginToken } = await createLoginRequest(
|
||||||
|
db,
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
{ expiresAt: new Date(Date.now() - 1000) }, // Expired
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctx = createAPIContext(db, { loginRequestToken: loginToken });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
call(router.auth.webauthn.createAuthenticationOptions, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Login request expired or not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}); // Close outer describeE2E
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1844
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
1844
apps/api-server/src/__tests__/e2e/orgs.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,19 +12,21 @@ import type { Kysely } from "kysely";
|
|||||||
import type { APIContext } from "../../context.js";
|
import type { APIContext } from "../../context.js";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { call } from "@orpc/server";
|
import { call } from "@orpc/server";
|
||||||
|
import {
|
||||||
|
createTestUser,
|
||||||
|
describeE2E,
|
||||||
|
destroySharedDb,
|
||||||
|
getSharedDb,
|
||||||
|
initTestDb,
|
||||||
|
KNOWN_AAGUIDS,
|
||||||
|
TEST_RP,
|
||||||
|
withTestTransaction,
|
||||||
|
} from "@reviq/test-helpers";
|
||||||
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
import { VirtualAuthenticator } from "@reviq/virtual-authenticator";
|
||||||
import { router } from "../../router.js";
|
import { router } from "../../router.js";
|
||||||
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
import { COOKIE_NAMES } from "../../utils/cookies.js";
|
||||||
import { hashToken } from "../../utils/crypto.js";
|
import { hashToken } from "../../utils/crypto.js";
|
||||||
import { getUserPasskeys } from "../../utils/webauthn.js";
|
import { getUserPasskeys } from "../../utils/webauthn.js";
|
||||||
import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js";
|
|
||||||
import {
|
|
||||||
createTestUser,
|
|
||||||
destroySharedDb,
|
|
||||||
getSharedDb,
|
|
||||||
initTestDb,
|
|
||||||
} from "../helpers/test-db.js";
|
|
||||||
import { withTestTransaction } from "../helpers/test-transaction.js";
|
|
||||||
|
|
||||||
/** Session expiry duration: 24 hours in milliseconds */
|
/** Session expiry duration: 24 hours in milliseconds */
|
||||||
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -198,6 +200,7 @@ async function authenticate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describeE2E("webauthn", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initTestDb();
|
await initTestDb();
|
||||||
});
|
});
|
||||||
@@ -483,7 +486,8 @@ describe("authentication flow", () => {
|
|||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
const loginCtx = createLoginRequestContext(db, loginToken);
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } =
|
||||||
|
await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
{ context: loginCtx },
|
{ context: loginCtx },
|
||||||
@@ -525,7 +529,8 @@ describe("authentication flow", () => {
|
|||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
const loginCtx = createLoginRequestContext(db, loginToken);
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } =
|
||||||
|
await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
{ context: loginCtx },
|
{ context: loginCtx },
|
||||||
@@ -563,7 +568,8 @@ describe("authentication flow", () => {
|
|||||||
user.email,
|
user.email,
|
||||||
);
|
);
|
||||||
const loginCtx = createLoginRequestContext(db, loginToken);
|
const loginCtx = createLoginRequestContext(db, loginToken);
|
||||||
const { options: authOptions, challengeId: authChallengeId } = await call(
|
const { options: authOptions, challengeId: authChallengeId } =
|
||||||
|
await call(
|
||||||
router.auth.webauthn.createAuthenticationOptions,
|
router.auth.webauthn.createAuthenticationOptions,
|
||||||
undefined,
|
undefined,
|
||||||
{ context: loginCtx },
|
{ context: loginCtx },
|
||||||
@@ -864,7 +870,9 @@ describe("passkey management", () => {
|
|||||||
expect(passkeys).toHaveLength(2);
|
expect(passkeys).toHaveLength(2);
|
||||||
|
|
||||||
// Verify first passkey data (router returns id, name, createdAt, lastUsedAt)
|
// Verify first passkey data (router returns id, name, createdAt, lastUsedAt)
|
||||||
const icloudPasskey = passkeys.find((p) => p.name === "iCloud Keychain");
|
const icloudPasskey = passkeys.find(
|
||||||
|
(p) => p.name === "iCloud Keychain",
|
||||||
|
);
|
||||||
if (!icloudPasskey) {
|
if (!icloudPasskey) {
|
||||||
throw new Error("Expected iCloud Keychain passkey to exist");
|
throw new Error("Expected iCloud Keychain passkey to exist");
|
||||||
}
|
}
|
||||||
@@ -1003,7 +1011,9 @@ describe("passkey management", () => {
|
|||||||
email: "delete-with-password@test.com",
|
email: "delete-with-password@test.com",
|
||||||
passwordHash: "fake-password-hash",
|
passwordHash: "fake-password-hash",
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(db, user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
@@ -1019,7 +1029,9 @@ describe("passkey management", () => {
|
|||||||
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
|
await call(router.me.passkeys.delete, { passkeyId }, { context: ctx });
|
||||||
|
|
||||||
// Verify passkey is deleted
|
// Verify passkey is deleted
|
||||||
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx });
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
expect(passkeys).toHaveLength(0);
|
expect(passkeys).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1052,7 +1064,9 @@ describe("passkey management", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Verify only one passkey remains
|
// Verify only one passkey remains
|
||||||
passkeys = await call(router.me.passkeys.list, undefined, { context: ctx });
|
passkeys = await call(router.me.passkeys.list, undefined, {
|
||||||
|
context: ctx,
|
||||||
|
});
|
||||||
expect(passkeys).toHaveLength(1);
|
expect(passkeys).toHaveLength(1);
|
||||||
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
firstPasskey = expectFirst(passkeys, "Expected passkey to exist");
|
||||||
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
expect(firstPasskey.id).not.toBe(firstPasskeyId);
|
||||||
@@ -1066,7 +1080,9 @@ describe("passkey management", () => {
|
|||||||
email: "delete-last@test.com",
|
email: "delete-last@test.com",
|
||||||
// No password set
|
// No password set
|
||||||
});
|
});
|
||||||
const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin });
|
const authenticator = new VirtualAuthenticator({
|
||||||
|
origin: TEST_RP.origin,
|
||||||
|
});
|
||||||
|
|
||||||
await registerPasskey(db, user.id, user.email, authenticator);
|
await registerPasskey(db, user.id, user.email, authenticator);
|
||||||
|
|
||||||
@@ -1139,9 +1155,13 @@ describe("passkey management", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User2's passkey should still exist
|
// User2's passkey should still exist
|
||||||
const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, {
|
const user2PasskeysAfter = await call(
|
||||||
|
router.me.passkeys.list,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
context: ctx2,
|
context: ctx2,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
expect(user2PasskeysAfter).toHaveLength(1);
|
expect(user2PasskeysAfter).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1194,3 +1214,4 @@ describe("passkey management", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}); // Close outer describe.skipIf
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ export async function signupWithPassword(
|
|||||||
// Hash password
|
// Hash password
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
// Create user
|
// Create user (handle race condition if concurrent signup with same email)
|
||||||
|
try {
|
||||||
const user = await db
|
const user = await db
|
||||||
.insertInto("users")
|
.insertInto("users")
|
||||||
.values({
|
.values({
|
||||||
@@ -63,6 +64,16 @@ export async function signupWithPassword(
|
|||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return user.id;
|
return user.id;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle duplicate email (unique constraint violation)
|
||||||
|
// Use generic error to prevent email enumeration
|
||||||
|
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Unable to create account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,7 +157,8 @@ export async function signupWithPasskey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user and passkey in a transaction
|
// Create user and passkey in a transaction (handle race condition if concurrent signup)
|
||||||
|
try {
|
||||||
const result = await db.transaction().execute(async (trx) => {
|
const result = await db.transaction().execute(async (trx) => {
|
||||||
// Create user
|
// Create user
|
||||||
const user = await trx
|
const user = await trx
|
||||||
@@ -195,6 +207,16 @@ export async function signupWithPasskey(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return result.userId;
|
return result.userId;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle duplicate email (unique constraint violation)
|
||||||
|
// Use generic error to prevent email enumeration
|
||||||
|
if (error instanceof Error && error.message.includes("users_email_key")) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
|
message: "Unable to create account",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -241,7 +263,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => {
|
|||||||
);
|
);
|
||||||
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
userId = await signupWithPasskey(context.db, email, passkeyInfo, rpInfo);
|
||||||
} else {
|
} else {
|
||||||
// Should never reach here due to schema validation
|
// Unreachable - schema validation requires password or passkeyInfo
|
||||||
throw new ORPCError("BAD_REQUEST", {
|
throw new ORPCError("BAD_REQUEST", {
|
||||||
message: "Either password or passkeyInfo is required",
|
message: "Either password or passkeyInfo is required",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,10 +115,11 @@ export async function countOwners(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const result = await db
|
const result = await db
|
||||||
.selectFrom("org_members")
|
.selectFrom("org_members")
|
||||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
.select((eb) => eb.fn.countAll().as("count"))
|
||||||
.where("org_id", "=", orgId)
|
.where("org_id", "=", orgId)
|
||||||
.where("role", "=", "owner")
|
.where("role", "=", "owner")
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
return result.count;
|
// PostgreSQL COUNT returns bigint (string), convert to number
|
||||||
|
return Number(result.count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache",
|
"clean": "rm -rf dist .eslintcache",
|
||||||
"test": "bun test"
|
"test": "bun test src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.0.1",
|
"@noble/hashes": "^2.0.1",
|
||||||
|
|||||||
25
bun.lock
25
bun.lock
@@ -35,12 +35,11 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@reviq/test-helpers": "workspace:*",
|
||||||
"@reviq/virtual-authenticator": "workspace:*",
|
"@reviq/virtual-authenticator": "workspace:*",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"@types/zxcvbn": "^4.4.5",
|
"@types/zxcvbn": "^4.4.5",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"pg": "^8.16.3",
|
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -192,6 +191,24 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/testing/test-helpers": {
|
||||||
|
"name": "@reviq/test-helpers",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@reviq/db": "workspace:*",
|
||||||
|
"@reviq/db-schema": "workspace:*",
|
||||||
|
"kysely": "^0.28.2",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/testing/virtual-authenticator": {
|
"packages/testing/virtual-authenticator": {
|
||||||
"name": "@reviq/virtual-authenticator",
|
"name": "@reviq/virtual-authenticator",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -201,7 +218,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
@@ -439,6 +456,8 @@
|
|||||||
|
|
||||||
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
|
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
|
||||||
|
|
||||||
|
"@reviq/test-helpers": ["@reviq/test-helpers@workspace:packages/testing/test-helpers"],
|
||||||
|
|
||||||
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"],
|
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"],
|
||||||
|
|
||||||
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],
|
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],
|
||||||
|
|||||||
5
bunfig.toml
Normal file
5
bunfig.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[test]
|
||||||
|
coveragePathIgnorePatterns = [
|
||||||
|
"**/dist/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
]
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
\restrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
|
||||||
|
|
||||||
-- Dumped from database version 17.7
|
-- Dumped from database version 17.7
|
||||||
-- Dumped by pg_dump version 17.7
|
-- Dumped by pg_dump version 17.7
|
||||||
@@ -1084,7 +1083,6 @@ ALTER TABLE ONLY public.user_devices
|
|||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|
||||||
\unrestrict F9AizESreuRieL4inRcHWWg3hyNET0FgnBDFBBBU3cZGPEpHjb591l8S2iglpap
|
|
||||||
|
|
||||||
|
|
||||||
--
|
--
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"clean": "turbo clean",
|
"clean": "turbo clean",
|
||||||
"test": "turbo test",
|
"test": "turbo test",
|
||||||
|
"test:unit": "SKIP_DB_TESTS=1 turbo test",
|
||||||
|
"test:all": "turbo test",
|
||||||
|
"test:cov": "bun test --coverage",
|
||||||
|
"test:unit:cov": "SKIP_DB_TESTS=1 bun test --coverage",
|
||||||
"db:codegen": "bun run --cwd packages/db-schema generate"
|
"db:codegen": "bun run --cwd packages/db-schema generate"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "bun test",
|
"test": "bun test src/",
|
||||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
"lint": "eslint . --cache"
|
"lint": "eslint . --cache"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"test": "bun test"
|
"test": "bun test src/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"composite": true,
|
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"types": ["node", "bun"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
12
packages/testing/test-helpers/eslint.config.js
Normal file
12
packages/testing/test-helpers/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { configs } from "@macalinao/eslint-config";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...configs.fast,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
33
packages/testing/test-helpers/package.json
Normal file
33
packages/testing/test-helpers/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@reviq/test-helpers",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
|
"lint": "eslint . --cache"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@reviq/db": "workspace:*",
|
||||||
|
"@reviq/db-schema": "workspace:*",
|
||||||
|
"kysely": "^0.28.2",
|
||||||
|
"pg": "^8.16.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@macalinao/eslint-config": "catalog:",
|
||||||
|
"@macalinao/tsconfig": "catalog:",
|
||||||
|
"@types/bun": "catalog:",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
|
"eslint": "catalog:",
|
||||||
|
"typescript": "catalog:"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/testing/test-helpers/src/index.ts
Normal file
18
packages/testing/test-helpers/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export { describeE2E, SKIP_DB_TESTS } from "./skip-db-tests.js";
|
||||||
|
export {
|
||||||
|
DEFAULT_TEST_AAGUID,
|
||||||
|
KNOWN_AAGUIDS,
|
||||||
|
TEST_RP,
|
||||||
|
} from "./test-constants.js";
|
||||||
|
export {
|
||||||
|
createTestDb,
|
||||||
|
createTestUser,
|
||||||
|
destroySharedDb,
|
||||||
|
destroyTestDb,
|
||||||
|
getSharedDb,
|
||||||
|
getTestDatabaseUrl,
|
||||||
|
initTestDb,
|
||||||
|
runMigrations,
|
||||||
|
truncateAllTables,
|
||||||
|
} from "./test-db.js";
|
||||||
|
export { withTestTransaction } from "./test-transaction.js";
|
||||||
18
packages/testing/test-helpers/src/skip-db-tests.ts
Normal file
18
packages/testing/test-helpers/src/skip-db-tests.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe } from "bun:test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skip flag for database-dependent tests.
|
||||||
|
* Set SKIP_DB_TESTS=1 to skip e2e tests that require a database.
|
||||||
|
*/
|
||||||
|
export const SKIP_DB_TESTS: boolean = process.env.SKIP_DB_TESTS === "1";
|
||||||
|
|
||||||
|
const _describeSkipIf = describe.skipIf(SKIP_DB_TESTS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use for describe blocks that require database access.
|
||||||
|
* Automatically prefixes name with [e2e].
|
||||||
|
* Skips tests when SKIP_DB_TESTS=1 is set.
|
||||||
|
*/
|
||||||
|
export function describeE2E(name: string, fn: () => void): void {
|
||||||
|
_describeSkipIf(`[e2e] ${name}`, fn);
|
||||||
|
}
|
||||||
@@ -64,20 +64,31 @@ export function getTestDatabaseUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a postgres URL to extract components
|
* Parses a postgres URL to extract components.
|
||||||
|
* Supports both TCP and unix socket connections.
|
||||||
|
*
|
||||||
|
* Unix socket URL format: postgresql:///dbname?host=/var/run/postgresql
|
||||||
*/
|
*/
|
||||||
function parsePostgresUrl(url: string): {
|
function parsePostgresUrl(url: string): {
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number | undefined;
|
||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
database: string;
|
database: string;
|
||||||
} {
|
} {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
|
|
||||||
|
// Unix socket: hostname is empty, socket path in `host` query param
|
||||||
|
const isUnixSocket = !parsed.hostname;
|
||||||
|
const socketPath = parsed.searchParams.get("host");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host: parsed.hostname,
|
host: isUnixSocket
|
||||||
port: Number.parseInt(parsed.port || "5432", 10),
|
? (socketPath ?? "/var/run/postgresql")
|
||||||
user: parsed.username,
|
: parsed.hostname,
|
||||||
|
port: isUnixSocket ? undefined : Number.parseInt(parsed.port || "5432", 10),
|
||||||
|
// eslint-disable-next-line turbo/no-undeclared-env-vars, @typescript-eslint/prefer-nullish-coalescing -- USER is a system env var, and we want empty string to fall back
|
||||||
|
user: parsed.username || process.env.USER || "postgres",
|
||||||
password: parsed.password,
|
password: parsed.password,
|
||||||
database: parsed.pathname.slice(1), // Remove leading /
|
database: parsed.pathname.slice(1), // Remove leading /
|
||||||
};
|
};
|
||||||
6
packages/testing/test-helpers/tsconfig.json
Normal file
6
packages/testing/test-helpers/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["bun"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,19 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"test": "bun test"
|
"test": "bun test src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/types": "^12.0.0"
|
"@simplewebauthn/types": "^12.0.0"
|
||||||
@@ -18,7 +23,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@macalinao/eslint-config": "catalog:",
|
"@macalinao/eslint-config": "catalog:",
|
||||||
"@macalinao/tsconfig": "catalog:",
|
"@macalinao/tsconfig": "catalog:",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"composite": true,
|
|
||||||
"types": ["node", "bun"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"test": "bun test"
|
"test": "bun test src/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20250529.0",
|
"@cloudflare/workers-types": "^4.20250529.0",
|
||||||
|
|||||||
16
scripts/db-dump
Executable file
16
scripts/db-dump
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wrapper for dbmate dump that strips PostgreSQL's \restrict lines.
|
||||||
|
# PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump output
|
||||||
|
# (CVE-2025-8714 security fix), causing schema.sql to change on every dump.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCHEMA_FILE="${DBMATE_SCHEMA_FILE:-./db/schema.sql}"
|
||||||
|
|
||||||
|
dbmate dump "$@"
|
||||||
|
|
||||||
|
# Strip \restrict and \unrestrict lines (they start with backslash)
|
||||||
|
if [[ -f "$SCHEMA_FILE" ]]; then
|
||||||
|
grep -v '^\\' "$SCHEMA_FILE" > "${SCHEMA_FILE}.tmp"
|
||||||
|
mv "${SCHEMA_FILE}.tmp" "$SCHEMA_FILE"
|
||||||
|
fi
|
||||||
16
scripts/db-migrate
Executable file
16
scripts/db-migrate
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Wrapper for dbmate migrate that strips PostgreSQL's \restrict lines.
|
||||||
|
# PostgreSQL 17.6+ adds random \restrict/\unrestrict tokens to pg_dump output
|
||||||
|
# (CVE-2025-8714 security fix), causing schema.sql to change on every dump.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCHEMA_FILE="${DBMATE_SCHEMA_FILE:-./db/schema.sql}"
|
||||||
|
|
||||||
|
dbmate migrate "$@"
|
||||||
|
|
||||||
|
# Strip \restrict and \unrestrict lines (they start with backslash)
|
||||||
|
if [[ -f "$SCHEMA_FILE" ]]; then
|
||||||
|
grep -v '^\\' "$SCHEMA_FILE" > "${SCHEMA_FILE}.tmp"
|
||||||
|
mv "${SCHEMA_FILE}.tmp" "$SCHEMA_FILE"
|
||||||
|
fi
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
ruleDirs:
|
ruleDirs:
|
||||||
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rules/
|
- .ast-grep/rules/
|
||||||
testConfigs:
|
testConfigs:
|
||||||
- testDir: /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/rule-tests/
|
- testDir: .ast-grep/rule-tests/
|
||||||
utilDirs:
|
utilDirs:
|
||||||
- /Users/igm/proj/reviq/publisher-dashboard/.ast-grep/utils/
|
- .ast-grep/utils/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turbo.build/schema.json",
|
"$schema": "https://turbo.build/schema.json",
|
||||||
"globalEnv": ["DATABASE_URL", "PORT"],
|
"globalEnv": ["DATABASE_URL", "PORT", "TEST_DATABASE_URL"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"inputs": ["src/**/*.ts", "src/**/*.test.ts"],
|
"inputs": ["src/**/*.ts", "src/**/*.test.ts"],
|
||||||
|
"env": ["SKIP_DB_TESTS", "TEST_DATABASE_URL"],
|
||||||
"cache": false
|
"cache": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user