Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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,9 +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\(\)/);
// 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. 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 = 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 () => {
Expand Down Expand Up @@ -146,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);
});
});
Loading
Loading