feat: run the full app locally without OAuth apps (#532)

* feat: run the full app locally without OAuth apps or third-party keys

- compose.yml: Postgres plus an Upstash-compatible Redis REST shim; the
  top of apps/web/.env.example now works as-is against it
- Dev-only email/password sign-in (Better Auth, NODE_ENV=development) with
  a small form on the login page; OAuth is no longer required in dev
- `pnpm db:seed`: free and pro dev users with tracked domains in each
  verification state and a few notifications; refuses non-local databases
  unless --force
- Email: without RESEND_API_KEY in dev, render to apps/web/public/_dev-emails/
  and log a link instead of throwing; contact sync no-ops
- Blob: without credentials in dev, store files in apps/web/public/_dev-blob/
  via the files-sdk fs adapter
- drizzle.config also reads apps/web/.env.local, so `pnpm db:migrate` works
  with the env file the README tells you to create
- Logger treats an empty LOG_LEVEL (as copied from .env.example) as unset
  instead of crashing
- README: local setup walkthrough and an optional-services table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LBbE72EGVdtZGj8LSCuZF

* chore: hash seed passwords with @better-auth/utils instead of better-auth

The seed script only needs `hashPassword`, which `better-auth/crypto`
re-exports from `@better-auth/utils/password`. Depend on that small package
directly so `@domainstack/db` no longer pulls in all of better-auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LBbE72EGVdtZGj8LSCuZF

* fix: address review findings on local dev setup

- compose: bind Postgres and the Redis REST shim to 127.0.0.1, pin
  serverless-redis-http to 0.0.10, and wait for a healthy Redis
- `pnpm db:seed --force` now reaches the script (root task forwards args)
- Seed output never prints a real CRON_SECRET; README notes substituting it
- Dev email outbox: unique UUID filenames, written to apps/web/.dev-emails/
  (outside public/, so the dev server never serves confirmation links),
  inline logo rewritten to the hosted image, and write failures returned in
  Resend's `{ data: null, error }` shape instead of thrown
- Disable email/password sign-up; dev accounts come from the seed
- Dev sign-in form recovers when `signIn.email` throws
- Blob dev-fallback test now uploads and checks the file on disk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LBbE72EGVdtZGj8LSCuZF

* chore: restore generated next-env.d.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LBbE72EGVdtZGj8LSCuZF

* fix: correct `--force` passthrough for `pnpm db:seed`

- Remove the trailing `--` from the root `db:seed` turbo task; Turbo forwards extra args to the underlying script automatically, so the double-dash was being consumed rather than passed through
- Update the README command example and seed script header comment to use `pnpm db:seed -- --force` (the correct pnpm arg-separator syntax)
- Reformat the optional-services table with aligned column padding

* fix: keep text-only emails in the dev outbox

Render `react`, then `html`, then escaped `text` (Resend's accepted bodies)
instead of writing an empty file for text-only payloads. An email with no
body now fails through the existing Resend-style error path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012LBbE72EGVdtZGj8LSCuZF

* test: mock the dev sign-in form in the login content test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop the local email outbox

Without RESEND_API_KEY, sends fail as they did before; writing emails to disk
in development was more machinery than the local setup needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 authored and GitHub committed 2026-10-01 18:20:06 -04:00
1 parent 079ed06d5a
commit 8df985ce38
17 files changed
+494 -30

No files matched your search

+3
View File
@@ -57,3 +57,6 @@ next-env.d.ts
# next-agents-md
.next-docs/
# local dev fallback for blob storage
apps/web/public/_dev-blob/
+33 -7
View File
@@ -51,7 +51,7 @@
## Development
This is a **[Turborepo](https://turborepo.dev/docs) monorepo**.
This is a **[Turborepo](https://turborepo.dev/docs) monorepo**. You need Node.js 24+, pnpm, and Docker.
### 1. Clone & install
@@ -61,31 +61,57 @@ cd domainstack.io
pnpm install
```
### 2. Configure environment variables
### 2. Start local services and configure env
Create `.env.local` in the `apps/web` directory and populate [required variables](apps/web/.env.example):
[`compose.yml`](compose.yml) runs Postgres and an Upstash-compatible Redis. The top block of [`.env.example`](apps/web/.env.example) already points at them:
```bash
docker compose up -d
cp apps/web/.env.example apps/web/.env.local
```
At minimum, you'll need `DATABASE_URL` pointing to a Postgres database.
Maintainers can use `vercel env pull apps/web/.env.local` instead to get real credentials.
### 3. Set up the database
Apply Drizzle migrations to initialize the database schema:
```bash
pnpm db:migrate
pnpm db:seed
```
The seed creates two users, `free@dev.local` and `pro@dev.local` (password `password123`), with tracked domains in each verification state. It only runs against a local database unless you pass `--force` (`pnpm db:seed -- --force`).
### 4. Start development
```bash
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000).
Open [http://localhost:3000/login](http://localhost:3000/login) and use the **Dev sign-in** form. Email/password sign-in only exists when `NODE_ENV=development`.
To fill in report data and change-detection baselines for the seeded domains, trigger the crons by hand:
```bash
curl -H "Authorization: Bearer dev" http://localhost:3000/api/cron/warm-domains
curl -H "Authorization: Bearer dev" http://localhost:3000/api/cron/monitor-domains
```
If you pulled real env vars, replace `dev` with your `CRON_SECRET`.
### Optional services
Every other variable in `.env.example` is optional locally:
| Service | Without it (in development) |
| -------------------------------------- | -------------------------------------------------------------------------------------- |
| OAuth (GitHub, GitLab, Google, Vercel) | Sign in as a seeded user with email/password |
| Resend | Emails are not sent: each send fails with "Resend is not configured" |
| Vercel Blob | Favicons, screenshots and OG images are stored in `apps/web/public/_dev-blob/` |
| Upstash Redis | Rate limiting, session caching and monitor locks are skipped |
| Polar | Billing is disabled. When a token is set, Polar runs in sandbox outside production |
| Global Config | Provider detection falls back to "unknown" |
| Dynadot, IPLocate, PostHog | Pricing, geolocation and analytics are skipped |
| Logo.dev | Provider logos come from the other logo sources only |
## License
+18 -14
View File
@@ -1,3 +1,15 @@
# ---------------------------------------------------------------------------
# Local development (works as-is with `docker compose up -d`)
# Everything below this block is optional locally: unset services are skipped
# or fall back to local stand-ins (see README "Development").
# ---------------------------------------------------------------------------
NEXT_PUBLIC_BASE_URL=http://localhost:3000
DATABASE_URL=postgres://domainstack:domainstack@localhost:5432/domainstack
UPSTASH_REDIS_REST_URL=http://localhost:8079
UPSTASH_REDIS_REST_TOKEN=dev-token
BETTER_AUTH_SECRET=dev-secret-change-me-at-least-32-chars
CRON_SECRET=dev
# PostHog keys for analytics/error tracking and build-time sourcemap uploads
NEXT_PUBLIC_POSTHOG_HOST=
NEXT_PUBLIC_POSTHOG_KEY=
@@ -7,11 +19,8 @@ POSTHOG_PROJECT_ID=
# Optional: override Pino log level (default: debug in development, info in production, warn in tests)
LOG_LEVEL=
# Postgres connection string (with credentials)
DATABASE_URL=
# Authentication (better-auth)
BETTER_AUTH_SECRET=
# OAuth providers. At least one is required in production; in development
# you can sign in as a seeded user (`pnpm db:seed`) with email/password instead.
# GitHub OAuth
# Create an app at https://github.com/settings/developers
@@ -54,10 +63,6 @@ POLAR_SUCCESS_URL=
RESEND_API_KEY=
RESEND_FROM_EMAIL=
# Vercel Cron secret for authenticating cron requests
# Generate with: openssl rand -hex 32
CRON_SECRET=
# Vercel Global Config URL (set by the Global Config integration; formerly Edge Config)
# See packages/edge-config/src/index.ts for JSON schema and setup instructions
GLOBAL_CONFIG=
@@ -65,7 +70,8 @@ GLOBAL_CONFIG=
# Vercel Blob storage credentials. On Vercel, VERCEL_OIDC_TOKEN and BLOB_STORE_ID
# are injected automatically when a Blob store is connected and are used when
# BLOB_READ_WRITE_TOKEN is unset (a read-write token takes priority if present).
# Locally, set BLOB_READ_WRITE_TOKEN (or pull OIDC credentials with `vercel env pull`).
# Locally, set BLOB_READ_WRITE_TOKEN (or pull OIDC credentials with `vercel env pull`),
# or leave it unset to store files in apps/web/public/_dev-blob/.
BLOB_READ_WRITE_TOKEN=
# Blob key hashing secret (required outside development)
@@ -86,8 +92,6 @@ LOGO_DEV_PUBLISHABLE_KEY=
# Get your key at https://www.iplocate.io
IPLOCATE_API_KEY=
# Upstash Redis for KV storage and rate limiting
# Create a database at https://console.upstash.com
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
# Upstash Redis (UPSTASH_REDIS_REST_* set above) is used for rate limiting,
# session caching and monitor locks. Create a hosted one at https://console.upstash.com
REDIS_URL=
@@ -0,0 +1,93 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { signIn } from "@domainstack/auth/client";
import { Button } from "@domainstack/ui/button";
import { Field, FieldError, FieldLabel } from "@domainstack/ui/field";
import { Input } from "@domainstack/ui/input";
import { Spinner } from "@domainstack/ui/spinner";
interface DevSignInFormProps {
/** URL to navigate to after a successful sign-in */
callbackURL: string;
/** Callback when navigating away (e.g., to close modal) */
onNavigate?: () => void;
}
/**
* Email/password sign-in for local development only. The server enables
* Better Auth's email/password provider only when NODE_ENV is "development";
* accounts come from `pnpm db:seed`.
*/
export function DevSignInForm({ callbackURL, onNavigate }: DevSignInFormProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const field = (name: string) => {
const value = form.get(name);
return typeof value === "string" ? value : "";
};
setError(null);
setIsPending(true);
try {
const { error: signInError } = await signIn.email({
email: field("email").trim(),
password: field("password"),
});
if (signInError) {
setIsPending(false);
setError(signInError.message ?? "Sign-in failed. Did you run `pnpm db:seed`?");
return;
}
} catch {
setIsPending(false);
setError("Sign-in failed. Please try again.");
return;
}
onNavigate?.();
router.push(callbackURL);
};
return (
<form
onSubmit={handleSubmit}
className="flex w-full flex-col gap-3 rounded-lg border border-dashed p-3"
>
<p className="text-xs font-medium text-muted-foreground">Dev sign-in (local only)</p>
<Field data-invalid={error ? true : undefined}>
<FieldLabel>Email</FieldLabel>
<Input
name="email"
type="email"
autoComplete="username"
spellCheck={false}
defaultValue="free@dev.local"
placeholder="free@dev.local…"
/>
</Field>
<Field data-invalid={error ? true : undefined}>
<FieldLabel>Password</FieldLabel>
<Input
name="password"
type="password"
autoComplete="current-password"
defaultValue="password123"
/>
<FieldError>{error}</FieldError>
</Field>
<Button type="submit" variant="outline" disabled={isPending}>
{isPending && <Spinner />}
Sign in
</Button>
</form>
);
}
@@ -25,6 +25,9 @@ vi.mock("@/components/auth/oauth-button", () => ({
<div data-testid="oauth" data-callback={callbackURL} />
),
}));
vi.mock("@/components/auth/dev-sign-in-form", () => ({
DevSignInForm: () => null,
}));
import { LoginContent } from "@/components/auth/login-content";
import { render } from "@/mocks/react";
@@ -5,6 +5,7 @@ import { usePathname, useSearchParams } from "next/navigation";
import posthogClient from "posthog-js";
import { useEffect, useState } from "react";
import { DevSignInForm } from "@/components/auth/dev-sign-in-form";
import { OAuthButton } from "@/components/auth/oauth-button";
import { Logo } from "@/components/logo";
import { useAuthCallback } from "@/hooks/use-auth-callback";
@@ -71,6 +72,9 @@ export function LoginContent({ className, onNavigate, callbackURL }: LoginConten
onLoadingChange={(loading) => setLoadingProvider(loading ? provider.id : null)}
/>
))}
{process.env.NODE_ENV === "development" && (
<DevSignInForm callbackURL={effectiveCallbackURL} onNavigate={onNavigate} />
)}
</div>
<p className="mt-6 text-center text-xs leading-relaxed text-muted-foreground">
By signing in, you agree to our{" "}
+47
View File
@@ -0,0 +1,47 @@
# Local services for `pnpm dev`. See README "Development".
#
# docker compose up -d # start
# docker compose down -v # stop and wipe data
name: domainstack
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_USER: domainstack
POSTGRES_PASSWORD: domainstack
POSTGRES_DB: domainstack
ports:
# Loopback only: the dev credentials below are checked in
- "127.0.0.1:5432:5432"
volumes:
- postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U domainstack"]
interval: 2s
timeout: 5s
retries: 15
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 5s
retries: 15
# Upstash-compatible REST API in front of Redis, for @upstash/redis
redis-http:
image: hiett/serverless-redis-http:0.0.10
environment:
SRH_MODE: env
SRH_TOKEN: dev-token
SRH_CONNECTION_STRING: redis://redis:6379
ports:
- "127.0.0.1:8079:80"
depends_on:
redis:
condition: service_healthy
volumes:
postgres:
+1
View File
@@ -25,6 +25,7 @@
"db:push": "turbo run db:push",
"db:migrate": "turbo run db:migrate",
"db:studio": "turbo run db:studio",
"db:seed": "turbo run db:seed",
"clean": "node -e \"const { globSync, rmSync } = require('node:fs'); for (const pattern of process.argv.slice(1)) for (const path of globSync(pattern)) rmSync(path, { recursive: true, force: true, maxRetries: process.platform === 'win32' ? 10 : 0 })\" -- .turbo \"apps/*/.turbo\" \"apps/*/node_modules\" \"apps/*/*.tsbuildinfo\" apps/web/.next \"packages/*/.turbo\" \"packages/*/node_modules\" \"packages/*/*.tsbuildinfo\" node_modules pnpm-lock.yaml"
},
"devDependencies": {
+11 -2
View File
@@ -32,6 +32,10 @@ const logger = createLogger({ source: "auth" });
const redis = getRedis();
// Local development also allows email/password sign-in (for seeded dev users),
// so OAuth apps are optional there.
const isDev = process.env.NODE_ENV === "development";
// Validate required env vars
if (!process.env.BETTER_AUTH_SECRET) {
throw new Error("BETTER_AUTH_SECRET is required");
@@ -96,8 +100,8 @@ const { providers: socialProviders, enabledProviders } = buildOAuthProviders({
: undefined,
});
// Ensure at least one OAuth provider is configured
if (enabledProviders.length === 0) {
// Ensure at least one OAuth provider is configured (outside local development)
if (enabledProviders.length === 0 && !isDev) {
throw new Error(
"At least one OAuth provider must be configured (GitHub, GitLab, Google, or Vercel)",
);
@@ -208,6 +212,11 @@ export const auth = betterAuth({
},
},
socialProviders,
emailAndPassword: {
enabled: isDev,
// Dev accounts come from `pnpm db:seed`; no need to expose sign-up
disableSignUp: true,
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
+47
View File
@@ -1,3 +1,7 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { blobKey, getFiles } from "./files";
@@ -77,3 +81,46 @@ describe("getFiles", () => {
expect((await files.head("abc/32x32.png")).type).toBe("image/webp");
});
});
describe("getFiles in development without credentials", () => {
async function importFresh() {
vi.resetModules();
return await import("./files");
}
it("stores files on disk and serves them from the dev server", async () => {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("BLOB_READ_WRITE_TOKEN", "");
vi.stubEnv("VERCEL_OIDC_TOKEN", "");
vi.stubEnv("NEXT_PUBLIC_BASE_URL", "http://localhost:3000/");
const cwd = await mkdtemp(path.join(tmpdir(), "blob-dev-"));
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd);
try {
const { getFiles: getDevFiles } = await importFresh();
await getDevFiles().upload("abc/32x32.webp", WEBP);
expect(await readFile(path.join(cwd, "public", "_dev-blob", "abc", "32x32.webp"))).toEqual(
WEBP,
);
expect(await getDevFiles().url("abc/32x32.webp")).toBe(
"http://localhost:3000/_dev-blob/abc/32x32.webp",
);
} finally {
cwdSpy.mockRestore();
await rm(cwd, { recursive: true, force: true });
}
});
it("uses Vercel Blob when a token is set", async () => {
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("BLOB_READ_WRITE_TOKEN", "vercel_blob_rw_test");
adapter.upload.mockClear();
const { getFiles: getDevFiles } = await importFresh();
await getDevFiles().upload("abc/32x32.webp", WEBP);
expect(adapter.upload).toHaveBeenCalledOnce();
});
});
+35 -3
View File
@@ -1,17 +1,48 @@
import { createHmac } from "node:crypto";
import path from "node:path";
import { createFiles } from "files-sdk";
import { contentType } from "files-sdk/content-type";
import { fs } from "files-sdk/fs";
import { vercelBlob } from "files-sdk/vercel-blob";
import { createLogger } from "@domainstack/logger";
const logger = createLogger({ source: "blob" });
/** Public path the dev fallback writes under; `next dev` serves it from `public/`. */
const DEV_BLOB_DIR = "_dev-blob";
/**
* In local development without Blob credentials, store files on disk under
* the web app's `public/` directory so the dev server can serve them.
*/
function shouldUseLocalFiles(): boolean {
return (
process.env.NODE_ENV === "development" &&
!process.env.BLOB_READ_WRITE_TOKEN &&
!(process.env.VERCEL_OIDC_TOKEN && process.env.BLOB_STORE_ID)
);
}
function buildAdapter() {
if (!shouldUseLocalFiles()) {
// Defaults: public access, addRandomSuffix: false, allowOverwrite: true
return vercelBlob();
}
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
logger.info({ dir: `public/${DEV_BLOB_DIR}` }, "blob not configured, storing files locally");
return fs({
// `next dev` runs with the web app as its working directory
root: path.join(process.cwd(), "public", DEV_BLOB_DIR),
urlBaseUrl: `${baseUrl.replace(/\/$/, "")}/${DEV_BLOB_DIR}`,
});
}
function buildFiles() {
return createFiles({
// Defaults: public access, addRandomSuffix: false, allowOverwrite: true
adapter: vercelBlob(),
adapter: buildAdapter(),
// Store the Content-Type sniffed from the bytes, not the one implied by the key
plugins: [contentType()],
// 3 attempts total, 100ms then 200ms backoff
@@ -30,7 +61,8 @@ let files: ReturnType<typeof buildFiles> | undefined;
/**
* Shared storage client, built on first use because the Vercel Blob adapter
* throws at construction when no credentials are configured.
* throws at construction when no credentials are configured. In development
* without credentials it falls back to local files (see `buildAdapter`).
*/
export function getFiles(): ReturnType<typeof buildFiles> {
return (files ??= buildFiles());
+4 -1
View File
@@ -1,6 +1,9 @@
import "dotenv/config";
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";
// packages/db/.env wins; fall back to the web app's env file used by `pnpm dev`
config({ path: [".env", "../../apps/web/.env.local"], quiet: true });
export default defineConfig({
schema: "./src/schema.ts",
out: "./drizzle",
+5 -2
View File
@@ -19,7 +19,8 @@
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"db:seed": "tsx scripts/seed.ts"
},
"dependencies": {
"@domainstack/constants": "workspace:*",
@@ -30,11 +31,13 @@
"pg": "^8.23.1"
},
"devDependencies": {
"@better-auth/utils": "^0.4.2",
"@domainstack/typescript-config": "workspace:*",
"@electric-sql/pglite": "^0.5.8",
"@types/node": "catalog:",
"@types/pg": "^8.23.1",
"dotenv": "^18.0.5",
"drizzle-kit": "^0.31.11"
"drizzle-kit": "^0.31.11",
"tsx": "^4.23.15"
}
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Seed a local database with dev users and tracked domains.
*
* pnpm db:seed # refuses to run against a non-local DATABASE_URL
* pnpm db:seed -- --force # run anyway
*
* Safe to re-run: users, accounts and tracked domains are upserted on fixed
* keys, and the seeded users' notifications are replaced.
*/
import { config } from "dotenv";
config({ path: [".env", "../../apps/web/.env.local"], quiet: true });
const DEV_PASSWORD = "password123";
const DEV_USERS = [
{ id: "dev-user-free", name: "Free Dev", email: "free@dev.local", tier: "free" },
{ id: "dev-user-pro", name: "Pro Dev", email: "pro@dev.local", tier: "pro" },
] as const;
type TrackedState = "pending" | "verified" | "failing" | "archived";
/** Real, stable domains so the lookup pipeline has live data to fetch. */
const TRACKED: Record<(typeof DEV_USERS)[number]["id"], Array<[string, TrackedState]>> = {
"dev-user-free": [
["example.com", "verified"],
["github.com", "verified"],
["vercel.com", "failing"],
["wikipedia.org", "pending"],
],
"dev-user-pro": [
["example.com", "verified"],
["cloudflare.com", "verified"],
["mozilla.org", "verified"],
["stripe.com", "failing"],
["npmjs.com", "pending"],
["archive.org", "archived"],
],
};
const DAY = 24 * 60 * 60 * 1000;
function assertLocalDatabase() {
const url = process.env.DATABASE_URL;
if (!url) {
throw new Error("DATABASE_URL is not set (checked packages/db/.env and apps/web/.env.local)");
}
const { hostname } = new URL(url);
const isLocal = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname);
if (!isLocal && !process.argv.includes("--force")) {
throw new Error(
`Refusing to seed non-local database at "${hostname}". Pass --force to override.`,
);
}
}
async function main() {
assertLocalDatabase();
// Imported after env loading so the db client sees DATABASE_URL
const { randomBytes } = await import("node:crypto");
const { hashPassword } = await import("@better-auth/utils/password");
const { eq, inArray, sql } = await import("drizzle-orm");
const { db } = await import("../src/client");
const { accounts, domains, notifications, userTrackedDomains, users } =
await import("../src/schema");
const { ensureDomainRecord } = await import("../src/queries/domains");
const { createNotification } = await import("../src/queries/notifications");
const { createSubscription, updateUserTier } = await import("../src/queries/user-subscription");
const password = await hashPassword(DEV_PASSWORD);
const now = new Date();
for (const user of DEV_USERS) {
await db
.insert(users)
.values({ id: user.id, name: user.name, email: user.email, emailVerified: true })
.onConflictDoUpdate({ target: users.id, set: { name: user.name, email: user.email } });
// Better Auth's email/password provider stores the hash on a "credential" account
await db
.insert(accounts)
.values({
id: `${user.id}-credential`,
accountId: user.id,
providerId: "credential",
userId: user.id,
password,
updatedAt: now,
})
.onConflictDoUpdate({ target: accounts.id, set: { password, updatedAt: now } });
await createSubscription(user.id);
await updateUserTier(user.id, user.tier);
const trackedIds: Record<string, string> = {};
for (const [domainName, state] of TRACKED[user.id]) {
const domain = await ensureDomainRecord(domainName);
const verified = state === "verified" || state === "failing";
const values = {
verified,
verificationMethod: verified ? ("dns_txt" as const) : null,
verificationStatus:
state === "failing"
? ("failing" as const)
: verified
? ("verified" as const)
: ("unverified" as const),
verifiedAt: verified ? new Date(now.getTime() - 30 * DAY) : null,
lastVerifiedAt: verified ? new Date(now.getTime() - DAY) : null,
verificationFailedAt: state === "failing" ? new Date(now.getTime() - 2 * DAY) : null,
archivedAt: state === "archived" ? new Date(now.getTime() - 7 * DAY) : null,
};
const [tracked] = await db
.insert(userTrackedDomains)
.values({
userId: user.id,
domainId: domain.id,
verificationToken: randomBytes(16).toString("hex"),
...values,
})
.onConflictDoUpdate({
target: [userTrackedDomains.userId, userTrackedDomains.domainId],
set: values,
})
.returning({ id: userTrackedDomains.id });
trackedIds[domainName] = tracked.id;
}
await db.delete(notifications).where(eq(notifications.userId, user.id));
const [failingDomain] = TRACKED[user.id].find(([, state]) => state === "failing") ?? [];
if (failingDomain) {
await createNotification({
userId: user.id,
trackedDomainId: trackedIds[failingDomain],
type: "verification_failing",
title: `Verification failing for ${failingDomain}`,
message: `We couldn't find the verification record for ${failingDomain}. Restore it to keep monitoring this domain.`,
data: { domainName: failingDomain },
});
}
await createNotification({
userId: user.id,
trackedDomainId: trackedIds["example.com"],
type: "provider_change",
title: "DNS provider changed for example.com",
message: "example.com moved to a new DNS provider.",
data: { domainName: "example.com" },
});
}
// Mark the seeded domains as recently viewed so the warm-domains cron fills
// in their report data (registration, DNS, certificates, …)
const domainNames = [...new Set(Object.values(TRACKED).flatMap((list) => list.map(([d]) => d)))];
await db
.update(domains)
.set({ lastAccessedAt: sql`now()` })
.where(inArray(domains.name, domainNames));
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000";
// Only echo the documented dev default; never print a real secret
const secret = process.env.CRON_SECRET === "dev" ? "dev" : "$CRON_SECRET";
console.info(`
Seeded ${DEV_USERS.length} users. Sign in at ${baseUrl}/login with:
${DEV_USERS.map((u) => ` ${u.email} / ${DEV_PASSWORD} (${u.tier})`).join("\n")}
With \`pnpm dev\` running, fill in report data and change-detection baselines:
curl -H "Authorization: Bearer ${secret}" ${baseUrl}/api/cron/warm-domains
curl -H "Authorization: Bearer ${secret}" ${baseUrl}/api/cron/monitor-domains
${secret === "dev" ? "" : "(set CRON_SECRET in your shell to the value in apps/web/.env.local)\n"}`);
}
main().then(
() => process.exit(0),
(err: unknown) => {
console.error(err);
process.exit(1);
},
);
+1 -1
View File
@@ -75,7 +75,7 @@ function createDestination(): pino.DestinationStream {
* Shared logger options.
*/
const baseOptions: pino.LoggerOptions = {
level: process.env.LOG_LEVEL ?? (isTest ? "warn" : isDev ? "debug" : "info"),
level: process.env.LOG_LEVEL || (isTest ? "warn" : isDev ? "debug" : "info"),
timestamp: pino.stdTimeFunctions.isoTime,
base: undefined, // Removes the unhelpful pid and hostname fields
formatters: {
+6
View File
@@ -596,6 +596,9 @@ importers:
specifier: ^8.23.1
version: 8.23.1
devDependencies:
'@better-auth/utils':
specifier: ^0.4.2
version: 0.4.3
'@domainstack/typescript-config':
specifier: workspace:*
version: link:../typescript-config
@@ -614,6 +617,9 @@ importers:
drizzle-kit:
specifier: ^0.31.11
version: 0.31.11
tsx:
specifier: ^4.23.15
version: 4.23.15
packages/edge-config:
dependencies:
+3
View File
@@ -81,6 +81,9 @@
"db:migrate": {
"cache": false
},
"db:seed": {
"cache": false
},
"db:studio": {
"persistent": true,
"cache": false