Skip to content
Open
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
16 changes: 16 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ Private assets (NOT included)
No private corpora, reinforced lexicons/patterns, prompts, or hosted-server
assets are included in this repository or its npm packages.

Trademark & brand
-----------------
The MIT License covers the code; it does NOT grant any right to the "patina"
name or brand. "patina" is a trademark of devswha (Korean trademark filing in
progress with KIPO). You may use, fork, modify, and redistribute the code under
the MIT License, but you may not use the "patina" name, logo, or branding in a
way that implies affiliation with or endorsement by the patina project, nor to
market a competing hosted service as "patina". Rebrand forks under your own name.

Hosted Pro (separate from this open baseline)
---------------------------------------------
The hosted Pro tier (the enhanced Korean engine and related server assets) is a
private, non-distributed offering. This repository ships only the open baseline
plus the public Pro client contract, gating, and a non-enhancing stub engine;
the enhanced engine itself is never included here.

Acknowledgements
----------------
Inspired by oh-my-zsh's plugin architecture, Wikipedia's "Signs of AI writing"
Expand Down
99 changes: 99 additions & 0 deletions api/lemon-webhook.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// @ts-check
// Lemon Squeezy webhook endpoint. Thin glue over src/lemon-webhook.js.
//
// CRITICAL: the signature is computed over the RAW request bytes, so this
// handler must read the unparsed body (do not rely on a framework JSON parse,
// which would re-serialize and break verification). Configure the platform to
// disable body parsing for this route.

import { createRestKv } from './rewrite.js';
import { createMemoryKv, isProductionPosture } from '../src/rate-limit.js';
import { createLemonWebhookProcessor } from '../src/lemon-webhook.js';
import { hashLicenseKey } from '../src/pro-entitlements.js';
import { byteLength } from '../src/web-rewrite-contract.js';

/** Case-insensitive single header lookup. */
function headerValue(headers, name) {
const lower = name.toLowerCase();
for (const [k, v] of Object.entries(headers || {})) {
if (k.toLowerCase() === lower) return Array.isArray(v) ? v[0] : v;
}
return undefined;
}

/**
* Read the raw request body (string or stream), capped to bound abuse.
* @param {any} req
* @returns {Promise<string|Buffer>}
*/
async function readRawBody(req) {
const MAX_BODY_BYTES = 64 * 1024;
if (typeof req.body === 'string') {
if (byteLength(req.body) > MAX_BODY_BYTES) throw new Error('payload too large');
return req.body;
}
if (Buffer.isBuffer(req.rawBody)) {
if (req.rawBody.length > MAX_BODY_BYTES) throw new Error('payload too large');
return req.rawBody;
}
/** @type {Buffer[]} */
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > 64 * 1024) throw new Error('payload too large');
chunks.push(chunk);
}
return Buffer.concat(chunks);
}

/**
* @param {{env?: Record<string,string|undefined>, kv?: any, logger?: {info?: Function, warn?: Function}, now?: () => number}} [options]
*/
export function createLemonWebhookApiHandler({ env = /** @type {Record<string,string|undefined>} */ (process.env), kv: injectedKv, logger = console, now = () => Date.now() } = {}) {
const restKv = createRestKv(env);
const kv = injectedKv ?? (isProductionPosture(env) ? restKv : (restKv ?? createMemoryKv()));

return async function handler(req, res) {
const send = (status, payload) => {
res.statusCode = status;
res.setHeader?.('Content-Type', 'application/json');
res.setHeader?.('Cache-Control', 'no-store');
res.end?.(JSON.stringify(payload));
logger.info?.('lemon.webhook', { route: '/api/lemon-webhook', status });
};

if (req.method && req.method !== 'POST') return send(405, { error: 'method not allowed' });
if (!kv) return send(503, { error: 'webhook storage unavailable' });

let rawBody;
try {
rawBody = await readRawBody(req);
} catch {
return send(413, { error: 'payload too large' });
}

const processor = createLemonWebhookProcessor({
kv,
webhookSecret: env.PATINA_LEMON_WEBHOOK_SECRET || '',
licenseHmacSecret: env.PATINA_PRO_HMAC_SECRET || '',
hashKey: hashLicenseKey,
now,
logger,
});

let result;
try {
result = await processor.process({ rawBody, signature: headerValue(req.headers, 'x-signature') });
} catch {
return send(503, { error: 'webhook processing unavailable' });
}

if ('applied' in result) return send(200, { ok: true, applied: result.applied });
return send(result.status, { error: result.reason });
};
}

export default async function handler(req, res) {
return createLemonWebhookApiHandler()(req, res);
}
96 changes: 96 additions & 0 deletions api/pro-session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// @ts-check
// Pro session exchange endpoint: POST a raw Lemon license key ONCE, receive an
// opaque short-lived Pro session token. Thin glue over src/pro-session.js — the
// security logic lives there; this wires request/response, KV selection,
// production fail-closed posture, no-store headers, and sanitized logging.

import { createRestKv } from './rewrite.js';
import { createMemoryKv, isProductionPosture } from '../src/rate-limit.js';
import { createProSessionExchange } from '../src/pro-session.js';
import { hashLicenseKey } from '../src/pro-entitlements.js';
import { byteLength } from '../src/web-rewrite-contract.js';

/**
* Read and JSON-parse a request body. Honors a pre-parsed `req.body` (Vercel)
* and otherwise drains the stream. Caps the payload so a giant body cannot DoS.
*
* @param {any} req
* @returns {Promise<unknown>}
*/
async function readJsonBody(req) {
const MAX_BODY_BYTES = 16 * 1024;
if (req && typeof req.body === 'string') {
if (byteLength(req.body) > MAX_BODY_BYTES) throw new Error('payload too large');
return req.body.length > 0 ? JSON.parse(req.body) : {};
}
// A pre-parsed object body (Vercel) is returned as-is; the exchange reads only
// an OWN `licenseKey` string, so a polluted prototype cannot smuggle a key.
if (req && req.body != null && typeof req.body === 'object') return req.body;
/** @type {Buffer[]} */
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > 16 * 1024) throw new Error('payload too large');
chunks.push(chunk);
}
if (chunks.length === 0) return {};
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}

/**
* @param {{env?: Record<string,string|undefined>, kv?: any, verifyLicense?: (raw:string)=>Promise<object|null>, logger?: {info?: Function, warn?: Function}, now?: () => number}} [options]
*/
export function createProSessionApiHandler({ env = /** @type {Record<string,string|undefined>} */ (process.env), kv: injectedKv, verifyLicense, logger = console, now = () => Date.now() } = {}) {
const restKv = createRestKv(env);
// Production must use the durable REST KV; never silently fall back to the
// in-memory store (which would lose sessions and fail open across instances).
const kv = injectedKv ?? (isProductionPosture(env) ? restKv : (restKv ?? createMemoryKv()));

return async function handler(req, res) {
res.setHeader?.('Cache-Control', 'no-store');
res.setHeader?.('Content-Type', 'application/json');

const send = (status, payload) => {
res.statusCode = status;
res.end?.(JSON.stringify(payload));
// Sanitized log ONLY: status code, never the raw key/email/token/body.
logger.info?.('pro-session.exchange', { route: '/api/pro-session', status });
};

if (req.method && req.method !== 'POST') return send(405, { error: 'method not allowed' });
if (!kv) return send(503, { error: 'pro session storage unavailable' });

let body;
try {
body = await readJsonBody(req);
} catch {
return send(400, { error: 'invalid request body' });
}

const exchange = createProSessionExchange({
kv,
hmacSecret: env.PATINA_PRO_HMAC_SECRET || '',
verifyLicense,
hashKey: hashLicenseKey,
now,
});

let result;
try {
result = await exchange.exchange(/** @type {any} */ (body));
} catch {
// A KV/provider outage must fail closed as a sanitized 503, never an
// uncaught framework error that leaks a stack or skips the no-store path.
return send(503, { error: 'pro session storage unavailable' });
}
if ('proSessionToken' in result) {
return send(200, { proSessionToken: result.proSessionToken, expiresAt: result.expiresAt, status: result.status });
}
return send(result.status, { error: result.reason });
};
}

export default async function handler(req, res) {
return createProSessionApiHandler()(req, res);
}
118 changes: 111 additions & 7 deletions api/rewrite.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// @ts-check
import { createRateLimiter, createMemoryKv, isProductionPosture } from '../src/rate-limit.js';
import { createRewriteHandler } from '../src/rewrite-handler.js';
import { encodeStreamFrame, WEB_TIERS } from '../src/web-rewrite-contract.js';
import { encodeStreamFrame, WEB_TIERS, STREAM_FRAME_TYPES } from '../src/web-rewrite-contract.js';
import { buildRewriteMetric } from '../src/web-observability.js';
import { runWebRewriteStream } from '../src/web-rewrite-stream.js';
import { createStubEnhancedEngine } from '../src/enhanced-rewrite-engine-contract.js';
import { createProMetering } from '../src/pro-metering.js';
import { hashSessionToken, sessionKey, entitlementKey, verifyProSession } from '../src/pro-session.js';

/**
* @param {unknown} value
Expand All @@ -26,7 +29,7 @@ function parseKvNumber(value) {
* Create a dependency-free Upstash/Vercel KV REST adapter.
*
* @param {Record<string,string|undefined>} env
* @returns {null|{get(key: string): Promise<unknown>, incr(key: string, options?: {ttlMs?: number}): Promise<number>}}
* @returns {null|{get(key: string): Promise<unknown>, set(key: string, val: string, options?: {ttlMs?: number}): Promise<void>, incr(key: string, options?: {ttlMs?: number}): Promise<number>}}
*/
export function createRestKv(env = {}) {
const base = env.KV_REST_API_URL;
Expand All @@ -35,11 +38,15 @@ export function createRestKv(env = {}) {
const root = base.replace(/\/+$/, '');
const headers = { Authorization: `Bearer ${token}` };

async function read(path) {
const response = await globalThis.fetch(`${root}${path}`, { headers });
async function request(path, { method = 'GET', body = undefined } = {}) {
/** @type {RequestInit} */
const init = { method, headers };
if (body != null) init.body = body;
const response = await globalThis.fetch(`${root}${path}`, init);
if (!response.ok) throw new Error('kv request failed');
return response.json();
}
const read = (path) => request(path);

return {
async get(key) {
Expand All @@ -57,22 +64,119 @@ export function createRestKv(env = {}) {
}
return value;
},
async set(key, val, { ttlMs } = {}) {
// Shared store contract (memory KV + this REST KV):
// - VALUES ARE STRINGS. Callers serialize objects themselves; a
// non-string is rejected (fail loud) instead of being implicitly
// JSON.stringify'd, so memory and REST never diverge on value shape.
// - KEYS MUST BE OPAQUE/HMAC ids (no raw license key, email, or token).
// The key is URL-encoded into the path while the value travels in the
// POST body, so a secret value is never exposed in request URLs/logs.
// - TTL granularity: a positive ttlMs is rounded UP to whole seconds
// (Redis EX). Contract TTLs are >= 1s, so memory (ms) and REST (s)
// agree at second granularity; sub-second TTLs are out of contract.
if (typeof val !== 'string') throw new TypeError('kv set value must be a string');
const encoded = encodeURIComponent(key);
const seconds = (typeof ttlMs === 'number' && ttlMs > 0) ? Math.max(1, Math.ceil(ttlMs / 1000)) : undefined;
const path = seconds ? `/set/${encoded}?EX=${seconds}` : `/set/${encoded}`;
await request(path, { method: 'POST', body: val });
},
};
}

/**
* @param {{env?: Record<string,string|undefined>, runWebRewriteStreamImpl?: typeof runWebRewriteStream, logger?: {info?: Function, warn?: Function, error?: Function, debug?: Function}, now?: () => number}} [options]
* @param {{env?: Record<string,string|undefined>, kv?: any, runWebRewriteStreamImpl?: typeof runWebRewriteStream, enhancedEngine?: {kind?:string, isAvailable:Function, rewrite:Function}, logger?: {info?: Function, warn?: Function, error?: Function, debug?: Function}, now?: () => number}} [options]
*/
export function createRewriteApiHandler({ env = /** @type {Record<string,string|undefined>} */ (process.env), runWebRewriteStreamImpl = runWebRewriteStream, logger = console, now = () => Date.now() } = {}) {
export function createRewriteApiHandler({ env = /** @type {Record<string,string|undefined>} */ (process.env), kv: injectedKv, runWebRewriteStreamImpl = runWebRewriteStream, enhancedEngine = createStubEnhancedEngine(), logger = console, now = () => Date.now() } = {}) {
const restKv = createRestKv(env);
const kv = isProductionPosture(env) ? restKv : (restKv ?? createMemoryKv());
const kv = injectedKv ?? (isProductionPosture(env) ? restKv : (restKv ?? createMemoryKv()));
const proMetering = createProMetering({ kv, now });
/** @param {unknown} v */
const parseRecord = (v) => {
if (v == null) return null;
if (typeof v === 'object') return v;
if (typeof v !== 'string') return null;
try { const p = JSON.parse(v); return p && typeof p === 'object' ? p : null; } catch { return null; }
};
/** @type {Record<string, number>} */
const PRO_SESSION_STATUS = { no_session: 401, expired: 401, absolute_expired: 401, entitlement_revoked: 402 };

// Pro path: gate-on requests (G001 only emits tier 'pro' when the gate is on)
// are verified by opaque session token (G004) -> entitlement (G003) -> Pro
// metering (G006) -> the enhanced engine adapter. Every failure is explicit
// and fail-closed; it never falls back to free/BYOK or the shared LLM.
async function runProRewrite({ res, request }) {
const denyMetric = (status) => logger.info?.('rewrite.metric', buildRewriteMetric({
tier: 'pro', provider: 'enhanced', model: enhancedEngine.kind || 'enhanced', status,
latencyMs: 0, quotaDecision: 'denied', charCount: typeof request.text === 'string' ? request.text.length : 0,
}));
const jsonErr = (status, error) => {
res.statusCode = status;
res.setHeader?.('Content-Type', 'application/json');
res.setHeader?.('Cache-Control', 'no-store');
res.end?.(JSON.stringify({ error }));
denyMetric(status);
};

const proSecret = env.PATINA_PRO_HMAC_SECRET;
if (!proSecret) return jsonErr(503, 'pro service unavailable');

let tokenHash;
try {
tokenHash = hashSessionToken(proSecret, request.proSessionToken);
} catch {
return jsonErr(401, 'invalid pro session');
}
let sessionRecord;
let entitlement;
try {
sessionRecord = parseRecord(await kv.get(sessionKey(tokenHash)));
entitlement = sessionRecord && typeof /** @type {any} */ (sessionRecord).entitlementId === 'string'
? parseRecord(await kv.get(entitlementKey(/** @type {any} */ (sessionRecord).entitlementId)))
: null;
} catch {
// A KV outage during the Pro lookup fails closed as an explicit 503 (the
// pro path never degrades to a generic 500 or to free/BYOK).
return jsonErr(503, 'pro session storage unavailable');
}
const verdict = verifyProSession({ sessionRecord: /** @type {any} */ (sessionRecord), entitlement, now: now() });
if (!verdict.ok) return jsonErr(PRO_SESSION_STATUS[verdict.reason ?? 'no_session'] ?? 401, 'pro session not valid');

const meter = await proMetering.check({ entitlementId: /** @type {any} */ (sessionRecord).entitlementId });
if (!meter.allowed) {
const denied = /** @type {{status:number, reason:string}} */ (meter);
return jsonErr(denied.status, denied.reason);
}

if (!enhancedEngine.isAvailable(env)) return jsonErr(503, 'pro engine unavailable');
let result;
try {
result = await enhancedEngine.rewrite({ text: request.text, lang: request.lang, mode: request.mode, original: request.original, history: request.history });
} catch {
return jsonErr(503, 'pro engine error');
}

res.statusCode = 200;
res.setHeader?.('Content-Type', 'application/x-ndjson');
res.setHeader?.('Cache-Control', 'no-store');
const startedAt = now();
res.write?.(encodeStreamFrame({ type: STREAM_FRAME_TYPES.START }));
res.write?.(encodeStreamFrame({ type: STREAM_FRAME_TYPES.DELTA, text: result.text }));
res.write?.(encodeStreamFrame({ type: STREAM_FRAME_TYPES.DONE, scores: result.scores }));
res.end?.();
logger.info?.('rewrite.metric', buildRewriteMetric({
tier: 'pro', provider: 'enhanced', model: enhancedEngine.kind || 'enhanced', status: 200,
latencyMs: now() - startedAt, quotaDecision: 'allowed', charCount: typeof request.text === 'string' ? request.text.length : 0,
}));
}
return createRewriteHandler({
rateLimiter: createRateLimiter({
kv,
hmacSecret: env.PATINA_QUOTA_HMAC_SECRET,
env,
}),
runRewrite: async ({ res, request }) => {
if (request.tier === WEB_TIERS.PRO) return runProRewrite({ res, request });
// Resolve the effective LLM key server-side: BYOK uses the caller's key;
// free uses the server's own provider key (never the request, which has
// no key on the free tier). Fail closed if the free service is unconfigured.
Expand Down
Loading