feat(onboarding): add stubbed account API contract for onboarding v2

Typed frontend seams for the signup-first onboarding flow's backend
endpoints that do not exist yet: email/password signup + login,
invitee-bound community invites, in-app community creation, and email
invites. Every function resolves fixture data so the whole flow is
walkable in the UI with zero backend changes; error cases use stable
OnboardingAccountError codes the UI can branch on.

Engineers replace the bodies (not the signatures) when the real
endpoints land. PR 1 of the onboarding v2 sequence.

Co-authored-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@buzz.block.builderlab.xyz>
Signed-off-by: npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3 <29b529ff43be424db89f51bff58a38cf86942acbffff98a14ce50b38c2a57d8f@buzz.block.builderlab.xyz>
This commit is contained in:
npub19x6jnl6rhepymwyl2xlltz3ce7rfg2ktllle3g2vu59n3s490k8s9n40l3
2026-07-22 16:42:56 -07:00
parent c96ae0ea97
commit 747720cff9
2 changed files with 295 additions and 0 deletions
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
connectInvitedCommunity,
createCommunity,
isValidEmail,
listInvitedCommunities,
logIn,
MIN_PASSWORD_LENGTH,
OnboardingAccountError,
sendCommunityInvites,
signUp,
} from "./onboardingAccountStubs.ts";
test("isValidEmail accepts plausible addresses and rejects junk", () => {
assert.equal(isValidEmail("cynthia@example.com"), true);
assert.equal(isValidEmail(" padded@example.com "), true);
assert.equal(isValidEmail("no-at-sign.example.com"), false);
assert.equal(isValidEmail("missing@tld"), false);
assert.equal(isValidEmail("spaces in@example.com"), false);
assert.equal(isValidEmail(""), false);
});
test("signUp resolves for a valid email and strong-enough password", async () => {
const result = await signUp({
email: "cynthia@example.com",
password: "a".repeat(MIN_PASSWORD_LENGTH),
});
assert.deepEqual(result, { ok: true });
});
test("signUp rejects an invalid email with a stable error code", async () => {
await assert.rejects(
signUp({ email: "not-an-email", password: "long-enough-password" }),
(error) =>
error instanceof OnboardingAccountError && error.code === "email_invalid",
);
});
test("signUp rejects a short password with password_weak", async () => {
await assert.rejects(
signUp({
email: "cynthia@example.com",
password: "a".repeat(MIN_PASSWORD_LENGTH - 1),
}),
(error) =>
error instanceof OnboardingAccountError && error.code === "password_weak",
);
});
test("logIn resolves for a plausible credential shape", async () => {
assert.deepEqual(
await logIn({ email: "cynthia@example.com", password: "hunter22" }),
{ ok: true },
);
});
test("logIn rejects bad shapes with credentials_invalid", async () => {
for (const attempt of [
{ email: "not-an-email", password: "hunter22" },
{ email: "cynthia@example.com", password: "" },
]) {
await assert.rejects(
logIn(attempt),
(error) =>
error instanceof OnboardingAccountError &&
error.code === "credentials_invalid",
);
}
});
test("listInvitedCommunities returns fixture invites with the fields the hub renders", async () => {
const invites = await listInvitedCommunities();
assert.ok(invites.length >= 1);
for (const invite of invites) {
assert.equal(typeof invite.inviteId, "string");
assert.equal(typeof invite.name, "string");
assert.equal(typeof invite.host, "string");
assert.match(invite.relayWsUrl, /^wss?:\/\//);
}
});
test("connectInvitedCommunity resolves a known invite and rejects an unknown one", async () => {
const [invite] = await listInvitedCommunities();
const result = await connectInvitedCommunity(invite.inviteId);
assert.equal(result.relayWsUrl, invite.relayWsUrl);
await assert.rejects(
connectInvitedCommunity("definitely-not-an-invite"),
(error) => error instanceof OnboardingAccountError,
);
});
test("createCommunity slugs the relay host from the name", async () => {
const created = await createCommunity({
name: "The Land of Ooo",
description: "A magical workspace",
});
assert.equal(created.name, "The Land of Ooo");
assert.equal(
created.relayWsUrl,
"wss://the-land-of-ooo.communities.buzz.xyz",
);
});
test("createCommunity rejects an empty name", async () => {
await assert.rejects(
createCommunity({ name: " ", description: "" }),
(error) => error instanceof OnboardingAccountError,
);
});
test("sendCommunityInvites counts only valid emails", async () => {
const result = await sendCommunityInvites({
emails: ["kalvin@example.com", "not-an-email", "wes@example.com"],
});
assert.deepEqual(result, { sent: 2 });
});
@@ -0,0 +1,176 @@
/**
* Frontend-only stubs for the onboarding v2 "standard flow" backend seams.
*
* The signup-first onboarding (see PLANS/ONBOARDING_V2_STANDARD_FLOW.md in the
* workspace) needs endpoints that do not exist on the relay yet: email/password
* accounts, invitee-bound community invites, in-app community creation, and
* email invites. This module is the typed contract for those endpoints; every
* function currently resolves fixture data after a short delay so the whole
* flow is walkable in the UI with zero backend changes.
*
* Engineers: replace the bodies (not the signatures) when the real endpoints
* land. Error cases are expressed as thrown `OnboardingAccountError`s with
* stable `code` values the UI already branches on.
*/
export type OnboardingAccountErrorCode =
| "email_invalid"
| "email_taken"
| "password_weak"
| "credentials_invalid"
| "community_name_taken"
| "network";
export class OnboardingAccountError extends Error {
readonly code: OnboardingAccountErrorCode;
constructor(code: OnboardingAccountErrorCode, message: string) {
super(message);
this.name = "OnboardingAccountError";
this.code = code;
}
}
export type InvitedCommunity = {
/** Opaque invite identifier used to claim the invite. */
inviteId: string;
/** Human-readable community name, e.g. "The Land of Ooo". */
name: string;
/** Community hostname shown under the name in the hub list. */
host: string;
/** Relay websocket URL to connect to on accept. */
relayWsUrl: string;
};
export type CreatedCommunity = {
name: string;
relayWsUrl: string;
};
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** Minimum password length enforced client-side until the backend owns policy. */
export const MIN_PASSWORD_LENGTH = 8;
function delay(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
/** Client-side email shape check shared by the signup and login forms. */
export function isValidEmail(email: string): boolean {
return EMAIL_PATTERN.test(email.trim());
}
/**
* Create an email/password account. In the real flow this pairs the account
* with the freshly generated identity key; the stub only validates shape.
*/
export async function signUp({
email,
password,
}: {
email: string;
password: string;
}): Promise<{ ok: true }> {
await delay(400);
if (!isValidEmail(email)) {
throw new OnboardingAccountError(
"email_invalid",
"Enter a valid email address.",
);
}
if (password.length < MIN_PASSWORD_LENGTH) {
throw new OnboardingAccountError(
"password_weak",
`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`,
);
}
return { ok: true };
}
/** Log in a returning email/password user. Stub accepts any valid shape. */
export async function logIn({
email,
password,
}: {
email: string;
password: string;
}): Promise<{ ok: true }> {
await delay(400);
if (!isValidEmail(email) || password.length === 0) {
throw new OnboardingAccountError(
"credentials_invalid",
"Email or password is incorrect.",
);
}
return { ok: true };
}
/**
* Communities the current account has been invited to. Invites are bound to
* the invitee server-side, so onboarding can list them without a pasted link.
*/
export async function listInvitedCommunities(): Promise<InvitedCommunity[]> {
await delay(300);
return [
{
inviteId: "stub-invite-1",
name: "The Land of Ooo",
host: "land-of-ooo.communities.buzz.xyz",
relayWsUrl: "wss://land-of-ooo.communities.buzz.xyz",
},
];
}
/** Accept an invitee-bound invite and return the relay to connect to. */
export async function connectInvitedCommunity(
inviteId: string,
): Promise<{ relayWsUrl: string }> {
await delay(300);
const invites = await listInvitedCommunities();
const invite = invites.find((candidate) => candidate.inviteId === inviteId);
if (!invite) {
throw new OnboardingAccountError("network", "Invite is no longer valid.");
}
return { relayWsUrl: invite.relayWsUrl };
}
/** Create a new hosted community from inside onboarding. */
export async function createCommunity({
name,
description,
avatarUrl,
}: {
name: string;
description: string;
avatarUrl?: string;
}): Promise<CreatedCommunity> {
await delay(500);
void description;
void avatarUrl;
const trimmed = name.trim();
if (!trimmed) {
throw new OnboardingAccountError(
"community_name_taken",
"Enter a community name.",
);
}
const slug = trimmed
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return {
name: trimmed,
relayWsUrl: `wss://${slug || "community"}.communities.buzz.xyz`,
};
}
/** Send email invites to join the newly created community. */
export async function sendCommunityInvites({
emails,
}: {
emails: string[];
}): Promise<{ sent: number }> {
await delay(300);
const valid = emails.filter((email) => isValidEmail(email));
return { sent: valid.length };
}