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 @@ -81,6 +81,7 @@ docs/superpowers/
.kilocode/

# generated assets
/apps/web/public/api-docs/
/apps/storybook/stories/generated/
/apps/storybook/public/screenshots/

Expand Down
9 changes: 9 additions & 0 deletions apps/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ const nextConfig = {
// Security headers
async headers() {
return [
{
source: '/api-docs/swagger-ui/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
// Apply to all routes
source: '/(.*)',
Expand Down
8 changes: 5 additions & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "bash ../../scripts/dev.sh",
"dev:prod-db": "USE_PRODUCTION_DB=true bash ../../scripts/dev.sh",
"build": "next build",
"copy:swagger-ui-assets": "node scripts/copy-swagger-ui-assets.mjs",
"dev": "pnpm run copy:swagger-ui-assets && bash ../../scripts/dev.sh",
"dev:prod-db": "pnpm run copy:swagger-ui-assets && USE_PRODUCTION_DB=true bash ../../scripts/dev.sh",
"build": "pnpm run copy:swagger-ui-assets && next build",
"start": "next start",
"stripe": "stripe listen --forward-to http://localhost:${STRIPE_FORWARD_PORT:-$(cat ../../.dev-port 2>/dev/null || echo 3000)}/api/stripe/webhook",
"lint": "pnpm -w exec oxlint --config .oxlintrc.json apps/web/src",
Expand Down Expand Up @@ -162,6 +163,7 @@
"sonner": "2.0.7",
"stripe": "catalog:",
"stytch": "12.43.1",
"swagger-ui-dist": "5.32.6",
"tailwind-merge": "3.5.0",
"tldts": "7.0.30",
"ulid": "catalog:",
Expand Down
23 changes: 23 additions & 0 deletions apps/web/scripts/copy-swagger-ui-assets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { copyFile, mkdir } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { join } from 'node:path';

const require = createRequire(import.meta.url);
const swaggerUiDist = require('swagger-ui-dist');

const swaggerUiVersion = '5.32.6';
const sourceDir = swaggerUiDist.getAbsoluteFSPath();
const destinationDir = new URL(
`../public/api-docs/swagger-ui/${swaggerUiVersion}/`,
import.meta.url
);

await mkdir(destinationDir, { recursive: true });

await Promise.all([
copyFile(
join(sourceDir, 'swagger-ui-bundle.js'),
new URL('swagger-ui-bundle.js', destinationDir)
),
copyFile(join(sourceDir, 'swagger-ui.css'), new URL('swagger-ui.css', destinationDir)),
]);
17 changes: 17 additions & 0 deletions apps/web/src/app/api/docs/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, test } from '@jest/globals';
import { GET } from './route';

describe('GET /api/docs', () => {
test('renders Swagger UI as read-only without submit controls', async () => {
const response = GET();
const body = await response.text();

expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8');
expect(body).toContain('supportedSubmitMethods: []');
expect(body).toContain('tryItOutEnabled: false');
expect(body).toContain('.swagger-ui .auth-wrapper');
expect(body).toContain('.swagger-ui .authorization__btn');
expect(body).toContain('display: none !important');
});
});
122 changes: 122 additions & 0 deletions apps/web/src/app/api/docs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { randomBytes } from 'node:crypto';

const swaggerUiAssetBaseUrl = '/api-docs/swagger-ui/5.32.6';

function swaggerUiHtml(nonce: string) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kilo Code API Docs</title>
<link rel="stylesheet" href="${swaggerUiAssetBaseUrl}/swagger-ui.css" />
<style nonce="${nonce}">
:root {
color-scheme: dark;
--kilo-background: #171717;
--kilo-surface: #222222;
--kilo-foreground: #fafafa;
--kilo-muted: #b4b4b4;
--kilo-primary: #edff00;
}

body {
margin: 0;
background: var(--kilo-background);
}

.kilo-docs-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: var(--kilo-surface);
color: var(--kilo-foreground);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}

.kilo-docs-title {
margin: 0;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
}

.kilo-docs-subtitle {
margin: 4px 0 0;
color: var(--kilo-muted);
font-size: 13px;
}

.kilo-docs-link {
border-radius: 6px;
background: var(--kilo-primary);
color: #171717;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}

#swagger-ui {
background: #ffffff;
min-height: calc(100vh - 73px);
}

.swagger-ui .auth-wrapper,
.swagger-ui .authorization__btn {
display: none !important;
}

</style>
</head>
<body>
<header class="kilo-docs-header">
<div>
<h1 class="kilo-docs-title">Kilo Code API Docs</h1>
<p class="kilo-docs-subtitle">Swagger UI generated from the allowlisted tRPC OpenAPI document.</p>
</div>
<a class="kilo-docs-link" href="/api/openapi.json">Open JSON</a>
</header>
<div id="swagger-ui"></div>
<script src="${swaggerUiAssetBaseUrl}/swagger-ui-bundle.js"></script>
<script nonce="${nonce}">
window.ui = SwaggerUIBundle({
url: '/api/openapi.json',
dom_id: '#swagger-ui',
deepLinking: true,
persistAuthorization: false,
supportedSubmitMethods: [],
tryItOutEnabled: false,
});
</script>
</body>
</html>`;
}

function contentSecurityPolicy(nonce: string) {
return [
"default-src 'none'",
"base-uri 'none'",
"connect-src 'self'",
"font-src 'self'",
"frame-ancestors 'none'",
"img-src 'self' data:",
`script-src 'self' 'nonce-${nonce}'`,
`style-src 'self' 'nonce-${nonce}'`,
].join('; ');
}

export function GET() {
const nonce = randomBytes(16).toString('base64');

return new Response(swaggerUiHtml(nonce), {
headers: {
'content-security-policy': contentSecurityPolicy(nonce),
'content-type': 'text/html; charset=utf-8',
},
});
}
11 changes: 11 additions & 0 deletions apps/web/src/app/api/openapi.json/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { generateTrpcOpenApiDocument } from '@/lib/openapi/trpc-openapi';

const openApiDocument = generateTrpcOpenApiDocument();

export function GET() {
return Response.json(openApiDocument, {
headers: {
'cache-control': 'public, max-age=3600',
},
});
}
Loading