diff --git a/.env.example b/.env.example
index d048138d..3a0f9ca1 100644
--- a/.env.example
+++ b/.env.example
@@ -25,10 +25,10 @@ MDBASE_CONNECT_REGISTRATION=closed
# Password authentication can coexist with external providers or operate on its
# own. This stable HMAC secret protects privacy-safe shared rate-limit keys.
-# Configure both legal document URLs before enabling invited signup.
+# Configure both legal document URLs before enabling invited or public signup.
# MDBASE_CONNECT_AUTH_RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
-# Optional public beta-access form origin. Requests are accepted only from this
-# exact browser origin and use the shared database-backed rate limiter.
+# Optional legacy beta-access origin. The retired request endpoint returns 410
+# to this exact browser origin and never stores a new request.
# MDBASE_CONNECT_BETA_ACCESS_ORIGIN=https://mdbase.dev
# MDBASE_CONNECT_TERMS_URL=https://example.com/terms/
# MDBASE_CONNECT_PRIVACY_URL=https://example.com/privacy/
@@ -50,6 +50,9 @@ MDBASE_CONNECT_TRUST_PROXY=0
# Local agent only: fixed browser loopback port. The SDK default is 28485.
# MDBASE_CONNECT_LOOPBACK_PORT=28485
+# Non-secret deployment identity published by /health. Managed deployments set
+# production, staging, or lab; the local compose environment sets local.
+# MDBASE_CONNECT_ENVIRONMENT=local
# Optional Web Push. Generate one VAPID keypair and configure all three values
# together. The subject must be a mailto: or HTTPS URI.
diff --git a/README.md b/README.md
index 8a17e85f..4b0ebece 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,8 @@
# mdbase connect
> [!IMPORTANT]
-> **Invite-only beta:** The managed mdbase connect cloud service is currently
-> available by invitation only. Access to `connect.mdbase.dev`, including
-> hosted collections and the managed connection service, requires beta access.
-> If you have been invited, follow the access instructions you received.
+> **Beta:** The managed mdbase connect cloud service is transitioning from
+> invite-only access to public signup. Existing invitation links remain valid.
mdbase connect lets you use the applications you choose with the Markdown data
you control. An application gets access to one collection only after you
@@ -69,12 +67,10 @@ can be revoked with `mdbase connect hosted disconnect `.
## Getting started during the beta
-There is no public signup for the managed cloud service while it is in
-invite-only beta.
+To use the managed cloud service, create an account from the Connect sign-in
+page or follow an existing invitation link. Then:
-If you have beta access:
-
-1. Follow your invitation to create or access your mdbase connect account.
+1. Create or sign in to your mdbase connect account.
2. Install the desktop build provided for your platform.
3. Open the desktop app and pair your computer.
4. Add an existing mdbase collection or create a hosted collection.
diff --git a/apps/editor/README.md b/apps/editor/README.md
index 84b85866..3fc503d8 100644
--- a/apps/editor/README.md
+++ b/apps/editor/README.md
@@ -83,7 +83,8 @@ for CI, sign in with Wrangler once, then run:
```sh
pnpm dlx wrangler@4.114.0 login
-pnpm deploy:dev
+pnpm deploy:dev # lab (experimental default)
+MDBASE_ENV=staging pnpm deploy:dev # staging release rehearsal
```
The command builds workspace packages, generates the editor for the staging
diff --git a/apps/editor/src/EnvironmentBadge.tsx b/apps/editor/src/EnvironmentBadge.tsx
new file mode 100644
index 00000000..789d2b1c
--- /dev/null
+++ b/apps/editor/src/EnvironmentBadge.tsx
@@ -0,0 +1,24 @@
+import type { JSX } from "react";
+
+export type VisibleEnvironment = "lab" | "staging" | "local";
+
+const visibleEnvironments = new Set(["lab", "staging", "local"]);
+
+export function visibleEnvironment(value: string | undefined): VisibleEnvironment | null {
+ const normalized = value?.trim().toLowerCase() as VisibleEnvironment | undefined;
+ return normalized && visibleEnvironments.has(normalized) ? normalized : null;
+}
+
+export function EnvironmentBadge(): JSX.Element | null {
+ const environment = visibleEnvironment(import.meta.env.VITE_MDBASE_ENV);
+ if (!environment) return null;
+ return (
+
+ {environment}
+
+ );
+}
diff --git a/apps/editor/src/environment-badge.css b/apps/editor/src/environment-badge.css
new file mode 100644
index 00000000..1d2f82b9
--- /dev/null
+++ b/apps/editor/src/environment-badge.css
@@ -0,0 +1,39 @@
+.environment-badge {
+ position: fixed;
+ z-index: 10000;
+ top: 5px;
+ right: 5px;
+ padding: 4px 7px 3px;
+ border: 1px solid currentColor;
+ background: #f4f7f7;
+ box-shadow: 2px 2px 0 rgb(20 34 35 / 14%);
+ color: #34585d;
+ font: 500 9px/1.2 "Azeret Mono", ui-monospace, monospace;
+ letter-spacing: 0.14em;
+ pointer-events: none;
+ text-transform: uppercase;
+}
+
+.environment-badge.is-lab {
+ background: #fff2c7;
+ color: #7b4d00;
+}
+
+.environment-badge.is-staging {
+ background: #ffebe8;
+ color: #8d3029;
+}
+
+.environment-badge.is-local {
+ background: #e5f5f8;
+ color: #176377;
+}
+
+@media (max-width: 640px) {
+ .environment-badge {
+ top: 3px;
+ right: 3px;
+ padding: 3px 5px 2px;
+ font-size: 8px;
+ }
+}
diff --git a/apps/editor/src/main.tsx b/apps/editor/src/main.tsx
index 47142962..ed93639b 100644
--- a/apps/editor/src/main.tsx
+++ b/apps/editor/src/main.tsx
@@ -6,9 +6,11 @@ import { createRoot } from "react-dom/client";
import { AppErrorBoundary } from "./AppErrorBoundary";
import { DemoCollectionGateway } from "./demo-gateway";
import { ConnectCollectionGateway } from "./gateway";
+import { EnvironmentBadge } from "./EnvironmentBadge";
import "@mdbase/connect-ui/motion.css";
import "./phosphor-icons.generated.css";
import "./styles.css";
+import "./environment-badge.css";
const EditorApp = lazy(() => import("./App").then((module) => ({ default: module.App })));
const ConnectWorkspace = lazy(() => import("./ConnectApp").then((module) => ({ default: module.ConnectApp })));
@@ -25,5 +27,5 @@ const gateway = demoCount > 0
: new ConnectCollectionGateway();
createRoot(document.getElementById("root")!).render(
- Opening mdbase…}>{connectWorkspace ? : }
+ Opening mdbase…}>{connectWorkspace ? : }
);
diff --git a/apps/portal/portal-model.test.mjs b/apps/portal/portal-model.test.mjs
index acf9515b..c04e4b7d 100644
--- a/apps/portal/portal-model.test.mjs
+++ b/apps/portal/portal-model.test.mjs
@@ -6,7 +6,7 @@ test("captures one-time auth fragments before rendering and removes them from hi
const replacements = [];
const secrets = capturePortalBootstrapSecrets(
{
- hash: "#invitation=%20invite-secret%20&reset=reset-secret",
+ hash: "#invitation=%20invite-secret%20&verification=verify-secret&reset=reset-secret",
pathname: "/signup",
search: "?return_to=%2Fauthorize%2Frequest"
},
@@ -20,6 +20,7 @@ test("captures one-time auth fragments before rendering and removes them from hi
assert.deepEqual(secrets, {
invitationToken: "invite-secret",
+ verificationToken: "verify-secret",
resetToken: "reset-secret"
});
assert.deepEqual(replacements, [{
@@ -37,7 +38,11 @@ test("does not rewrite unrelated fragments", () => {
{ state: null, replaceState() { replaced = true; } }
);
- assert.deepEqual(secrets, { invitationToken: "", resetToken: "" });
+ assert.deepEqual(secrets, {
+ invitationToken: "",
+ verificationToken: "",
+ resetToken: ""
+ });
assert.equal(replaced, false);
});
diff --git a/apps/portal/src/auth-view.tsx b/apps/portal/src/auth-view.tsx
index 4dc317be..587bb384 100644
--- a/apps/portal/src/auth-view.tsx
+++ b/apps/portal/src/auth-view.tsx
@@ -97,12 +97,17 @@ export function Login() {
}
{config.registration !== "open" && (
- Don’t have an invite? Request beta access.
+ Don’t have an invite? Public signup is opening soon.
{config.password_registration && (
<> Already invited? Use the one-time link in your invitation email to create your account with a password. After signing in, you can connect Google from your account settings and use it for future sign-ins.>
)}
)}
+ {config.registration === "open" && config.password_public_registration && (
+
+ New to mdbase Connect? Create an account.
+
+ )}
);
@@ -358,9 +363,17 @@ export function ResetPassword({ resetToken }: { resetToken: string }) {
);
}
-export function Signup({ invitationToken }: { invitationToken: string }) {
+export function Signup({
+ invitationToken,
+ verificationToken
+}: {
+ invitationToken: string;
+ verificationToken: string;
+}) {
const [config, setConfig] = useState(null);
- const [invitation, setInvitation] = useState(null);
+ const [verifiedEmail, setVerifiedEmail] = useState("");
+ const [email, setEmail] = useState("");
+ const [requestSubmitted, setRequestSubmitted] = useState(false);
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
@@ -377,19 +390,29 @@ export function Signup({ invitationToken }: { invitationToken: string }) {
try {
const authentication = await api("/v1/auth/config");
setConfig(authentication);
- if (
- !invitationToken
- || !authentication.password_registration
- || !authentication.agreements
- ) return;
- const result = await api<{ invitation: InvitationPreview }>(
- "/v1/auth/password/invitation",
- {
- method: "POST",
- body: JSON.stringify({ invitation_token: invitationToken })
- }
- );
- setInvitation(result.invitation);
+ if (!authentication.agreements) return;
+ if (invitationToken && authentication.password_invitation_registration) {
+ const result = await api<{ invitation: InvitationPreview }>(
+ "/v1/auth/password/invitation",
+ {
+ method: "POST",
+ body: JSON.stringify({ invitation_token: invitationToken })
+ }
+ );
+ setVerifiedEmail(result.invitation.email);
+ } else if (
+ verificationToken
+ && authentication.password_public_registration
+ ) {
+ const result = await api<{ verification: VerificationPreview }>(
+ "/v1/auth/password/signup/verification",
+ {
+ method: "POST",
+ body: JSON.stringify({ verification_token: verificationToken })
+ }
+ );
+ setVerifiedEmail(result.verification.email);
+ }
} catch (reason) {
setError(message(reason));
} finally {
@@ -397,11 +420,28 @@ export function Signup({ invitationToken }: { invitationToken: string }) {
}
}
void prepare();
- }, [invitationToken]);
+ }, [invitationToken, verificationToken]);
+
+ async function requestVerification(event: React.FormEvent) {
+ event.preventDefault();
+ setBusy(true);
+ setError("");
+ try {
+ await api("/v1/auth/password/signup/request", {
+ method: "POST",
+ body: JSON.stringify({ email, return_to: returnTarget() })
+ });
+ setRequestSubmitted(true);
+ } catch (reason) {
+ setError(message(reason));
+ } finally {
+ setBusy(false);
+ }
+ }
async function createAccount(event: React.FormEvent) {
event.preventDefault();
- if (!config?.agreements || !invitation) return;
+ if (!config?.agreements || !verifiedEmail) return;
if (password !== passwordConfirmation) {
setError("Passwords do not match.");
return;
@@ -409,12 +449,17 @@ export function Signup({ invitationToken }: { invitationToken: string }) {
setBusy(true);
setError("");
try {
+ const publicSignup = Boolean(verificationToken && !invitationToken);
const result = await api<{
onboarding?: { starter_collection?: "pending" } | null;
- }>("/v1/auth/password/signup", {
+ }>(publicSignup
+ ? "/v1/auth/password/signup/public"
+ : "/v1/auth/password/signup", {
method: "POST",
body: JSON.stringify({
- invitation_token: invitationToken,
+ ...(publicSignup
+ ? { verification_token: verificationToken }
+ : { invitation_token: invitationToken }),
name,
password,
terms_version: config.agreements.terms.version,
@@ -433,24 +478,62 @@ export function Signup({ invitationToken }: { invitationToken: string }) {
}
if (loading || !config) return ;
- const ready = Boolean(
- invitation
- && config.password_registration
- && config.agreements
+ const isInvitation = Boolean(invitationToken);
+ const hasVerification = Boolean(verificationToken);
+ const ready = Boolean(verifiedEmail && config.agreements);
+ const canRequest = Boolean(
+ !isInvitation
+ && !hasVerification
+ && config.password_public_registration
+ );
+ if (canRequest) return (
+
+
+
+ Create account
+ {requestSubmitted ? "Check your email." : "Create your account"}
+
+ {requestSubmitted
+ ? "If that address can be used, its one-time verification link is on the way."
+ : "Start with your email. We’ll verify the address before asking you to choose a password."}
+
+ {error && {error}
}
+ {!requestSubmitted && (
+
+ )}
+ Return to sign in
+
+
);
return (
- Private preview / invitation
- {ready ? "Create your account" : "This invitation can’t be opened"}
+ {isInvitation ? "Invitation" : "Email verified"}
+ {ready ? "Create your account" : "This account setup link can’t be opened"}
{ready
- ? "Your email is already verified by this one-time invitation. Create a password, then we’ll open a small starter collection in the editor."
- : invitationToken
+ ? `${isInvitation ? "Your invitation verified your email" : "Your email is verified"}. Choose a password and we’ll prepare a small starter collection.`
+ : isInvitation || hasVerification
? "The link is invalid, expired, already used, or account setup is temporarily unavailable."
- : "Open the complete account setup link from your invitation email."}
+ : "Public account creation is temporarily unavailable."}
{error && {error}
}
- {ready && invitation && config.agreements && (
+ {ready && config.agreements && (
)}
@@ -546,6 +629,8 @@ interface AuthConfig {
password_login?: true;
password_recovery?: true;
password_registration?: true;
+ password_invitation_registration?: true;
+ password_public_registration?: true;
agreements?: {
terms: { version: string; url: string };
privacy: { version: string; url: string };
@@ -559,6 +644,11 @@ interface InvitationPreview {
privacy_version: string;
}
+interface VerificationPreview {
+ email: string;
+ expires_at: string;
+}
+
interface GoogleAccountsApi {
accounts: {
id: {
diff --git a/apps/portal/src/main.tsx b/apps/portal/src/main.tsx
index fffa2c36..c39cc533 100644
--- a/apps/portal/src/main.tsx
+++ b/apps/portal/src/main.tsx
@@ -36,7 +36,10 @@ function Portal({ bootstrapSecrets }: { bootstrapSecrets: PortalBootstrapSecrets
const authorityTransferId = location.pathname.match(/^\/transfer\/([0-9a-f-]+)$/i)?.[1];
const authorizationId = location.pathname.match(/^\/authorize\/([0-9a-f-]+)$/i)?.[1];
if (location.pathname === "/login") return ;
- if (location.pathname === "/signup") return ;
+ if (location.pathname === "/signup") return ;
if (location.pathname === "/getting-started") return ;
if (location.pathname === "/forgot-password") return ;
if (location.pathname === "/reset-password") return ;
diff --git a/apps/portal/src/portal-model.ts b/apps/portal/src/portal-model.ts
index 4fbb3d44..46fb0ada 100644
--- a/apps/portal/src/portal-model.ts
+++ b/apps/portal/src/portal-model.ts
@@ -72,6 +72,7 @@ export function returnTarget() {
}
export type PortalBootstrapSecrets = Readonly<{
invitationToken: string;
+ verificationToken: string;
resetToken: string;
}>;
@@ -82,9 +83,14 @@ export function capturePortalBootstrapSecrets(
const parameters = new URLSearchParams(currentLocation.hash.slice(1));
const secrets = Object.freeze({
invitationToken: parameters.get("invitation")?.trim() ?? "",
+ verificationToken: parameters.get("verification")?.trim() ?? "",
resetToken: parameters.get("reset")?.trim() ?? ""
});
- if (secrets.invitationToken || secrets.resetToken) {
+ if (
+ secrets.invitationToken
+ || secrets.verificationToken
+ || secrets.resetToken
+ ) {
currentHistory.replaceState(
currentHistory.state,
"",
diff --git a/docker-compose.yml b/docker-compose.yml
index a19d7934..3fa5c6b3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -157,6 +157,7 @@ services:
HOST: 0.0.0.0
PORT: 8787
PUBLIC_URL: ${PUBLIC_URL:-http://127.0.0.1:8787}
+ MDBASE_CONNECT_ENVIRONMENT: ${MDBASE_CONNECT_ENVIRONMENT:-unspecified}
DATABASE_URL: postgres://${POSTGRES_USER:-mdbase}:${POSTGRES_PASSWORD:-replace-this-password}@postgres:5432/${POSTGRES_DB:-mdbase_connect}
MDBASE_CONNECT_DEV_AUTH: ${MDBASE_CONNECT_DEV_AUTH:-1}
MDBASE_CONNECT_MANAGEMENT_ORIGINS: ${MDBASE_CONNECT_MANAGEMENT_ORIGINS:-http://127.0.0.1:5173}
diff --git a/docs/account-authentication.md b/docs/account-authentication.md
index c7c71393..44d44087 100644
--- a/docs/account-authentication.md
+++ b/docs/account-authentication.md
@@ -48,9 +48,15 @@ They are never truncated or normalized.
Password login is available at `POST /v1/auth/password/login`. Invitation
inspection and redemption use `POST /v1/auth/password/invitation` and
-`POST /v1/auth/password/signup`. All three require an exact same-origin
-`Origin` header. Successful login and signup issue the same HTTP-only,
-same-site session cookie used by external providers.
+`POST /v1/auth/password/signup`. Public password registration requests,
+inspects, and redeems an email-verification challenge through
+`POST /v1/auth/password/signup/request`,
+`POST /v1/auth/password/signup/verification`, and
+`POST /v1/auth/password/signup/public`. The request may include a same-origin
+`return_to`; it is carried through the verification email so account creation
+can resume the original authorization or transactional flow. All authentication
+mutations require an exact same-origin `Origin` header. Successful login and signup issue the
+same HTTP-only, same-site session cookie used by external providers.
Password recovery uses `POST /v1/auth/password/recovery` to request a link and
`POST /v1/auth/password/reset` to redeem it. The request endpoint always
@@ -88,6 +94,15 @@ referrers. The portal removes the fragment from browser history immediately,
then submits the token in a same-origin JSON request. Neither application logs
nor database rows may contain the plaintext token.
+Public signup verification uses the equivalent fragment-only boundary:
+`/signup#verification=`. The request endpoint returns the same `202`
+body whether the address is available or belongs to an existing account. Both
+paths perform the same challenge write, but only available addresses receive
+the link. Challenges are one-hour and single-use;
+requesting another invalidates the previous challenge. Account creation,
+verified email ownership, password credential, agreement acceptance, session,
+entitlement, and starter-collection scheduling commit in one transaction.
+
Password reset links use the same boundary:
`/reset-password#reset=`. The challenge expires after one hour.
Requesting another link invalidates the previous active challenge before
@@ -112,11 +127,12 @@ as soon as PostgreSQL commits it. If authentication volume later warrants a
cache, invalidation must use PostgreSQL notifications or a similarly shared
mechanism; an instance-local TTL alone must not weaken emergency shutdown.
-Password registration currently supports `invite` mode only. `open` continues
-to govern configured external providers, but it does not advertise password
-signup: public password registration needs a separate email-verification flow.
-This prevents an operator setting from silently creating unverified
-email/password accounts.
+Password invitations are redeemable in both `invite` and `open` modes so a
+policy transition does not strand issued invitations. Public password signup
+is advertised only in `open` mode, and only when password authentication, the
+shared abuse limiter, current legal documents, audited email delivery, and a
+runtime email transport are all available. The server never creates an
+unverified email/password account.
## Abuse controls
@@ -126,11 +142,11 @@ addresses or IP addresses and never unkeyed hashes of low-entropy identifiers.
Separate scopes cover normalized email, source network, account, and global
send volume.
-Recovery requests allow three attempts per normalized address and ten per
-source network per hour. Reset redemption is separately limited by token and
-source network. All scopes also consume the shared global authentication
-limit. The unauthenticated HTTP response remains generic until a limit is
-crossed.
+Recovery and public-signup requests allow three attempts per normalized
+address and ten per source network per hour. Reset and signup-verification
+redemption are separately limited by token and source network. All scopes also
+consume the shared global authentication limit. The unauthenticated request
+response remains generic until a limit is crossed.
The application will own limit duration, escalation, and cleanup policy. The
database table owns only the shared counter state. This lets the beta use
@@ -179,11 +195,11 @@ Password signup also requires
the exact documents represented by the database policy versions. Both must use
HTTPS outside loopback development.
-Password recovery additionally requires
+Password recovery and public password registration additionally require
`MDBASE_CONNECT_RESEND_API_KEY` and `MDBASE_CONNECT_EMAIL_FROM` on the Connect
runtime and `email_delivery_enabled` in the audited database policy. The portal
-does not advertise recovery unless the shared rate limiter, password
-authentication, email-delivery policy, and runtime transport are all active.
+does not advertise either capability unless its complete dependency set is
+active.
## Instance administration
diff --git a/docs/google-auth.md b/docs/google-auth.md
index 1c03fc44..e7696039 100644
--- a/docs/google-auth.md
+++ b/docs/google-auth.md
@@ -75,6 +75,11 @@ Open registration applies to every configured external provider. Do not enable
it on the public service until the homepage, privacy policy, support contact,
account lifecycle, monitoring, and abuse response are ready.
+Open registration also advertises verified email-and-password signup when the
+password-authentication limiter, current legal documents, audited email
+delivery, and runtime email transport are all configured. Invitation links
+remain redeemable after the policy moves from `invite` to `open`.
+
Invitation-only registration is also available:
```text
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
index 5eb506ff..c8db1d82 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -34,7 +34,7 @@ ignored by Git and must not be copied into images or backups.
## DNS, TLS, and authentication
Choose a public HTTPS origin such as `https://connect.example.com`.
-Authentication may use GitHub, invited email/password accounts, or both.
+Authentication may use GitHub, verified email/password accounts, or both.
For GitHub, create an OAuth application with:
@@ -52,7 +52,10 @@ must be stable and identical across every Connect instance. Leave registration
`closed` and password authentication disabled in the database until deployment
and migration checks pass. Then use the audited operator CLI described in
[`account-authentication.md`](./account-authentication.md) to configure document
-versions, enable invite mode, and create invitations.
+versions, enable invite mode, and create invitations. To enable public password
+registration later, configure the runtime email transport, enable audited email
+delivery, and move the registration policy to `open`; public signup is not
+advertised unless every dependency is active.
The optional `auth-admin` Compose profile runs the same CLI as a hardened
one-shot container without exposing an administration endpoint. For example:
@@ -70,8 +73,9 @@ account mutation. In particular, restore does not revive any credential
revoked by suspension.
Populate `RESEND_API_KEY` and `EMAIL_FROM` to deliver invitations with
-`invite create --send-email enabled` and to offer password recovery in the
-Connect portal. The same restricted sending credential is passed to the
+`invite create --send-email enabled`, offer password recovery, and support
+verified public password signup in the Connect portal. The same restricted
+sending credential is passed to the
one-shot operator CLI and the Connect runtime. Without it, the CLI returns the
sensitive invitation URL for delivery through another trusted process and the
portal does not advertise password recovery.
diff --git a/package.json b/package.json
index 6ab72dba..a52a0aa6 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"dev:desktop:isolated": "node scripts/isolated-desktop.mjs",
"dev:desktop:fresh": "node scripts/isolated-desktop.mjs --fresh",
"dev:desktop:staging": "node scripts/isolated-desktop.mjs --staging",
+ "dev:desktop:environment": "node scripts/isolated-desktop.mjs",
"deploy:dev": "node scripts/deploy-editor-dev.mjs",
"e2e": "node test/system/run.mjs --suite local",
"e2e:container": "node test/system/run.mjs --suite container",
diff --git a/scripts/deploy-editor-dev.mjs b/scripts/deploy-editor-dev.mjs
index 4dc29e91..d2363b98 100644
--- a/scripts/deploy-editor-dev.mjs
+++ b/scripts/deploy-editor-dev.mjs
@@ -29,18 +29,20 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
}
export async function deployDevelopmentEditor(environment, run = runCommand) {
- const target = environment.MDBASE_ENV ?? "lab";
+ const target = environment.MDBASE_ENV?.trim() || "lab";
if (target !== "lab" && target !== "staging") {
throw new Error("Development editor deployments are restricted to lab and staging.");
}
const deployment = developmentDeployments[target];
- if (environment.MDBASE_CONNECT_URL && environment.MDBASE_CONNECT_URL !== deployment.connectOrigin) {
+ const requestedOrigin = environment.MDBASE_CONNECT_URL?.trim();
+ if (requestedOrigin && requestedOrigin !== deployment.connectOrigin) {
throw new Error(`MDBASE_CONNECT_URL does not match the ${target} environment.`);
}
const previousManifest = await readFile(manifestPath);
const deploymentEnvironment = {
...environment,
MDBASE_ENV: target,
+ VITE_MDBASE_ENV: target,
MDBASE_EDITOR_ORIGIN: deployment.editorOrigin,
MDBASE_EDITOR_BASE_PATH: "/",
MDBASE_CONNECT_URL: deployment.connectOrigin,
diff --git a/scripts/deploy-editor-dev.test.mjs b/scripts/deploy-editor-dev.test.mjs
index 51f1ea26..0a53d8e9 100644
--- a/scripts/deploy-editor-dev.test.mjs
+++ b/scripts/deploy-editor-dev.test.mjs
@@ -9,6 +9,8 @@ test("builds and deploys the editor against lab by default", async () => {
});
const build = calls.find(({ args }) => args.includes("mdbase-editor") && args.includes("build"));
+ assert.equal(build.environment.MDBASE_ENV, "lab");
+ assert.equal(build.environment.VITE_MDBASE_ENV, "lab");
assert.equal(build.environment.MDBASE_EDITOR_ORIGIN, developmentDeployments.lab.editorOrigin);
assert.equal(build.environment.MDBASE_CONNECT_URL, developmentDeployments.lab.connectOrigin);
assert.equal(build.environment.VITE_MDBASE_CONNECT_URL, developmentDeployments.lab.connectOrigin);
@@ -33,6 +35,7 @@ test("staging requires an explicit environment and production is rejected", asyn
const deploy = calls.find(({ args }) => args.includes("wrangler@4.114.0"));
assert.ok(deploy.args.includes("--branch=staging"));
assert.equal(deploy.environment.MDBASE_CONNECT_URL, developmentDeployments.staging.connectOrigin);
+ assert.equal(deploy.environment.VITE_MDBASE_ENV, "staging");
await assert.rejects(
deployDevelopmentEditor({ MDBASE_ENV: "production" }, async () => undefined),
diff --git a/scripts/dev-environment.mjs b/scripts/dev-environment.mjs
index 22c94b44..8691d346 100644
--- a/scripts/dev-environment.mjs
+++ b/scripts/dev-environment.mjs
@@ -25,6 +25,7 @@ const environment = await createConnectEnvironment({
allowLocalApps: true,
environment: {
PUBLIC_URL: origins.publicUrl,
+ MDBASE_CONNECT_ENVIRONMENT: "local",
MDBASE_CONNECT_MANAGEMENT_ORIGINS: origins.managementOrigins.join(","),
MDBASE_EDITOR_ORIGIN: origins.editorOrigin,
MDBASE_CONNECT_REGISTRATION: "invite",
diff --git a/scripts/isolated-desktop.mjs b/scripts/isolated-desktop.mjs
index 5d4f6c11..6fd21caf 100644
--- a/scripts/isolated-desktop.mjs
+++ b/scripts/isolated-desktop.mjs
@@ -25,9 +25,11 @@ export async function isolatedDesktopConfiguration(
allocatePort = availablePort
) {
const staging = arguments_.includes("--staging");
+ const namedEnvironment = environment.MDBASE_ENV?.trim()
+ || (staging ? "staging" : "development");
const profileDirectory = staging
? stagingDesktop.profileDirectory
- : "desktop-development-profile";
+ : `desktop-${namedEnvironment}-profile`;
const userData = resolve(
environment.MDBASE_CONNECT_DEV_USER_DATA
?? resolve(repoRoot, ".tmp", profileDirectory)
@@ -39,20 +41,56 @@ export async function isolatedDesktopConfiguration(
?? (staging ? stagingDesktop.loopbackPort : String(await allocatePort()));
const childEnvironment = {
...environment,
+ VITE_MDBASE_ENV: environment.VITE_MDBASE_ENV ?? namedEnvironment,
MDBASE_CONNECT_HOME: connectHome,
MDBASE_CONNECT_USER_DATA_DIR: userData,
MDBASE_CONNECT_LOOPBACK_PORT: loopbackPort,
MDBASE_CONNECT_REGISTER_DEEP_LINKS:
environment.MDBASE_CONNECT_REGISTER_DEEP_LINKS ?? "0"
};
+ const serverTargets = [
+ environment.MDBASE_CONNECT_URL,
+ environment.MDBASE_CONNECT_SERVER_URL,
+ environment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL
+ ].map((value) => value?.trim()).filter(Boolean);
+ const distinctServerTargets = [...new Set(serverTargets)];
+ if (distinctServerTargets.length > 1) {
+ throw new Error("Isolated desktop Connect server targets must match.");
+ }
+ const configuredServer = distinctServerTargets[0];
+ const configuredEditor = environment.MDBASE_EDITOR_URL?.trim();
if (staging) {
- childEnvironment.MDBASE_EDITOR_URL =
- environment.MDBASE_EDITOR_URL ?? stagingDesktop.editorUrl;
+ if (configuredServer && configuredServer !== stagingDesktop.serverUrl) {
+ throw new Error("The staging desktop requires the staging Connect service.");
+ }
+ if (configuredEditor && configuredEditor !== stagingDesktop.editorUrl) {
+ throw new Error("The staging desktop requires the staging editor.");
+ }
+ childEnvironment.MDBASE_EDITOR_URL = stagingDesktop.editorUrl;
childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL =
- environment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL ?? stagingDesktop.serverUrl;
+ stagingDesktop.serverUrl;
+ } else {
+ if (Boolean(configuredServer) !== Boolean(configuredEditor)) {
+ throw new Error(
+ "Named isolated desktop environments require both Connect and editor URLs."
+ );
+ }
+ if (
+ ["lab", "staging", "production"].includes(namedEnvironment)
+ && !configuredServer
+ ) {
+ throw new Error(
+ `${namedEnvironment} isolated desktops require explicit Connect and editor URLs.`
+ );
+ }
+ if (configuredServer && configuredEditor) {
+ childEnvironment.MDBASE_EDITOR_URL = configuredEditor;
+ childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL = configuredServer;
+ }
}
return {
staging,
+ namedEnvironment,
fresh: arguments_.includes("--fresh"),
userData,
connectHome,
@@ -79,8 +117,11 @@ export async function runIsolatedDesktop(
console.log(`Isolated Electron profile: ${configuration.userData}`);
console.log(`Isolated connector state: ${configuration.connectHome}`);
console.log(`Connector loopback port: ${configuration.loopbackPort}`);
- if (configuration.staging) {
- console.log(`Staging Connect service: ${stagingDesktop.serverUrl}`);
+ if (configuration.childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL) {
+ console.log(
+ `${configuration.namedEnvironment} Connect service: `
+ + configuration.childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL
+ );
}
console.log("The normal mdbase connect profile and credentials will not be read.");
diff --git a/scripts/lib/isolated-desktop.test.mjs b/scripts/lib/isolated-desktop.test.mjs
index f135d2fa..598b456a 100644
--- a/scripts/lib/isolated-desktop.test.mjs
+++ b/scripts/lib/isolated-desktop.test.mjs
@@ -65,3 +65,55 @@ test("explicit development overrides remain available without reading production
assert.equal(configuration.childEnvironment.MDBASE_CONNECT_HOME, connectHome);
assert.equal(configuration.childEnvironment.MDBASE_CONNECT_REGISTER_DEEP_LINKS, "1");
});
+
+test("registry-provided environments configure Electron without staging-only flags", async () => {
+ const configuration = await isolatedDesktopConfiguration({
+ MDBASE_ENV: "lab",
+ MDBASE_CONNECT_URL: "https://mdbase-connect-lab.onrender.com",
+ MDBASE_EDITOR_URL: "https://candidate-b.mdbase-editor.pages.dev",
+ MDBASE_CONNECT_LOOPBACK_PORT: "28487"
+ }, [], async () => 1);
+
+ assert.equal(configuration.namedEnvironment, "lab");
+ assert.equal(configuration.loopbackPort, "28487");
+ assert.equal(
+ configuration.childEnvironment.VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL,
+ "https://mdbase-connect-lab.onrender.com"
+ );
+ assert.equal(
+ configuration.childEnvironment.MDBASE_EDITOR_URL,
+ "https://candidate-b.mdbase-editor.pages.dev"
+ );
+});
+
+test("named environments reject incomplete or conflicting endpoint pairs", async () => {
+ await assert.rejects(
+ isolatedDesktopConfiguration({
+ MDBASE_ENV: "lab",
+ MDBASE_CONNECT_URL: "https://mdbase-connect-lab.onrender.com"
+ }, [], async () => 1),
+ /require both Connect and editor URLs/
+ );
+ await assert.rejects(
+ isolatedDesktopConfiguration({ MDBASE_ENV: "production" }, [], async () => 1),
+ /require explicit Connect and editor URLs/
+ );
+ await assert.rejects(
+ isolatedDesktopConfiguration({
+ MDBASE_CONNECT_URL: "https://connect.mdbase.dev",
+ VITE_MDBASE_CONNECT_DEFAULT_SERVER_URL: "https://connect-staging.mdbase.dev",
+ MDBASE_EDITOR_URL: "https://editor.mdbase.dev"
+ }, [], async () => 1),
+ /server targets must match/
+ );
+});
+
+test("staging flags reject endpoint overrides from another environment", async () => {
+ await assert.rejects(
+ isolatedDesktopConfiguration({
+ MDBASE_CONNECT_URL: "https://connect.mdbase.dev",
+ MDBASE_EDITOR_URL: "https://editor.mdbase.dev"
+ }, ["--staging"], async () => 1),
+ /requires the staging Connect service/
+ );
+});
diff --git a/services/server/src/app.test.ts b/services/server/src/app.test.ts
index 8b469f73..00474c09 100644
--- a/services/server/src/app.test.ts
+++ b/services/server/src/app.test.ts
@@ -116,6 +116,28 @@ describe("mdbase connect server", () => {
});
});
+ it("publishes a verifiable non-secret environment identity", async () => {
+ const db = await createDatabase("memory");
+ resources.push(() => db.end());
+ const { app } = await buildApp({
+ db,
+ devAuth: true,
+ publicUrl: "https://connect-lab.example",
+ environment: "lab"
+ });
+ resources.push(() => app.close());
+
+ const health = await app.inject({ method: "GET", url: "/health" });
+
+ expect(health.statusCode).toBe(200);
+ expect(health.json()).toMatchObject({
+ ok: true,
+ service: "mdbase-connect",
+ environment: "lab",
+ public_origin: "https://connect-lab.example"
+ });
+ });
+
it("stays ready when the hosted provider reports retryable notification degradation", async () => {
const db = await createDatabase("memory");
resources.push(() => db.end());
diff --git a/services/server/src/app.ts b/services/server/src/app.ts
index 7e52526e..1578bc0b 100644
--- a/services/server/src/app.ts
+++ b/services/server/src/app.ts
@@ -63,6 +63,7 @@ import { sessionToken } from "./platform/session-cookies.js";
interface BuildOptions {
db: DatabasePool;
revision?: string;
+ environment?: string;
devAuth?: boolean;
tailscaleAuth?: boolean;
githubAuth?: GitHubAuthConfig;
@@ -252,6 +253,7 @@ export async function buildApp(options: BuildOptions) {
hostedCollections: options.hostedCollections === true,
hostedProvider: options.hostedProvider,
revision: options.revision,
+ environment: options.environment,
publicUrl,
editorOrigin: options.editorOrigin
});
@@ -262,13 +264,8 @@ export async function buildApp(options: BuildOptions) {
});
}
if (options.betaAccessOrigin) {
- if (!options.authRateLimitSecret) {
- throw new Error("Beta access requests require a rate-limit secret.");
- }
registerBetaAccessRoutes(app, {
- db: options.db,
- allowedOrigin: options.betaAccessOrigin,
- rateLimitSecret: options.authRateLimitSecret
+ allowedOrigin: options.betaAccessOrigin
});
}
registerPasswordAuthRoutes(app, {
diff --git a/services/server/src/entitlements.ts b/services/server/src/entitlements.ts
index d5f91a14..cd5eb831 100644
--- a/services/server/src/entitlements.ts
+++ b/services/server/src/entitlements.ts
@@ -108,6 +108,44 @@ export async function materializeInvitationEntitlement(
};
}
+export async function materializePublicSignupEntitlement(
+ db: DatabaseQueryable,
+ userId: string
+): Promise<{ providerAccountId: string; entitlementRevision: number }> {
+ await db.query(
+ `INSERT INTO account_entitlement_grants
+ (id, user_id, profile_code, source, source_reference)
+ VALUES ($1, $2, $3, 'subscription', 'public_signup_v1')
+ ON CONFLICT DO NOTHING`,
+ [randomUUID(), userId, BETA_ENTITLEMENT_PROFILE]
+ );
+ const account = await db.query<{
+ provider_account_id: string;
+ entitlement_revision: string | number;
+ }>(
+ `INSERT INTO account_storage_accounts
+ (user_id, provider_account_id)
+ VALUES ($1, $2)
+ ON CONFLICT (user_id) DO UPDATE SET
+ updated_at = account_storage_accounts.updated_at
+ RETURNING provider_account_id, entitlement_revision`,
+ [userId, randomUUID()]
+ );
+ await db.query(
+ `INSERT INTO account_email_preferences (user_id)
+ VALUES ($1)
+ ON CONFLICT (user_id) DO NOTHING`,
+ [userId]
+ );
+ return {
+ providerAccountId: account.rows[0]!.provider_account_id,
+ entitlementRevision: safeNumber(
+ account.rows[0]!.entitlement_revision,
+ "entitlement revision"
+ )
+ };
+}
+
export async function ensureDevelopmentEntitlement(
db: DatabaseQueryable,
userId: string
diff --git a/services/server/src/features/auth/password-routes.ts b/services/server/src/features/auth/password-routes.ts
index 0635ddc5..3f280467 100644
--- a/services/server/src/features/auth/password-routes.ts
+++ b/services/server/src/features/auth/password-routes.ts
@@ -26,9 +26,17 @@ import {
PasswordRecoveryUnavailableError
} from "../../password-recovery.js";
import { sendPasswordResetEmail } from "../../password-reset-email.js";
+import {
+ PublicSignupService,
+ PublicSignupUnavailableError
+} from "../../public-signup.js";
+import { sendPublicSignupVerificationEmail } from "../../public-signup-email.js";
import { PASSWORD_MAX_UTF8_BYTES } from "../../password.js";
import type { AuthenticationLegalDocuments } from "../../runtime-config.js";
-import { apiError } from "../../platform/http-errors.js";
+import {
+ apiError,
+ RequestValidationError
+} from "../../platform/http-errors.js";
import { requireSameOrigin } from "../../platform/request-security.js";
import { setSessionCookie } from "../../platform/session-cookies.js";
@@ -56,6 +64,12 @@ const PASSWORD_SIGNUP_IP_LIMIT: AuthRateLimitRule = {
baseBlockSeconds: 15 * 60,
maxBlockSeconds: 6 * 60 * 60
};
+const PASSWORD_SIGNUP_EMAIL_LIMIT: AuthRateLimitRule = {
+ maxAttempts: 3,
+ windowSeconds: 60 * 60,
+ baseBlockSeconds: 15 * 60,
+ maxBlockSeconds: 6 * 60 * 60
+};
const PASSWORD_RECOVERY_EMAIL_LIMIT: AuthRateLimitRule = {
maxAttempts: 3,
windowSeconds: 60 * 60,
@@ -114,6 +128,10 @@ export function registerPasswordAuthRoutes(
options.db,
options.authenticationPolicy
);
+ const publicSignup = new PublicSignupService(
+ options.db,
+ options.authenticationPolicy
+ );
const authenticationRateLimiter = options.authRateLimitSecret
? new AuthRateLimiter(options.db, options.authRateLimitSecret)
: null;
@@ -124,12 +142,22 @@ export function registerPasswordAuthRoutes(
const passwordLogin =
authenticationSettings.passwordAuthEnabled
&& authenticationRateLimiter !== null;
- const passwordRegistration =
- passwordLogin
- && authenticationSettings.registrationMode === "invite"
- && Boolean(authenticationSettings.termsVersion)
+ const legalReady =
+ Boolean(authenticationSettings.termsVersion)
&& Boolean(authenticationSettings.privacyVersion)
&& options.authenticationLegalDocuments !== undefined;
+ const passwordInvitationRegistration =
+ passwordLogin
+ && ["invite", "open"].includes(authenticationSettings.registrationMode)
+ && legalReady;
+ const passwordPublicRegistration =
+ passwordLogin
+ && authenticationSettings.registrationMode === "open"
+ && authenticationSettings.emailDeliveryEnabled
+ && options.emailTransport !== undefined
+ && legalReady;
+ const passwordRegistration =
+ passwordInvitationRegistration || passwordPublicRegistration;
const passwordRecoveryAvailable =
passwordLogin
&& authenticationSettings.emailDeliveryEnabled
@@ -173,6 +201,12 @@ export function registerPasswordAuthRoutes(
...(passwordRegistration
? {
password_registration: true,
+ ...(passwordInvitationRegistration
+ ? { password_invitation_registration: true }
+ : {}),
+ ...(passwordPublicRegistration
+ ? { password_public_registration: true }
+ : {}),
agreements: {
terms: {
version: authenticationSettings.termsVersion!,
@@ -191,6 +225,204 @@ export function registerPasswordAuthRoutes(
};
});
+ app.post("/v1/auth/password/signup/request", async (request, reply) => {
+ reply.header("cache-control", "no-store");
+ requireSameOrigin(request, options.publicUrl);
+ if (!authenticationRateLimiter || !options.emailTransport) {
+ throw new PublicSignupUnavailableError();
+ }
+ if (!options.authenticationLegalDocuments) {
+ throw new AuthenticationPolicyIncompleteError();
+ }
+ const input = z.object({
+ email: z.email().max(320),
+ return_to: z.string().max(2_048).optional()
+ }).strict().parse(request.body);
+ const normalizedEmail = normalizeEmailAddress(input.email);
+ const returnTarget = safePublicSignupReturnTarget(
+ input.return_to,
+ options.publicUrl
+ );
+ const allowed = await consumeAuthenticationLimits(
+ authenticationRateLimiter,
+ [
+ {
+ scope: "password.signup_request.email",
+ key: normalizedEmail,
+ rule: PASSWORD_SIGNUP_EMAIL_LIMIT
+ },
+ {
+ scope: "password.signup_request.ip",
+ key: request.ip,
+ rule: PASSWORD_SIGNUP_IP_LIMIT
+ },
+ {
+ scope: "password.signup_request.global",
+ key: "global",
+ rule: PASSWORD_AUTH_GLOBAL_LIMIT
+ }
+ ],
+ reply
+ );
+ if (!allowed) return;
+ const verification = await publicSignup.create(normalizedEmail);
+ reply.code(202).send({
+ accepted: true,
+ message: "If that address can be used, a verification link is on its way."
+ });
+ if (!verification) return reply;
+
+ let delivery:
+ | { status: "sent"; provider: string; messageId: string }
+ | {
+ status: "failed";
+ provider: string;
+ code: string;
+ retryable: boolean;
+ };
+ try {
+ const sent = await sendPublicSignupVerificationEmail(
+ options.emailTransport,
+ {
+ challengeId: verification.challengeId,
+ to: verification.email,
+ verificationUrl: publicSignupVerificationUrl(
+ options.publicUrl,
+ verification.token,
+ returnTarget
+ ),
+ expiresAt: verification.expiresAt
+ }
+ );
+ delivery = {
+ status: "sent",
+ provider: sent.provider,
+ messageId: sent.messageId
+ };
+ } catch (error) {
+ delivery = {
+ status: "failed",
+ provider: error instanceof EmailDeliveryError ? "resend" : "unknown",
+ code: error instanceof EmailDeliveryError
+ ? error.code
+ : "unexpected_error",
+ retryable: error instanceof EmailDeliveryError && error.retryable
+ };
+ request.log.error({
+ challenge_id: verification.challengeId,
+ provider: delivery.provider,
+ provider_code: delivery.code,
+ retryable: delivery.retryable
+ }, "Public signup verification email delivery failed");
+ }
+ try {
+ await publicSignup.recordDelivery(verification.challengeId, delivery);
+ } catch (error) {
+ request.log.error({
+ err: error,
+ challenge_id: verification.challengeId
+ }, "Public signup delivery audit failed");
+ }
+ return reply;
+ });
+
+ app.post("/v1/auth/password/signup/verification", async (request, reply) => {
+ reply.header("cache-control", "no-store");
+ requireSameOrigin(request, options.publicUrl);
+ if (!authenticationRateLimiter || !options.emailTransport) {
+ throw new PublicSignupUnavailableError();
+ }
+ const input = z.object({
+ verification_token: z.string().min(1).max(200)
+ }).strict().parse(request.body);
+ const allowed = await consumeAuthenticationLimits(
+ authenticationRateLimiter,
+ [
+ {
+ scope: "password.signup_verification.token",
+ key: input.verification_token,
+ rule: PASSWORD_SIGNUP_TOKEN_LIMIT
+ },
+ {
+ scope: "password.signup_verification.ip",
+ key: request.ip,
+ rule: PASSWORD_SIGNUP_IP_LIMIT
+ },
+ {
+ scope: "password.signup_verification.global",
+ key: "global",
+ rule: PASSWORD_AUTH_GLOBAL_LIMIT
+ }
+ ],
+ reply
+ );
+ if (!allowed) return;
+ const verification = await publicSignup.details(input.verification_token);
+ return {
+ verification: {
+ email: verification.email,
+ expires_at: verification.expiresAt.toISOString()
+ }
+ };
+ });
+
+ app.post("/v1/auth/password/signup/public", async (request, reply) => {
+ reply.header("cache-control", "no-store");
+ requireSameOrigin(request, options.publicUrl);
+ if (!authenticationRateLimiter || !options.emailTransport) {
+ throw new PublicSignupUnavailableError();
+ }
+ if (!options.authenticationLegalDocuments) {
+ throw new AuthenticationPolicyIncompleteError();
+ }
+ const input = z.object({
+ verification_token: z.string().min(1).max(200),
+ name: z.string().trim().min(1).max(100),
+ password: z.string().min(1).max(PASSWORD_MAX_UTF8_BYTES),
+ terms_version: z.string().min(1).max(100),
+ privacy_version: z.string().min(1).max(100),
+ timezone: ianaTimezoneSchema.optional()
+ }).strict().parse(request.body);
+ const allowed = await consumeAuthenticationLimits(
+ authenticationRateLimiter,
+ [
+ {
+ scope: "password.signup_verification.token",
+ key: input.verification_token,
+ rule: PASSWORD_SIGNUP_TOKEN_LIMIT
+ },
+ {
+ scope: "password.signup_verification.ip",
+ key: request.ip,
+ rule: PASSWORD_SIGNUP_IP_LIMIT
+ },
+ {
+ scope: "password.signup_verification.global",
+ key: "global",
+ rule: PASSWORD_AUTH_GLOBAL_LIMIT
+ }
+ ],
+ reply
+ );
+ if (!allowed) return;
+ const session = await publicSignup.complete({
+ verificationToken: input.verification_token,
+ name: input.name,
+ password: input.password,
+ termsVersion: input.terms_version,
+ privacyVersion: input.privacy_version,
+ timezone: input.timezone,
+ clientName: sessionClientName(request.headers["user-agent"])
+ });
+ setSessionCookie(reply, session.token, options.publicUrl);
+ return reply.code(201).send({
+ user: session.user,
+ onboarding: session.starterCollectionPending
+ ? { starter_collection: "pending" }
+ : null
+ });
+ });
+
app.post("/v1/auth/password/signup", async (request, reply) => {
reply.header("cache-control", "no-store");
requireSameOrigin(request, options.publicUrl);
@@ -477,6 +709,39 @@ export function registerPasswordAuthRoutes(
});
}
+function safePublicSignupReturnTarget(
+ value: string | undefined,
+ publicUrl: string
+): string | null {
+ if (!value) return null;
+ const publicOrigin = new URL(publicUrl).origin;
+ let target: URL;
+ try {
+ target = new URL(value, publicOrigin);
+ } catch {
+ throw new RequestValidationError("Public signup return target is invalid.");
+ }
+ if (
+ target.origin !== publicOrigin
+ || target.username
+ || target.password
+ ) {
+ throw new RequestValidationError("Public signup return target is invalid.");
+ }
+ return `${target.pathname}${target.search}${target.hash}`;
+}
+
+function publicSignupVerificationUrl(
+ publicUrl: string,
+ verificationToken: string,
+ returnTarget: string | null
+): string {
+ const url = new URL("/signup", publicUrl);
+ if (returnTarget) url.searchParams.set("return_to", returnTarget);
+ url.hash = new URLSearchParams({ verification: verificationToken }).toString();
+ return url.href;
+}
+
async function consumeAuthenticationLimits(
limiter: AuthRateLimiter,
attempts: AuthenticationLimitAttempt[],
diff --git a/services/server/src/features/beta-access/routes.test.ts b/services/server/src/features/beta-access/routes.test.ts
index 6607fb28..6bf7ea5d 100644
--- a/services/server/src/features/beta-access/routes.test.ts
+++ b/services/server/src/features/beta-access/routes.test.ts
@@ -10,46 +10,27 @@ afterEach(async () => {
});
describe("beta access requests", () => {
- it("stores one normalized request and returns the same response for repeats", async () => {
+ it("returns a stable closure response without storing a request", async () => {
const { app, db } = await fixture();
- for (const email of ["Person@Example.com", " person@example.com "]) {
- const response = await app.inject({
- method: "POST",
- url: "/v1/beta-access-requests",
- headers: { origin: allowedOrigin },
- payload: { email }
- });
- expect(response.statusCode).toBe(202);
- expect(response.json()).toEqual({ accepted: true });
- expect(response.headers["cache-control"]).toBe("no-store");
- }
- const stored = await db.query<{
- email: string;
- normalized_email: string;
- email_normalization_version: number;
- }>(
- `SELECT email, normalized_email, email_normalization_version
- FROM beta_access_requests`
- );
- expect(stored.rows).toEqual([{
- email: "Person@Example.com",
- normalized_email: "person@example.com",
- email_normalization_version: 1
- }]);
- });
-
- it("ignores the honeypot and rejects untrusted origins and invalid addresses", async () => {
- const { app, db } = await fixture();
- const honeypot = await app.inject({
+ const response = await app.inject({
method: "POST",
url: "/v1/beta-access-requests",
headers: { origin: allowedOrigin },
- payload: { email: "bot@example.com", website: "https://spam.example" }
+ payload: { email: "person@example.com" }
+ });
+
+ expect(response.statusCode).toBe(410);
+ expect(response.json().error).toEqual({
+ code: "beta_access_closed",
+ message: "Beta access requests are closed. Public signup is opening soon."
});
- expect(honeypot.statusCode).toBe(202);
+ expect(response.headers["cache-control"]).toBe("no-store");
const stored = await db.query("SELECT id FROM beta_access_requests");
expect(stored.rows).toHaveLength(0);
+ });
+ it("still rejects untrusted origins", async () => {
+ const { app } = await fixture();
const denied = await app.inject({
method: "POST",
url: "/v1/beta-access-requests",
@@ -58,37 +39,6 @@ describe("beta access requests", () => {
});
expect(denied.statusCode).toBe(403);
expect(denied.json().error.code).toBe("origin_denied");
-
- const invalid = await app.inject({
- method: "POST",
- url: "/v1/beta-access-requests",
- headers: { origin: allowedOrigin },
- payload: { email: "not-an-address" }
- });
- expect(invalid.statusCode).toBe(400);
- expect(invalid.json().error.code).toBe("invalid_request");
- });
-
- it("applies a shared email rate limit", async () => {
- const { app } = await fixture();
- for (let attempt = 0; attempt < 5; attempt += 1) {
- const accepted = await app.inject({
- method: "POST",
- url: "/v1/beta-access-requests",
- headers: { origin: allowedOrigin },
- payload: { email: "limited@example.com" }
- });
- expect(accepted.statusCode).toBe(202);
- }
- const denied = await app.inject({
- method: "POST",
- url: "/v1/beta-access-requests",
- headers: { origin: allowedOrigin },
- payload: { email: "limited@example.com" }
- });
- expect(denied.statusCode).toBe(429);
- expect(denied.json().error.code).toBe("rate_limited");
- expect(Number(denied.headers["retry-after"])).toBeGreaterThan(0);
});
});
diff --git a/services/server/src/features/beta-access/routes.ts b/services/server/src/features/beta-access/routes.ts
index d0111e1e..35ece2b3 100644
--- a/services/server/src/features/beta-access/routes.ts
+++ b/services/server/src/features/beta-access/routes.ts
@@ -1,112 +1,24 @@
-import type { FastifyInstance, FastifyReply } from "fastify";
-import { z } from "zod";
-import {
- AuthRateLimiter,
- type AuthRateLimitRule
-} from "../../auth-rate-limit.js";
-import { BetaAccessRequestService } from "../../beta-access.js";
-import type { DatabasePool } from "../../database-types.js";
-import { normalizeEmailAddress } from "../../email-identity.js";
+import type { FastifyInstance } from "fastify";
import { apiError, OriginDeniedError } from "../../platform/http-errors.js";
-const EMAIL_LIMIT: AuthRateLimitRule = {
- maxAttempts: 5,
- windowSeconds: 24 * 60 * 60,
- baseBlockSeconds: 60 * 60,
- maxBlockSeconds: 24 * 60 * 60
-};
-const IP_LIMIT: AuthRateLimitRule = {
- maxAttempts: 20,
- windowSeconds: 60 * 60,
- baseBlockSeconds: 60 * 60,
- maxBlockSeconds: 24 * 60 * 60
-};
-const GLOBAL_LIMIT: AuthRateLimitRule = {
- maxAttempts: 300,
- windowSeconds: 60 * 60,
- baseBlockSeconds: 15 * 60,
- maxBlockSeconds: 60 * 60
-};
-
interface BetaAccessRoutesOptions {
- db: DatabasePool;
allowedOrigin: string;
- rateLimitSecret: string;
}
export function registerBetaAccessRoutes(
app: FastifyInstance,
options: BetaAccessRoutesOptions
): void {
- const requests = new BetaAccessRequestService(options.db);
- const limiter = new AuthRateLimiter(options.db, options.rateLimitSecret);
-
app.post("/v1/beta-access-requests", {
config: { rateLimit: { max: 30, timeWindow: "1 minute" } }
- }, async (request, reply) => {
+ }, (request, reply) => {
reply.header("cache-control", "no-store");
if (request.headers.origin !== options.allowedOrigin) {
throw new OriginDeniedError();
}
- const input = z.object({
- email: z.string().trim().pipe(z.email().max(320)),
- website: z.string().max(500).optional()
- }).strict().parse(request.body);
- if (input.website?.trim()) {
- return reply.code(202).send({ accepted: true });
- }
- const normalizedEmail = normalizeEmailAddress(input.email);
- const allowed = await consumeLimits(limiter, [
- {
- scope: "beta_access.email",
- key: normalizedEmail,
- rule: EMAIL_LIMIT
- },
- {
- scope: "beta_access.ip",
- key: request.ip,
- rule: IP_LIMIT
- },
- {
- scope: "beta_access.global",
- key: "global",
- rule: GLOBAL_LIMIT
- }
- ], reply);
- if (!allowed) return;
- await requests.request(input.email);
- return reply.code(202).send({ accepted: true });
+ return reply.code(410).send(apiError(
+ "beta_access_closed",
+ "Beta access requests are closed. Public signup is opening soon."
+ ));
});
}
-
-async function consumeLimits(
- limiter: AuthRateLimiter,
- attempts: Array<{
- scope: string;
- key: string;
- rule: AuthRateLimitRule;
- }>,
- reply: FastifyReply
-): Promise {
- let retryAfterSeconds = 0;
- for (const attempt of attempts) {
- const decision = await limiter.consume(
- attempt.scope,
- attempt.key,
- attempt.rule
- );
- if (!decision.allowed) {
- retryAfterSeconds = Math.max(
- retryAfterSeconds,
- decision.retryAfterSeconds
- );
- }
- }
- if (retryAfterSeconds === 0) return true;
- reply.header("retry-after", String(retryAfterSeconds));
- reply.code(429).send(apiError(
- "rate_limited",
- "Too many access requests. Please try again later."
- ));
- return false;
-}
diff --git a/services/server/src/features/system/routes.ts b/services/server/src/features/system/routes.ts
index f58fa980..b704fb22 100644
--- a/services/server/src/features/system/routes.ts
+++ b/services/server/src/features/system/routes.ts
@@ -9,6 +9,7 @@ export interface SystemRoutesOptions {
hostedCollections: boolean;
hostedProvider?: Pick;
revision?: string;
+ environment?: string;
publicUrl: string;
editorOrigin?: string;
}
@@ -18,11 +19,15 @@ export function registerSystemRoutes(
options: SystemRoutesOptions
): void {
const revision = options.revision?.trim() || undefined;
+ const environment = options.environment?.trim() || undefined;
app.get("/health", async () => ({
ok: true,
service: "mdbase-connect",
protocol_version: 1,
+ ...(environment
+ ? { environment, public_origin: new URL(options.publicUrl).origin }
+ : {}),
...(revision ? { revision } : {})
}));
diff --git a/services/server/src/index.ts b/services/server/src/index.ts
index 85a3c636..ac98791a 100644
--- a/services/server/src/index.ts
+++ b/services/server/src/index.ts
@@ -24,6 +24,7 @@ const { app } = await buildApp({
db,
revision: process.env.RENDER_GIT_COMMIT,
publicUrl: runtime.publicUrl,
+ environment: runtime.environment,
portalDist,
devAuth: runtime.devAuth,
tailscaleAuth: runtime.tailscaleAuth,
diff --git a/services/server/src/password-auth-routes.test.ts b/services/server/src/password-auth-routes.test.ts
index 75c87357..2863ecbc 100644
--- a/services/server/src/password-auth-routes.test.ts
+++ b/services/server/src/password-auth-routes.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { buildApp } from "./app.js";
import { AuthenticationPolicyStore } from "./authentication-policy.js";
import { createDatabase } from "./db.js";
+import type { TransactionalEmail } from "./email.js";
import { PasswordAccountService } from "./password-auth.js";
const resources: Array<() => Promise> = [];
@@ -222,7 +223,7 @@ describe("password authentication HTTP boundary", () => {
expect((await db.query("SELECT id FROM users")).rows).toHaveLength(0);
});
- it("keeps registration unavailable when legal documents are missing or mode is open", async () => {
+ it("requires legal documents and keeps invitations usable when registration opens", async () => {
const db = await createDatabase("memory");
resources.push(() => db.end());
const policy = await configurePolicy(db);
@@ -286,7 +287,11 @@ describe("password authentication HTTP boundary", () => {
});
expect(openConfig.json().registration).toBe("open");
expect(openConfig.json().password_login).toBe(true);
- expect(openConfig.json().password_registration).toBeUndefined();
+ expect(openConfig.json()).toMatchObject({
+ password_registration: true,
+ password_invitation_registration: true
+ });
+ expect(openConfig.json().password_public_registration).toBeUndefined();
const openSignup = await withDocuments.app.inject({
method: "POST",
url: "/v1/auth/password/signup",
@@ -299,8 +304,142 @@ describe("password authentication HTTP boundary", () => {
privacy_version: invitation.privacyVersion
}
});
- expect(openSignup.statusCode).toBe(503);
- expect((await db.query("SELECT id FROM users")).rows).toHaveLength(0);
+ expect(openSignup.statusCode).toBe(201);
+ expect((await db.query("SELECT id FROM users")).rows).toHaveLength(1);
+ });
+
+ it("verifies public signup email without revealing existing accounts, then rejects replay", async () => {
+ const db = await createDatabase("memory");
+ resources.push(() => db.end());
+ const policy = new AuthenticationPolicyStore(db, "closed");
+ await policy.update({
+ registrationMode: "open",
+ passwordAuthEnabled: true,
+ emailDeliveryEnabled: true,
+ termsVersion: "terms-2026-08",
+ privacyVersion: "privacy-2026-08",
+ expectedRevision: 0,
+ updatedBy: "operator:test",
+ reason: "Configure public signup route test"
+ });
+ const deliveries: TransactionalEmail[] = [];
+ const { app } = await buildApp({
+ db,
+ publicUrl: origin,
+ authRateLimitSecret: "test-auth-rate-limit-secret-value",
+ authenticationLegalDocuments: {
+ termsUrl: "https://mdbase.dev/terms/",
+ privacyUrl: "https://mdbase.dev/privacy/"
+ },
+ emailTransport: {
+ async send(message) {
+ deliveries.push(message);
+ return { provider: "test", messageId: `message-${deliveries.length}` };
+ }
+ }
+ });
+ resources.push(() => app.close());
+ const config = await app.inject({ method: "GET", url: "/v1/auth/config" });
+ expect(config.json()).toMatchObject({
+ registration: "open",
+ password_registration: true,
+ password_invitation_registration: true,
+ password_public_registration: true
+ });
+
+ const crossOrigin = await app.inject({
+ method: "POST",
+ url: "/v1/auth/password/signup/request",
+ headers: { origin: "https://evil.example" },
+ payload: { email: "person@example.com" }
+ });
+ expect(crossOrigin.statusCode).toBe(403);
+ const externalReturn = await app.inject({
+ method: "POST",
+ url: "/v1/auth/password/signup/request",
+ headers: { origin },
+ payload: {
+ email: "person@example.com",
+ return_to: "https://evil.example/authorize/request"
+ }
+ });
+ expect(externalReturn.statusCode).toBe(400);
+
+ const requested = await app.inject({
+ method: "POST",
+ url: "/v1/auth/password/signup/request",
+ headers: { origin },
+ payload: {
+ email: "Person@Example.com",
+ return_to: "/authorize/request?source=signup#resume"
+ }
+ });
+ expect(requested.statusCode).toBe(202);
+ expect(deliveries).toHaveLength(1);
+ const verificationUrl = new URL(
+ deliveries[0]!.text.match(/https:\/\/[^\s]+/u)![0]
+ );
+ expect(verificationUrl.pathname).toBe("/signup");
+ expect(verificationUrl.searchParams.get("return_to"))
+ .toBe("/authorize/request?source=signup#resume");
+ const verificationToken = new URLSearchParams(
+ verificationUrl.hash.slice(1)
+ ).get("verification")!;
+ expect(verificationToken).toMatch(/^vfy_/u);
+ expect(JSON.stringify((await db.query(
+ "SELECT token_hash FROM authentication_challenges"
+ )).rows)).not.toContain(verificationToken);
+
+ const preview = await app.inject({
+ method: "POST",
+ url: "/v1/auth/password/signup/verification",
+ headers: { origin },
+ payload: { verification_token: verificationToken }
+ });
+ expect(preview.statusCode).toBe(200);
+ expect(preview.json().verification.email).toBe("person@example.com");
+ const completed = await app.inject({
+ method: "POST",
+ url: "/v1/auth/password/signup/public",
+ headers: { origin },
+ payload: {
+ verification_token: verificationToken,
+ name: "Person Example",
+ password: "a durable public account password",
+ terms_version: "terms-2026-08",
+ privacy_version: "privacy-2026-08",
+ timezone: "Australia/Melbourne"
+ }
+ });
+ expect(completed.statusCode).toBe(201);
+ expect(completed.cookies.find(
+ ({ name }) => name === "__Host-mdbase_session"
+ )).toMatchObject({ httpOnly: true, secure: true, sameSite: "Lax" });
+
+ const replay = await app.inject({
+ method: "POST",
+ url: "/v1/auth/password/signup/public",
+ headers: { origin },
+ payload: {
+ verification_token: verificationToken,
+ name: "Person Example",
+ password: "a durable public account password",
+ terms_version: "terms-2026-08",
+ privacy_version: "privacy-2026-08"
+ }
+ });
+ expect(replay.statusCode).toBe(400);
+ expect(replay.json().error.code).toBe("invalid_signup_verification");
+
+ const existing = await app.inject({
+ method: "POST",
+ url: "/v1/auth/password/signup/request",
+ headers: { origin },
+ payload: { email: "PERSON@example.com" }
+ });
+ expect(existing.statusCode).toBe(202);
+ expect(existing.json()).toEqual(requested.json());
+ expect(deliveries).toHaveLength(1);
});
});
diff --git a/services/server/src/password-auth.ts b/services/server/src/password-auth.ts
index 62e7dcc1..22a5dd07 100644
--- a/services/server/src/password-auth.ts
+++ b/services/server/src/password-auth.ts
@@ -252,7 +252,7 @@ export class PasswordAccountService {
async acceptInvitation(input: AcceptInvitationInput): Promise {
const name = requiredText(input.name, 100, "Account name");
if (input.invitationToken.length > 200) throw new InvalidInvitationError();
- requireSignupEnabled(await this.policy.current());
+ requireInvitationSignupEnabled(await this.policy.current());
const invitationHash = tokenHash(input.invitationToken);
const preliminary = await this.db.query(
`SELECT invitation.id, invitation.email, invitation.normalized_email,
@@ -277,7 +277,7 @@ export class PasswordAccountService {
try {
await connection.query("BEGIN");
const settings = await this.policy.currentForAccountChange(connection);
- requireSignupEnabled(settings);
+ requireInvitationSignupEnabled(settings);
const invitation = await connection.query<
Omit
>(
@@ -395,7 +395,7 @@ export class PasswordAccountService {
async invitationDetails(invitationToken: string): Promise {
if (invitationToken.length > 200) throw new InvalidInvitationError();
const settings = await this.policy.current();
- requireSignupEnabled(settings);
+ requireInvitationSignupEnabled(settings);
const invitation = await this.db.query(
`SELECT id, email, normalized_email, terms_version, privacy_version,
expires_at
@@ -646,10 +646,10 @@ function requiredAgreements(settings: AuthenticationSettings): {
};
}
-function requireSignupEnabled(settings: AuthenticationSettings): void {
+function requireInvitationSignupEnabled(settings: AuthenticationSettings): void {
if (
!settings.passwordAuthEnabled
- || settings.registrationMode !== "invite"
+ || !["invite", "open"].includes(settings.registrationMode)
) {
throw new PasswordAuthenticationUnavailableError();
}
diff --git a/services/server/src/platform/error-handler.ts b/services/server/src/platform/error-handler.ts
index 89ee2d35..546e6787 100644
--- a/services/server/src/platform/error-handler.ts
+++ b/services/server/src/platform/error-handler.ts
@@ -32,6 +32,10 @@ import {
PasswordRecoveryUnavailableError
} from "../password-recovery.js";
import { PasswordPolicyError } from "../password.js";
+import {
+ InvalidPublicSignupVerificationError,
+ PublicSignupUnavailableError
+} from "../public-signup.js";
import { InvalidEmailAddressError } from "../email-identity.js";
import {
ConnectorOperationError,
@@ -194,6 +198,12 @@ export function registerErrorHandler(app: FastifyInstance): void {
"This password reset link is invalid, expired, or has already been used."
));
}
+ if (error instanceof InvalidPublicSignupVerificationError) {
+ return reply.code(400).send(apiError(
+ "invalid_signup_verification",
+ "This email verification link is invalid, expired, or has already been used."
+ ));
+ }
if (error instanceof PasswordPolicyError) {
return reply.code(400).send(apiError("invalid_password", error.message));
}
@@ -215,6 +225,12 @@ export function registerErrorHandler(app: FastifyInstance): void {
"Password recovery is temporarily unavailable."
));
}
+ if (error instanceof PublicSignupUnavailableError) {
+ return reply.code(503).send(apiError(
+ "public_signup_unavailable",
+ "Public account creation is temporarily unavailable."
+ ));
+ }
if (error instanceof AuthenticationPolicyIncompleteError) {
request.log.error("Password authentication policy is incomplete");
return reply.code(503).send(apiError(
diff --git a/services/server/src/public-signup-email.test.ts b/services/server/src/public-signup-email.test.ts
new file mode 100644
index 00000000..f3d2e334
--- /dev/null
+++ b/services/server/src/public-signup-email.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it, vi } from "vitest";
+import type { EmailTransport } from "./email.js";
+import {
+ renderPublicSignupVerificationEmail,
+ sendPublicSignupVerificationEmail
+} from "./public-signup-email.js";
+
+const verification = {
+ challengeId: "3e74e919-fc87-4e90-ad93-05d40464ecac",
+ to: "person@example.com",
+ verificationUrl:
+ "https://connect.mdbase.dev/signup#verification=vfy_abcdefghijklmnopqrstuvwxyz0123456789ABCDE",
+ expiresAt: new Date("2026-08-21T02:30:00.000Z")
+};
+
+describe("public signup verification email", () => {
+ it("keeps the one-time token in the fragment and a same-origin return target", () => {
+ const verificationUrl = new URL(verification.verificationUrl);
+ verificationUrl.searchParams.set("return_to", "/authorize/request?source=signup");
+ const rendered = renderPublicSignupVerificationEmail({
+ ...verification,
+ verificationUrl: verificationUrl.href
+ });
+ expect(rendered.subject).toBe("Verify your email for mdbase connect");
+ expect(rendered.text).toContain(verificationUrl.href);
+ expect(rendered.html).toContain(verificationUrl.href.replaceAll("&", "&"));
+ expect(rendered.html).not.toContain("