Skip to content

Commit b3b0b02

Browse files
authored
Merge pull request #307 from GTBitsOfGood/claude/festive-lamport-l3btp7
Keep localhost sign-in working with a tenant BETTER_AUTH_URL, and stop the seed duplicating notifications
2 parents 70b0529 + 19ef2a9 commit b3b0b02

5 files changed

Lines changed: 88 additions & 22 deletions

File tree

‎docs/VISIONARIES_DEVELOPMENT.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@ email configuration for ordinary development.
1212

1313
Add `visionariestothethrone.lvh.me` to the comma-separated `ALLOWED_DEV_ORIGINS`
1414
value in your local `.env` (preserve any existing entries). Restart Next.js after
15-
changing it. For this tenant's auth work, set:
15+
changing it.
16+
17+
Leave `BETTER_AUTH_URL` at the template's `http://localhost:3000`. Visionaries,
18+
ServiceStart at `localhost:3000`, and the other seeded tenants all work with it
19+
at the same time. Change it only when you need links that Better Auth builds,
20+
such as the password reset link, to open on the Visionaries host:
1621

1722
```dotenv
1823
BETTER_AUTH_URL=http://visionariestothethrone.lvh.me:3000
1924
```
2025

26+
`localhost:3000` keeps working with either value. Restart Next.js after changing
27+
it.
28+
2129
With your **local development database** configured, run:
2230

2331
```bash

‎lib/auth.ts‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,28 @@ const baseURL = process.env.BETTER_AUTH_URL || getBaseUrl();
2828
* Tenants are served from subdomains of the tenant root domain (`<slug>.lvh.me`
2929
* in local development). Better Auth only trusts its base URL by default and
3030
* rejects cookie-bearing requests from other origins, so trust the tenant
31-
* hosts too. These are static patterns rather than a per-request function
32-
* because Better Auth stores per-request origins on a context shared by
33-
* concurrent requests.
31+
* hosts too. Outside production, also trust localhost, which serves the
32+
* default tenant locally, so it keeps working when BETTER_AUTH_URL points at
33+
* another tenant's host. These are static patterns rather than a per-request
34+
* function because Better Auth stores per-request origins on a context shared
35+
* by concurrent requests.
3436
*/
35-
function tenantTrustedOrigins(): string[] {
36-
const { protocol, port } = new URL(baseURL);
37+
export function tenantTrustedOrigins(authBaseURL: string): string[] {
38+
const { protocol, port } = new URL(authBaseURL);
3739
const suffix = port ? `:${port}` : "";
40+
const isProduction = process.env.NODE_ENV === "production";
3841
const roots = [getTenantRootDomain()];
39-
if (process.env.NODE_ENV !== "production" && !roots.includes("lvh.me")) {
42+
if (!isProduction && !roots.includes("lvh.me")) {
4043
roots.push("lvh.me");
4144
}
42-
return roots.flatMap((root) => [
45+
const origins = roots.flatMap((root) => [
4346
`${protocol}//${root}${suffix}`,
4447
`${protocol}//*.${root}${suffix}`,
4548
]);
49+
if (!isProduction) {
50+
origins.push(`${protocol}//localhost${suffix}`);
51+
}
52+
return origins;
4653
}
4754

4855
export const auth = betterAuth({
@@ -177,7 +184,7 @@ export const auth = betterAuth({
177184
},
178185
},
179186
baseURL,
180-
trustedOrigins: tenantTrustedOrigins(),
187+
trustedOrigins: tenantTrustedOrigins(baseURL),
181188
database: drizzleAdapter(db, {
182189
provider: "pg",
183190
usePlural: true,

‎scripts/seed.ts‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import "dotenv/config"; //must be first to load environment variables
2-
import { randomUUID } from "node:crypto";
32
import { EventVisibility, JoinRequestStatus, schema } from "../lib/schema";
43
import db from "../lib/db";
54
import { auth } from "@/lib/auth";
@@ -580,15 +579,33 @@ export async function main() {
580579

581580
for (const email of memberEmails) {
582581
for (const org of ORGS) {
583-
const notificationValues = notificationTemplates.map((tmpl) => ({
584-
id: randomUUID(),
585-
userId: userIdsByOrganization.get(org.id)!.get(email)!,
586-
organizationId: org.id,
587-
type: tmpl.type,
588-
text: tmpl.text,
589-
read: tmpl.read,
590-
createdAt: new Date(Date.now() - tmpl.minutesAgo * 60 * 1000),
591-
}));
582+
const userId = userIdsByOrganization.get(org.id)!.get(email)!;
583+
// Skip the ones the user already has. Seeds before the IDs below were
584+
// stable gave them random IDs, which onConflictDoNothing can't match.
585+
const existing = await db
586+
.select({ text: schema.notifications.text })
587+
.from(schema.notifications)
588+
.where(
589+
and(
590+
eq(schema.notifications.userId, userId),
591+
eq(schema.notifications.organizationId, org.id),
592+
),
593+
);
594+
const existingTexts = new Set(existing.map((row) => row.text));
595+
const notificationValues = notificationTemplates
596+
.map((tmpl, index) => ({
597+
// Stable, so a seed running at the same time hits
598+
// onConflictDoNothing instead of inserting a second copy.
599+
id: `notif_${org.id}_${userId}_${index}`,
600+
userId,
601+
organizationId: org.id,
602+
type: tmpl.type,
603+
text: tmpl.text,
604+
read: tmpl.read,
605+
createdAt: new Date(Date.now() - tmpl.minutesAgo * 60 * 1000),
606+
}))
607+
.filter((notification) => !existingTexts.has(notification.text));
608+
if (notificationValues.length === 0) continue;
592609

593610
await db
594611
.insert(schema.notifications)

‎tests/unit/lib/auth.test.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// @vitest-environment node
2-
import { describe, expect, it } from "vitest";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
33
import { and, eq } from "drizzle-orm";
44
import db from "@/lib/db";
5-
import { auth } from "@/lib/auth";
5+
import { auth, tenantTrustedOrigins } from "@/lib/auth";
66
import { eventRsvps, members, MemberType, shiftRSVPs } from "@/lib/schema";
77
import {
88
addMember,
@@ -207,6 +207,29 @@ describe("trusted origins", () => {
207207
});
208208
});
209209

210+
describe(tenantTrustedOrigins, () => {
211+
afterEach(() => {
212+
vi.unstubAllEnvs();
213+
});
214+
215+
it("keeps trusting localhost when the base URL is a tenant's host", () => {
216+
const origins = tenantTrustedOrigins(
217+
"http://visionariestothethrone.lvh.me:3000",
218+
);
219+
220+
expect(origins).toContain("http://localhost:3000");
221+
expect(origins).toContain("http://*.lvh.me:3000");
222+
});
223+
224+
it("doesn't trust localhost in production", () => {
225+
vi.stubEnv("NODE_ENV", "production");
226+
227+
const origins = tenantTrustedOrigins("https://acme.servicestart.com");
228+
229+
expect(origins.some((origin) => origin.includes("localhost"))).toBe(false);
230+
});
231+
});
232+
210233
describe("member type", () => {
211234
it("is returned with the member", async () => {
212235
const org = await createOrganization("member-type-read");

‎tests/unit/scripts/seed.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ import {
1313
formComponents,
1414
FormStatus,
1515
MemberType,
16+
notifications,
1617
} from "@/lib/schema";
1718
import { FormDefinitionSchema } from "@/lib/forms/schema";
1819
import { OrganizationConfigService } from "@/lib/services/OrganizationConfigService";
1920
import { hashPassword } from "better-auth/crypto";
2021
import { main } from "@/scripts/seed";
21-
import { and, asc, eq, isNull } from "drizzle-orm";
22+
import { and, asc, count, eq, isNull } from "drizzle-orm";
2223
import { expect, it } from "vitest";
2324
import {
2425
createForm,
@@ -54,6 +55,16 @@ it("should not create duplicate organizations on multiple runs", async () => {
5455
expect(orgs.length).toBe(1); // Only one organization with the slug 'servicestart' should exist
5556
});
5657

58+
it("doesn't duplicate notifications on multiple runs", async () => {
59+
await main();
60+
const [first] = await db.select({ count: count() }).from(notifications);
61+
await main();
62+
const [second] = await db.select({ count: count() }).from(notifications);
63+
64+
expect(first.count).toBeGreaterThan(0);
65+
expect(second.count).toBe(first.count);
66+
});
67+
5768
it("creates users/accounts such that they can be signed into", async () => {
5869
await main();
5970

0 commit comments

Comments
 (0)