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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
transaction: vi.fn(),
getByUsername: vi.fn(),
generateConfigFile: vi.fn(),
publishConfigFile: vi.fn(),
auditLog: vi.fn(),
buildConfig: vi.fn(),
getBlogUrl: vi.fn(),
Expand Down Expand Up @@ -40,6 +41,7 @@ vi.mock('../services/tenant-service', () => ({
vi.mock('../services/config-service', () => ({
ConfigService: {
generateConfigFile: mocks.generateConfigFile,
publishConfigFile: mocks.publishConfigFile,
},
}));

Expand Down Expand Up @@ -78,6 +80,7 @@ describe('POST /activate config publication', () => {
subscriptionStatus: 'active',
});
mocks.generateConfigFile.mockReset().mockResolvedValue('/configs/alice.json');
mocks.publishConfigFile.mockReset().mockResolvedValue(undefined);
mocks.auditLog.mockReset();
});

Expand Down Expand Up @@ -180,6 +183,7 @@ describe('internal endpoint audit trail', () => {
subscriptionStatus: 'active',
});
mocks.generateConfigFile.mockReset().mockResolvedValue('/configs/alice.json');
mocks.publishConfigFile.mockReset().mockResolvedValue(undefined);
mocks.buildConfig.mockReset().mockResolvedValue({ version: 1 });
mocks.getBlogUrl.mockReset().mockReturnValue('https://alice.blogs.ecency.com');
mocks.isDomainClaimed.mockReset().mockResolvedValue(false);
Expand Down Expand Up @@ -524,21 +528,85 @@ describe('internal endpoint audit trail', () => {
const created = await post('/claim-blog', { username: 'alice' });

expect(created.status).toBe(200);
// The flag the claiming UI distinguishes on: an existing tenant comes
// back unchanged with none of the customization applied, and the UI must
// not present that as a fresh provision.
expect(await created.json()).toMatchObject({ created: true });
expect(mocks.auditLog.mock.calls[0][0]).toMatchObject({
tenantId: 'tenant-9',
eventType: 'tenant.pro_blog_claimed',
eventData: { username: 'alice', created: true, subscriptionStatus: 'active' },
});
// Published BY USERNAME (a locked re-read), never from the
// transaction-returned row: that snapshot can overwrite a newer config
// another writer committed between the claim's commit and this publish.
expect(mocks.publishConfigFile).toHaveBeenCalledWith('alice');
expect(mocks.generateConfigFile).not.toHaveBeenCalled();

mocks.auditLog.mockReset();
mocks.transaction.mockResolvedValueOnce({ created: false, row });

const existing = await post('/claim-blog', { username: 'alice' });

expect(existing.status).toBe(200);
expect(await existing.json()).toMatchObject({ created: false });
expect(mocks.auditLog.mock.calls[0][0]).toMatchObject({
eventType: 'tenant.pro_blog_claimed',
eventData: { created: false },
});
});

it('passes the customize step through to the claimed config', async () => {
// The claim carries the same customization the paid signup does; a Pro
// claimant must not be locked to a default-looking instance.
const row = {
id: 'tenant-9',
username: 'alice',
owner: 'alice',
subscription_status: 'active',
subscription_plan: 'standard',
subscription_started_at: null,
subscription_expires_at: null,
custom_domain: null,
custom_domain_verified: false,
custom_domain_verified_at: null,
config: {},
created_at: '2026-07-27T10:25:13.000Z',
updated_at: '2026-07-27T10:25:13.000Z',
};
mocks.transaction.mockResolvedValueOnce({ created: true, row });

const response = await post('/claim-blog', {
username: 'alice',
title: 'Alice writes',
styleTemplate: 'journal',
accent: '#9c4a1e',
fontPreset: 'editorial',
});

expect(response.status).toBe(200);
expect(mocks.buildConfig).toHaveBeenCalledWith('alice', {
title: 'Alice writes',
description: undefined,
styleTemplate: 'journal',
accent: '#9c4a1e',
fontPreset: 'editorial',
});
});

it('rejects customization that fails the public rosters instead of dropping it', async () => {
// Silently ignoring a chosen template would report a successful claim that
// looks nothing like what the claimant picked. Same validation surface as
// the public create path.
for (const body of [
{ username: 'alice', styleTemplate: 'no-such-template' },
{ username: 'alice', accent: 'red' },
{ username: 'alice', fontPreset: 'comic-sans' },
]) {
const response = await post('/claim-blog', body);
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: 'invalid_request' });
}
expect(mocks.buildConfig).not.toHaveBeenCalled();
});
});
48 changes: 45 additions & 3 deletions apps/self-hosted/hosting/api/src/routes/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { reconcileHivesignerClientIds } from '../services/hivesigner-registry';
import { AuditService, parseClientIp } from '../services/audit-service';
import { mapTenantFromDb, type Tenant } from '../types';
import { addVerifiedDomainOrigin } from '../utils/cors-domains';
import { ACCENT_HEX_PATTERN, FONT_PRESET_KEYS } from '../appearance';
import { STYLE_TEMPLATES } from '../style-templates';

export const internalRoutes = new Hono();

Expand Down Expand Up @@ -484,9 +486,41 @@ internalRoutes.post('/claim-blog', async (c) => {
const title = typeof body?.title === 'string' ? body.title.slice(0, 100) : undefined;
const description = typeof body?.description === 'string' ? body.description.slice(0, 500) : undefined;

// The claim carries the same customize step as the paid signup, validated
// against the same rosters the public create path enforces (routes/tenants.ts).
// Fail closed on junk rather than dropping it: silently ignoring a chosen
// template would report a successful claim that looks nothing like the
// preview the claimant picked.
const styleTemplate =
typeof body?.styleTemplate === 'string' ? body.styleTemplate : undefined;
if (
styleTemplate !== undefined &&
!(STYLE_TEMPLATES as readonly string[]).includes(styleTemplate)
) {
return c.json({ error: 'invalid_request' }, 400);
}
const accent = typeof body?.accent === 'string' ? body.accent : undefined;
if (accent !== undefined && !ACCENT_HEX_PATTERN.test(accent)) {
return c.json({ error: 'invalid_request' }, 400);
}
const fontPreset =
typeof body?.fontPreset === 'string' ? body.fontPreset : undefined;
if (
fontPreset !== undefined &&
!(FONT_PRESET_KEYS as readonly string[]).includes(fontPreset)
) {
return c.json({ error: 'invalid_request' }, 400);
}

try {
// Build config outside the transaction (pure, no I/O).
const config = await TenantService.buildConfig(username, { title, description });
const config = await TenantService.buildConfig(username, {
title,
description,
styleTemplate,
accent,
fontPreset,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const result = await db.transaction<{ created: boolean; row: any }>(async (client) => {
// Try to create; ON CONFLICT means the tenant already exists. DO UPDATE revives a row the
Expand Down Expand Up @@ -557,10 +591,13 @@ internalRoutes.post('/claim-blog', async (c) => {

const tenant = mapTenantFromDb(result.row);

// Generate the config file for a freshly-created tenant (non-critical, outside the tx).
// Publish the config for a freshly-created tenant (non-critical, outside
// the tx). By username, not the transaction-returned row: publishing a
// pre-commit snapshot can overwrite a newer config another writer
// committed in between; publishConfigFile re-reads under the tenant lock.
if (result.created) {
try {
await ConfigService.generateConfigFile(tenant);
await ConfigService.publishConfigFile(username);
} catch (err) {
console.error(`[internal/claim-blog] config generation failed for ${username}:`, err);
}
Expand All @@ -576,6 +613,11 @@ internalRoutes.post('/claim-blog', async (c) => {
});

return c.json({
// Surfaced so the claiming UI can tell a fresh provision from an
// existing tenant returned unchanged: the latter applied NONE of the
// customization the claimant may have filled in, and reporting it as a
// plain success would claim otherwise.
created: result.created,
tenant: {
username: tenant.username,
blogUrl: TenantService.getBlogUrl(tenant),
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/app/api/hosting/claim-blog/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,21 @@ export async function POST(request: NextRequest) {

const title = typeof body.title === "string" ? body.title : undefined;
const description = typeof body.description === "string" ? body.description : undefined;
// The customize step from the paid signup applies to the claim too. Passed
// through as-is; the hosting service validates them against its rosters.
const styleTemplate = typeof body.styleTemplate === "string" ? body.styleTemplate : undefined;
const accent = typeof body.accent === "string" ? body.accent : undefined;
const fontPreset = typeof body.fontPreset === "string" ? body.fontPreset : undefined;

let upstream: Response;
try {
upstream = await callHostingInternal("/v1/internal/claim-blog", secret, {
username,
title,
description
description,
styleTemplate,
accent,
fontPreset
});
} catch {
return Response.json({ error: "Hosting service unavailable" }, { status: 502 });
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/features/hosting-signup/hosting-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export interface HostingTemplate {
*/
export const ACCENT_HEX_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;

/**
* Client-side mirror of the hosting API's font preset roster
* (hosting/api/src/appearance.ts FONT_PRESET_KEYS), shared by every surface
* that offers the appearance step so the option lists cannot drift apart.
*/
export const FONT_PRESETS = ["classic", "editorial", "modern", "technical", "system"] as const;

export interface CreateTenantResult {
tenant: { username: string; subscriptionStatus: string; blogUrl: string };
paymentInstructions: { to: string; amount: string; memo: string; note?: string };
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/hosting-signup/hosting-signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
hostingProSkuForMonths,
isValidCommunityId,
ACCENT_HEX_PATTERN,
FONT_PRESETS,
HOSTING_CUSTOM_DOMAIN_MONTHLY_USD,
type HostingPaymentMethods,
type HostingTemplate
Expand Down Expand Up @@ -49,7 +50,6 @@ 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}`;
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
"not-pro": "Ecency Pro membership is required to claim a free blog.",
"unavailable": "Blog hosting is not available right now.",
"claimed-title": "Your blog is ready",
"already-title": "Your blog is already set up",
"already-note": "Claiming again does not change its settings. Manage the title, look and domain from Your hosted sites.",
"manage-link": "Go to Your hosted sites",
"custom-domain-upsell": "Want your own domain like blog.yoursite.com? Add a custom domain for $3/mo.",
"add-custom-domain": "Add a custom domain"
},
Expand Down
Loading
Loading