Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/self-hosted/hosting/api/src/appearance.ts
Original file line number Diff line number Diff line change
@@ -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})$/;
3 changes: 3 additions & 0 deletions apps/self-hosted/hosting/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand All @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/templates.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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);
}
});
});
14 changes: 14 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/templates.ts
Original file line number Diff line number Diff line change
@@ -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() });
});
119 changes: 119 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/tenant-create-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) => next(),
adminMiddleware: async (_c: unknown, next: () => Promise<void>) => next(),
}));
vi.mock('../middleware/payment-target-lock', () => ({
withPaymentTargetLock: async (
_c: unknown,
next: () => Promise<void>,
) => next(),
}));
vi.mock('../services/tenant-service', async (importOriginal) => {
const actual = await importOriginal<any>();
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);
});
});
23 changes: 22 additions & 1 deletion apps/self-hosted/hosting/api/src/routes/tenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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);
}

Expand All @@ -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
Expand Down
50 changes: 50 additions & 0 deletions apps/self-hosted/hosting/api/src/services/flat-overrides.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
22 changes: 14 additions & 8 deletions apps/self-hosted/hosting/api/src/services/tenant-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
subscription_plan = 'standard',
subscription_status = 'inactive',
created_at = NOW(),
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading