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
89 changes: 89 additions & 0 deletions src/approvals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { PolicyRule } from "./policy/types.js";

export type ApprovalStatus = "pending" | "approved" | "denied";

export interface PendingApproval {
id: string;
namespace: string;
method: string;
path: string;
rule: PolicyRule;
requestedAt: number;
status: ApprovalStatus;
resolvedAt?: number;
ttlMs: number; // approval valid for this many ms after grant
}

export class ApprovalStore {
private approvals = new Map<string, PendingApproval>();
private ttlMs: number;

constructor(ttlMs = 3600_000) {
this.ttlMs = ttlMs;
}

create(params: {
namespace: string;
method: string;
path: string;
rule: PolicyRule;
}): PendingApproval {
const approval: PendingApproval = {
id: globalThis.crypto.randomUUID(),
...params,
requestedAt: Date.now(),
status: "pending",
ttlMs: this.ttlMs,
};
this.approvals.set(approval.id, approval);
return approval;
}

get(id: string): PendingApproval | undefined {
return this.approvals.get(id);
}

list(): PendingApproval[] {
return [...this.approvals.values()];
}

approve(id: string): PendingApproval | undefined {
const a = this.approvals.get(id);
if (!a || a.status !== "pending") return undefined;
a.status = "approved";
a.resolvedAt = Date.now();
return a;
}

deny(id: string): PendingApproval | undefined {
const a = this.approvals.get(id);
if (!a || a.status !== "pending") return undefined;
a.status = "denied";
a.resolvedAt = Date.now();
return a;
}

/** Returns true if there's an approved (non-expired) approval for this request */
isApproved(params: {
namespace: string;
method: string;
path: string;
rule: PolicyRule;
}): boolean {
const now = Date.now();
for (const a of this.approvals.values()) {
if (
a.status === "approved" &&
a.namespace === params.namespace &&
a.method === params.method &&
a.path === params.path &&
a.rule.message === params.rule.message &&
a.resolvedAt !== undefined &&
now - a.resolvedAt < a.ttlMs
) {
return true;
}
}
return false;
}
}
41 changes: 41 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { PolicyRule } from "./policy/types.js";

export class CodemodeError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
}
}

export class ApprovalRequiredError extends CodemodeError {
code = "APPROVAL_REQUIRED" as const;
namespace: string;
method: string;
path: string;
rule: PolicyRule;
approvalId: string;
approvalUrl?: string;

constructor(params: {
namespace: string;
method: string;
path: string;
rule: PolicyRule;
approvalId: string;
approvalUrl?: string;
}) {
const msg = [
`APPROVAL_REQUIRED: ${params.method} ${params.path} in namespace "${params.namespace}" requires approval.`,
params.rule.message ? `Rule: "${params.rule.message}"` : null,
params.approvalUrl ? `Request approval at: ${params.approvalUrl}` : null,
`After approval is granted, retry the same request.`,
].filter(Boolean).join("\n");
super(msg);
this.namespace = params.namespace;
this.method = params.method;
this.path = params.path;
this.rule = params.rule;
this.approvalId = params.approvalId;
this.approvalUrl = params.approvalUrl;
}
}
16 changes: 16 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,19 @@ export { resolveRefs, processSpec, extractTags, extractServerBasePath } from "./

// Response truncation
export { truncateResponse } from "./truncate.js";

// Errors
export { CodemodeError, ApprovalRequiredError } from "./errors.js";

// Approvals
export { ApprovalStore } from "./approvals.js";
export type { ApprovalStatus, PendingApproval } from "./approvals.js";

// Policy
export { PolicyEngine } from "./policy/engine.js";
export type { Policy, PolicyAction, PolicyRule } from "./policy/types.js";
export type { PolicyEngineOptions } from "./policy/engine.js";

// Platform API
export { createPlatformApi } from "./platform/api.js";
export type { PlatformApiOptions } from "./platform/api.js";
59 changes: 59 additions & 0 deletions src/platform/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Hono } from "hono";
import type { ApprovalStore } from "../approvals.js";

/**
* Options for creating the platform API router.
*/
export interface PlatformApiOptions {
approvalStore?: ApprovalStore;
}

/**
* Create the platform REST API Hono app.
*
* Routes:
* GET /v1/approvals — list all approvals
* POST /v1/approvals/:id/approve — approve a pending approval
* POST /v1/approvals/:id/deny — deny a pending approval
*/
export function createPlatformApi(options: PlatformApiOptions = {}): Hono {
const app = new Hono();

// ── Approvals ───────────────────────────────────────────────────────────────

app.get("/v1/approvals", (c) => {
const store = options.approvalStore;
if (!store) {
return c.json({ error: "Approval store not configured" }, 503);
}
return c.json({ approvals: store.list() });
});

app.post("/v1/approvals/:id/approve", (c) => {
const store = options.approvalStore;
if (!store) {
return c.json({ error: "Approval store not configured" }, 503);
}
const id = c.req.param("id");
const approval = store.approve(id);
if (!approval) {
return c.json({ error: `Approval ${id} not found or not pending` }, 404);
}
return c.json({ approval });
});

app.post("/v1/approvals/:id/deny", (c) => {
const store = options.approvalStore;
if (!store) {
return c.json({ error: "Approval store not configured" }, 503);
}
const id = c.req.param("id");
const approval = store.deny(id);
if (!approval) {
return c.json({ error: `Approval ${id} not found or not pending` }, 404);
}
return c.json({ approval });
});

return app;
}
144 changes: 144 additions & 0 deletions src/policy/engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { ApprovalStore } from "../approvals.js";
import { ApprovalRequiredError } from "../errors.js";
import type { Policy, PolicyRule } from "./types.js";

/**
* Convert a glob-style pattern to a RegExp.
* Supports:
* - `*` — matches any single path segment (no slashes)
* - `**` — matches any suffix (including slashes)
*/
function patternToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex specials (not * ?)
.replace(/\*\*/g, "__DOUBLE_STAR__")
.replace(/\*/g, "[^/]*")
.replace(/__DOUBLE_STAR__/g, ".*");
return new RegExp(`^${escaped}$`, "i");
}

/**
* Find the first matching rule for a given method + path pair.
*/
function findMatchingRule(
rules: PolicyRule[],
method: string,
path: string,
): PolicyRule | undefined {
const upperMethod = method.toUpperCase();
for (const rule of rules) {
const methodMatches =
rule.method === "*" || rule.method.toUpperCase() === upperMethod;
if (!methodMatches) continue;

const pathRegex = patternToRegex(rule.path);
if (pathRegex.test(path)) {
return rule;
}
}
return undefined;
}

/**
* Options for constructing a PolicyEngine.
*/
export interface PolicyEngineOptions {
/** Policies to evaluate in order. First matching rule across all policies wins. */
policies: Policy[];
/** Optional approval store — required to support `action: "approval"` rules. */
approvalStore?: ApprovalStore;
/** Base URL used to construct approvalUrl in ApprovalRequiredError. */
approvalBaseUrl?: string;
}

/**
* PolicyEngine evaluates request policies and enforces allow/deny/approval rules.
*/
export class PolicyEngine {
private policies: Policy[];
private approvalStore: ApprovalStore | undefined;
private approvalBaseUrl: string;

constructor(options: PolicyEngineOptions) {
this.policies = options.policies;
this.approvalStore = options.approvalStore;
this.approvalBaseUrl = options.approvalBaseUrl ?? "http://localhost";
}

/**
* Evaluate a request against all policies.
*
* - "allow" → returns normally
* - "deny" → throws Error
* - "approval" → checks ApprovalStore; if approved passes through; if not, creates pending approval and throws ApprovalRequiredError
*
* If no rule matches, the request is allowed by default.
*/
evaluate(params: {
namespace: string;
method: string;
path: string;
}): void {
const { namespace, method, path } = params;

let matchedRule: PolicyRule | undefined;

for (const policy of this.policies) {
const rule = findMatchingRule(policy.rules, method, path);
if (rule) {
matchedRule = rule;
break;
}
}

if (!matchedRule) {
// Default: allow
return;
}

if (matchedRule.action === "allow") {
return;
}

if (matchedRule.action === "deny") {
const msg = matchedRule.message
? `Request denied: ${matchedRule.message}`
: `Request denied: ${method} ${path} is not allowed.`;
throw new Error(msg);
}

if (matchedRule.action === "approval") {
if (!this.approvalStore) {
throw new Error(
"Policy requires approval but no ApprovalStore is configured.",
);
}

// Check if already approved
if (
this.approvalStore.isApproved({ namespace, method, path, rule: matchedRule })
) {
return;
}

// Create a pending approval record
const approval = this.approvalStore.create({
namespace,
method,
path,
rule: matchedRule,
});

const approvalUrl = `${this.approvalBaseUrl}/v1/approvals/${approval.id}/approve`;

throw new ApprovalRequiredError({
namespace,
method,
path,
rule: matchedRule,
approvalId: approval.id,
approvalUrl,
});
}
}
}
31 changes: 31 additions & 0 deletions src/policy/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Policy action — what to do when a rule matches.
*/
export type PolicyAction = "allow" | "deny" | "approval";

/**
* A single policy rule that matches requests by method/path pattern.
*/
export interface PolicyRule {
/** HTTP method to match (case-insensitive). Use "*" to match any method. */
method: string;
/** Path pattern to match. Supports wildcards: "*" matches a single segment, "**" matches any suffix. */
path: string;
/** Action to take when this rule matches. */
action: PolicyAction;
/** Optional human-readable message to include in errors. */
message?: string;
}

/**
* A policy is a named set of rules evaluated in order.
* The first matching rule wins. If no rule matches, the default is "allow".
*/
export interface Policy {
/** Unique identifier for this policy. */
id: string;
/** Human-readable name. */
name: string;
/** Ordered list of rules — first match wins. */
rules: PolicyRule[];
}
Loading
Loading