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
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand DownExpand Up@@ -37,7 +37,20 @@
"pack": "pnpm pack"
},
"exports": {
"./package.json": "./package.json"
"./package.json": {
"import": "./package.json",
"require": "./package.json"
},
"./proxy": {
"import": {
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"require": {
"types": "./dist/proxy.d.cts",
"default": "./dist/proxy.cjs"
}
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
Expand Down
228 changes: 21 additions & 207 deletions packages/cli/src/actions/proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { serve } from '@hono/node-server';
import {
ConfigExpr,
InvocationExpr,
Expand All@@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { createHonoHandler } from '@zenstackhq/server/hono';
import { serve } from '@hono/node-server';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { cors } from 'hono/cors';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type BetterSqlite3 from 'better-sqlite3';
import colors from 'colors';
import { createJiti } from 'jiti';
import type { createPool as MysqlCreatePool } from 'mysql2';
import { verify } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { detect, resolveCommand } from 'package-manager-detector';
import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
} from '../proxy';
import { execSync } from '../utils/exec-utils';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { runPull } from './db';
import { z } from 'zod';
import { run as runGenerate } from './generate';

export {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
};

type Options = {
output?: string;
schema?: string;
Expand All@@ -48,34 +60,6 @@ type Options = {
introspect?: boolean;
};

export const ProxyAuthError = {
MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header',
INVALID_TIMESTAMP: 'Request timestamp is expired or invalid',
INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format',
} as const;

export type ProxyAuthErrorCode = keyof typeof ProxyAuthError;

function rejectAuth(c: Context, code: ProxyAuthErrorCode) {
return c.json({ code, message: ProxyAuthError[code] }, 401);
}

const UserClaimSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('superUser') }),
z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }),
]);

type UserClaim = z.infer<typeof UserClaimSchema>;

function normalizePublicKey(key: string): string {
key = key.trim();
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
return key;
}
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
}

export async function run(options: Options) {
// Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var.
options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] };
Expand DownExpand Up@@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem
throw new CliError(`Unsupported database provider: ${provider}`);
}
}
export interface CreateProxyAppOptions {
client: ClientContract<SchemaDef>;
schema: SchemaDef;
authDb?: ClientContract<SchemaDef>;
auth?: {
studioAuthKey: string;
/** Seconds within which a signed request is considered valid. Defaults to 60. */
signatureToleranceSecs: number;
};
cors?: Parameters<typeof cors>[0];
}

export function createProxyApp(options: CreateProxyAppOptions): Hono;
export function createProxyApp(
client: ClientContract<SchemaDef>,
schema: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono;
export function createProxyApp(
optionsOrClient: CreateProxyAppOptions | ClientContract<SchemaDef>,
schema?: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono {
let options: CreateProxyAppOptions;
if ('client' in optionsOrClient && 'schema' in optionsOrClient) {
options = optionsOrClient as CreateProxyAppOptions;
} else {
options = {
client: optionsOrClient as ClientContract<SchemaDef>,
schema: schema!,
authDb,
auth,
};
}

const app = new Hono();
app.use('*', cors(options.cors));

if (options.auth?.studioAuthKey) {
const toleranceSecs = options.auth.signatureToleranceSecs;
const normalizedKey = normalizePublicKey(options.auth.studioAuthKey);
const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs);
app.use('/api/model/*', sigMiddleware);
app.use('/api/schema', sigMiddleware);
}

app.use(
'/api/model/*',
createHonoHandler({
apiHandler: new RPCApiHandler({ schema: options.schema }),
getClient: (c) =>
resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey),
}),
);

app.get('/api/schema', (c) => {
return c.json({ ...options.schema, zenstackVersion: getVersion() });
});

return app;
}

function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler {
let lastInvalidSigWarnAt = 0;
const WARN_THROTTLE_SECS = 60;

function warnInvalidSignature() {
const now = Math.floor(Date.now() / 1000);
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
lastInvalidSigWarnAt = now;
console.warn(
colors.yellow(
'Warning: Received a request with an invalid signature. ' +
'Please double-check whether you have the correct public API key configured.',
),
);
}
}

return async (c, next) => {
const signatureHeader = c.req.header('x-zenstack-signature');
if (!signatureHeader) {
return rejectAuth(c, 'MISSING_SIGNATURE_HEADER');
}

const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const sigPart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !sigPart) {
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
const timestamp = timestampPart.substring(2);
const sig = sigPart.substring(3);

const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) {
return rejectAuth(c, 'INVALID_TIMESTAMP');
}

let payload: string;
if (c.req.method === 'GET' || c.req.method === 'DELETE') {
const rawUrl = c.req.url;
const qMark = rawUrl.indexOf('?');
payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : '';
} else {
payload = await c.req.text();
}

const authHeader = c.req.header('authorization');
const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined;

const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`;

try {
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
if (!isValid) {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
} catch {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}

return next();
};
}

function resolveClient(
client: ClientContract<SchemaDef>,
authDb: ClientContract<SchemaDef>,
c: Context,
isAuthKeyEnabled: boolean,
): ClientContract<SchemaDef> {
const authHeader = c.req.header('authorization');

if (!isAuthKeyEnabled && !authHeader) {
return client;
}

if (!authHeader?.startsWith('Bearer ')) {
return authDb;
}

const token = authHeader.substring(7);
let claim: UserClaim;
try {
claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
} catch (err) {
console.error(
colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`),
);
return authDb;
}

if (claim.type === 'superUser') {
return client;
} else {
return authDb.$setAuth(claim.data as any) as ClientContract<SchemaDef>;
}
}

function startServer(
client: ClientContract<SchemaDef>,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand DownExpand Up@@ -37,7 +37,20 @@
"pack": "pnpm pack"
},
"exports": {
"./package.json": "./package.json"
"./package.json": {
"import": "./package.json",
"require": "./package.json"
},
"./proxy": {
"import": {
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"require": {
"types": "./dist/proxy.d.cts",
"default": "./dist/proxy.cjs"
}
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
Expand Down
228 changes: 21 additions & 207 deletions packages/cli/src/actions/proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { serve } from '@hono/node-server';
import {
ConfigExpr,
InvocationExpr,
Expand All@@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { createHonoHandler } from '@zenstackhq/server/hono';
import { serve } from '@hono/node-server';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { cors } from 'hono/cors';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type BetterSqlite3 from 'better-sqlite3';
import colors from 'colors';
import { createJiti } from 'jiti';
import type { createPool as MysqlCreatePool } from 'mysql2';
import { verify } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { detect, resolveCommand } from 'package-manager-detector';
import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
} from '../proxy';
import { execSync } from '../utils/exec-utils';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { runPull } from './db';
import { z } from 'zod';
import { run as runGenerate } from './generate';

export {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
};

type Options = {
output?: string;
schema?: string;
Expand All@@ -48,34 +60,6 @@ type Options = {
introspect?: boolean;
};

export const ProxyAuthError = {
MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header',
INVALID_TIMESTAMP: 'Request timestamp is expired or invalid',
INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format',
} as const;

export type ProxyAuthErrorCode = keyof typeof ProxyAuthError;

function rejectAuth(c: Context, code: ProxyAuthErrorCode) {
return c.json({ code, message: ProxyAuthError[code] }, 401);
}

const UserClaimSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('superUser') }),
z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }),
]);

type UserClaim = z.infer<typeof UserClaimSchema>;

function normalizePublicKey(key: string): string {
key = key.trim();
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
return key;
}
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
}

export async function run(options: Options) {
// Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var.
options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] };
Expand DownExpand Up@@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem
throw new CliError(`Unsupported database provider: ${provider}`);
}
}
export interface CreateProxyAppOptions {
client: ClientContract<SchemaDef>;
schema: SchemaDef;
authDb?: ClientContract<SchemaDef>;
auth?: {
studioAuthKey: string;
/** Seconds within which a signed request is considered valid. Defaults to 60. */
signatureToleranceSecs: number;
};
cors?: Parameters<typeof cors>[0];
}

export function createProxyApp(options: CreateProxyAppOptions): Hono;
export function createProxyApp(
client: ClientContract<SchemaDef>,
schema: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono;
export function createProxyApp(
optionsOrClient: CreateProxyAppOptions | ClientContract<SchemaDef>,
schema?: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono {
let options: CreateProxyAppOptions;
if ('client' in optionsOrClient && 'schema' in optionsOrClient) {
options = optionsOrClient as CreateProxyAppOptions;
} else {
options = {
client: optionsOrClient as ClientContract<SchemaDef>,
schema: schema!,
authDb,
auth,
};
}

const app = new Hono();
app.use('*', cors(options.cors));

if (options.auth?.studioAuthKey) {
const toleranceSecs = options.auth.signatureToleranceSecs;
const normalizedKey = normalizePublicKey(options.auth.studioAuthKey);
const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs);
app.use('/api/model/*', sigMiddleware);
app.use('/api/schema', sigMiddleware);
}

app.use(
'/api/model/*',
createHonoHandler({
apiHandler: new RPCApiHandler({ schema: options.schema }),
getClient: (c) =>
resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey),
}),
);

app.get('/api/schema', (c) => {
return c.json({ ...options.schema, zenstackVersion: getVersion() });
});

return app;
}

function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler {
let lastInvalidSigWarnAt = 0;
const WARN_THROTTLE_SECS = 60;

function warnInvalidSignature() {
const now = Math.floor(Date.now() / 1000);
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
lastInvalidSigWarnAt = now;
console.warn(
colors.yellow(
'Warning: Received a request with an invalid signature. ' +
'Please double-check whether you have the correct public API key configured.',
),
);
}
}

return async (c, next) => {
const signatureHeader = c.req.header('x-zenstack-signature');
if (!signatureHeader) {
return rejectAuth(c, 'MISSING_SIGNATURE_HEADER');
}

const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const sigPart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !sigPart) {
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
const timestamp = timestampPart.substring(2);
const sig = sigPart.substring(3);

const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) {
return rejectAuth(c, 'INVALID_TIMESTAMP');
}

let payload: string;
if (c.req.method === 'GET' || c.req.method === 'DELETE') {
const rawUrl = c.req.url;
const qMark = rawUrl.indexOf('?');
payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : '';
} else {
payload = await c.req.text();
}

const authHeader = c.req.header('authorization');
const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined;

const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`;

try {
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
if (!isValid) {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
} catch {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}

return next();
};
}

function resolveClient(
client: ClientContract<SchemaDef>,
authDb: ClientContract<SchemaDef>,
c: Context,
isAuthKeyEnabled: boolean,
): ClientContract<SchemaDef> {
const authHeader = c.req.header('authorization');

if (!isAuthKeyEnabled && !authHeader) {
return client;
}

if (!authHeader?.startsWith('Bearer ')) {
return authDb;
}

const token = authHeader.substring(7);
let claim: UserClaim;
try {
claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
} catch (err) {
console.error(
colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`),
);
return authDb;
}

if (claim.type === 'superUser') {
return client;
} else {
return authDb.$setAuth(claim.data as any) as ClientContract<SchemaDef>;
}
}

function startServer(
client: ClientContract<SchemaDef>,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand DownExpand Up@@ -37,7 +37,20 @@
"pack": "pnpm pack"
},
"exports": {
"./package.json": "./package.json"
"./package.json": {
"import": "./package.json",
"require": "./package.json"
},
"./proxy": {
"import": {
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"require": {
"types": "./dist/proxy.d.cts",
"default": "./dist/proxy.cjs"
}
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
Expand Down
228 changes: 21 additions & 207 deletions packages/cli/src/actions/proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { serve } from '@hono/node-server';
import {
ConfigExpr,
InvocationExpr,
Expand All@@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { createHonoHandler } from '@zenstackhq/server/hono';
import { serve } from '@hono/node-server';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { cors } from 'hono/cors';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type BetterSqlite3 from 'better-sqlite3';
import colors from 'colors';
import { createJiti } from 'jiti';
import type { createPool as MysqlCreatePool } from 'mysql2';
import { verify } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { detect, resolveCommand } from 'package-manager-detector';
import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
} from '../proxy';
import { execSync } from '../utils/exec-utils';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { runPull } from './db';
import { z } from 'zod';
import { run as runGenerate } from './generate';

export {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
};

type Options = {
output?: string;
schema?: string;
Expand All@@ -48,34 +60,6 @@ type Options = {
introspect?: boolean;
};

export const ProxyAuthError = {
MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header',
INVALID_TIMESTAMP: 'Request timestamp is expired or invalid',
INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format',
} as const;

export type ProxyAuthErrorCode = keyof typeof ProxyAuthError;

function rejectAuth(c: Context, code: ProxyAuthErrorCode) {
return c.json({ code, message: ProxyAuthError[code] }, 401);
}

const UserClaimSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('superUser') }),
z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }),
]);

type UserClaim = z.infer<typeof UserClaimSchema>;

function normalizePublicKey(key: string): string {
key = key.trim();
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
return key;
}
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
}

export async function run(options: Options) {
// Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var.
options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] };
Expand DownExpand Up@@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem
throw new CliError(`Unsupported database provider: ${provider}`);
}
}
export interface CreateProxyAppOptions {
client: ClientContract<SchemaDef>;
schema: SchemaDef;
authDb?: ClientContract<SchemaDef>;
auth?: {
studioAuthKey: string;
/** Seconds within which a signed request is considered valid. Defaults to 60. */
signatureToleranceSecs: number;
};
cors?: Parameters<typeof cors>[0];
}

export function createProxyApp(options: CreateProxyAppOptions): Hono;
export function createProxyApp(
client: ClientContract<SchemaDef>,
schema: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono;
export function createProxyApp(
optionsOrClient: CreateProxyAppOptions | ClientContract<SchemaDef>,
schema?: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono {
let options: CreateProxyAppOptions;
if ('client' in optionsOrClient && 'schema' in optionsOrClient) {
options = optionsOrClient as CreateProxyAppOptions;
} else {
options = {
client: optionsOrClient as ClientContract<SchemaDef>,
schema: schema!,
authDb,
auth,
};
}

const app = new Hono();
app.use('*', cors(options.cors));

if (options.auth?.studioAuthKey) {
const toleranceSecs = options.auth.signatureToleranceSecs;
const normalizedKey = normalizePublicKey(options.auth.studioAuthKey);
const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs);
app.use('/api/model/*', sigMiddleware);
app.use('/api/schema', sigMiddleware);
}

app.use(
'/api/model/*',
createHonoHandler({
apiHandler: new RPCApiHandler({ schema: options.schema }),
getClient: (c) =>
resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey),
}),
);

app.get('/api/schema', (c) => {
return c.json({ ...options.schema, zenstackVersion: getVersion() });
});

return app;
}

function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler {
let lastInvalidSigWarnAt = 0;
const WARN_THROTTLE_SECS = 60;

function warnInvalidSignature() {
const now = Math.floor(Date.now() / 1000);
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
lastInvalidSigWarnAt = now;
console.warn(
colors.yellow(
'Warning: Received a request with an invalid signature. ' +
'Please double-check whether you have the correct public API key configured.',
),
);
}
}

return async (c, next) => {
const signatureHeader = c.req.header('x-zenstack-signature');
if (!signatureHeader) {
return rejectAuth(c, 'MISSING_SIGNATURE_HEADER');
}

const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const sigPart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !sigPart) {
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
const timestamp = timestampPart.substring(2);
const sig = sigPart.substring(3);

const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) {
return rejectAuth(c, 'INVALID_TIMESTAMP');
}

let payload: string;
if (c.req.method === 'GET' || c.req.method === 'DELETE') {
const rawUrl = c.req.url;
const qMark = rawUrl.indexOf('?');
payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : '';
} else {
payload = await c.req.text();
}

const authHeader = c.req.header('authorization');
const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined;

const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`;

try {
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
if (!isValid) {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
} catch {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}

return next();
};
}

function resolveClient(
client: ClientContract<SchemaDef>,
authDb: ClientContract<SchemaDef>,
c: Context,
isAuthKeyEnabled: boolean,
): ClientContract<SchemaDef> {
const authHeader = c.req.header('authorization');

if (!isAuthKeyEnabled && !authHeader) {
return client;
}

if (!authHeader?.startsWith('Bearer ')) {
return authDb;
}

const token = authHeader.substring(7);
let claim: UserClaim;
try {
claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
} catch (err) {
console.error(
colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`),
);
return authDb;
}

if (claim.type === 'superUser') {
return client;
} else {
return authDb.$setAuth(claim.data as any) as ClientContract<SchemaDef>;
}
}

function startServer(
client: ClientContract<SchemaDef>,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand DownExpand Up@@ -37,7 +37,20 @@
"pack": "pnpm pack"
},
"exports": {
"./package.json": "./package.json"
"./package.json": {
"import": "./package.json",
"require": "./package.json"
},
"./proxy": {
"import": {
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"require": {
"types": "./dist/proxy.d.cts",
"default": "./dist/proxy.cjs"
}
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
Expand Down
228 changes: 21 additions & 207 deletions packages/cli/src/actions/proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { serve } from '@hono/node-server';
import {
ConfigExpr,
InvocationExpr,
Expand All@@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { createHonoHandler } from '@zenstackhq/server/hono';
import { serve } from '@hono/node-server';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { cors } from 'hono/cors';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type BetterSqlite3 from 'better-sqlite3';
import colors from 'colors';
import { createJiti } from 'jiti';
import type { createPool as MysqlCreatePool } from 'mysql2';
import { verify } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { detect, resolveCommand } from 'package-manager-detector';
import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
} from '../proxy';
import { execSync } from '../utils/exec-utils';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { runPull } from './db';
import { z } from 'zod';
import { run as runGenerate } from './generate';

export {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
};

type Options = {
output?: string;
schema?: string;
Expand All@@ -48,34 +60,6 @@ type Options = {
introspect?: boolean;
};

export const ProxyAuthError = {
MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header',
INVALID_TIMESTAMP: 'Request timestamp is expired or invalid',
INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format',
} as const;

export type ProxyAuthErrorCode = keyof typeof ProxyAuthError;

function rejectAuth(c: Context, code: ProxyAuthErrorCode) {
return c.json({ code, message: ProxyAuthError[code] }, 401);
}

const UserClaimSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('superUser') }),
z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }),
]);

type UserClaim = z.infer<typeof UserClaimSchema>;

function normalizePublicKey(key: string): string {
key = key.trim();
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
return key;
}
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
}

export async function run(options: Options) {
// Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var.
options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] };
Expand DownExpand Up@@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem
throw new CliError(`Unsupported database provider: ${provider}`);
}
}
export interface CreateProxyAppOptions {
client: ClientContract<SchemaDef>;
schema: SchemaDef;
authDb?: ClientContract<SchemaDef>;
auth?: {
studioAuthKey: string;
/** Seconds within which a signed request is considered valid. Defaults to 60. */
signatureToleranceSecs: number;
};
cors?: Parameters<typeof cors>[0];
}

export function createProxyApp(options: CreateProxyAppOptions): Hono;
export function createProxyApp(
client: ClientContract<SchemaDef>,
schema: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono;
export function createProxyApp(
optionsOrClient: CreateProxyAppOptions | ClientContract<SchemaDef>,
schema?: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono {
let options: CreateProxyAppOptions;
if ('client' in optionsOrClient && 'schema' in optionsOrClient) {
options = optionsOrClient as CreateProxyAppOptions;
} else {
options = {
client: optionsOrClient as ClientContract<SchemaDef>,
schema: schema!,
authDb,
auth,
};
}

const app = new Hono();
app.use('*', cors(options.cors));

if (options.auth?.studioAuthKey) {
const toleranceSecs = options.auth.signatureToleranceSecs;
const normalizedKey = normalizePublicKey(options.auth.studioAuthKey);
const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs);
app.use('/api/model/*', sigMiddleware);
app.use('/api/schema', sigMiddleware);
}

app.use(
'/api/model/*',
createHonoHandler({
apiHandler: new RPCApiHandler({ schema: options.schema }),
getClient: (c) =>
resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey),
}),
);

app.get('/api/schema', (c) => {
return c.json({ ...options.schema, zenstackVersion: getVersion() });
});

return app;
}

function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler {
let lastInvalidSigWarnAt = 0;
const WARN_THROTTLE_SECS = 60;

function warnInvalidSignature() {
const now = Math.floor(Date.now() / 1000);
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
lastInvalidSigWarnAt = now;
console.warn(
colors.yellow(
'Warning: Received a request with an invalid signature. ' +
'Please double-check whether you have the correct public API key configured.',
),
);
}
}

return async (c, next) => {
const signatureHeader = c.req.header('x-zenstack-signature');
if (!signatureHeader) {
return rejectAuth(c, 'MISSING_SIGNATURE_HEADER');
}

const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const sigPart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !sigPart) {
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
const timestamp = timestampPart.substring(2);
const sig = sigPart.substring(3);

const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) {
return rejectAuth(c, 'INVALID_TIMESTAMP');
}

let payload: string;
if (c.req.method === 'GET' || c.req.method === 'DELETE') {
const rawUrl = c.req.url;
const qMark = rawUrl.indexOf('?');
payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : '';
} else {
payload = await c.req.text();
}

const authHeader = c.req.header('authorization');
const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined;

const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`;

try {
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
if (!isValid) {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
} catch {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}

return next();
};
}

function resolveClient(
client: ClientContract<SchemaDef>,
authDb: ClientContract<SchemaDef>,
c: Context,
isAuthKeyEnabled: boolean,
): ClientContract<SchemaDef> {
const authHeader = c.req.header('authorization');

if (!isAuthKeyEnabled && !authHeader) {
return client;
}

if (!authHeader?.startsWith('Bearer ')) {
return authDb;
}

const token = authHeader.substring(7);
let claim: UserClaim;
try {
claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
} catch (err) {
console.error(
colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`),
);
return authDb;
}

if (claim.type === 'superUser') {
return client;
} else {
return authDb.$setAuth(claim.data as any) as ClientContract<SchemaDef>;
}
}

function startServer(
client: ClientContract<SchemaDef>,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand DownExpand Up@@ -37,7 +37,20 @@
"pack": "pnpm pack"
},
"exports": {
"./package.json": "./package.json"
"./package.json": {
"import": "./package.json",
"require": "./package.json"
},
"./proxy": {
"import": {
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"require": {
"types": "./dist/proxy.d.cts",
"default": "./dist/proxy.cjs"
}
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
Expand Down
228 changes: 21 additions & 207 deletions packages/cli/src/actions/proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { serve } from '@hono/node-server';
import {
ConfigExpr,
InvocationExpr,
Expand All@@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { createHonoHandler } from '@zenstackhq/server/hono';
import { serve } from '@hono/node-server';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { cors } from 'hono/cors';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type BetterSqlite3 from 'better-sqlite3';
import colors from 'colors';
import { createJiti } from 'jiti';
import type { createPool as MysqlCreatePool } from 'mysql2';
import { verify } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { detect, resolveCommand } from 'package-manager-detector';
import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
} from '../proxy';
import { execSync } from '../utils/exec-utils';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { runPull } from './db';
import { z } from 'zod';
import { run as runGenerate } from './generate';

export {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
};

type Options = {
output?: string;
schema?: string;
Expand All@@ -48,34 +60,6 @@ type Options = {
introspect?: boolean;
};

export const ProxyAuthError = {
MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header',
INVALID_TIMESTAMP: 'Request timestamp is expired or invalid',
INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format',
} as const;

export type ProxyAuthErrorCode = keyof typeof ProxyAuthError;

function rejectAuth(c: Context, code: ProxyAuthErrorCode) {
return c.json({ code, message: ProxyAuthError[code] }, 401);
}

const UserClaimSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('superUser') }),
z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }),
]);

type UserClaim = z.infer<typeof UserClaimSchema>;

function normalizePublicKey(key: string): string {
key = key.trim();
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
return key;
}
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
}

export async function run(options: Options) {
// Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var.
options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] };
Expand DownExpand Up@@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem
throw new CliError(`Unsupported database provider: ${provider}`);
}
}
export interface CreateProxyAppOptions {
client: ClientContract<SchemaDef>;
schema: SchemaDef;
authDb?: ClientContract<SchemaDef>;
auth?: {
studioAuthKey: string;
/** Seconds within which a signed request is considered valid. Defaults to 60. */
signatureToleranceSecs: number;
};
cors?: Parameters<typeof cors>[0];
}

export function createProxyApp(options: CreateProxyAppOptions): Hono;
export function createProxyApp(
client: ClientContract<SchemaDef>,
schema: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono;
export function createProxyApp(
optionsOrClient: CreateProxyAppOptions | ClientContract<SchemaDef>,
schema?: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono {
let options: CreateProxyAppOptions;
if ('client' in optionsOrClient && 'schema' in optionsOrClient) {
options = optionsOrClient as CreateProxyAppOptions;
} else {
options = {
client: optionsOrClient as ClientContract<SchemaDef>,
schema: schema!,
authDb,
auth,
};
}

const app = new Hono();
app.use('*', cors(options.cors));

if (options.auth?.studioAuthKey) {
const toleranceSecs = options.auth.signatureToleranceSecs;
const normalizedKey = normalizePublicKey(options.auth.studioAuthKey);
const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs);
app.use('/api/model/*', sigMiddleware);
app.use('/api/schema', sigMiddleware);
}

app.use(
'/api/model/*',
createHonoHandler({
apiHandler: new RPCApiHandler({ schema: options.schema }),
getClient: (c) =>
resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey),
}),
);

app.get('/api/schema', (c) => {
return c.json({ ...options.schema, zenstackVersion: getVersion() });
});

return app;
}

function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler {
let lastInvalidSigWarnAt = 0;
const WARN_THROTTLE_SECS = 60;

function warnInvalidSignature() {
const now = Math.floor(Date.now() / 1000);
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
lastInvalidSigWarnAt = now;
console.warn(
colors.yellow(
'Warning: Received a request with an invalid signature. ' +
'Please double-check whether you have the correct public API key configured.',
),
);
}
}

return async (c, next) => {
const signatureHeader = c.req.header('x-zenstack-signature');
if (!signatureHeader) {
return rejectAuth(c, 'MISSING_SIGNATURE_HEADER');
}

const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const sigPart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !sigPart) {
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
const timestamp = timestampPart.substring(2);
const sig = sigPart.substring(3);

const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) {
return rejectAuth(c, 'INVALID_TIMESTAMP');
}

let payload: string;
if (c.req.method === 'GET' || c.req.method === 'DELETE') {
const rawUrl = c.req.url;
const qMark = rawUrl.indexOf('?');
payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : '';
} else {
payload = await c.req.text();
}

const authHeader = c.req.header('authorization');
const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined;

const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`;

try {
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
if (!isValid) {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
} catch {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}

return next();
};
}

function resolveClient(
client: ClientContract<SchemaDef>,
authDb: ClientContract<SchemaDef>,
c: Context,
isAuthKeyEnabled: boolean,
): ClientContract<SchemaDef> {
const authHeader = c.req.header('authorization');

if (!isAuthKeyEnabled && !authHeader) {
return client;
}

if (!authHeader?.startsWith('Bearer ')) {
return authDb;
}

const token = authHeader.substring(7);
let claim: UserClaim;
try {
claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
} catch (err) {
console.error(
colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`),
);
return authDb;
}

if (claim.type === 'superUser') {
return client;
} else {
return authDb.$setAuth(claim.data as any) as ClientContract<SchemaDef>;
}
}

function startServer(
client: ClientContract<SchemaDef>,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand DownExpand Up@@ -37,7 +37,20 @@
"pack": "pnpm pack"
},
"exports": {
"./package.json": "./package.json"
"./package.json": {
"import": "./package.json",
"require": "./package.json"
},
"./proxy": {
"import": {
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"require": {
"types": "./dist/proxy.d.cts",
"default": "./dist/proxy.cjs"
}
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
Expand Down
228 changes: 21 additions & 207 deletions packages/cli/src/actions/proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { serve } from '@hono/node-server';
import {
ConfigExpr,
InvocationExpr,
Expand All@@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { createHonoHandler } from '@zenstackhq/server/hono';
import { serve } from '@hono/node-server';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { cors } from 'hono/cors';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type BetterSqlite3 from 'better-sqlite3';
import colors from 'colors';
import { createJiti } from 'jiti';
import type { createPool as MysqlCreatePool } from 'mysql2';
import { verify } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { detect, resolveCommand } from 'package-manager-detector';
import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
} from '../proxy';
import { execSync } from '../utils/exec-utils';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { runPull } from './db';
import { z } from 'zod';
import { run as runGenerate } from './generate';

export {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
};

type Options = {
output?: string;
schema?: string;
Expand All@@ -48,34 +60,6 @@ type Options = {
introspect?: boolean;
};

export const ProxyAuthError = {
MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header',
INVALID_TIMESTAMP: 'Request timestamp is expired or invalid',
INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format',
} as const;

export type ProxyAuthErrorCode = keyof typeof ProxyAuthError;

function rejectAuth(c: Context, code: ProxyAuthErrorCode) {
return c.json({ code, message: ProxyAuthError[code] }, 401);
}

const UserClaimSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('superUser') }),
z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }),
]);

type UserClaim = z.infer<typeof UserClaimSchema>;

function normalizePublicKey(key: string): string {
key = key.trim();
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
return key;
}
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
}

export async function run(options: Options) {
// Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var.
options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] };
Expand DownExpand Up@@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem
throw new CliError(`Unsupported database provider: ${provider}`);
}
}
export interface CreateProxyAppOptions {
client: ClientContract<SchemaDef>;
schema: SchemaDef;
authDb?: ClientContract<SchemaDef>;
auth?: {
studioAuthKey: string;
/** Seconds within which a signed request is considered valid. Defaults to 60. */
signatureToleranceSecs: number;
};
cors?: Parameters<typeof cors>[0];
}

export function createProxyApp(options: CreateProxyAppOptions): Hono;
export function createProxyApp(
client: ClientContract<SchemaDef>,
schema: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono;
export function createProxyApp(
optionsOrClient: CreateProxyAppOptions | ClientContract<SchemaDef>,
schema?: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono {
let options: CreateProxyAppOptions;
if ('client' in optionsOrClient && 'schema' in optionsOrClient) {
options = optionsOrClient as CreateProxyAppOptions;
} else {
options = {
client: optionsOrClient as ClientContract<SchemaDef>,
schema: schema!,
authDb,
auth,
};
}

const app = new Hono();
app.use('*', cors(options.cors));

if (options.auth?.studioAuthKey) {
const toleranceSecs = options.auth.signatureToleranceSecs;
const normalizedKey = normalizePublicKey(options.auth.studioAuthKey);
const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs);
app.use('/api/model/*', sigMiddleware);
app.use('/api/schema', sigMiddleware);
}

app.use(
'/api/model/*',
createHonoHandler({
apiHandler: new RPCApiHandler({ schema: options.schema }),
getClient: (c) =>
resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey),
}),
);

app.get('/api/schema', (c) => {
return c.json({ ...options.schema, zenstackVersion: getVersion() });
});

return app;
}

function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler {
let lastInvalidSigWarnAt = 0;
const WARN_THROTTLE_SECS = 60;

function warnInvalidSignature() {
const now = Math.floor(Date.now() / 1000);
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
lastInvalidSigWarnAt = now;
console.warn(
colors.yellow(
'Warning: Received a request with an invalid signature. ' +
'Please double-check whether you have the correct public API key configured.',
),
);
}
}

return async (c, next) => {
const signatureHeader = c.req.header('x-zenstack-signature');
if (!signatureHeader) {
return rejectAuth(c, 'MISSING_SIGNATURE_HEADER');
}

const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const sigPart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !sigPart) {
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
const timestamp = timestampPart.substring(2);
const sig = sigPart.substring(3);

const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) {
return rejectAuth(c, 'INVALID_TIMESTAMP');
}

let payload: string;
if (c.req.method === 'GET' || c.req.method === 'DELETE') {
const rawUrl = c.req.url;
const qMark = rawUrl.indexOf('?');
payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : '';
} else {
payload = await c.req.text();
}

const authHeader = c.req.header('authorization');
const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined;

const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`;

try {
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
if (!isValid) {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
} catch {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}

return next();
};
}

function resolveClient(
client: ClientContract<SchemaDef>,
authDb: ClientContract<SchemaDef>,
c: Context,
isAuthKeyEnabled: boolean,
): ClientContract<SchemaDef> {
const authHeader = c.req.header('authorization');

if (!isAuthKeyEnabled && !authHeader) {
return client;
}

if (!authHeader?.startsWith('Bearer ')) {
return authDb;
}

const token = authHeader.substring(7);
let claim: UserClaim;
try {
claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
} catch (err) {
console.error(
colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`),
);
return authDb;
}

if (claim.type === 'superUser') {
return client;
} else {
return authDb.$setAuth(claim.data as any) as ClientContract<SchemaDef>;
}
}

function startServer(
client: ClientContract<SchemaDef>,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand DownExpand Up@@ -37,7 +37,20 @@
"pack": "pnpm pack"
},
"exports": {
"./package.json": "./package.json"
"./package.json": {
"import": "./package.json",
"require": "./package.json"
},
"./proxy": {
"import": {
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"require": {
"types": "./dist/proxy.d.cts",
"default": "./dist/proxy.cjs"
}
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
Expand Down
228 changes: 21 additions & 207 deletions packages/cli/src/actions/proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { serve } from '@hono/node-server';
import {
ConfigExpr,
InvocationExpr,
Expand All@@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { createHonoHandler } from '@zenstackhq/server/hono';
import { serve } from '@hono/node-server';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { cors } from 'hono/cors';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type BetterSqlite3 from 'better-sqlite3';
import colors from 'colors';
import { createJiti } from 'jiti';
import type { createPool as MysqlCreatePool } from 'mysql2';
import { verify } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { detect, resolveCommand } from 'package-manager-detector';
import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
} from '../proxy';
import { execSync } from '../utils/exec-utils';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { runPull } from './db';
import { z } from 'zod';
import { run as runGenerate } from './generate';

export {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
};

type Options = {
output?: string;
schema?: string;
Expand All@@ -48,34 +60,6 @@ type Options = {
introspect?: boolean;
};

export const ProxyAuthError = {
MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header',
INVALID_TIMESTAMP: 'Request timestamp is expired or invalid',
INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format',
} as const;

export type ProxyAuthErrorCode = keyof typeof ProxyAuthError;

function rejectAuth(c: Context, code: ProxyAuthErrorCode) {
return c.json({ code, message: ProxyAuthError[code] }, 401);
}

const UserClaimSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('superUser') }),
z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }),
]);

type UserClaim = z.infer<typeof UserClaimSchema>;

function normalizePublicKey(key: string): string {
key = key.trim();
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
return key;
}
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
}

export async function run(options: Options) {
// Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var.
options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] };
Expand DownExpand Up@@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem
throw new CliError(`Unsupported database provider: ${provider}`);
}
}
export interface CreateProxyAppOptions {
client: ClientContract<SchemaDef>;
schema: SchemaDef;
authDb?: ClientContract<SchemaDef>;
auth?: {
studioAuthKey: string;
/** Seconds within which a signed request is considered valid. Defaults to 60. */
signatureToleranceSecs: number;
};
cors?: Parameters<typeof cors>[0];
}

export function createProxyApp(options: CreateProxyAppOptions): Hono;
export function createProxyApp(
client: ClientContract<SchemaDef>,
schema: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono;
export function createProxyApp(
optionsOrClient: CreateProxyAppOptions | ClientContract<SchemaDef>,
schema?: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono {
let options: CreateProxyAppOptions;
if ('client' in optionsOrClient && 'schema' in optionsOrClient) {
options = optionsOrClient as CreateProxyAppOptions;
} else {
options = {
client: optionsOrClient as ClientContract<SchemaDef>,
schema: schema!,
authDb,
auth,
};
}

const app = new Hono();
app.use('*', cors(options.cors));

if (options.auth?.studioAuthKey) {
const toleranceSecs = options.auth.signatureToleranceSecs;
const normalizedKey = normalizePublicKey(options.auth.studioAuthKey);
const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs);
app.use('/api/model/*', sigMiddleware);
app.use('/api/schema', sigMiddleware);
}

app.use(
'/api/model/*',
createHonoHandler({
apiHandler: new RPCApiHandler({ schema: options.schema }),
getClient: (c) =>
resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey),
}),
);

app.get('/api/schema', (c) => {
return c.json({ ...options.schema, zenstackVersion: getVersion() });
});

return app;
}

function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler {
let lastInvalidSigWarnAt = 0;
const WARN_THROTTLE_SECS = 60;

function warnInvalidSignature() {
const now = Math.floor(Date.now() / 1000);
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
lastInvalidSigWarnAt = now;
console.warn(
colors.yellow(
'Warning: Received a request with an invalid signature. ' +
'Please double-check whether you have the correct public API key configured.',
),
);
}
}

return async (c, next) => {
const signatureHeader = c.req.header('x-zenstack-signature');
if (!signatureHeader) {
return rejectAuth(c, 'MISSING_SIGNATURE_HEADER');
}

const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const sigPart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !sigPart) {
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
const timestamp = timestampPart.substring(2);
const sig = sigPart.substring(3);

const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) {
return rejectAuth(c, 'INVALID_TIMESTAMP');
}

let payload: string;
if (c.req.method === 'GET' || c.req.method === 'DELETE') {
const rawUrl = c.req.url;
const qMark = rawUrl.indexOf('?');
payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : '';
} else {
payload = await c.req.text();
}

const authHeader = c.req.header('authorization');
const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined;

const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`;

try {
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
if (!isValid) {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
} catch {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}

return next();
};
}

function resolveClient(
client: ClientContract<SchemaDef>,
authDb: ClientContract<SchemaDef>,
c: Context,
isAuthKeyEnabled: boolean,
): ClientContract<SchemaDef> {
const authHeader = c.req.header('authorization');

if (!isAuthKeyEnabled && !authHeader) {
return client;
}

if (!authHeader?.startsWith('Bearer ')) {
return authDb;
}

const token = authHeader.substring(7);
let claim: UserClaim;
try {
claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
} catch (err) {
console.error(
colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`),
);
return authDb;
}

if (claim.type === 'superUser') {
return client;
} else {
return authDb.$setAuth(claim.data as any) as ClientContract<SchemaDef>;
}
}

function startServer(
client: ClientContract<SchemaDef>,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.9.1",
"version": "3.9.2",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand DownExpand Up@@ -37,7 +37,20 @@
"pack": "pnpm pack"
},
"exports": {
"./package.json": "./package.json"
"./package.json": {
"import": "./package.json",
"require": "./package.json"
},
"./proxy": {
"import": {
"types": "./dist/proxy.d.mts",
"default": "./dist/proxy.mjs"
},
"require": {
"types": "./dist/proxy.d.cts",
"default": "./dist/proxy.cjs"
}
}
},
"dependencies": {
"@zenstackhq/common-helpers": "workspace:*",
Expand Down
228 changes: 21 additions & 207 deletions packages/cli/src/actions/proxy.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import { serve } from '@hono/node-server';
import {
ConfigExpr,
InvocationExpr,
Expand All@@ -13,30 +14,41 @@ import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { PolicyPlugin } from '@zenstackhq/plugin-policy';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { createHonoHandler } from '@zenstackhq/server/hono';
import { serve } from '@hono/node-server';
import { Hono, type Context, type MiddlewareHandler } from 'hono';
import { cors } from 'hono/cors';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type BetterSqlite3 from 'better-sqlite3';
import colors from 'colors';
import { createJiti } from 'jiti';
import type { createPool as MysqlCreatePool } from 'mysql2';
import { verify } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { detect, resolveCommand } from 'package-manager-detector';
import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
} from '../proxy';
import { execSync } from '../utils/exec-utils';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, isPackageInstalled, loadPackage, loadSchemaDocument } from './action-utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { runPull } from './db';
import { z } from 'zod';
import { run as runGenerate } from './generate';

export {
createProxyApp,
type CreateProxyAppOptions,
createSignatureMiddleware,
normalizePublicKey,
ProxyAuthError,
type ProxyAuthErrorCode,
resolveClient,
};

type Options = {
output?: string;
schema?: string;
Expand All@@ -48,34 +60,6 @@ type Options = {
introspect?: boolean;
};

export const ProxyAuthError = {
MISSING_SIGNATURE_HEADER: 'Missing x-zenstack-signature header',
INVALID_TIMESTAMP: 'Request timestamp is expired or invalid',
INVALID_SIGNATURE_FORMAT: 'Invalid x-zenstack-signature format',
} as const;

export type ProxyAuthErrorCode = keyof typeof ProxyAuthError;

function rejectAuth(c: Context, code: ProxyAuthErrorCode) {
return c.json({ code, message: ProxyAuthError[code] }, 401);
}

const UserClaimSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('superUser') }),
z.object({ type: z.literal('user'), data: z.record(z.string(), z.unknown()) }),
]);

type UserClaim = z.infer<typeof UserClaimSchema>;

function normalizePublicKey(key: string): string {
key = key.trim();
if (key.startsWith('-----BEGIN PUBLIC KEY-----')) {
return key;
}
const b64 = key.replace(/-/g, '+').replace(/_/g, '/');
return `-----BEGIN PUBLIC KEY-----\n${b64}\n-----END PUBLIC KEY-----`;
}

export async function run(options: Options) {
// Resolve public key: CLI arg takes precedence, then ZENSTACK_STUDIO_AUTH_KEY env var.
options = { ...options, studioAuthKey: options.studioAuthKey ?? process.env['ZENSTACK_STUDIO_AUTH_KEY'] };
Expand DownExpand Up@@ -258,176 +242,6 @@ export async function createDialect(provider: string, databaseUrl: string, schem
throw new CliError(`Unsupported database provider: ${provider}`);
}
}
export interface CreateProxyAppOptions {
client: ClientContract<SchemaDef>;
schema: SchemaDef;
authDb?: ClientContract<SchemaDef>;
auth?: {
studioAuthKey: string;
/** Seconds within which a signed request is considered valid. Defaults to 60. */
signatureToleranceSecs: number;
};
cors?: Parameters<typeof cors>[0];
}

export function createProxyApp(options: CreateProxyAppOptions): Hono;
export function createProxyApp(
client: ClientContract<SchemaDef>,
schema: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono;
export function createProxyApp(
optionsOrClient: CreateProxyAppOptions | ClientContract<SchemaDef>,
schema?: SchemaDef,
authDb?: ClientContract<SchemaDef>,
auth?: {
studioAuthKey: string;
signatureToleranceSecs: number;
},
): Hono {
let options: CreateProxyAppOptions;
if ('client' in optionsOrClient && 'schema' in optionsOrClient) {
options = optionsOrClient as CreateProxyAppOptions;
} else {
options = {
client: optionsOrClient as ClientContract<SchemaDef>,
schema: schema!,
authDb,
auth,
};
}

const app = new Hono();
app.use('*', cors(options.cors));

if (options.auth?.studioAuthKey) {
const toleranceSecs = options.auth.signatureToleranceSecs;
const normalizedKey = normalizePublicKey(options.auth.studioAuthKey);
const sigMiddleware = createSignatureMiddleware(normalizedKey, toleranceSecs);
app.use('/api/model/*', sigMiddleware);
app.use('/api/schema', sigMiddleware);
}

app.use(
'/api/model/*',
createHonoHandler({
apiHandler: new RPCApiHandler({ schema: options.schema }),
getClient: (c) =>
resolveClient(options.client, options.authDb ?? options.client, c, !!options.auth?.studioAuthKey),
}),
);

app.get('/api/schema', (c) => {
return c.json({ ...options.schema, zenstackVersion: getVersion() });
});

return app;
}

function createSignatureMiddleware(publicKey: string, toleranceSeconds: number): MiddlewareHandler {
let lastInvalidSigWarnAt = 0;
const WARN_THROTTLE_SECS = 60;

function warnInvalidSignature() {
const now = Math.floor(Date.now() / 1000);
if (now - lastInvalidSigWarnAt >= WARN_THROTTLE_SECS) {
lastInvalidSigWarnAt = now;
console.warn(
colors.yellow(
'Warning: Received a request with an invalid signature. ' +
'Please double-check whether you have the correct public API key configured.',
),
);
}
}

return async (c, next) => {
const signatureHeader = c.req.header('x-zenstack-signature');
if (!signatureHeader) {
return rejectAuth(c, 'MISSING_SIGNATURE_HEADER');
}

const parts = signatureHeader.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const sigPart = parts.find((p) => p.startsWith('v1='));
if (!timestampPart || !sigPart) {
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
const timestamp = timestampPart.substring(2);
const sig = sigPart.substring(3);

const requestTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(requestTime) || Math.abs(now - requestTime) > toleranceSeconds) {
return rejectAuth(c, 'INVALID_TIMESTAMP');
}

let payload: string;
if (c.req.method === 'GET' || c.req.method === 'DELETE') {
const rawUrl = c.req.url;
const qMark = rawUrl.indexOf('?');
payload = qMark >= 0 ? rawUrl.substring(qMark + 1) : '';
} else {
payload = await c.req.text();
}

const authHeader = c.req.header('authorization');
const authorizationToken = authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : undefined;

const message = authorizationToken ? `${payload}${timestamp}${authorizationToken}` : `${payload}${timestamp}`;

try {
const isValid = verify(null, Buffer.from(message, 'utf8'), publicKey, Buffer.from(sig, 'base64url'));
if (!isValid) {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}
} catch {
warnInvalidSignature();
return rejectAuth(c, 'INVALID_SIGNATURE_FORMAT');
}

return next();
};
}

function resolveClient(
client: ClientContract<SchemaDef>,
authDb: ClientContract<SchemaDef>,
c: Context,
isAuthKeyEnabled: boolean,
): ClientContract<SchemaDef> {
const authHeader = c.req.header('authorization');

if (!isAuthKeyEnabled && !authHeader) {
return client;
}

if (!authHeader?.startsWith('Bearer ')) {
return authDb;
}

const token = authHeader.substring(7);
let claim: UserClaim;
try {
claim = UserClaimSchema.parse(JSON.parse(Buffer.from(token, 'base64').toString('utf8')));
} catch (err) {
console.error(
colors.red(`Failed to parse user claim from token: ${err instanceof Error ? err.message : String(err)}`),
);
return authDb;
}

if (claim.type === 'superUser') {
return client;
} else {
return authDb.$setAuth(claim.data as any) as ClientContract<SchemaDef>;
}
}

function startServer(
client: ClientContract<SchemaDef>,
Expand Down
Loading
Loading