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
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() });
});
122 changes: 122 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,122 @@
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/rate-limit', () => ({
rateLimit: () => 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);
});
});
31 changes: 30 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,8 @@ 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 { rateLimit } from '../middleware/rate-limit';
import { db } from '../db/client';
import {
TenantService,
Expand Down Expand Up @@ -33,6 +35,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 +63,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 @@ -210,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.
Expand All @@ -224,8 +237,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 +260,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');
});
});
Loading
Loading