From 016e736507f10b5ed011fe303a02934aa4b42711 Mon Sep 17 00:00:00 2001
From: feruzm
Date: Tue, 11 Aug 2026 21:51:26 +0000
Subject: [PATCH 1/4] hosting: customize step before payment
Signup asked only title and description; theme was hardcoded and every
new instance started visually identical. The configure step becomes a
customize step: template cards rendered from a new GET /v1/templates
catalog (roster + display metadata, so the signup can never carry its
own template list), accent quick picks plus a validated free hex field
that blocks Continue while invalid, a font pairing select, and identity
prefilled from the account profile into empty fields only, taken back
out again if the name changes before payment. Choices persist per name
in localStorage so an abandoned tab resumes, and are cleared on
success. Skipping every choice produces exactly the previous default
payload.
Server side: createTenantSchema and the flat PATCH vocabulary accept
accent (#rgb/#rrggbb) and fontPreset (closed key set shared with the
SPA via appearance.ts, with a lockstep test against FONT_PRESETS);
normalizeFlatOverrides maps them under general.styles without planting
an empty styles object.
The reservation lifecycle now honors the step's promise that the look
on screen is the look that activates: a same-owner unpaid reservation
is refreshed by re-creation (route lets it through after ownership
validation; the upsert takes the new config), and the client re-sends
creation whenever the name or the composed config changed instead of
guarding on the name alone. Live tenants and other owners' reservations
still 409.
Closes #1414
---
.../self-hosted/hosting/api/src/appearance.ts | 28 ++
apps/self-hosted/hosting/api/src/index.ts | 3 +
.../hosting/api/src/routes/templates.test.ts | 35 +++
.../hosting/api/src/routes/templates.ts | 14 +
.../src/routes/tenant-create-refresh.test.ts | 119 +++++++
.../hosting/api/src/routes/tenants.ts | 23 +-
.../api/src/services/flat-overrides.test.ts | 50 +++
.../services/tenant-service-cleanup.test.ts | 8 +-
.../api/src/services/tenant-service.ts | 22 +-
.../hosting/api/src/style-template-display.ts | 92 ++++++
.../src/styles/style-template-roster.test.ts | 18 ++
.../features/hosting-signup/accent-picker.tsx | 66 ++++
.../features/hosting-signup/hosting-api.ts | 25 ++
.../hosting-signup/hosting-signup.tsx | 233 ++++++++++++--
.../hosting-signup/template-picker.tsx | 83 +++++
apps/web/src/features/i18n/locales/en-US.json | 14 +
.../hosting-signup/hosting-signup.spec.tsx | 291 +++++++++++++++++-
17 files changed, 1081 insertions(+), 43 deletions(-)
create mode 100644 apps/self-hosted/hosting/api/src/appearance.ts
create mode 100644 apps/self-hosted/hosting/api/src/routes/templates.test.ts
create mode 100644 apps/self-hosted/hosting/api/src/routes/templates.ts
create mode 100644 apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts
create mode 100644 apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts
create mode 100644 apps/self-hosted/hosting/api/src/style-template-display.ts
create mode 100644 apps/web/src/features/hosting-signup/accent-picker.tsx
create mode 100644 apps/web/src/features/hosting-signup/template-picker.tsx
diff --git a/apps/self-hosted/hosting/api/src/appearance.ts b/apps/self-hosted/hosting/api/src/appearance.ts
new file mode 100644
index 0000000000..469686a943
--- /dev/null
+++ b/apps/self-hosted/hosting/api/src/appearance.ts
@@ -0,0 +1,28 @@
+/**
+ * Appearance constants shared with the blog SPA, next to the style template
+ * roster (style-templates.ts) and under the same rules: dependency-free,
+ * because the SPA bundles these, and living here because the API image builds
+ * from hosting/api alone and could not import in the other direction.
+ *
+ * The SPA's font preset definitions (src/core/theme-appearance.ts) carry the
+ * actual font stacks; the API needs only the closed key set for validating
+ * signup overrides. A lockstep test on the SPA side
+ * (src/styles/style-template-roster.test.ts) keeps the two agreeing.
+ */
+export const FONT_PRESET_KEYS = Object.freeze([
+ 'classic',
+ 'editorial',
+ 'modern',
+ 'technical',
+ 'system',
+] as const);
+
+export type FontPresetKey = (typeof FONT_PRESET_KEYS)[number];
+
+/**
+ * `#rgb` or `#rrggbb`, the same shapes the SPA's parseHexColor accepts. The
+ * signup path validates and rejects; the Configuration Editor's full-document
+ * path stays lenient on purpose, because the SPA already treats an
+ * unparseable accent as "the template's own accent stands".
+ */
+export const ACCENT_HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
diff --git a/apps/self-hosted/hosting/api/src/index.ts b/apps/self-hosted/hosting/api/src/index.ts
index 70c381bc3c..e8ca3d1461 100644
--- a/apps/self-hosted/hosting/api/src/index.ts
+++ b/apps/self-hosted/hosting/api/src/index.ts
@@ -9,6 +9,7 @@ import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { secureHeaders } from 'hono/secure-headers';
import { tenantRoutes } from './routes/tenants';
+import { templateRoutes } from './routes/templates';
import { domainRoutes } from './routes/domains';
import { paymentRoutes } from './routes/payments';
import { authRoutes } from './routes/auth';
@@ -68,6 +69,7 @@ app.use('/v1/tenants/*', generalLimit);
app.use('/v1/tenants', generalLimit);
app.use('/v1/domains/*', generalLimit);
app.use('/v1/payments/*', generalLimit);
+app.use('/v1/templates', generalLimit);
app.use('/v1/auth/*', generalLimit);
app.use('/v1/auth/*', authLimit);
@@ -85,6 +87,7 @@ app.use('/v1/internal/*', internalLimit);
// API Routes
app.route('/v1/tenants', tenantRoutes);
+app.route('/v1/templates', templateRoutes);
app.route('/v1/domains', domainRoutes);
app.route('/v1/payments', paymentRoutes);
app.route('/v1/auth', authRoutes);
diff --git a/apps/self-hosted/hosting/api/src/routes/templates.test.ts b/apps/self-hosted/hosting/api/src/routes/templates.test.ts
new file mode 100644
index 0000000000..f6d37272f8
--- /dev/null
+++ b/apps/self-hosted/hosting/api/src/routes/templates.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { STYLE_TEMPLATES } from '../style-templates';
+import { templateRoutes } from './templates';
+
+describe('GET /v1/templates', () => {
+ it('serves one card per roster entry with display fields and a single default', async () => {
+ const res = await templateRoutes.request('http://localhost/');
+ expect(res.status).toBe(200);
+ expect(res.headers.get('cache-control')).toContain('max-age');
+
+ const body = (await res.json()) as {
+ templates: Array<{
+ id: string;
+ name: string;
+ tagline: string;
+ isDefault: boolean;
+ colors: Record;
+ headingStyle: string;
+ }>;
+ };
+
+ expect(body.templates.map((t) => t.id).sort()).toEqual(
+ [...STYLE_TEMPLATES].sort(),
+ );
+ expect(body.templates.filter((t) => t.isDefault)).toHaveLength(1);
+ for (const t of body.templates) {
+ expect(t.name.length).toBeGreaterThan(0);
+ expect(t.tagline.length).toBeGreaterThan(0);
+ for (const key of ['background', 'surface', 'accent', 'text']) {
+ expect(t.colors[key], `${t.id} colors.${key}`).toBeTruthy();
+ }
+ expect(['serif', 'sans', 'mono']).toContain(t.headingStyle);
+ }
+ });
+});
diff --git a/apps/self-hosted/hosting/api/src/routes/templates.ts b/apps/self-hosted/hosting/api/src/routes/templates.ts
new file mode 100644
index 0000000000..27ea15bb21
--- /dev/null
+++ b/apps/self-hosted/hosting/api/src/routes/templates.ts
@@ -0,0 +1,14 @@
+import { Hono } from 'hono';
+import { templateCatalog } from '../style-template-display';
+
+/**
+ * Public template catalog for the signup UI's picker. Static data straight
+ * from the roster and its display map, so the signup can never carry its own
+ * copy of the template list. Cacheable: it changes only on deploy.
+ */
+export const templateRoutes = new Hono();
+
+templateRoutes.get('/', (c) => {
+ c.header('Cache-Control', 'public, max-age=300');
+ return c.json({ templates: templateCatalog() });
+});
diff --git a/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts b/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts
new file mode 100644
index 0000000000..f6ad2391b0
--- /dev/null
+++ b/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts
@@ -0,0 +1,119 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const state = vi.hoisted(() => ({
+ existing: null as any,
+ created: [] as any[],
+}));
+
+vi.mock('../db/client', () => ({
+ db: {
+ // The audit logger chains .catch on these, so they must return promises.
+ query: vi.fn(async () => ({ rows: [] })),
+ queryOne: vi.fn(async () => null),
+ },
+}));
+vi.mock('../middleware/auth', () => ({
+ authMiddleware: async (_c: unknown, next: () => Promise) => next(),
+ adminMiddleware: async (_c: unknown, next: () => Promise) => next(),
+}));
+vi.mock('../middleware/payment-target-lock', () => ({
+ withPaymentTargetLock: async (
+ _c: unknown,
+ next: () => Promise,
+ ) => next(),
+}));
+vi.mock('../services/tenant-service', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ isReregisterableAbandoned: () => false,
+ TenantService: {
+ ...actual.TenantService,
+ getByUsername: async () => state.existing,
+ isListenerCaughtUp: async () => true,
+ verifyHiveAccount: async () => true,
+ verifyCommunityControlledBy: async () => true,
+ create: async (username: string, owner: string, config: any) => {
+ state.created.push({ username, owner, config });
+ return {
+ username,
+ owner,
+ subscriptionStatus: 'inactive',
+ subscriptionPlan: 'standard',
+ };
+ },
+ },
+ };
+});
+
+const { tenantRoutes } = await import('./tenants');
+
+function post(body: unknown) {
+ return tenantRoutes.request('http://localhost/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+}
+
+describe('POST /v1/tenants appearance validation', () => {
+ beforeEach(() => {
+ state.existing = null;
+ state.created = [];
+ });
+
+ it('rejects an accent that is not a hex color', async () => {
+ const res = await post({ username: 'alice', config: { accent: 'red' } });
+ expect(res.status).toBe(400);
+ expect(state.created).toHaveLength(0);
+ });
+
+ it('rejects an unknown font preset', async () => {
+ const res = await post({ username: 'alice', config: { fontPreset: 'comic-sans' } });
+ expect(res.status).toBe(400);
+ expect(state.created).toHaveLength(0);
+ });
+
+ it('accepts valid appearance values', async () => {
+ const res = await post({
+ username: 'alice',
+ config: { styleTemplate: 'magazine', accent: '#ff6600', fontPreset: 'classic' },
+ });
+ expect(res.status).toBe(201);
+ expect(state.created[0].config.accent).toBe('#ff6600');
+ });
+});
+
+/**
+ * The customize step promises that the look on screen is the look that
+ * activates: a same-owner unpaid reservation is refreshed by re-creation
+ * (the latest submission wins), while anyone else's reservation and any
+ * live tenant stay 409.
+ */
+describe('POST /v1/tenants refreshes a same-owner unpaid reservation', () => {
+ beforeEach(() => {
+ state.existing = null;
+ state.created = [];
+ });
+
+ it('lets the owner re-submit an inactive reservation with a new look', async () => {
+ state.existing = { username: 'alice', owner: 'alice', subscriptionStatus: 'inactive' };
+ const res = await post({ username: 'alice', config: { styleTemplate: 'developer' } });
+ expect(res.status).toBe(201);
+ expect(state.created[0].config.styleTemplate).toBe('developer');
+ });
+
+ it("still refuses someone else's inactive reservation", async () => {
+ state.existing = { username: 'alice', owner: 'mallory', subscriptionStatus: 'inactive' };
+ const res = await post({ username: 'alice', config: { styleTemplate: 'developer' } });
+ expect(res.status).toBe(409);
+ expect(state.created).toHaveLength(0);
+ });
+
+ it('still refuses a live tenant', async () => {
+ state.existing = { username: 'alice', owner: 'alice', subscriptionStatus: 'active' };
+ const res = await post({ username: 'alice', config: {} });
+ expect(res.status).toBe(409);
+ expect(state.created).toHaveLength(0);
+ });
+});
diff --git a/apps/self-hosted/hosting/api/src/routes/tenants.ts b/apps/self-hosted/hosting/api/src/routes/tenants.ts
index b415589ae6..e87e69f37c 100644
--- a/apps/self-hosted/hosting/api/src/routes/tenants.ts
+++ b/apps/self-hosted/hosting/api/src/routes/tenants.ts
@@ -6,6 +6,7 @@ import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { STYLE_TEMPLATES } from '../style-templates';
+import { ACCENT_HEX_PATTERN, FONT_PRESET_KEYS } from '../appearance';
import { db } from '../db/client';
import {
TenantService,
@@ -33,6 +34,8 @@ const createTenantSchema = z.object({
config: z.object({
theme: z.enum(['light', 'dark', 'system']).optional(),
styleTemplate: z.enum(STYLE_TEMPLATES).optional(),
+ accent: z.string().regex(ACCENT_HEX_PATTERN, 'accent must be #rgb or #rrggbb').optional(),
+ fontPreset: z.enum(FONT_PRESET_KEYS).optional(),
type: z.enum(['blog', 'community']).optional(),
communityId: z.string().optional(),
title: z.string().max(100).optional(),
@@ -59,6 +62,8 @@ const fullConfigDocSchema = z.object({
const flatConfigUpdateSchema = z.object({
theme: z.enum(['light', 'dark', 'system']).optional(),
styleTemplate: z.enum(STYLE_TEMPLATES).optional(),
+ accent: z.string().regex(ACCENT_HEX_PATTERN, 'accent must be #rgb or #rrggbb').optional(),
+ fontPreset: z.enum(FONT_PRESET_KEYS).optional(),
title: z.string().max(100).optional(),
description: z.string().max(500).optional(),
listType: z.enum(['list', 'grid']).optional(),
@@ -224,8 +229,19 @@ tenantRoutes.post(
// revives it below. Within the quarantine — or while the payment listener isn't confirmed
// caught up to head (so a pending on-chain payment for it may be unprocessed) — it is still
// treated as taken so an in-flight payment for it isn't overwritten.
+ //
+ // A same-owner 'inactive' reservation is NOT taken: that is the customize step re-submitting
+ // before any payment, and the latest submitted look must win (create() refreshes the stored
+ // config). The owner comparison happens after validation below, which is what enforces the
+ // ownership rules (a community's declared owner is checked on-chain; a personal blog's owner
+ // is the account itself, which is the same trust model first creation has always had).
const existing = await TenantService.getByUsername(body.username);
- if (existing && !(isReregisterableAbandoned(existing) && (await TenantService.isListenerCaughtUp()))) {
+ const sameNameInactive = !!existing && existing.subscriptionStatus === 'inactive';
+ if (
+ existing &&
+ !sameNameInactive &&
+ !(isReregisterableAbandoned(existing) && (await TenantService.isListenerCaughtUp()))
+ ) {
return c.json({ error: 'Username already registered' }, 409);
}
@@ -236,6 +252,11 @@ tenantRoutes.post(
}
const { owner } = validation;
+ // An inactive reservation may only be refreshed by the owner it was reserved for.
+ if (sameNameInactive && existing!.owner !== owner) {
+ return c.json({ error: 'Username already registered' }, 409);
+ }
+
// Create tenant (inactive until payment). The served config file is NOT written here:
// nginx serves any file that exists with no subscription check, so writing it now would
// put an unpaid blog live forever. The file is generated only on activation (payment
diff --git a/apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts b/apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts
new file mode 100644
index 0000000000..3c18371071
--- /dev/null
+++ b/apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest';
+import { TenantService } from './tenant-service';
+
+// Pure mapping: no DB, no RPC.
+
+describe('normalizeFlatOverrides appearance keys', () => {
+ it('maps accent and fontPreset under general.styles, the paths the editor writes', () => {
+ const normalized = TenantService.normalizeFlatOverrides({
+ styleTemplate: 'magazine',
+ accent: '#ff6600',
+ fontPreset: 'classic',
+ });
+
+ expect(normalized.configuration.general.styleTemplate).toBe('magazine');
+ expect(normalized.configuration.general.styles).toEqual({
+ accent: '#ff6600',
+ fontPreset: 'classic',
+ });
+ });
+
+ it('writes no styles object at all when neither knob was sent', () => {
+ const normalized = TenantService.normalizeFlatOverrides({
+ title: 'A blog',
+ });
+ // An empty object would still merge and could shadow a stored section.
+ expect(normalized.configuration.general.styles).toBeUndefined();
+ });
+
+ it('carries a single knob without inventing the other', () => {
+ const normalized = TenantService.normalizeFlatOverrides({
+ accent: '#0af',
+ });
+ expect(normalized.configuration.general.styles).toEqual({
+ accent: '#0af',
+ });
+ });
+
+ it('seeded config carries the signup appearance choices end to end', async () => {
+ const config = await TenantService.buildConfig(
+ 'alice',
+ { styleTemplate: 'developer', accent: '#89b4fa', fontPreset: 'technical' },
+ 'alice',
+ );
+ expect(config.configuration.general.styleTemplate).toBe('developer');
+ expect(config.configuration.general.styles.accent).toBe('#89b4fa');
+ expect(config.configuration.general.styles.fontPreset).toBe('technical');
+ // The seed's own defaults survive alongside.
+ expect(config.configuration.general.theme).toBe('system');
+ });
+});
diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts b/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
index 192e1f01da..db920e47ab 100644
--- a/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
+++ b/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
@@ -82,8 +82,12 @@ describe('TenantService.create (revives abandoned reservations)', () => {
// active checkout is not swept mid-payment.
expect(sql).toMatch(/tenants\.subscription_status = 'inactive' AND tenants\.owner = EXCLUDED\.owner/);
expect(sql).toMatch(/created_at = NOW\(\)/);
- // Config/owner are only overwritten for the abandoned (reclaim) branch, never on a resume.
- expect(sql).toMatch(/config = CASE WHEN tenants\.subscription_status = 'abandoned'/);
+ // The owner is only overwritten for the abandoned (reclaim) branch, but the CONFIG takes
+ // the new submission on BOTH branches: a same-owner unpaid reservation is the customize
+ // step re-submitting, and the latest look must win (silently keeping the old config is
+ // the bug this line used to pin as intended behavior).
+ expect(sql).toMatch(/owner = CASE WHEN tenants\.subscription_status = 'abandoned'/);
+ expect(sql).toMatch(/config = EXCLUDED\.config/);
expect(params[3]).toBe(ABANDONED_REREGISTER_QUARANTINE_HOURS);
});
diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service.ts b/apps/self-hosted/hosting/api/src/services/tenant-service.ts
index 97b70da1eb..0bc1e2e85d 100644
--- a/apps/self-hosted/hosting/api/src/services/tenant-service.ts
+++ b/apps/self-hosted/hosting/api/src/services/tenant-service.ts
@@ -220,12 +220,13 @@ export const TenantService = {
// - 'abandoned' (past the re-registration quarantine): a fresh reservation RECLAIMS the name —
// overwrite owner + config. The quarantine (updated_at older than the window) protects a row
// whose earlier payment may still be in flight.
- // - 'inactive' owned by the SAME owner: this is a re-entry into checkout for an existing
- // reservation. REFRESH its grace clock (created_at = NOW) so the abandoned sweep can't
- // reclaim it while it is actively being paid for — closing the window where an old reservation
- // is swept mid-checkout and then overwritten before a slow payment (e.g. a card order ePoints
- // retries with backoff for far longer than the quarantine) is finally recorded. Keep owner +
- // config unchanged here (via CASE) so re-entry never overwrites an unpaid reservation's config.
+ // - 'inactive' owned by the SAME owner: this is the customize step re-submitting (or a
+ // re-entry into checkout) for an existing unpaid reservation. REFRESH its grace clock
+ // (created_at = NOW) so the abandoned sweep can't reclaim it while it is actively being
+ // paid for, and TAKE the newly submitted config: the reservation is unpaid and the signup
+ // UI promises that the look on screen is the look that activates, so the latest
+ // submission wins. (This used to keep the stored config, which silently discarded every
+ // re-customization.)
//
// Any other row (live tenant, or a different owner's inactive reservation) leaves the WHERE
// unsatisfied, returns no row, and is surfaced as a conflict. A brand-new username inserts.
@@ -235,8 +236,7 @@ export const TenantService = {
ON CONFLICT (username) DO UPDATE
SET owner = CASE WHEN tenants.subscription_status = 'abandoned'
THEN EXCLUDED.owner ELSE tenants.owner END,
- config = CASE WHEN tenants.subscription_status = 'abandoned'
- THEN EXCLUDED.config ELSE tenants.config END,
+ config = EXCLUDED.config,
subscription_plan = 'standard',
subscription_status = 'inactive',
created_at = NOW(),
@@ -1212,6 +1212,12 @@ export const TenantService = {
};
if (configOverrides.theme) normalized.configuration.general.theme = configOverrides.theme;
if (configOverrides.styleTemplate) normalized.configuration.general.styleTemplate = configOverrides.styleTemplate;
+ // Appearance knobs land under general.styles, the same paths the editor writes.
+ if (configOverrides.accent || configOverrides.fontPreset) {
+ normalized.configuration.general.styles = {};
+ if (configOverrides.accent) normalized.configuration.general.styles.accent = configOverrides.accent;
+ if (configOverrides.fontPreset) normalized.configuration.general.styles.fontPreset = configOverrides.fontPreset;
+ }
if (configOverrides.type) normalized.configuration.instanceConfiguration.type = configOverrides.type;
if (configOverrides.communityId) normalized.configuration.instanceConfiguration.communityId = configOverrides.communityId;
// undefined means "not provided"; an explicit empty string clears the field.
diff --git a/apps/self-hosted/hosting/api/src/style-template-display.ts b/apps/self-hosted/hosting/api/src/style-template-display.ts
new file mode 100644
index 0000000000..2d84654ffc
--- /dev/null
+++ b/apps/self-hosted/hosting/api/src/style-template-display.ts
@@ -0,0 +1,92 @@
+import { DEFAULT_STYLE_TEMPLATE, type StyleTemplate } from './style-templates';
+
+/**
+ * Presentation metadata for the template picker (GET /v1/templates). The
+ * signup UI renders its cards from this, so the list can never drift from the
+ * roster: the map is `satisfies Record`, and adding a
+ * roster entry fails this file's typecheck until its card exists.
+ *
+ * Colors are the template's own light-mode tokens (bg-primary, bg-secondary,
+ * accent, text-primary from src/styles/themes/.css), duplicated here as
+ * plain values because the CSS lives in the SPA image and this API cannot
+ * read it. The roster guard suite keeps ids honest; these swatches are
+ * decorative and safe to lag a token tweak.
+ */
+export interface StyleTemplateDisplay {
+ name: string;
+ tagline: string;
+ colors: {
+ background: string;
+ surface: string;
+ accent: string;
+ text: string;
+ };
+ /** Broad classification for the card's type sample, not a font stack. */
+ headingStyle: 'serif' | 'sans' | 'mono';
+}
+
+export const STYLE_TEMPLATE_DISPLAY = {
+ medium: {
+ name: 'Medium',
+ tagline: 'Clean long-form reading with a classic serif voice',
+ colors: {
+ background: '#ffffff',
+ surface: '#fafafa',
+ accent: 'rgba(0, 0, 0, 0.84)',
+ text: 'rgba(0, 0, 0, 0.84)',
+ },
+ headingStyle: 'serif',
+ },
+ minimal: {
+ name: 'Minimal',
+ tagline: 'Quiet, spacious and out of the way of your words',
+ colors: {
+ background: '#ffffff',
+ surface: '#fafafa',
+ accent: '#0066cc',
+ text: '#1a1a1a',
+ },
+ headingStyle: 'sans',
+ },
+ magazine: {
+ name: 'Magazine',
+ tagline: 'Warm editorial look with display headlines',
+ colors: {
+ background: '#faf8f5',
+ surface: '#f5f2ed',
+ accent: '#8b4513',
+ text: '#2c2825',
+ },
+ headingStyle: 'serif',
+ },
+ developer: {
+ name: 'Developer',
+ tagline: 'Dark, code-friendly and easy on late-night eyes',
+ colors: {
+ background: '#1e1e2e',
+ surface: '#181825',
+ accent: '#89b4fa',
+ text: '#cdd6f4',
+ },
+ headingStyle: 'mono',
+ },
+ 'modern-gradient': {
+ name: 'Modern',
+ tagline: 'Bright surfaces with a vivid accent',
+ colors: {
+ background: '#f8fafc',
+ surface: '#ffffff',
+ accent: '#7c3aed',
+ text: '#0f172a',
+ },
+ headingStyle: 'sans',
+ },
+} satisfies Record;
+
+export function templateCatalog() {
+ return Object.entries(STYLE_TEMPLATE_DISPLAY).map(([id, display]) => ({
+ id,
+ isDefault: id === DEFAULT_STYLE_TEMPLATE,
+ ...display,
+ }));
+}
diff --git a/apps/self-hosted/src/styles/style-template-roster.test.ts b/apps/self-hosted/src/styles/style-template-roster.test.ts
index 3e0388c71e..e67d74d891 100644
--- a/apps/self-hosted/src/styles/style-template-roster.test.ts
+++ b/apps/self-hosted/src/styles/style-template-roster.test.ts
@@ -77,3 +77,21 @@ describe('style template roster', () => {
}
});
});
+
+/**
+ * The API validates signup font presets against FONT_PRESET_KEYS
+ * (hosting/api/src/appearance.ts); the SPA's FONT_PRESETS carries the actual
+ * stacks. The two must name the same set or a preset accepted at signup
+ * renders as the template default, silently.
+ */
+describe('font preset keys stay in lockstep with the API', () => {
+ it('matches theme-appearance FONT_PRESETS exactly', async () => {
+ const { FONT_PRESET_KEYS } = await import(
+ '../../hosting/api/src/appearance'
+ );
+ const { FONT_PRESETS } = await import('../core/theme-appearance');
+ expect(Object.keys(FONT_PRESETS).sort()).toEqual(
+ [...FONT_PRESET_KEYS].sort(),
+ );
+ });
+});
diff --git a/apps/web/src/features/hosting-signup/accent-picker.tsx b/apps/web/src/features/hosting-signup/accent-picker.tsx
new file mode 100644
index 0000000000..225b1cca27
--- /dev/null
+++ b/apps/web/src/features/hosting-signup/accent-picker.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import { FormControl } from "@ui/input";
+import i18next from "i18next";
+import { ACCENT_HEX_PATTERN } from "./hosting-api";
+
+interface Props {
+ /** The committed accent (valid hex), or null for the template's own. */
+ value: string | null;
+ /** The raw field text, which may be mid-edit and invalid. */
+ input: string;
+ onInput: (raw: string) => void;
+ onPick: (hex: string | null) => void;
+}
+
+/**
+ * A short row of quick picks plus a free hex field. Curated rather than a
+ * wheel: one accent is the whole knob, and the instance derives hover and
+ * contrast from it, so any readable hue works.
+ */
+const QUICK_PICKS = ["#e74c3c", "#e67e22", "#1a8917", "#0066cc", "#7c3aed", "#e91e8c"];
+
+export function AccentPicker({ value, input, onInput, onPick }: Props) {
+ const trimmed = input.trim();
+ const invalid = trimmed.length > 0 && !ACCENT_HEX_PATTERN.test(trimmed);
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/hosting-signup/hosting-api.ts b/apps/web/src/features/hosting-signup/hosting-api.ts
index 95535f70bd..60130a0bae 100644
--- a/apps/web/src/features/hosting-signup/hosting-api.ts
+++ b/apps/web/src/features/hosting-signup/hosting-api.ts
@@ -18,6 +18,10 @@ export interface HostingPaymentMethods {
export interface HostingConfigInput {
theme?: "light" | "dark" | "system";
styleTemplate?: string;
+ /** One hex color (#rgb or #rrggbb); the instance derives hover/contrast from it. */
+ accent?: string;
+ /** A font pairing key from the hosting API's closed set. */
+ fontPreset?: string;
title?: string;
description?: string;
/** Instance kind. Omit (or "blog") for a personal blog; "community" hosts a Hive community. */
@@ -26,6 +30,23 @@ export interface HostingConfigInput {
communityId?: string;
}
+/** One card in the template catalog served by the hosting API (GET /v1/templates). */
+export interface HostingTemplate {
+ id: string;
+ name: string;
+ tagline: string;
+ isDefault: boolean;
+ colors: { background: string; surface: string; accent: string; text: string };
+ headingStyle: "serif" | "sans" | "mono";
+}
+
+/**
+ * Client-side mirror of the hosting API's accent validation
+ * (hosting/api/src/appearance.ts). The server is authoritative; this only
+ * exists so the form can refuse an unusable value before submission.
+ */
+export const ACCENT_HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
+
export interface CreateTenantResult {
tenant: { username: string; subscriptionStatus: string; blogUrl: string };
paymentInstructions: { to: string; amount: string; memo: string; note?: string };
@@ -83,6 +104,10 @@ export const hostingApi = {
paymentMethods: () => get("/v1/payments/methods"),
+ /** The template catalog for the signup picker. Served by the API so the
+ * list can never drift from what tenant creation accepts. */
+ templates: () => get<{ templates: HostingTemplate[] }>("/v1/templates"),
+
/**
* Create the (inactive) tenant. Payment then activates it. `username` is the tenant subdomain
* (the Hive user for a personal blog, or the community id for a community). `owner` is the Hive
diff --git a/apps/web/src/features/hosting-signup/hosting-signup.tsx b/apps/web/src/features/hosting-signup/hosting-signup.tsx
index b3576e90cb..95e5508162 100644
--- a/apps/web/src/features/hosting-signup/hosting-signup.tsx
+++ b/apps/web/src/features/hosting-signup/hosting-signup.tsx
@@ -16,10 +16,16 @@ import {
hostingSkuForMonths,
hostingProSkuForMonths,
isValidCommunityId,
+ ACCENT_HEX_PATTERN,
HOSTING_CUSTOM_DOMAIN_MONTHLY_USD,
- type HostingPaymentMethods
+ type HostingPaymentMethods,
+ type HostingTemplate
} from "./hosting-api";
import { CustomDomainManager } from "./custom-domain-manager";
+import { TemplatePicker } from "./template-picker";
+import { AccentPicker } from "./accent-picker";
+import { getAccountFullQueryOptions } from "@ecency/sdk";
+import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
// Lazy-load the card checkout so /hosting doesn't pull @stripe/stripe-js (which injects the
@@ -36,12 +42,37 @@ const HostingCardCheckout = dynamic(
}
);
-type Step = "username" | "configure" | "payment" | "success";
+type Step = "username" | "customize" | "payment" | "success";
type Method = "hbd" | "card";
type InstanceType = "blog" | "community";
const TERMS = [1, 3, 6, 12];
+/** Font pairing keys the hosting API accepts; labels live in i18n. */
+const FONT_PRESETS = ["classic", "editorial", "modern", "technical", "system"] as const;
+
+/** localStorage key for an in-progress customization, so an abandoned tab resumes. */
+const customizeDraftKey = (name: string) => `ecency:hosting:customize:${name}`;
+
+interface CustomizeDraft {
+ styleTemplate?: string | null;
+ accent?: string | null;
+ fontPreset?: string | null;
+ title?: string;
+ description?: string;
+}
+
+function readCustomizeDraft(name: string): CustomizeDraft | null {
+ try {
+ const raw = localStorage.getItem(customizeDraftKey(name));
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ return parsed && typeof parsed === "object" ? (parsed as CustomizeDraft) : null;
+ } catch {
+ return null;
+ }
+}
+
// sessionStorage key for a one-click HBD payment that was broadcast but not yet confirmed. Lets a
// redirecting signer (or a page reload) resume polling for activation on return. Session-scoped so
// it never lingers past the tab.
@@ -70,6 +101,14 @@ export function HostingSignup() {
const [communityId, setCommunityId] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
+ // The look, chosen on the customize step. null everywhere means the default
+ // look: skipping the step entirely must produce exactly today's defaults.
+ const [styleTemplate, setStyleTemplate] = useState(null);
+ const [accent, setAccent] = useState(null);
+ const [accentInput, setAccentInput] = useState("");
+ const [fontPreset, setFontPreset] = useState(null);
+ const [templates, setTemplates] = useState(null);
+ const [templatesFailed, setTemplatesFailed] = useState(false);
const [months, setMonths] = useState(1);
// Custom domain add-on: switches to the $3/mo "prohosting" plan so the tenant activates on the
// internal pro plan and can attach a custom domain after checkout.
@@ -82,10 +121,12 @@ export function HostingSignup() {
const [busy, setBusy] = useState(false);
// Card confirmed -> the term/method are locked so a remount can't cancel the activation poll.
const [paying, setPaying] = useState(false);
- // The username we actually created a tenant for; if the user goes back and changes it,
- // we must create the new one before payment (a stale guard would let them pay for a blog
- // that was never created and never activates).
- const createdForRef = useRef("");
+ // What we last reserved: the name AND the exact config sent. Going back and changing
+ // anything (name, look, identity) must re-send createTenant before payment: the server
+ // refreshes a same-owner unpaid reservation with the latest submission, so the look on
+ // screen is the look that activates. A stale guard would either let the user pay for a
+ // blog that was never created, or silently activate an older look.
+ const createdForRef = useRef<{ name: string; payload: string } | null>(null);
// Expiry of an ALREADY-ACTIVE tenant captured when entering the payment step (renewal).
// "I've sent the payment" must then require the expiry to move FORWARD, otherwise a
// renewing owner sees "your blog is live" without any payment having landed.
@@ -137,7 +178,7 @@ export function HostingSignup() {
if (!canManageDomain && customDomain) setCustomDomain(false);
}, [canManageDomain, customDomain]);
- const goConfigure = () => {
+ const goCustomize = () => {
setError("");
if (isCommunity) {
// The owner (creator) must be logged in: the owner comes from the active account.
@@ -156,9 +197,96 @@ export function HostingSignup() {
return;
}
}
- setStep("configure");
+ // The previous name's auto-prefilled identity must not ride into a different name's
+ // tenant: take back exactly what the prefill planted, if the user has not edited it.
+ // Computed locally so the draft restore below sees the cleared values, not the stale
+ // state of this closure.
+ let nextTitle = title;
+ let nextDescription = description;
+ const planted = plantedRef.current;
+ if (planted && planted.name !== tenantUsername) {
+ if (planted.title && nextTitle === planted.title) nextTitle = "";
+ if (planted.description && nextDescription === planted.description) nextDescription = "";
+ plantedRef.current = null;
+ }
+ // An abandoned tab resumes its customization for the same name.
+ const draft = readCustomizeDraft(tenantUsername);
+ if (draft) {
+ if (draft.styleTemplate !== undefined) setStyleTemplate(draft.styleTemplate);
+ if (draft.accent !== undefined) {
+ setAccent(draft.accent);
+ setAccentInput(draft.accent ?? "");
+ }
+ if (draft.fontPreset !== undefined) setFontPreset(draft.fontPreset);
+ if (draft.title && !nextTitle) nextTitle = draft.title;
+ if (draft.description && !nextDescription) nextDescription = draft.description;
+ }
+ setTitle(nextTitle);
+ setDescription(nextDescription);
+ setStep("customize");
};
+ // Template catalog, fetched once when the customize step is first shown. A failed
+ // fetch must not block signup: the picker explains and the instance starts on the
+ // default look. Optional call: an older service without the endpoint behaves like
+ // a failed fetch.
+ useEffect(() => {
+ if (step !== "customize" || templates || templatesFailed) return;
+ Promise.resolve()
+ .then(() => hostingApi.templates?.())
+ .then((r) => {
+ if (r && Array.isArray(r.templates)) setTemplates(r.templates);
+ else setTemplatesFailed(true);
+ })
+ .catch(() => setTemplatesFailed(true));
+ }, [step, templates, templatesFailed]);
+
+ // Prefill identity from the account's profile so the step starts with the owner's
+ // own words instead of empty boxes. Only for a personal blog (a community's title
+ // is resolved server-side from the community record), only into EMPTY fields, and
+ // only once per name so typing is never fought.
+ const { data: prefillAccount } = useQuery({
+ ...getAccountFullQueryOptions(tenantUsername),
+ enabled: step === "customize" && !isCommunity && tenantUsername.length >= 3
+ });
+ const prefilledForRef = useRef("");
+ // What the prefill planted and for whom, so a NAME CHANGE can take it back out again:
+ // without this, bob's signup would silently carry alice's profile title and bio because
+ // the empty-field guard sees them as already filled.
+ const plantedRef = useRef<{ name: string; title: string; description: string } | null>(null);
+ useEffect(() => {
+ if (step !== "customize" || isCommunity || !prefillAccount) return;
+ if (prefilledForRef.current === tenantUsername) return;
+ prefilledForRef.current = tenantUsername;
+ const profile = (prefillAccount as any)?.profile;
+ const plantedTitle = profile?.name && !title ? String(profile.name).slice(0, 100) : "";
+ const plantedDescription =
+ profile?.about && !description ? String(profile.about).slice(0, 500) : "";
+ if (plantedTitle) setTitle(plantedTitle);
+ if (plantedDescription) setDescription(plantedDescription);
+ if (plantedTitle || plantedDescription) {
+ plantedRef.current = {
+ name: tenantUsername,
+ title: plantedTitle,
+ description: plantedDescription
+ };
+ }
+ }, [step, isCommunity, prefillAccount, tenantUsername, title, description]);
+
+ // Persist the in-progress customization per name.
+ useEffect(() => {
+ if (step !== "customize" || !tenantUsername) return;
+ try {
+ localStorage.setItem(
+ customizeDraftKey(tenantUsername),
+ JSON.stringify({ styleTemplate, accent, fontPreset, title, description })
+ );
+ } catch {}
+ }, [step, tenantUsername, styleTemplate, accent, fontPreset, title, description]);
+
+ const accentPending =
+ accentInput.trim().length > 0 && !ACCENT_HEX_PATTERN.test(accentInput.trim());
+
// Create the (inactive) tenant for the CURRENT username, then move to payment. Payment
// activates it. Re-creates when the username changed since the last creation.
const goPayment = useCallback(async () => {
@@ -169,14 +297,19 @@ export function HostingSignup() {
// blog account itself (which may pay by HBD while logged out).
const owner = isCommunity ? (activeUser?.username ?? "") : uname;
try {
- if (createdForRef.current !== uname) {
- const res = await hostingApi.createTenant(uname, owner, {
- theme: "system",
- title: title.trim() || undefined,
- description: description.trim() || undefined,
- ...(isCommunity ? { type: "community", communityId: uname } : {})
- });
- createdForRef.current = uname;
+ const config = {
+ theme: "system" as const,
+ title: title.trim() || undefined,
+ description: description.trim() || undefined,
+ styleTemplate: styleTemplate ?? undefined,
+ accent: accent ?? undefined,
+ fontPreset: fontPreset ?? undefined,
+ ...(isCommunity ? { type: "community" as const, communityId: uname } : {})
+ };
+ const payload = JSON.stringify(config);
+ if (createdForRef.current?.name !== uname || createdForRef.current?.payload !== payload) {
+ const res = await hostingApi.createTenant(uname, owner, config);
+ createdForRef.current = { name: uname, payload };
setBlogUrl(res.tenant.blogUrl);
renewBaselineExpiryRef.current = null; // freshly created, inactive
}
@@ -211,7 +344,10 @@ export function HostingSignup() {
setError(i18next.t("hosting.already-registered"));
return;
}
- createdForRef.current = uname;
+ // A 409 now only means an active/expired tenant (renewal) or someone else's
+ // reservation; unpaid same-owner reservations are refreshed above. Record the name
+ // with no payload so a later look change still attempts a fresh create.
+ createdForRef.current = { name: uname, payload: "" };
setBlogUrl(`https://${uname}.${baseDomain}`);
// Renewal of an already-active tenant: remember the current expiry so activation is only
// confirmed once it advances. null (an inactive tenant resuming its first payment) means
@@ -222,7 +358,15 @@ export function HostingSignup() {
} finally {
setBusy(false);
}
- }, [tenantUsername, isCommunity, title, description, activeUser]);
+ }, [tenantUsername, isCommunity, title, description, styleTemplate, accent, fontPreset, activeUser]);
+
+ // The reservation is made and paid for; the in-progress draft has served its purpose.
+ useEffect(() => {
+ if (step !== "success" || !tenantUsername) return;
+ try {
+ localStorage.removeItem(customizeDraftKey(tenantUsername));
+ } catch {}
+ }, [step, tenantUsername]);
// Deep-link from the "Your hosted sites" manage panel: ?resume=[&type=community] jumps
// straight to the payment step for an existing reservation, so an "Awaiting payment" tenant has a
@@ -576,17 +720,55 @@ export function HostingSignup() {
diff --git a/apps/web/src/features/hosting-signup/template-picker.tsx b/apps/web/src/features/hosting-signup/template-picker.tsx
new file mode 100644
index 0000000000..a4bcb5fc63
--- /dev/null
+++ b/apps/web/src/features/hosting-signup/template-picker.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import i18next from "i18next";
+import type { HostingTemplate } from "./hosting-api";
+
+interface Props {
+ templates: HostingTemplate[] | null;
+ /** Load failure: signup must keep working; the instance starts on the default look. */
+ failed: boolean;
+ value: string | null;
+ onChange: (id: string | null) => void;
+}
+
+const HEADING_FONT: Record = {
+ serif: "font-serif",
+ sans: "font-sans",
+ mono: "font-mono"
+};
+
+/**
+ * The template choice as cards instead of a dropdown: each card is a small
+ * mock of the template built from its own palette (page, surface bar, accent
+ * dot, a type sample), so picking a look reads as picking a look. Selecting
+ * the already-selected card clears back to the default.
+ */
+export function TemplatePicker({ templates, failed, value, onChange }: Props) {
+ if (failed) {
+ return
+ );
+}
diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json
index fc5c14a17b..d989d45d63 100644
--- a/apps/web/src/features/i18n/locales/en-US.json
+++ b/apps/web/src/features/i18n/locales/en-US.json
@@ -19,6 +19,20 @@
"community-desc-placeholder": "e.g. A place to share and discuss photography",
"configure-hint-blog": "This sets the title and tagline shown on your blog. You can change it any time in the editor.",
"configure-hint-community": "This sets the title and tagline shown on your community site. You can change it any time in the editor.",
+ "customize-hint": "Make it yours before you pay. Pick a look, an accent and a name; your site starts exactly like this.",
+ "template-label": "Style",
+ "template-load-failed": "Styles could not be loaded right now. Your site starts with the default look and every style stays available later in your site's settings.",
+ "accent-label": "Accent color",
+ "accent-invalid": "Use a hex color like #0066cc (or leave it empty to keep the style's own).",
+ "accent-clear": "Clear",
+ "fonts-label": "Fonts",
+ "font-default": "Style default",
+ "font-classic": "Classic (serif with display headings)",
+ "font-editorial": "Editorial (serif with a modern masthead)",
+ "font-modern": "Modern (clean sans-serif)",
+ "font-technical": "Technical (sans with mono headings)",
+ "font-system": "System (fastest, no downloaded fonts)",
+ "changeable-later": "Everything here can be changed later from your site's settings panel.",
"term-months": "{{n}} mo",
"pay-card": "Card ${{amount}}",
"pay-hbd": "{{amount}} HBD",
diff --git a/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx b/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
index 6b690b0ffd..060b2ccf2e 100644
--- a/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
+++ b/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { renderWithQueryClient } from "@/specs/test-utils";
// One-click HBD pay: clicking "Pay with Hive" must broadcast the EXACT payment instructions
// (to / amount / memo) through the user's transfer mutation, then poll the tenant and advance to
@@ -9,8 +10,10 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
const mocks = vi.hoisted(() => ({
mutateAsync: vi.fn(),
authLoginType: "keychain" as string,
+ profiles: {} as Record,
hostingApi: {
paymentMethods: vi.fn(),
+ templates: vi.fn(),
createTenant: vi.fn(),
paymentInstructions: vi.fn(),
tenant: vi.fn(),
@@ -43,6 +46,7 @@ vi.mock("@/core/global-store", () => ({
}));
import { HostingSignup } from "@/features/hosting-signup/hosting-signup";
+import { getAccountFullQueryOptions } from "@ecency/sdk";
const INSTRUCTIONS = { to: "ecency.hosting", amount: "2.000 HBD", memo: "blog:alice" };
@@ -50,6 +54,7 @@ describe("HostingSignup one-click HBD pay", () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
+ localStorage.clear();
window.history.replaceState(null, "", "/"); // clear any ?resume= from a prior test
mocks.authLoginType = "keychain";
// Card disabled so the payment step defaults to the HBD rail (where the one-click lives).
@@ -66,6 +71,26 @@ describe("HostingSignup one-click HBD pay", () => {
}
});
hostingApi.paymentInstructions.mockResolvedValue(INSTRUCTIONS);
+ hostingApi.templates.mockResolvedValue({
+ templates: [
+ {
+ id: "medium",
+ name: "Medium",
+ tagline: "Clean",
+ isDefault: true,
+ colors: { background: "#fff", surface: "#fafafa", accent: "#111", text: "#111" },
+ headingStyle: "serif"
+ },
+ {
+ id: "magazine",
+ name: "Magazine",
+ tagline: "Editorial",
+ isDefault: false,
+ colors: { background: "#faf8f5", surface: "#f5f2ed", accent: "#8b4513", text: "#2c2825" },
+ headingStyle: "serif"
+ }
+ ]
+ });
// First activation: no baseline expiry, so "active" alone confirms.
hostingApi.tenant.mockResolvedValue({
username: "alice",
@@ -81,7 +106,7 @@ describe("HostingSignup one-click HBD pay", () => {
tenants: [{ username: "alice", type: "blog", subscriptionStatus: "inactive", owner: "alice" }]
});
window.history.replaceState(null, "", "/hosting?resume=alice");
- render();
+ renderWithQueryClient();
// Verified against the owned list, then resumes the reservation at payment (createTenant
// refreshes it), and the resume param is consumed from the URL.
await waitFor(() =>
@@ -94,7 +119,7 @@ describe("HostingSignup one-click HBD pay", () => {
it("ignores a ?resume= for a name the user does not own (no reservation created)", async () => {
hostingApi.tenantsByOwner.mockResolvedValue({ tenants: [] }); // alice owns nothing matching
window.history.replaceState(null, "", "/hosting?resume=victim");
- render();
+ renderWithQueryClient();
// Give the async owned-tenants check time to resolve, then assert no tenant was created and we
// stayed on the first step.
await waitFor(() => expect(hostingApi.tenantsByOwner).toHaveBeenCalled());
@@ -103,7 +128,7 @@ describe("HostingSignup one-click HBD pay", () => {
});
it("fetches custom-domain (:domain) HBD instructions when the add-on is toggled", async () => {
- render();
+ renderWithQueryClient();
fireEvent.click(screen.getByText("g.continue"));
fireEvent.click(await screen.findByText("g.continue"));
// Standard HBD instructions are fetched first (domain = false).
@@ -119,7 +144,7 @@ describe("HostingSignup one-click HBD pay", () => {
});
it("broadcasts the exact transfer and advances to success", async () => {
- render();
+ renderWithQueryClient();
// Username step (username pre-filled with the active account) -> configure -> payment.
fireEvent.click(screen.getByText("g.continue"));
@@ -145,7 +170,7 @@ describe("HostingSignup one-click HBD pay", () => {
});
it("does not broadcast until the user clicks pay (no accidental transfer)", async () => {
- render();
+ renderWithQueryClient();
fireEvent.click(screen.getByText("g.continue"));
fireEvent.click(await screen.findByText("g.continue"));
await screen.findByText("hosting.pay-hbd-oneclick");
@@ -158,7 +183,7 @@ describe("HostingSignup one-click HBD pay", () => {
"ecency:hosting:pending-hbd",
JSON.stringify({ tenant: "alice", blogUrl: "https://alice.blogs.ecency.com", baseline: null })
);
- render();
+ renderWithQueryClient();
await screen.findByText("hosting.success-title");
// Marker is cleared after a successful resume.
expect(sessionStorage.getItem("ecency:hosting:pending-hbd")).toBeNull();
@@ -171,7 +196,7 @@ describe("HostingSignup one-click HBD pay", () => {
subscriptionStatus: "inactive",
subscriptionExpiresAt: null
});
- render();
+ renderWithQueryClient();
fireEvent.click(screen.getByText("g.continue"));
fireEvent.click(await screen.findByText("g.continue"));
const payBtn = (await screen.findByRole("button", {
@@ -192,7 +217,7 @@ describe("HostingSignup one-click HBD pay", () => {
it("falls back to manual (no one-click) for a redirecting login like HiveSigner", async () => {
mocks.authLoginType = "hivesigner";
- render();
+ renderWithQueryClient();
fireEvent.click(screen.getByText("g.continue"));
fireEvent.click(await screen.findByText("g.continue"));
// Manual path is shown; the in-page one-click button is not offered (it would be abandoned by
@@ -201,3 +226,253 @@ describe("HostingSignup one-click HBD pay", () => {
expect(screen.queryByText("hosting.pay-hbd-oneclick")).toBeNull();
});
});
+
+describe("HostingSignup customize step", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ window.history.replaceState(null, "", "/");
+ mocks.authLoginType = "keychain";
+ hostingApi.paymentMethods.mockResolvedValue({
+ hbd: { enabled: true, monthly: "2.000", account: "ecency.hosting" },
+ x402: { enabled: false, monthly: "2.000" },
+ card: { enabled: false, monthlyUsdCents: 200 }
+ });
+ hostingApi.templates.mockResolvedValue({
+ templates: [
+ {
+ id: "medium",
+ name: "Medium",
+ tagline: "Clean",
+ isDefault: true,
+ colors: { background: "#fff", surface: "#fafafa", accent: "#111", text: "#111" },
+ headingStyle: "serif"
+ },
+ {
+ id: "magazine",
+ name: "Magazine",
+ tagline: "Editorial",
+ isDefault: false,
+ colors: { background: "#faf8f5", surface: "#f5f2ed", accent: "#8b4513", text: "#2c2825" },
+ headingStyle: "serif"
+ }
+ ]
+ });
+ hostingApi.createTenant.mockResolvedValue({
+ tenant: {
+ username: "alice",
+ subscriptionStatus: "inactive",
+ blogUrl: "https://alice.blogs.ecency.com"
+ }
+ });
+ hostingApi.paymentInstructions.mockResolvedValue(INSTRUCTIONS);
+ });
+
+ it("sends the chosen template and accent with tenant creation", async () => {
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+
+ // Pick the Magazine card once the catalog loads.
+ fireEvent.click(await screen.findByRole("radio", { name: /Magazine/ }));
+ // Pick an accent via the free field.
+ fireEvent.change(screen.getByLabelText("hosting.accent-label"), {
+ target: { value: "#ff6600" }
+ });
+ fireEvent.click(screen.getByText("g.continue"));
+
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalled());
+ const config = hostingApi.createTenant.mock.calls[0][2];
+ expect(config.styleTemplate).toBe("magazine");
+ expect(config.accent).toBe("#ff6600");
+ });
+
+ it("skipping every choice produces a creation payload with no style overrides", async () => {
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ await screen.findByRole("radio", { name: /Medium/ });
+ fireEvent.click(screen.getByText("g.continue"));
+
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalled());
+ const config = hostingApi.createTenant.mock.calls[0][2];
+ expect(config.styleTemplate).toBeUndefined();
+ expect(config.accent).toBeUndefined();
+ expect(config.fontPreset).toBeUndefined();
+ });
+
+ it("an invalid accent blocks Continue until fixed or cleared", async () => {
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ await screen.findByRole("radio", { name: /Medium/ });
+
+ fireEvent.change(screen.getByLabelText("hosting.accent-label"), {
+ target: { value: "not-a-color" }
+ });
+ expect(screen.getByText("hosting.accent-invalid")).toBeTruthy();
+ fireEvent.click(screen.getByText("g.continue"));
+ expect(hostingApi.createTenant).not.toHaveBeenCalled();
+
+ fireEvent.change(screen.getByLabelText("hosting.accent-label"), {
+ target: { value: "#0af" }
+ });
+ fireEvent.click(screen.getByText("g.continue"));
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalled());
+ expect(hostingApi.createTenant.mock.calls[0][2].accent).toBe("#0af");
+ });
+
+ it("keeps working as a plain form when the catalog cannot load", async () => {
+ hostingApi.templates.mockRejectedValue(new Error("down"));
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+
+ await screen.findByText("hosting.template-load-failed");
+ fireEvent.click(screen.getByText("g.continue"));
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalled());
+ expect(hostingApi.createTenant.mock.calls[0][2].styleTemplate).toBeUndefined();
+ });
+});
+
+describe("HostingSignup customize step: coverage the mutation review demanded", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ window.history.replaceState(null, "", "/");
+ mocks.authLoginType = "keychain";
+ mocks.profiles = {};
+ // The global SDK mock resolves account queries to undefined; the prefill tests need a
+ // controllable profile per name.
+ vi.mocked(getAccountFullQueryOptions as any).mockImplementation((username: string) => ({
+ queryKey: ["spec-account", username],
+ queryFn: async () => mocks.profiles[username] ?? null
+ }));
+ hostingApi.paymentMethods.mockResolvedValue({
+ hbd: { enabled: true, monthly: "2.000", account: "ecency.hosting" },
+ x402: { enabled: false, monthly: "2.000" },
+ card: { enabled: false, monthlyUsdCents: 200 }
+ });
+ hostingApi.templates.mockResolvedValue({
+ templates: [
+ {
+ id: "medium",
+ name: "Medium",
+ tagline: "Clean",
+ isDefault: true,
+ colors: { background: "#fff", surface: "#fafafa", accent: "#111", text: "#111" },
+ headingStyle: "serif"
+ },
+ {
+ id: "magazine",
+ name: "Magazine",
+ tagline: "Editorial",
+ isDefault: false,
+ colors: { background: "#faf8f5", surface: "#f5f2ed", accent: "#8b4513", text: "#2c2825" },
+ headingStyle: "serif"
+ }
+ ]
+ });
+ hostingApi.createTenant.mockResolvedValue({
+ tenant: {
+ username: "alice",
+ subscriptionStatus: "inactive",
+ blogUrl: "https://alice.blogs.ecency.com"
+ }
+ });
+ hostingApi.paymentInstructions.mockResolvedValue(INSTRUCTIONS);
+ hostingApi.tenant.mockResolvedValue({
+ username: "alice",
+ owner: "alice",
+ subscriptionStatus: "active",
+ subscriptionExpiresAt: "2026-08-16T00:00:00.000Z"
+ });
+ mutateAsync.mockResolvedValue({ id: "tx1" });
+ });
+
+ it("a chosen font preset reaches the creation payload", async () => {
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ await screen.findByRole("radio", { name: /Medium/ });
+
+ fireEvent.change(screen.getByLabelText("hosting.fonts-label"), {
+ target: { value: "technical" }
+ });
+ fireEvent.click(screen.getByText("g.continue"));
+
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalled());
+ expect(hostingApi.createTenant.mock.calls[0][2].fontPreset).toBe("technical");
+ });
+
+ it("restores a saved draft for the name and clears it after success", async () => {
+ localStorage.setItem(
+ "ecency:hosting:customize:alice",
+ JSON.stringify({ styleTemplate: "magazine", accent: "#0af", fontPreset: null })
+ );
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ await screen.findByRole("radio", { name: /Magazine/ });
+
+ // The draft restored into the pickers and flows into the payload untouched.
+ fireEvent.click(screen.getByText("g.continue"));
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalled());
+ const config = hostingApi.createTenant.mock.calls[0][2];
+ expect(config.styleTemplate).toBe("magazine");
+ expect(config.accent).toBe("#0af");
+
+ // Pay via one-click HBD and reach success: the draft has served its purpose.
+ const payBtn = (await screen.findByRole("button", {
+ name: "hosting.pay-hbd-oneclick"
+ })) as HTMLButtonElement;
+ await waitFor(() => expect(payBtn.disabled).toBe(false));
+ fireEvent.click(payBtn);
+ await screen.findByText("hosting.success-title");
+ expect(localStorage.getItem("ecency:hosting:customize:alice")).toBeNull();
+ });
+
+ it("prefills empty identity from the profile and takes it back out on a name change", async () => {
+ mocks.profiles.alice = { profile: { name: "Alice W", about: "Alice writes here" } };
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+
+ // Prefilled from alice's profile into the EMPTY fields.
+ await waitFor(() =>
+ expect(
+ (screen.getByPlaceholderText("hosting.blog-title-placeholder") as HTMLInputElement).value
+ ).toBe("Alice W")
+ );
+
+ // Change the name: the planted identity must not ride into bob's tenant.
+ fireEvent.click(screen.getByText("g.back"));
+ fireEvent.change(screen.getByPlaceholderText("yourname"), { target: { value: "bob" } });
+ fireEvent.click(screen.getByText("g.continue"));
+ await waitFor(() =>
+ expect(
+ (screen.getByPlaceholderText("hosting.blog-title-placeholder") as HTMLInputElement).value
+ ).not.toBe("Alice W")
+ );
+ fireEvent.click(screen.getByText("g.continue"));
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalled());
+ expect(hostingApi.createTenant.mock.calls[0][2].title).not.toBe("Alice W");
+ });
+
+ it("re-customizing after an abandoned reservation re-sends creation with the new look", async () => {
+ // First visit: reserve with the default look, then abandon before paying (payment has
+ // no back button, so the real re-entry path is a fresh page load with the draft).
+ const first = renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ await screen.findByRole("radio", { name: /Medium/ });
+ fireEvent.click(screen.getByText("g.continue"));
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalledTimes(1));
+ first.unmount();
+
+ // Second visit: the draft restores the step; pick a different look and continue.
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ fireEvent.click(await screen.findByRole("radio", { name: /Magazine/ }));
+ fireEvent.click(screen.getByText("g.continue"));
+
+ // The reservation is re-sent with the NEW config; the server refreshes the unpaid
+ // reservation so the look on screen is the look that activates.
+ await waitFor(() => expect(hostingApi.createTenant).toHaveBeenCalledTimes(2));
+ expect(hostingApi.createTenant.mock.calls[1][2].styleTemplate).toBe("magazine");
+ });
+});
From 859d3afb4a2805e614a95f4d9327028d85ebc4a6 Mon Sep 17 00:00:00 2001
From: feruzm
Date: Wed, 12 Aug 2026 05:47:32 +0000
Subject: [PATCH 2/4] hosting: preserve saved reservations on resume; review
fix batch
The reservation-refresh change interacted badly with two flows that
create with EMPTY customize state and would have wiped a saved look
back to defaults: the ?resume= deep link (now goes straight to the
payment step without re-sending creation; a sentinel dedup payload
keeps an active re-customization able to refresh) and any
overrides-less or structural-only create (the upsert now takes the new
config only when the caller composed one: theme/type/communityId ride
along on every create and no longer count).
Also from review: appearance state resets when the tenant name changes
so one name's look cannot ride into another's payload; an empty
template catalog degrades to the plain-form path instead of an
unexplained empty grid; the catalog fetch carries a cancellation guard;
the creation dedup key includes the owner; drafts carry savedAt and
expire client-side after 30 days; the success cleanup only clears the
draft of the flow that actually succeeded; profile prefill and the
accent input are typed without any.
---
.../services/tenant-service-cleanup.test.ts | 39 +++++++++++--
.../api/src/services/tenant-service.ts | 27 +++++++--
.../features/hosting-signup/accent-picker.tsx | 2 +-
.../hosting-signup/hosting-signup.tsx | 57 ++++++++++++++++---
.../hosting-signup/hosting-signup.spec.tsx | 12 ++--
5 files changed, 111 insertions(+), 26 deletions(-)
diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts b/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
index db920e47ab..2aaa31f83d 100644
--- a/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
+++ b/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
@@ -82,13 +82,15 @@ describe('TenantService.create (revives abandoned reservations)', () => {
// active checkout is not swept mid-payment.
expect(sql).toMatch(/tenants\.subscription_status = 'inactive' AND tenants\.owner = EXCLUDED\.owner/);
expect(sql).toMatch(/created_at = NOW\(\)/);
- // The owner is only overwritten for the abandoned (reclaim) branch, but the CONFIG takes
- // the new submission on BOTH branches: a same-owner unpaid reservation is the customize
- // step re-submitting, and the latest look must win (silently keeping the old config is
- // the bug this line used to pin as intended behavior).
+ // The owner is only overwritten for the abandoned (reclaim) branch. The CONFIG takes the
+ // new submission when the caller actually composed one ($5, the customize step) and also
+ // on reclaim; an overrides-less create keeps a saved reservation intact instead of wiping
+ // it back to defaults.
expect(sql).toMatch(/owner = CASE WHEN tenants\.subscription_status = 'abandoned'/);
- expect(sql).toMatch(/config = EXCLUDED\.config/);
+ expect(sql).toMatch(/config = CASE WHEN tenants\.subscription_status = 'abandoned' OR \$5/);
expect(params[3]).toBe(ABANDONED_REREGISTER_QUARANTINE_HOURS);
+ // This call passed no overrides, so the refresh flag must be false.
+ expect(params[4]).toBe(false);
});
it('throws a conflict when the upsert returns no row (a live or other-owner tenant holds the name)', async () => {
@@ -150,3 +152,30 @@ describe('TenantService.getByOwner', () => {
expect(sql).toMatch(/subscription_status != 'abandoned'/);
});
});
+
+describe('TenantService.create config-refresh flag', () => {
+ beforeEach(() => mocks.queryOne.mockReset());
+
+ const row = {
+ id: '1',
+ username: 'demo',
+ owner: 'demo',
+ subscription_status: 'inactive',
+ subscription_plan: 'standard',
+ config: {},
+ };
+
+ it('structural keys alone do not refresh a saved reservation', async () => {
+ mocks.queryOne.mockResolvedValueOnce(row);
+ await TenantService.create('demo', 'demo', { theme: 'system' });
+ const [, params] = mocks.queryOne.mock.calls[mocks.queryOne.mock.calls.length - 1];
+ expect(params[4]).toBe(false);
+ });
+
+ it('a composed field refreshes it', async () => {
+ mocks.queryOne.mockResolvedValueOnce(row);
+ await TenantService.create('demo', 'demo', { theme: 'system', styleTemplate: 'magazine' });
+ const [, params] = mocks.queryOne.mock.calls[mocks.queryOne.mock.calls.length - 1];
+ expect(params[4]).toBe(true);
+ });
+});
diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service.ts b/apps/self-hosted/hosting/api/src/services/tenant-service.ts
index 0bc1e2e85d..675a80b5df 100644
--- a/apps/self-hosted/hosting/api/src/services/tenant-service.ts
+++ b/apps/self-hosted/hosting/api/src/services/tenant-service.ts
@@ -223,20 +223,35 @@ export const TenantService = {
// - 'inactive' owned by the SAME owner: this is the customize step re-submitting (or a
// re-entry into checkout) for an existing unpaid reservation. REFRESH its grace clock
// (created_at = NOW) so the abandoned sweep can't reclaim it while it is actively being
- // paid for, and TAKE the newly submitted config: the reservation is unpaid and the signup
- // UI promises that the look on screen is the look that activates, so the latest
- // submission wins. (This used to keep the stored config, which silently discarded every
- // re-customization.)
+ // paid for. The CONFIG is taken only when the caller actually composed one ($5): the
+ // signup UI promises the look on screen is the look that activates, so a re-submission
+ // with overrides wins, while an overrides-less create (an API probe, or any legacy
+ // caller that only reserves) keeps the stored reservation intact instead of wiping a
+ // saved customization back to defaults.
//
// Any other row (live tenant, or a different owner's inactive reservation) leaves the WHERE
// unsatisfied, returns no row, and is surfaced as a conflict. A brand-new username inserts.
+ // Only fields the customize step actually expresses count as a composed
+ // config. Structural keys ride along on every create (the client always
+ // sends theme, a community always carries type and id), and counting them
+ // would make a skip-everything re-reserve wipe a saved customization.
+ const {
+ theme: _theme,
+ type: _type,
+ communityId: _communityId,
+ ...composed
+ } = configOverrides ?? {};
+ const hasOverrides = Object.values(composed).some(
+ (value) => value !== undefined,
+ );
const row = await db.queryOne(
`INSERT INTO tenants (username, owner, config, subscription_status, subscription_plan)
VALUES ($1, $2, $3, 'inactive', 'standard')
ON CONFLICT (username) DO UPDATE
SET owner = CASE WHEN tenants.subscription_status = 'abandoned'
THEN EXCLUDED.owner ELSE tenants.owner END,
- config = EXCLUDED.config,
+ config = CASE WHEN tenants.subscription_status = 'abandoned' OR $5
+ THEN EXCLUDED.config ELSE tenants.config END,
subscription_plan = 'standard',
subscription_status = 'inactive',
created_at = NOW(),
@@ -246,7 +261,7 @@ export const TenantService = {
AND ${CAUGHT_UP_SQL})
OR (tenants.subscription_status = 'inactive' AND tenants.owner = EXCLUDED.owner)
RETURNING *`,
- [username.toLowerCase(), ownerName, JSON.stringify(config), ABANDONED_REREGISTER_QUARANTINE_HOURS]
+ [username.toLowerCase(), ownerName, JSON.stringify(config), ABANDONED_REREGISTER_QUARANTINE_HOURS, hasOverrides]
);
if (!row) {
diff --git a/apps/web/src/features/hosting-signup/accent-picker.tsx b/apps/web/src/features/hosting-signup/accent-picker.tsx
index 225b1cca27..077d92d1a5 100644
--- a/apps/web/src/features/hosting-signup/accent-picker.tsx
+++ b/apps/web/src/features/hosting-signup/accent-picker.tsx
@@ -55,7 +55,7 @@ export function AccentPicker({ value, input, onInput, onPick }: Props) {
onInput(e.target.value)}
+ onChange={(e: { target: { value: string } }) => onInput(e.target.value)}
placeholder="#0066cc"
aria-invalid={invalid}
aria-label={i18next.t("hosting.accent-label")}
diff --git a/apps/web/src/features/hosting-signup/hosting-signup.tsx b/apps/web/src/features/hosting-signup/hosting-signup.tsx
index 95e5508162..c5e98d980a 100644
--- a/apps/web/src/features/hosting-signup/hosting-signup.tsx
+++ b/apps/web/src/features/hosting-signup/hosting-signup.tsx
@@ -62,12 +62,21 @@ interface CustomizeDraft {
description?: string;
}
+/** Older than any reservation grace window; a stale draft must not resurrect. */
+const DRAFT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
+
function readCustomizeDraft(name: string): CustomizeDraft | null {
try {
const raw = localStorage.getItem(customizeDraftKey(name));
if (!raw) return null;
const parsed = JSON.parse(raw);
- return parsed && typeof parsed === "object" ? (parsed as CustomizeDraft) : null;
+ if (!parsed || typeof parsed !== "object") return null;
+ const savedAt = (parsed as { savedAt?: number }).savedAt;
+ if (typeof savedAt === "number" && Date.now() - savedAt > DRAFT_MAX_AGE_MS) {
+ localStorage.removeItem(customizeDraftKey(name));
+ return null;
+ }
+ return parsed as CustomizeDraft;
} catch {
return null;
}
@@ -197,6 +206,15 @@ export function HostingSignup() {
return;
}
}
+ // Appearance choices belong to the name they were made for: a different name
+ // starts from the defaults (its own draft, if any, repopulates below).
+ if (customizeForRef.current && customizeForRef.current !== tenantUsername) {
+ setStyleTemplate(null);
+ setAccent(null);
+ setAccentInput("");
+ setFontPreset(null);
+ }
+ customizeForRef.current = tenantUsername;
// The previous name's auto-prefilled identity must not ride into a different name's
// tenant: take back exactly what the prefill planted, if the user has not edited it.
// Computed locally so the draft restore below sees the cleared values, not the stale
@@ -232,13 +250,22 @@ export function HostingSignup() {
// a failed fetch.
useEffect(() => {
if (step !== "customize" || templates || templatesFailed) return;
+ let cancelled = false;
Promise.resolve()
.then(() => hostingApi.templates?.())
.then((r) => {
- if (r && Array.isArray(r.templates)) setTemplates(r.templates);
+ if (cancelled) return;
+ // An empty catalog is a failure for the picker's purposes: an empty grid
+ // with no explanation is worse than the plain-form fallback.
+ if (r && Array.isArray(r.templates) && r.templates.length > 0) setTemplates(r.templates);
else setTemplatesFailed(true);
})
- .catch(() => setTemplatesFailed(true));
+ .catch(() => {
+ if (!cancelled) setTemplatesFailed(true);
+ });
+ return () => {
+ cancelled = true;
+ };
}, [step, templates, templatesFailed]);
// Prefill identity from the account's profile so the step starts with the owner's
@@ -250,6 +277,8 @@ export function HostingSignup() {
enabled: step === "customize" && !isCommunity && tenantUsername.length >= 3
});
const prefilledForRef = useRef("");
+ // Which name the current appearance state was composed for.
+ const customizeForRef = useRef("");
// What the prefill planted and for whom, so a NAME CHANGE can take it back out again:
// without this, bob's signup would silently carry alice's profile title and bio because
// the empty-field guard sees them as already filled.
@@ -258,7 +287,9 @@ export function HostingSignup() {
if (step !== "customize" || isCommunity || !prefillAccount) return;
if (prefilledForRef.current === tenantUsername) return;
prefilledForRef.current = tenantUsername;
- const profile = (prefillAccount as any)?.profile;
+ const profile = (
+ prefillAccount as { profile?: { name?: unknown; about?: unknown } } | undefined
+ )?.profile;
const plantedTitle = profile?.name && !title ? String(profile.name).slice(0, 100) : "";
const plantedDescription =
profile?.about && !description ? String(profile.about).slice(0, 500) : "";
@@ -279,7 +310,7 @@ export function HostingSignup() {
try {
localStorage.setItem(
customizeDraftKey(tenantUsername),
- JSON.stringify({ styleTemplate, accent, fontPreset, title, description })
+ JSON.stringify({ styleTemplate, accent, fontPreset, title, description, savedAt: Date.now() })
);
} catch {}
}, [step, tenantUsername, styleTemplate, accent, fontPreset, title, description]);
@@ -306,7 +337,7 @@ export function HostingSignup() {
fontPreset: fontPreset ?? undefined,
...(isCommunity ? { type: "community" as const, communityId: uname } : {})
};
- const payload = JSON.stringify(config);
+ const payload = JSON.stringify({ owner, config });
if (createdForRef.current?.name !== uname || createdForRef.current?.payload !== payload) {
const res = await hostingApi.createTenant(uname, owner, config);
createdForRef.current = { name: uname, payload };
@@ -363,6 +394,9 @@ export function HostingSignup() {
// The reservation is made and paid for; the in-progress draft has served its purpose.
useEffect(() => {
if (step !== "success" || !tenantUsername) return;
+ // Guarded on the reservation this flow actually made or resumed: a pending
+ // payment recovered for a DIFFERENT tenant must not delete this name's draft.
+ if (createdForRef.current?.name !== tenantUsername) return;
try {
localStorage.removeItem(customizeDraftKey(tenantUsername));
} catch {}
@@ -401,6 +435,13 @@ export function HostingSignup() {
setInstanceType(isComm ? "community" : "blog");
if (isComm) setCommunityId(t.username);
else setUsername(t.username);
+ // Straight to payment WITHOUT createTenant: the reservation exists and its
+ // saved customization must survive a resume click. The sentinel payload can
+ // never equal a composed one, so going Back and actively re-customizing
+ // still re-sends creation, which is the one flow that should refresh it.
+ createdForRef.current = { name: t.username.toLowerCase(), payload: "\u0000resume" };
+ setBlogUrl(t.blogUrl || `https://${t.username.toLowerCase()}.${baseDomain}`);
+ renewBaselineExpiryRef.current = null; // inactive: first payment, not a renewal
setResumeName(t.username.toLowerCase());
})();
return () => {
@@ -410,9 +451,9 @@ export function HostingSignup() {
useEffect(() => {
if (resumeName && step === "username" && tenantUsername === resumeName && activeUser) {
setResumeName(null);
- void goPayment();
+ setStep("payment");
}
- }, [resumeName, step, tenantUsername, activeUser, goPayment]);
+ }, [resumeName, step, tenantUsername, activeUser]);
// Deep-link from an unclaimed *.blogs.ecency.com subdomain's claim landing: ?claim=
// prefills the form so the visitor arrives ready to reserve that exact name. A hive-
diff --git a/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx b/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
index 060b2ccf2e..6dab01c628 100644
--- a/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
+++ b/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
@@ -101,18 +101,18 @@ describe("HostingSignup one-click HBD pay", () => {
mutateAsync.mockResolvedValue({ id: "tx1" });
});
- it("resumes to payment from a ?resume= deep-link only for an owned, pending reservation", async () => {
+ it("resumes to payment from a ?resume= deep-link WITHOUT re-sending creation", async () => {
hostingApi.tenantsByOwner.mockResolvedValue({
tenants: [{ username: "alice", type: "blog", subscriptionStatus: "inactive", owner: "alice" }]
});
window.history.replaceState(null, "", "/hosting?resume=alice");
renderWithQueryClient();
- // Verified against the owned list, then resumes the reservation at payment (createTenant
- // refreshes it), and the resume param is consumed from the URL.
- await waitFor(() =>
- expect(hostingApi.createTenant).toHaveBeenCalledWith("alice", "alice", expect.anything())
- );
+ // Straight to the payment step: the reservation exists and its saved
+ // customization must survive a resume click, so no createTenant is sent
+ // (a re-create from this flow's empty customize state would refresh the
+ // reservation with defaults). The resume param is consumed from the URL.
await screen.findByRole("button", { name: "hosting.pay-hbd-oneclick" });
+ expect(hostingApi.createTenant).not.toHaveBeenCalled();
expect(window.location.search).toBe("");
});
From 748842c7cd23f051ae887c88a7ccd81a85a5112a Mon Sep 17 00:00:00 2001
From: feruzm
Date: Wed, 12 Aug 2026 05:55:40 +0000
Subject: [PATCH 3/4] hosting: size the picker glyphs with size-N per the icon
guideline
---
apps/web/src/features/hosting-signup/accent-picker.tsx | 2 +-
apps/web/src/features/hosting-signup/template-picker.tsx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/features/hosting-signup/accent-picker.tsx b/apps/web/src/features/hosting-signup/accent-picker.tsx
index 077d92d1a5..56a70f66fb 100644
--- a/apps/web/src/features/hosting-signup/accent-picker.tsx
+++ b/apps/web/src/features/hosting-signup/accent-picker.tsx
@@ -36,7 +36,7 @@ export function AccentPicker({ value, input, onInput, onPick }: Props) {
onClick={() => {
onPick(value === hex ? null : hex);
}}
- className={`w-7 h-7 rounded-full border-2 focus:outline-none focus:ring-2 focus:ring-blue-dark-sky ${
+ className={`size-7 rounded-full border-2 focus:outline-none focus:ring-2 focus:ring-blue-dark-sky ${
value === hex ? "border-blue-dark-sky" : "border-transparent"
}`}
style={{ backgroundColor: hex }}
diff --git a/apps/web/src/features/hosting-signup/template-picker.tsx b/apps/web/src/features/hosting-signup/template-picker.tsx
index a4bcb5fc63..dd84eea1db 100644
--- a/apps/web/src/features/hosting-signup/template-picker.tsx
+++ b/apps/web/src/features/hosting-signup/template-picker.tsx
@@ -65,7 +65,7 @@ export function TemplatePicker({ templates, failed, value, onChange }: Props) {
Aa
From 892bc45e52f524b08012349054beee1ebfe9798e Mon Sep 17 00:00:00 2001
From: feruzm
Date: Wed, 12 Aug 2026 06:01:53 +0000
Subject: [PATCH 4/4] hosting: throttle tenant creation and stop no-op clock
refreshes
The same-owner refresh made the unauthenticated creation POST strictly
wider than develop for personal blogs, where the owner is derived from
the request: looping it could rewrite an unpaid reservation's config,
and every POST reset the grace clock, letting a third party pin any
name inactive forever, out of the abandoned sweep's reach.
Two mitigations, rate limiting over blocking: a tight per-IP budget on
the creation POST (10/min; humans reserve once), and the grace clock
now refreshes only for a composed submission or an abandoned reclaim,
so an overrides-less POST for an existing reservation is a pure no-op
that can neither rewrite the look nor pin the name. The residual model
is the one the PR body states: for an UNPAID reservation the owner's
latest composed submission wins, throttled.
---
.../hosting/api/src/routes/tenant-create-refresh.test.ts | 3 +++
apps/self-hosted/hosting/api/src/routes/tenants.ts | 8 ++++++++
.../api/src/services/tenant-service-cleanup.test.ts | 5 ++++-
.../hosting/api/src/services/tenant-service.ts | 3 ++-
4 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts b/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts
index f6ad2391b0..00cd5e58fd 100644
--- a/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts
+++ b/apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts
@@ -16,6 +16,9 @@ vi.mock('../middleware/auth', () => ({
authMiddleware: async (_c: unknown, next: () => Promise) => next(),
adminMiddleware: async (_c: unknown, next: () => Promise) => next(),
}));
+vi.mock('../middleware/rate-limit', () => ({
+ rateLimit: () => async (_c: unknown, next: () => Promise) => next(),
+}));
vi.mock('../middleware/payment-target-lock', () => ({
withPaymentTargetLock: async (
_c: unknown,
diff --git a/apps/self-hosted/hosting/api/src/routes/tenants.ts b/apps/self-hosted/hosting/api/src/routes/tenants.ts
index e87e69f37c..0b240909db 100644
--- a/apps/self-hosted/hosting/api/src/routes/tenants.ts
+++ b/apps/self-hosted/hosting/api/src/routes/tenants.ts
@@ -7,6 +7,7 @@ import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { STYLE_TEMPLATES } from '../style-templates';
import { ACCENT_HEX_PATTERN, FONT_PRESET_KEYS } from '../appearance';
+import { rateLimit } from '../middleware/rate-limit';
import { db } from '../db/client';
import {
TenantService,
@@ -215,8 +216,15 @@ export async function resolveAndValidateTenant(
return { ok: true, owner };
}
+// Creation is unauthenticated by design (a personal blog's owner IS the requested
+// account), so the same-owner refresh below is only as strong as this throttle:
+// a tight per-IP budget keeps "loop the POST to rewrite someone's unpaid
+// reservation" to a crawl while never blocking a human signing up.
+const createLimit = rateLimit({ name: 'tenant-create', limit: 10, windowMs: 60_000 });
+
tenantRoutes.post(
'/',
+ createLimit,
zValidator('json', createTenantSchema),
// Use the same target reservation as paid /subscribe. Otherwise this unpaid create can insert
// after the paid request's availability check but before its post-settlement tenant insert.
diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts b/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
index 2aaa31f83d..15156137a6 100644
--- a/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
+++ b/apps/self-hosted/hosting/api/src/services/tenant-service-cleanup.test.ts
@@ -81,7 +81,10 @@ describe('TenantService.create (revives abandoned reservations)', () => {
// Resume branch: an existing same-owner inactive reservation refreshes its grace clock so an
// active checkout is not swept mid-payment.
expect(sql).toMatch(/tenants\.subscription_status = 'inactive' AND tenants\.owner = EXCLUDED\.owner/);
- expect(sql).toMatch(/created_at = NOW\(\)/);
+ // The grace clock refreshes only for a composed submission or a reclaim: an
+ // overrides-less POST must not be able to pin a name inactive forever, out
+ // of the abandoned sweep's reach.
+ expect(sql).toMatch(/created_at = CASE WHEN tenants\.subscription_status = 'abandoned' OR \$5/);
// The owner is only overwritten for the abandoned (reclaim) branch. The CONFIG takes the
// new submission when the caller actually composed one ($5, the customize step) and also
// on reclaim; an overrides-less create keeps a saved reservation intact instead of wiping
diff --git a/apps/self-hosted/hosting/api/src/services/tenant-service.ts b/apps/self-hosted/hosting/api/src/services/tenant-service.ts
index 675a80b5df..a5394d6527 100644
--- a/apps/self-hosted/hosting/api/src/services/tenant-service.ts
+++ b/apps/self-hosted/hosting/api/src/services/tenant-service.ts
@@ -254,7 +254,8 @@ export const TenantService = {
THEN EXCLUDED.config ELSE tenants.config END,
subscription_plan = 'standard',
subscription_status = 'inactive',
- created_at = NOW(),
+ created_at = CASE WHEN tenants.subscription_status = 'abandoned' OR $5
+ THEN NOW() ELSE tenants.created_at END,
updated_at = NOW()
WHERE (tenants.subscription_status = 'abandoned'
AND tenants.updated_at < NOW() - ($4 * INTERVAL '1 hour')