Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,26 @@ export default function SignInPage() {
<SignInForm />

<p className="mt-3.5 text-center text-[12px] text-text-muted">
New to Mymir?{" "}
<Link
href="/sign-up"
className="text-accent-light hover:underline"
style={{ color: "var(--color-accent-light)" }}
>
Create an account
</Link>
{process.env.DEPLOY_TARGET === "cloudflare" ? (
<span
aria-disabled="true"
title="Sign-ups are invite-only during the hosted beta."
className="cursor-not-allowed opacity-60"
>
Sign-ups are invite-only
</span>
) : (
<>
New to Mymir?{" "}
<Link
href="/sign-up"
className="text-accent-light hover:underline"
style={{ color: "var(--color-accent-light)" }}
>
Create an account
</Link>
</>
)}
</p>
</>
}
Expand Down
32 changes: 19 additions & 13 deletions app/(auth)/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { AuthHero } from "@/components/auth/AuthHero";
import { SocialButtons } from "@/components/auth/SocialButtons";
import { SignUpForm } from "@/components/auth/SignUpForm";

const SIGNUPS_DISABLED = process.env.DEPLOY_TARGET === "cloudflare";

/**
* Sign-up page — mirrors the sign-in two-column shell with the
* registration form. Same disabled-with-tooltip social buttons (no
* backend providers wired) and the same static hero on the right.
*
* Post-create the user lands on `/`; `requireMembership` then forwards
* to `/onboarding/team` because a fresh account has zero memberships.
* Sign-up page. Renders the registration form on self-host and an
* "invite only" notice on the hosted Cloudflare deploy. Post-create the
* user lands on `/`; `requireMembership` forwards to `/onboarding/team`
* because a fresh account has zero memberships.
*
* @returns Server-rendered auth shell composing the sign-up form.
* @returns Server-rendered auth shell with form or invite-only notice
* depending on `DEPLOY_TARGET`.
*/
export default function SignUpPage() {
return (
Expand All @@ -25,21 +26,26 @@ export default function SignUpPage() {
className="text-[26px] font-semibold text-text-primary"
style={{ letterSpacing: "-0.01em", lineHeight: 1.15 }}
>
Create an account.
{SIGNUPS_DISABLED ? "Invite only." : "Create an account."}
</h1>
<p
className="mb-7 mt-2.5 text-[13.5px] text-text-muted"
style={{ lineHeight: 1.55 }}
>
Your project graph and decision history live here. Connect agents
through MCP from your CLI once you&rsquo;re in.
{SIGNUPS_DISABLED
? "Mymir is in a closed beta. New accounts are opening soon — until then, sign-ups are invite-only."
: "Your project graph and decision history live here. Connect agents through MCP from your CLI once you’re in."}
</p>

<SocialButtons />
<SignUpForm />
{SIGNUPS_DISABLED ? null : (
<>
<SocialButtons />
<SignUpForm />
</>
)}

<p className="mt-3.5 text-center text-[12px] text-text-muted">
Already have an account?{" "}
{SIGNUPS_DISABLED ? "Already invited?" : "Already have an account?"}{" "}
<Link
href="/sign-in"
className="hover:underline"
Expand Down
12 changes: 7 additions & 5 deletions cloudflare-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts` (hash: 08d6da77901a055559385853621147d7)
// Generated by Wrangler by running `wrangler types --env production --env-interface CloudflareEnv cloudflare-env.d.ts` (hash: ec62f4356648f0d7c9399ef56c9a03f3)
// Runtime types generated with workerd@1.20260515.1 2026-05-16 global_fetch_strictly_public,nodejs_compat
interface __BaseEnv_CloudflareEnv {
AUTH_KV: KVNamespace;
NEXT_INC_CACHE_R2_BUCKET: R2Bucket;
NEXT_TAG_CACHE_D1: D1Database;
RATE_LIMIT_API: RateLimit;
RATE_LIMIT_AUTH: RateLimit;
ASSETS: Fetcher;
DEPLOY_TARGET: "cloudflare";
BETTER_AUTH_SECRET: string;
Expand All @@ -13,13 +15,13 @@ interface __BaseEnv_CloudflareEnv {
DATABASE_SERVICE_ROLE_URL: string;
DATABASE_AUTH_URL: string;
MYMIR_DB_DRIVER: string;
MYMIR_BROKER: DurableObjectNamespace<import("./.open-next/worker").MymirBroker>;
NEXT_CACHE_DO_QUEUE: DurableObjectNamespace<import("./.open-next/worker").DOQueueHandler>;
WORKER_SELF_REFERENCE: Service<typeof import("./.open-next/worker").default>;
MYMIR_BROKER: DurableObjectNamespace<import("./worker-cf").MymirBroker>;
NEXT_CACHE_DO_QUEUE: DurableObjectNamespace<import("./worker-cf").DOQueueHandler>;
WORKER_SELF_REFERENCE: Service<typeof import("./worker-cf").default>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./.open-next/worker");
mainModule: typeof import("./worker-cf");
durableNamespaces: "MymirBroker" | "DOQueueHandler";
}
interface Env extends __BaseEnv_CloudflareEnv {}
Expand Down
4 changes: 4 additions & 0 deletions components/providers/RealtimeBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ export function RealtimeBridge() {
es = new EventSource("/api/events");
es.onmessage = (msg) => handle(msg.data);
es.onopen = () => {
const isReconnect = backoff !== INITIAL_BACKOFF_MS;
backoff = INITIAL_BACKOFF_MS;
if (isReconnect) {
qc.invalidateQueries();
}
};
es.onerror = () => {
es?.close();
Expand Down
14 changes: 13 additions & 1 deletion lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ import { ac, owner, admin, member as memberRole } from "@/lib/auth/permissions";
import { findOrgMemberUserIdsAsAdmin } from "@/lib/data/membership";
import { grantOrgAccess, revokeOrgAccess } from "@/lib/realtime/access";

const IS_CLOUDFLARE = process.env.DEPLOY_TARGET === "cloudflare";

if (IS_CLOUDFLARE && !process.env.BETTER_AUTH_URL) {
throw new Error(
"BETTER_AUTH_URL is required on the Cloudflare deploy target. " +
"Without it, Better-auth's trustedOrigins falls back to [] and CSRF " +
"protection accepts any origin. Set it in wrangler.jsonc env.production.vars.",
);
}

/**
* Better Auth server instance with email/password auth and
* organization-based team management. Adapts the `neon_auth` schema via
Expand All @@ -24,6 +34,8 @@ export const auth = betterAuth({
emailAndPassword: {
enabled: true,
revokeSessionsOnPasswordReset: true,
// Invite-only on hosted Cloudflare; self-host stays open.
disableSignUp: IS_CLOUDFLARE,
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
Expand All @@ -43,7 +55,7 @@ export const auth = betterAuth({
? [process.env.BETTER_AUTH_URL]
: [],
advanced: {
useSecureCookies: process.env.NODE_ENV === "production",
useSecureCookies: process.env.NODE_ENV === "production" || IS_CLOUDFLARE,
defaultCookieAttributes: {
httpOnly: true,
sameSite: "lax",
Expand Down
51 changes: 39 additions & 12 deletions lib/db/_driver.workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,37 @@ import type { AppDb, AuthDb, DbBundle } from "./_driver.node";
export type { AppDb, AuthDb, DbBundle, ClosablePool } from "./_driver.node";

/**
* Per-request Neon Pool tuning. `max: 1` because each pool's lifetime is a
* single request (created in `withRequestDbCore`, ended via
* `ctx.waitUntil(pool.end())`); a larger cap would open extra WebSocket
* connections to Neon that never resolve before teardown. `idleTimeoutMillis`
* is omitted on purpose for the same reason — idle reaping cannot fire
* inside a single request lifetime and the default is sufficient.
* Per-isolate Neon Pool tuning. The Pool is shared across requests within
* one isolate; each connection is single-use (`maxUses: 1`) so the
* "WebSocket cannot outlive a single request" constraint is honored at the
* connection level — a connection serves one query then is destroyed.
*
* `max` caps concurrent open connections per isolate. The pattern is the
* one recommended by OpenNext (https://opennext.js.org/cloudflare/howtos/db).
*/
const NEON_OPTS = { max: 5, maxUses: 1 } as const;

/**
* Attach a Pool-level error listener so unhandled idle-client errors from
* `pg` do not surface as `EventEmitter` uncaught events (which terminate
* the isolate and drop every in-flight request). Logged only; the Pool
* itself recovers by creating a fresh connection on the next `connect()`.
*
* @param pool - Neon pool to instrument.
* @param role - Role tag included in the log prefix.
* @returns The same pool, with the listener attached.
*/
const NEON_OPTS = { max: 1 } as const;
function attachPoolErrorLogger<P extends NeonPool>(pool: P, role: string): P {
pool.on("error", (err: unknown) => {
console.error(`[db:${role}] neon pool background error`, err);
});
return pool;
}

/**
* Build the application Drizzle client backed by `@neondatabase/serverless`.
* Creates a fresh `NeonPool` per call; callers MUST close it via
* `ctx.waitUntil(pool.end())` once the request completes.
* The underlying `NeonPool` is reused across requests within an isolate;
* each query opens a fresh single-use connection (`maxUses: 1`).
*
* @returns Pool + Drizzle instance bound to the public schema.
* @throws Error when `DATABASE_URL` is unset.
Expand All @@ -33,7 +51,10 @@ export function buildAppPool(): DbBundle<AppDb> {
"DATABASE_URL is required for the app runtime connection (app_user role).",
);
}
const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
const pool = attachPoolErrorLogger(
new NeonPool({ connectionString: url, ...NEON_OPTS }),
"app",
);
return {
pool,
db: drizzleNeon(pool, { schema: appSchema }) as unknown as AppDb,
Expand All @@ -54,7 +75,10 @@ export function buildAuthPool(): DbBundle<AuthDb> {
"(DML on neon_auth.*, no public-schema access).",
);
}
const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
const pool = attachPoolErrorLogger(
new NeonPool({ connectionString: url, ...NEON_OPTS }),
"auth",
);
return {
pool,
db: drizzleNeon(pool, { schema: authSchema }) as unknown as AuthDb,
Expand All @@ -74,7 +98,10 @@ export function buildServicePool(): DbBundle<AppDb> {
"DATABASE_SERVICE_ROLE_URL is required for service-role data access",
);
}
const pool = new NeonPool({ connectionString: url, ...NEON_OPTS });
const pool = attachPoolErrorLogger(
new NeonPool({ connectionString: url, ...NEON_OPTS }),
"service",
);
return {
pool,
db: drizzleNeon(pool, { schema: appSchema }) as unknown as AppDb,
Expand Down
55 changes: 31 additions & 24 deletions lib/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
buildAuthPool,
buildServicePool,
} from "@/lib/db/_driver";
import { autoSeedRequestDb } from "@/lib/db/request-scope";
import type { AppDb, AuthDb } from "@/lib/db/_driver.node";

export type { AppDb, AuthDb } from "@/lib/db/_driver.node";
Expand Down Expand Up @@ -56,35 +55,47 @@ export interface RequestScopedDb {

/**
* AsyncLocalStorage frame populated by the Workers request-scope helper.
* On self-host the frame is never entered; the global-cached singletons
* built lazily on first proxy access are used instead.
*
* Pinned to a `globalThis` slot keyed by `Symbol.for(...)` so every bundle
* that imports this module observes the same instance — on Workers the
* final artifact contains two copies of this file (one bundled by Next's
* webpack into `.open-next/worker.js`, another by wrangler's esbuild from
* `worker-cf.ts`), and without the global pin each would instantiate its
* own `AsyncLocalStorage`. OpenNext's `init.js` uses the same pattern for
* `__cloudflare-context__`.
*/
export const requestDbStore = new AsyncLocalStorage<RequestScopedDb>();
const REQUEST_DB_STORE_KEY = Symbol.for("@mymir/db/requestDbStore");
const symbolKeyedGlobal = globalThis as Record<symbol, unknown>;
if (!symbolKeyedGlobal[REQUEST_DB_STORE_KEY]) {
symbolKeyedGlobal[REQUEST_DB_STORE_KEY] =
new AsyncLocalStorage<RequestScopedDb>();
}
export const requestDbStore = symbolKeyedGlobal[
REQUEST_DB_STORE_KEY
] as AsyncLocalStorage<RequestScopedDb>;

type GlobalKey = "__mymirAppDb" | "__mymirAuthDb" | "__mymirServiceRoleDb";

/**
* Resolve the active Drizzle client for a role: prefer the AsyncLocalStorage
* frame, fall back per-target (Workers lazy-seeds, self-host caches on
* `globalThis`).
* Resolve the active Drizzle client for a role.
*
* On Cloudflare Workers, when the proxy is first read in a request without
* an active `withRequestDb` frame, the {@link autoSeedRequestDb} hook builds
* the three role pools, registers `ctx.waitUntil(pool.end())` for teardown,
* and seeds the ALS store for the remainder of the request's async tree. The
* `globalThis` cache is never consulted on Workers because the Neon
* serverless `Pool`'s WebSocket cannot span requests.
* Both deploy targets use a `globalThis`-cached singleton built lazily on
* first read. On self-host the Node `Pool` warms up once and serves every
* request via standard pg pooling. On Cloudflare Workers the Neon Pool is
* configured with `maxUses: 1` (see `lib/db/_driver.workers.ts`), so each
* connection is single-use even though the Pool instance persists across
* requests within an isolate — the "WebSocket cannot outlive a request"
* constraint is honored at the connection level, not the Pool level.
*
* On self-host the seeder is a stub (throws); the `globalThis` cache holds
* a single warm pool per role for the lifetime of the Node process.
* The {@link requestDbStore} ALS frame is consulted first for explicit
* scoping (e.g. tests that want to inject sentinel clients via
* `requestDbStore.run`). Production fetch paths simply hit the cache.
*
* @param key - Which role to read from the request-scope bundle.
* @param globalKey - Matching `globalThis.__mymir*` slot for self-host.
* @param key - Which role to read from the request-scope bundle (used only
* when an ALS frame is explicitly active).
* @param globalKey - Matching `globalThis.__mymir*` slot.
* @param builder - Factory invoked at most once to populate the slot.
* @returns Drizzle instance for the role.
* @throws Error on Workers when the seeder cannot register teardown (no
* active fetch context, e.g. scheduled handler) — see
* `autoSeedRequestDb` for the remediation.
*/
function getScopedOrGlobal<TDb extends AppDb | AuthDb>(
key: keyof RequestScopedDb,
Expand All @@ -93,10 +104,6 @@ function getScopedOrGlobal<TDb extends AppDb | AuthDb>(
): TDb {
const scoped = requestDbStore.getStore();
if (scoped) return scoped[key] as TDb;
if (process.env.DEPLOY_TARGET === "cloudflare") {
const seeded = autoSeedRequestDb();
return seeded[key] as TDb;
}
const cached = globalThis[globalKey];
if (cached) return cached as TDb;
const built = builder().db;
Expand Down
17 changes: 0 additions & 17 deletions lib/db/request-scope.node.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import "server-only";
import type { RequestScopedDb } from "./connection";

/**
* Self-host no-op for the per-request DB seeding helper.
Expand All @@ -16,19 +15,3 @@ import type { RequestScopedDb } from "./connection";
export async function withRequestDb<T>(fn: () => Promise<T>): Promise<T> {
return fn();
}

/**
* Self-host stub for {@link autoSeedRequestDb}. The connection proxy uses
* the globalThis fallback on self-host and never reaches the lazy seeder,
* so this body must never execute. Throwing rather than no-op keeps a
* misrouted call surfacing instead of silently corrupting state.
*
* @throws Always.
*/
export function autoSeedRequestDb(): RequestScopedDb {
throw new Error(
"autoSeedRequestDb is Workers-only; the self-host build should never " +
"call it (the connection proxy uses the globalThis cache). If you " +
"see this on self-host, the webpack alias indirection is broken.",
);
}
Loading
Loading