Skip to content
19 changes: 19 additions & 0 deletions cloudflare-deploy-infra/dispatcher/src/banner/banner-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** KV key prefix for banner records */
const BANNER_KEY_PREFIX = 'app-builder-banner:';

function getBannerKey(worker: string): string {
return `${BANNER_KEY_PREFIX}${worker}`;
}

export async function isBannerEnabled(kv: KVNamespace, worker: string): Promise<boolean> {
const value = await kv.get(getBannerKey(worker), { cacheTtl: 60 });
return value !== null;
}

export async function enableBanner(kv: KVNamespace, worker: string): Promise<void> {
await kv.put(getBannerKey(worker), '1');
}

export async function disableBanner(kv: KVNamespace, worker: string): Promise<void> {
await kv.delete(getBannerKey(worker));
}
151 changes: 151 additions & 0 deletions cloudflare-deploy-infra/dispatcher/src/banner/inject-banner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* HTMLRewriter-based banner injection for deployed sites.
* Injects a "Made with Kilo" badge in the bottom-right corner.
*/

/**
* Generates a cryptographically secure base64-encoded nonce for CSP.
* Uses 16 random bytes (128 bits) encoded as base64.
*/
function generateCSPNonce(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes));
}

/**
* Adds a nonce to a CSP directive value.
* Handles the special case where 'none' is present (must be replaced, not appended).
*/
function addNonceToDirective(value: string, nonceValue: string): string {
if (value.includes("'none'")) {
return value.replace("'none'", nonceValue);
}
return `${value} ${nonceValue}`;
}

/**
* Adds a nonce to the script-src directive of a CSP header.
* Also updates script-src-elem if present (since it takes precedence for <script> tags).
* If script-src doesn't exist, creates it based on default-src.
*/
function addNonceToCSP(csp: string, nonce: string): string {
const nonceValue = `'nonce-${nonce}'`;
const directives = csp
.split(';')
.map(d => d.trim())
.filter(Boolean);

const directiveMap = new Map<string, string>();
for (const directive of directives) {
const spaceIndex = directive.indexOf(' ');
if (spaceIndex === -1) {
directiveMap.set(directive.toLowerCase(), '');
} else {
const name = directive.slice(0, spaceIndex).toLowerCase();
const value = directive.slice(spaceIndex + 1);
directiveMap.set(name, value);
}
}

if (directiveMap.has('script-src')) {
const current = directiveMap.get('script-src') ?? '';
directiveMap.set('script-src', addNonceToDirective(current, nonceValue));
} else if (directiveMap.has('default-src')) {
const defaultSrc = directiveMap.get('default-src') ?? '';
directiveMap.set('script-src', addNonceToDirective(defaultSrc, nonceValue));
} else {
directiveMap.set('script-src', nonceValue);
}

if (directiveMap.has('script-src-elem')) {
const current = directiveMap.get('script-src-elem') ?? '';
directiveMap.set('script-src-elem', addNonceToDirective(current, nonceValue));
}

const result: string[] = [];
for (const [name, value] of directiveMap) {
result.push(value ? `${name} ${value}` : name);
}
return result.join('; ');
}

function getBannerScript(nonce: string): string {
return `<script nonce="${nonce}" data-kilo-banner>
(function() {
function inject() {
var badge = document.createElement('a');
badge.href = 'https://app.kilo.ai/app-builder';
badge.target = '_blank';
badge.rel = 'noopener noreferrer';
badge.style.cssText = 'position:fixed;bottom:16px;right:16px;z-index:2147483647;display:flex;align-items:center;gap:8px;padding:6px 12px 6px 6px;background:rgba(24,24,27,0.85);color:#fafafa;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:13px;font-weight:500;line-height:1;border-radius:10px;border:1px solid rgba(255,255,255,0.08);text-decoration:none;box-shadow:0 4px 12px rgba(0,0,0,0.4);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);transition:transform 0.2s,box-shadow 0.2s;';
badge.innerHTML = '<svg width="24" height="24" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;border-radius:6px"><rect width="512" height="512" rx="80" fill="#18181b"/><path d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z" fill="#FAF74F"/></svg><span>Made with Kilo</span>';

badge.addEventListener('mouseenter', function() { badge.style.transform = 'translateY(-1px)'; badge.style.boxShadow = '0 6px 16px rgba(0,0,0,0.5)'; });
badge.addEventListener('mouseleave', function() { badge.style.transform = 'none'; badge.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)'; });

document.body.appendChild(badge);
}
if (document.body) { inject(); }
else { document.addEventListener('DOMContentLoaded', inject); }
})();
</script>`;
}

/**
* Injects the "Made with Kilo" banner into an HTML response
* using HTMLRewriter. Handles CSP nonce injection.
*/
export function injectBanner(response: Response): Response {
const nonce = generateCSPNonce();
const bannerScript = getBannerScript(nonce);

const newHeaders = new Headers(response.headers);
newHeaders.delete('content-length');
// HTMLRewriter produces an uncompressed body, so the original encoding is no longer valid
newHeaders.delete('content-encoding');

// Modify CSP headers to allow our nonced script
const csp = response.headers.get('content-security-policy');
if (csp) {
newHeaders.set('content-security-policy', addNonceToCSP(csp, nonce));
}
const cspReportOnly = response.headers.get('content-security-policy-report-only');
if (cspReportOnly) {
newHeaders.set('content-security-policy-report-only', addNonceToCSP(cspReportOnly, nonce));
}

let injected = false;

const rewriter = new HTMLRewriter()
.on('head', {
element(element) {
if (!injected) {
element.append(bannerScript, { html: true });
injected = true;
}
},
})
.on('body', {
element(element) {
if (!injected) {
element.prepend(bannerScript, { html: true });
injected = true;
}
},
})
.onDocument({
end(end) {
if (!injected) {
end.append(bannerScript, { html: true });
}
},
});

const transformedResponse = rewriter.transform(response);
return new Response(transformedResponse.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}
19 changes: 18 additions & 1 deletion cloudflare-deploy-infra/dispatcher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Hono } from 'hono';
import { getCookie } from 'hono/cookie';
import type { Env } from './types';
import { getPasswordRecord } from './auth/password-store';
import { isBannerEnabled } from './banner/banner-store';
import { injectBanner } from './banner/inject-banner';
import { validateAuthCookie } from './auth/jwt';
import { api } from './routes/api';
import { auth } from './routes/auth';
Expand Down Expand Up @@ -103,7 +105,22 @@ subdomainApp.all('*', async c => {

// Forward request
try {
return (await worker.fetch(c.req.raw)) as unknown as Response;
const response = (await worker.fetch(c.req.raw)) as unknown as Response;

// Banner injection is best-effort: if KV or HTMLRewriter fails,
// return the original response rather than turning it into a 500.
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('text/html')) {
try {
if (await isBannerEnabled(c.env.DEPLOY_KV, workerName)) {
return injectBanner(response);
}
} catch (bannerError) {
console.error('Banner injection failed, serving original response:', bannerError);
}
}

return response;
} catch {
return c.text('Error forwarding request', 500);
}
Expand Down
33 changes: 33 additions & 0 deletions cloudflare-deploy-infra/dispatcher/src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { z } from 'zod';
import type { Env } from '../types';
import { hashPassword } from '../auth/password';
import { getPasswordRecord, setPasswordRecord, deletePasswordRecord } from '../auth/password-store';
import { isBannerEnabled, enableBanner, disableBanner } from '../banner/banner-store';
import {
workerNameSchema,
setPasswordRequestSchema,
Expand Down Expand Up @@ -136,3 +137,35 @@ api.delete('/slug-mapping/:worker', validateWorkerParam, async c => {

return c.json({ success: true });
});

// ============================================================================
// Banner Routes
// Manages the "Made with Kilo App Builder" badge for deployed sites
// ============================================================================

/**
* Get banner status.
*/
api.get('/app-builder-banner/:worker', validateWorkerParam, async c => {
const { worker } = c.req.valid('param');
const enabled = await isBannerEnabled(c.env.DEPLOY_KV, worker);
return c.json({ enabled });
});

/**
* Enable banner.
*/
api.put('/app-builder-banner/:worker', validateWorkerParam, async c => {
const { worker } = c.req.valid('param');
await enableBanner(c.env.DEPLOY_KV, worker);
return c.json({ success: true });
});

/**
* Disable banner.
*/
api.delete('/app-builder-banner/:worker', validateWorkerParam, async c => {
const { worker } = c.req.valid('param');
await disableBanner(c.env.DEPLOY_KV, worker);
return c.json({ success: true });
});
18 changes: 13 additions & 5 deletions cloudflare-deploy-infra/dispatcher/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const returnPathSchema = z
})
.catch('/');

export const successResponseSchema = z.object({
success: z.literal(true),
});

// --- API route schemas (api.ts) ---

export const setPasswordRequestSchema = z.object({
Expand All @@ -32,13 +36,15 @@ export const setSlugMappingRequestSchema = z.object({
slug: slugParamSchema,
});

export const setPasswordResponseSchema = z.object({
success: z.literal(true),
passwordSetAt: z.number(),
// --- Banner response schemas ---

export const getBannerResponseSchema = z.object({
enabled: z.boolean(),
});

export const deletePasswordResponseSchema = z.object({
export const setPasswordResponseSchema = z.object({
success: z.literal(true),
passwordSetAt: z.number(),
});

export const getPasswordResponseSchema = z.discriminatedUnion('protected', [
Expand Down Expand Up @@ -66,13 +72,15 @@ export const authFormSchema = z.object({

export type WorkerName = z.infer<typeof workerNameSchema>;
export type ReturnPath = z.infer<typeof returnPathSchema>;
export type SuccessResponse = z.infer<typeof successResponseSchema>;

export type SetPasswordRequest = z.infer<typeof setPasswordRequestSchema>;
export type SetPasswordResponse = z.infer<typeof setPasswordResponseSchema>;
export type DeletePasswordResponse = z.infer<typeof deletePasswordResponseSchema>;
export type GetPasswordResponse = z.infer<typeof getPasswordResponseSchema>;
export type ApiErrorResponse = z.infer<typeof apiErrorResponseSchema>;

export type AuthFormData = z.infer<typeof authFormSchema>;

export type SetSlugMappingRequest = z.infer<typeof setSlugMappingRequestSchema>;

export type GetBannerResponse = z.infer<typeof getBannerResponseSchema>;
2 changes: 2 additions & 0 deletions src/db/migrations/0009_harsh_rick_jones.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "deployments" ADD COLUMN "created_from" text;--> statement-breakpoint
UPDATE "deployments" SET "created_from" = CASE WHEN "source_type" = 'app-builder' THEN 'app-builder' ELSE 'deploy' END;
Loading