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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ playwright-report/

# Browser-tooling session artifacts (screenshots/snapshots from MCP-driven runs)
.playwright-mcp/
dist-ssr
7 changes: 7 additions & 0 deletions backend/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ human-readable summary; the spec is the machine-readable source of truth.

## Conventions

### Versioning

Every `/api/*` endpoint below is also mounted, identically, under `/api/v1/*`.
The unversioned prefix is the canonical one used by the frontend; external
integrations should pin `/api/v1` so a future breaking change (shipped as
`/api/v2`) never affects them.

### Authentication

The session is carried by **HttpOnly cookies** set on login (`frameset_access_token`,
Expand Down
57 changes: 57 additions & 0 deletions backend/migrations/024_harmonize_collations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
-- Harmonizes text collations on utf8mb4_unicode_ci across the schema.
--
-- 001_init created `users` (and later 023 `user_recovery_codes`) as
-- utf8mb4_unicode_ci but the project tables as utf8mb4_general_ci — two
-- different accent/case comparison rules living side by side. Nothing
-- compares text across those tables today, which is exactly why now is the
-- cheap moment to converge: one collation, one sorting behavior, no surprise
-- the day a cross-table comparison or UNION appears.
--
-- Deliberately NOT touched: `revoked_tokens`, whose `token` column is
-- intentionally ascii_bin (lowercase hex digests — see 021); CONVERT TO
-- CHARACTER SET rewrites every text column of a table and would clobber it.
--
-- Same idempotency style as 021: guarded via information_schema, so re-runs
-- (and already-converted databases) are no-ops. CONVERT TO also rebuilds the
-- indexes on the converted columns; these tables are small (per-user content,
-- 50-color palette cap), so the rewrite is instantaneous at this scale.

SET @collation := (
SELECT TABLE_COLLATION FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'projects'
);
SET @sql := IF(@collation <> 'utf8mb4_unicode_ci',
'ALTER TABLE `projects` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

SET @collation := (
SELECT TABLE_COLLATION FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_brush_norms'
);
SET @sql := IF(@collation <> 'utf8mb4_unicode_ci',
'ALTER TABLE `project_brush_norms` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

SET @collation := (
SELECT TABLE_COLLATION FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_typography_norms'
);
SET @sql := IF(@collation <> 'utf8mb4_unicode_ci',
'ALTER TABLE `project_typography_norms` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

SET @collation := (
SELECT TABLE_COLLATION FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project_palette'
);
SET @sql := IF(@collation <> 'utf8mb4_unicode_ci',
'ALTER TABLE `project_palette` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
'SELECT 1'
);
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
6 changes: 3 additions & 3 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,18 @@ app.use('/api/fonts', fontsRoutes);
// exemption applies; rate limited inside the router).
app.use('/api/share', shareRoutes);

// Versioned alias: the exact same routers, additionally reachable under
// /api/v1/* so an external integration can pin a version from day one. The
// unversioned /api remains the canonical path used by the SPA; a future
// breaking change would fork new routers under /api/v2 while these keep
// serving v1 clients unchanged. The CSRF middlewares above are mounted on
// '/api' and therefore already cover this prefix.
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', userRoutes);
app.use('/api/v1/projects', projectsRoutes);
app.use('/api/v1/fonts', fontsRoutes);
app.use('/api/v1/share', shareRoutes);

// E2E test mode only: lets a Playwright run read a verification code without
// a real inbox. Never mounted otherwise — see utils/testMode.js.
if (isE2ETestMode) {
Expand Down
5 changes: 4 additions & 1 deletion backend/src/docs/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ const openapiSpec = {
'(`POST`/`PUT`/`PATCH`/`DELETE`) use the double-submit CSRF pattern: send the ' +
'`frameset_csrf_token` cookie value back in the `x-csrf-token` header ' +
'(fetch one via `GET /api/auth/csrf-token`). The JSON body is capped at 10 kB ' +
'and sensitive endpoints are rate limited.',
'and sensitive endpoints are rate limited.\n\n' +
'**Versioning**: every `/api/*` path documented here is also mounted, identically, ' +
'under `/api/v1/*` — pin the versioned prefix for external integrations; a breaking ' +
'change would ship as `/api/v2` while `/api/v1` keeps serving existing clients.',
contact: { name: 'Axelle Tempier', email: 'axelle.tempier@gmail.com' },
},
servers: [{ url: '/', description: 'Same origin as the app' }],
Expand Down
15 changes: 12 additions & 3 deletions backend/src/docs/paths/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,9 +436,18 @@ module.exports = {
properties: {
name: { type: 'string' },
ownerName: { type: 'string' },
brushNorms: { type: 'array', items: { type: 'object' } },
typographyNorms: { type: 'array', items: { type: 'object' } },
palette: { type: 'array', items: { type: 'object' } },
brushNorms: {
type: 'array',
items: { $ref: '#/components/schemas/BrushNorm' },
},
typographyNorms: {
type: 'array',
items: { $ref: '#/components/schemas/TypographyNorm' },
},
palette: {
type: 'array',
items: { $ref: '#/components/schemas/PaletteColor' },
},
},
},
},
Expand Down
17 changes: 17 additions & 0 deletions backend/tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,23 @@ describe('application middleware', () => {
});
});

describe('versioned API alias', () => {
it('serves the same surface under /api/v1, CSRF protection included', async () => {
// Same endpoint, both prefixes.
const unversioned = await request(app).get('/api/auth/csrf-token');
const versioned = await request(app).get('/api/v1/auth/csrf-token');
expect(unversioned.status).toBe(200);
expect(versioned.status).toBe(200);
expect(versioned.body).toEqual(expect.objectContaining({ csrfToken: expect.any(String) }));

// The '/api'-mounted CSRF guard covers the alias too: an unprotected
// mutation is rejected exactly like on the canonical prefix.
const rejected = await request(app).post('/api/v1/auth/logout');
expect(rejected.status).toBe(403);
expect(rejected.body.error).toMatch(/csrf/i);
});
});

describe('security headers', () => {
it('exposes an explicit Content-Security-Policy header', async () => {
const res = await request(app).get('/api/auth/csrf-token');
Expand Down
10 changes: 8 additions & 2 deletions backend/tests/unit/openapiSync.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,17 @@ describe('OpenAPI spec stays in sync with the mounted routes', () => {
.map(normalizeRoutePath)
// The comparison covers the documented surface: /health and /api/*.
// Swagger's own endpoints (/api-docs*) and the E2E-only /api/_test
// routes (not mounted here) are intentionally undocumented.
// routes (not mounted here) are intentionally undocumented. /api/v1/*
// is a mount-level ALIAS of the same routers (see app.js): its surface
// is identical by construction, so it is documented once via the spec's
// servers list rather than duplicating every path entry.
.filter(
(route) =>
/ \/health$/.test(route) ||
(/ \/api\//.test(route) && !/ \/api-docs/.test(route) && !/ \/api\/_test/.test(route)),
(/ \/api\//.test(route) &&
!/ \/api-docs/.test(route) &&
!/ \/api\/_test/.test(route) &&
!/ \/api\/v1\//.test(route)),
);

const documented = Object.entries(openapiSpec.paths).flatMap(([specPath, operations]) =>
Expand Down
1 change: 1 addition & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ package-lock.json
public
test-results
playwright-report
dist-ssr
2 changes: 1 addition & 1 deletion frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const vitestGlobals = {

export default [
// Build output, dependencies and coverage reports are never linted.
{ ignores: ['dist', 'node_modules', 'coverage'] },
{ ignores: ['dist', 'dist-ssr', 'node_modules', 'coverage'] },

// Application and test source: browser environment, React component rules.
{
Expand Down
4 changes: 2 additions & 2 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
content="FrameSet keeps every project's graphic standards and color palette in one place."
/>
<meta property="og:url" content="https://frameset-taupe.vercel.app/" />
<meta property="og:image" content="https://frameset-taupe.vercel.app/FrameSet_Logo.png" />
<meta property="og:image" content="https://frameset-taupe.vercel.app/og-cover.png" />
<meta property="og:locale" content="en_US" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
Expand All @@ -38,7 +38,7 @@
name="twitter:description"
content="FrameSet keeps every project's graphic standards and color palette in one place."
/>
<meta name="twitter:image" content="https://frameset-taupe.vercel.app/FrameSet_Logo.png" />
<meta name="twitter:image" content="https://frameset-taupe.vercel.app/og-cover.png" />
<!-- Applied before paint (blocking, in <head>) to avoid a theme flash. External
file so a strict CSP `script-src 'self'` covers it without an inline hash. -->
<script src="/theme-init.js"></script>
Expand Down
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.mjs",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
Binary file added frontend/public/og-cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 68 additions & 0 deletions frontend/scripts/prerender.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Build-time prerendering of the public, auth-free routes: after the client
* build, the SSR bundle (src/entry-server.jsx, built to dist-ssr/) renders
* each route to HTML which is injected into dist/index.html's empty #root and
* written as <route>/index.html. Vercel serves matching static files before
* the SPA rewrite kicks in, so first paint no longer waits for React to boot
* — the app then hydrates in place (see src/main.jsx).
*
* Only routes listed here may be prerendered, and their page components MUST
* be imported eagerly in App.jsx (renderToString cannot await a lazy chunk).
* Auth-gated routes (login/register) are deliberately absent: their guard
* renders a loading state until the session probe settles, so their prerender
* would be an empty spinner.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ROUTES = ['/', '/privacy', '/terms'];
const ROOT_MARKER = '<div id="root"></div>';

const { render } = await import(pathToFileURL(path.join(root, 'dist-ssr', 'entry-server.js')).href);
const template = fs.readFileSync(path.join(root, 'dist', 'index.html'), 'utf8');
if (!template.includes(ROOT_MARKER)) {
throw new Error('prerender: dist/index.html no longer contains an empty #root to inject into.');
}

// The EMPTY shell must keep existing for every non-prerendered route: the SPA
// fallback rewrite points at /app-shell.html (see vercel.json), because
// index.html itself becomes the prerendered landing below. Serving the
// landing's HTML to, say, /login would make hydration mismatch on purpose.
fs.writeFileSync(path.join(root, 'dist', 'app-shell.html'), template);

// Prerendered pages also get the WHOLE stylesheet inlined in place of the
// render-blocking <link>: first paint then needs nothing beyond the HTML
// itself. Inlining everything (not "critical CSS" extraction) is deliberate —
// every rule is present, so dark mode and every breakpoint render exactly
// right with zero flash; at ~40KB (a few KB gzipped) the weight is cheap.
// The shell keeps the plain <link>, so the rest of the app is untouched.
const linkMatch = template.match(/<link rel="stylesheet"[^>]*href="([^"]+\.css)"[^>]*>/);
if (!linkMatch) {
throw new Error('prerender: could not find the stylesheet <link> in dist/index.html.');
}
const css = fs.readFileSync(path.join(root, 'dist', linkMatch[1].replace(/^\//, '')), 'utf8');
const inlineCss = (page) => page.replace(linkMatch[0], `<style>${css}</style>`);

for (const route of ROUTES) {
const html = render(route);
if (!html || html.length < 500) {
throw new Error(`prerender: suspiciously small render for ${route} (${html.length} chars).`);
}
// The route is stamped on the container so main.jsx only hydrates when the
// prerendered HTML actually belongs to the URL being served (a misrouted
// fallback falls back to a clean client render instead of a mismatch).
const page = inlineCss(
template.replace(ROOT_MARKER, `<div id="root" data-prerendered="${route}">${html}</div>`),
);
const outFile =
route === '/'
? path.join(root, 'dist', 'index.html')
: path.join(root, 'dist', route.slice(1), 'index.html');
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, page);
console.log(
`prerendered ${route} -> ${path.relative(root, outFile)} (${Math.round(page.length / 1024)}KB)`,
);
}
Loading
Loading