mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-10-26 08:36:22 -04:00
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:
1 parent
079ed06d5a
commit
8df985ce38
17 files changed
+494
-30
No files matched your search
@@ -57,3 +57,6 @@ next-env.d.ts
|
||||
|
||||
# next-agents-md
|
||||
.next-docs/
|
||||
|
||||
# local dev fallback for blob storage
|
||||
apps/web/public/_dev-blob/
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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: {
|
||||
|
||||
Generated
+6
@@ -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:
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
"db:migrate": {
|
||||
"cache": false
|
||||
},
|
||||
"db:seed": {
|
||||
"cache": false
|
||||
},
|
||||
"db:studio": {
|
||||
"persistent": true,
|
||||
"cache": false
|
||||
|
||||
Reference in new issue
Block a user