@tangle-network/agent-gateway 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/api-keys.d.ts +63 -0
- package/dist/api-keys.js +9 -0
- package/dist/api-keys.js.map +1 -0
- package/dist/chunk-4FULF5LW.js +55 -0
- package/dist/chunk-4FULF5LW.js.map +1 -0
- package/dist/chunk-5O75YDQP.js +89 -0
- package/dist/chunk-5O75YDQP.js.map +1 -0
- package/dist/chunk-5ZJPIIIV.js +56 -0
- package/dist/chunk-5ZJPIIIV.js.map +1 -0
- package/dist/chunk-EMGS63QE.js +398 -0
- package/dist/chunk-EMGS63QE.js.map +1 -0
- package/dist/chunk-Z22ALGHW.js +33 -0
- package/dist/chunk-Z22ALGHW.js.map +1 -0
- package/dist/index.d.ts +74 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +19 -0
- package/dist/middleware.js +9 -0
- package/dist/middleware.js.map +1 -0
- package/dist/nonce-store.d.ts +20 -0
- package/dist/nonce-store.js +7 -0
- package/dist/nonce-store.js.map +1 -0
- package/dist/publish.d.ts +42 -0
- package/dist/publish.js +7 -0
- package/dist/publish.js.map +1 -0
- package/dist/rate-limit.d.ts +33 -0
- package/dist/rate-limit.js +9 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/types.d.ts +157 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +37 -0
- package/src/api-keys.ts +187 -0
- package/src/filter.ts +138 -0
- package/src/index.ts +49 -0
- package/src/middleware.ts +321 -0
- package/src/nonce-store.ts +43 -0
- package/src/publish.ts +116 -0
- package/src/rate-limit.ts +87 -0
- package/src/types.ts +177 -0
- package/src/verify.ts +112 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nonce replay protection for x402/MPP payments.
|
|
3
|
+
* Tracks seen nonces to prevent the same payment from being used twice.
|
|
4
|
+
*/
|
|
5
|
+
interface NonceStore {
|
|
6
|
+
/** Check if nonce has been seen. Returns true if already used (reject). */
|
|
7
|
+
hasSeen(nonce: string): Promise<boolean>;
|
|
8
|
+
/** Mark nonce as used. TTL = how long to remember it (seconds). */
|
|
9
|
+
markSeen(nonce: string, ttlSeconds: number): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/** In-memory nonce store with automatic eviction */
|
|
12
|
+
declare class MemoryNonceStore implements NonceStore {
|
|
13
|
+
private seen;
|
|
14
|
+
private lastEviction;
|
|
15
|
+
hasSeen(nonce: string): Promise<boolean>;
|
|
16
|
+
markSeen(nonce: string, ttlSeconds: number): Promise<void>;
|
|
17
|
+
private evictExpired;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { MemoryNonceStore, type NonceStore };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as hono_types from 'hono/types';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
|
|
4
|
+
interface PublishedConfig {
|
|
5
|
+
enabled: boolean;
|
|
6
|
+
slug: string;
|
|
7
|
+
pricePerTokenUsd: number;
|
|
8
|
+
platformFeePercent: number;
|
|
9
|
+
/** Remote operator endpoint for sovereignty mode */
|
|
10
|
+
sandboxEndpoint?: string | null;
|
|
11
|
+
remoteSandboxId?: string | null;
|
|
12
|
+
remoteBearerToken?: string | null;
|
|
13
|
+
publishedAt: string;
|
|
14
|
+
}
|
|
15
|
+
interface PublishRequest {
|
|
16
|
+
slug?: string;
|
|
17
|
+
pricePerTokenUsd?: number;
|
|
18
|
+
platformFeePercent?: number;
|
|
19
|
+
sandboxEndpoint?: string | null;
|
|
20
|
+
remoteSandboxId?: string | null;
|
|
21
|
+
remoteBearerToken?: string | null;
|
|
22
|
+
}
|
|
23
|
+
/** Each agent implements this against their workspace/session model */
|
|
24
|
+
interface PublishStore {
|
|
25
|
+
/** Get current published config for a workspace/session */
|
|
26
|
+
getPublishedConfig(ownerId: string, resourceId: string): Promise<PublishedConfig | null>;
|
|
27
|
+
/** Set published config */
|
|
28
|
+
setPublishedConfig(ownerId: string, resourceId: string, config: PublishedConfig): Promise<void>;
|
|
29
|
+
/** Clear published config (unpublish) */
|
|
30
|
+
clearPublishedConfig(ownerId: string, resourceId: string): Promise<void>;
|
|
31
|
+
/** Check the resource exists and the user owns it */
|
|
32
|
+
verifyOwnership(ownerId: string, resourceId: string): Promise<boolean>;
|
|
33
|
+
}
|
|
34
|
+
interface PublishRoutesConfig {
|
|
35
|
+
store: PublishStore;
|
|
36
|
+
getAuthUserId: (request: Request) => Promise<string | null>;
|
|
37
|
+
/** Base URL for gateway endpoint display (e.g. "https://gtm.tangle.tools") */
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
declare function createPublishRoutes(config: PublishRoutesConfig): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
|
|
41
|
+
|
|
42
|
+
export { type PublishRequest, type PublishRoutesConfig, type PublishStore, type PublishedConfig, createPublishRoutes };
|
package/dist/publish.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sliding window rate limiter.
|
|
3
|
+
* In-memory by default. Override with KV-backed store for Workers.
|
|
4
|
+
*/
|
|
5
|
+
interface RateLimitConfig {
|
|
6
|
+
/** Max requests per window (default: 60) */
|
|
7
|
+
limit: number;
|
|
8
|
+
/** Window size in seconds (default: 60) */
|
|
9
|
+
windowSeconds: number;
|
|
10
|
+
}
|
|
11
|
+
interface RateLimitResult {
|
|
12
|
+
allowed: boolean;
|
|
13
|
+
remaining: number;
|
|
14
|
+
resetAt: number;
|
|
15
|
+
retryAfterSeconds?: number;
|
|
16
|
+
}
|
|
17
|
+
interface RateLimitStore {
|
|
18
|
+
/** Get timestamps of recent requests for this key */
|
|
19
|
+
get(key: string): Promise<number[]>;
|
|
20
|
+
/** Set timestamps for this key (with TTL) */
|
|
21
|
+
set(key: string, timestamps: number[], ttlSeconds: number): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** In-memory rate limit store with periodic eviction */
|
|
24
|
+
declare class MemoryRateLimitStore implements RateLimitStore {
|
|
25
|
+
private store;
|
|
26
|
+
private lastEviction;
|
|
27
|
+
get(key: string): Promise<number[]>;
|
|
28
|
+
set(key: string, timestamps: number[], ttlSeconds: number): Promise<void>;
|
|
29
|
+
private evictExpired;
|
|
30
|
+
}
|
|
31
|
+
declare function checkRateLimit(consumerId: string, config: RateLimitConfig, store: RateLimitStore): Promise<RateLimitResult>;
|
|
32
|
+
|
|
33
|
+
export { MemoryRateLimitStore, type RateLimitConfig, type RateLimitResult, type RateLimitStore, checkRateLimit };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { NonceStore } from './nonce-store.js';
|
|
2
|
+
import { RateLimitStore } from './rate-limit.js';
|
|
3
|
+
|
|
4
|
+
interface AgentMeta {
|
|
5
|
+
/** Unique agent identifier (workspace ID, session ID, etc.) */
|
|
6
|
+
id: string;
|
|
7
|
+
/** Owner/creator user ID */
|
|
8
|
+
ownerId: string;
|
|
9
|
+
/** Public URL slug */
|
|
10
|
+
slug: string;
|
|
11
|
+
/** System prompt for the agent (injected before consumer messages) */
|
|
12
|
+
systemPrompt?: string;
|
|
13
|
+
/** Per-token price in USD (default: 0.00002) */
|
|
14
|
+
pricePerTokenUsd: number;
|
|
15
|
+
/** Platform fee as decimal 0-1 (default: 0.20 = 20%) */
|
|
16
|
+
platformFeePercent: number;
|
|
17
|
+
/** Remote operator endpoint for sovereignty mode (null = centralized) */
|
|
18
|
+
sandboxEndpoint: string | null;
|
|
19
|
+
/** Sandbox ID on remote operator */
|
|
20
|
+
remoteSandboxId: string | null;
|
|
21
|
+
/** PASETO bearer token for remote operator auth */
|
|
22
|
+
remoteBearerToken: string | null;
|
|
23
|
+
/** Whether agent is published and accepting requests */
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
}
|
|
26
|
+
type PaymentMethod = 'x402' | 'mpp' | 'apikey' | 'none';
|
|
27
|
+
interface X402Config {
|
|
28
|
+
/** Ethereum operator address for SpendAuth verification */
|
|
29
|
+
operatorAddress: string;
|
|
30
|
+
/** Blockchain network ID (default: 3799) */
|
|
31
|
+
chainId: number;
|
|
32
|
+
/** ShieldedCredits contract address */
|
|
33
|
+
creditsAddress?: string;
|
|
34
|
+
/** RPC URL for on-chain verification (optional, demo mode skips this) */
|
|
35
|
+
rpcUrl?: string;
|
|
36
|
+
/** Demo mode: skip signature verification (default: false). NEVER enable in production. */
|
|
37
|
+
demoMode?: boolean;
|
|
38
|
+
/** Production signer verification. Called with the raw SpendAuth payload. Return true if signature is valid. */
|
|
39
|
+
verifySigner?: (payload: Record<string, unknown>) => Promise<boolean>;
|
|
40
|
+
}
|
|
41
|
+
interface MppConfig {
|
|
42
|
+
/** MPP realm (e.g. "agents.tangle.tools") */
|
|
43
|
+
realm: string;
|
|
44
|
+
/** MPP method name (default: "blueprintevm") */
|
|
45
|
+
method?: string;
|
|
46
|
+
}
|
|
47
|
+
interface PaymentResult {
|
|
48
|
+
method: PaymentMethod;
|
|
49
|
+
consumerId: string;
|
|
50
|
+
}
|
|
51
|
+
interface ApiKeyInfo {
|
|
52
|
+
keyId: string;
|
|
53
|
+
consumerId: string;
|
|
54
|
+
/** Scopes this key is authorized for (e.g. ["chat", "forms"]) */
|
|
55
|
+
scopes?: string[];
|
|
56
|
+
/** Per-key rate limit override (requests per minute). If set, overrides global rate limit. */
|
|
57
|
+
rateLimitPerMinute?: number;
|
|
58
|
+
/** Per-key daily limit override. */
|
|
59
|
+
dailyLimit?: number;
|
|
60
|
+
}
|
|
61
|
+
interface GatewayUsageEvent {
|
|
62
|
+
agentId: string;
|
|
63
|
+
agentSlug: string;
|
|
64
|
+
consumerId: string;
|
|
65
|
+
paymentMethod: PaymentMethod;
|
|
66
|
+
inputTokens: number;
|
|
67
|
+
outputTokens: number;
|
|
68
|
+
totalCostUsd: number;
|
|
69
|
+
ownerEarnedUsd: number;
|
|
70
|
+
platformFeeUsd: number;
|
|
71
|
+
durationMs: number;
|
|
72
|
+
}
|
|
73
|
+
interface SandboxStreamEvent {
|
|
74
|
+
type?: string;
|
|
75
|
+
data?: {
|
|
76
|
+
part?: {
|
|
77
|
+
type?: string;
|
|
78
|
+
text?: string;
|
|
79
|
+
};
|
|
80
|
+
delta?: string;
|
|
81
|
+
finalText?: string;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
interface SandboxBox {
|
|
85
|
+
streamPrompt(message: string, opts?: {
|
|
86
|
+
sessionId?: string;
|
|
87
|
+
systemPrompt?: string;
|
|
88
|
+
}): AsyncIterable<SandboxStreamEvent>;
|
|
89
|
+
}
|
|
90
|
+
interface GatewayConfig {
|
|
91
|
+
/** Resolve agent metadata by slug. Return null if not found or not published. */
|
|
92
|
+
resolveAgent: (slug: string) => Promise<AgentMeta | null>;
|
|
93
|
+
/** Get a sandbox instance for the agent. Called after payment is verified. */
|
|
94
|
+
getSandbox: (agent: AgentMeta) => Promise<SandboxBox>;
|
|
95
|
+
/** Record a usage event after request completes. */
|
|
96
|
+
recordUsage: (event: GatewayUsageEvent) => Promise<void>;
|
|
97
|
+
/** x402 payment configuration */
|
|
98
|
+
x402: X402Config;
|
|
99
|
+
/** MPP (Machine Payments Protocol) configuration. If provided, gateway accepts Authorization: Payment headers. */
|
|
100
|
+
mpp?: MppConfig;
|
|
101
|
+
/**
|
|
102
|
+
* Verify an API key. Return key info if valid, null if invalid.
|
|
103
|
+
* Default: accepts any `sk_agent_*` key (demo mode).
|
|
104
|
+
*/
|
|
105
|
+
verifyApiKey?: (authHeader: string) => Promise<ApiKeyInfo | null>;
|
|
106
|
+
/**
|
|
107
|
+
* Settle payment after successful response.
|
|
108
|
+
* For x402: call ShieldedCredits.claimPayment()
|
|
109
|
+
* For API key: deduct from spending limit
|
|
110
|
+
* Default: no-op (demo mode).
|
|
111
|
+
*/
|
|
112
|
+
settlePayment?: (payment: PaymentResult, cost: number) => Promise<void>;
|
|
113
|
+
/** Base URL for API key purchase links (e.g. "https://film.tangle.tools") */
|
|
114
|
+
baseUrl?: string;
|
|
115
|
+
/** Max message length in chars (default: 8000) */
|
|
116
|
+
maxMessageLength?: number;
|
|
117
|
+
/** Required scope for chat endpoint (default: "chat"). API keys must include this scope. */
|
|
118
|
+
requiredScope?: string;
|
|
119
|
+
/** Block requests with detected injection patterns (default: false — log only) */
|
|
120
|
+
blockInjection?: boolean;
|
|
121
|
+
/** Rate limiting config. Default: 60 requests per 60 seconds per consumer. */
|
|
122
|
+
rateLimit?: {
|
|
123
|
+
limit: number;
|
|
124
|
+
windowSeconds: number;
|
|
125
|
+
};
|
|
126
|
+
/** Custom rate limit store (default: in-memory). Use KV-backed for Workers. */
|
|
127
|
+
rateLimitStore?: RateLimitStore;
|
|
128
|
+
/** Nonce replay protection store (default: in-memory). Rejects reused x402 nonces. */
|
|
129
|
+
nonceStore?: NonceStore;
|
|
130
|
+
}
|
|
131
|
+
interface ChatMessage {
|
|
132
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
133
|
+
content: string;
|
|
134
|
+
}
|
|
135
|
+
interface ChatCompletionRequest {
|
|
136
|
+
model?: string;
|
|
137
|
+
messages: ChatMessage[];
|
|
138
|
+
stream?: boolean;
|
|
139
|
+
temperature?: number;
|
|
140
|
+
max_tokens?: number;
|
|
141
|
+
}
|
|
142
|
+
interface ChatCompletionChunk {
|
|
143
|
+
id: string;
|
|
144
|
+
object: 'chat.completion.chunk';
|
|
145
|
+
created: number;
|
|
146
|
+
model: string;
|
|
147
|
+
choices: Array<{
|
|
148
|
+
index: number;
|
|
149
|
+
delta: {
|
|
150
|
+
content?: string;
|
|
151
|
+
role?: string;
|
|
152
|
+
};
|
|
153
|
+
finish_reason: string | null;
|
|
154
|
+
}>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type { AgentMeta, ApiKeyInfo, ChatCompletionChunk, ChatCompletionRequest, ChatMessage, GatewayConfig, GatewayUsageEvent, MppConfig, PaymentMethod, PaymentResult, SandboxBox, SandboxStreamEvent, X402Config };
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tangle-network/agent-gateway",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "x402 + API key payment gateway for Tangle agent products. Mount on any Hono app to expose agents as paid OpenAI-compatible endpoints.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./middleware": {
|
|
12
|
+
"types": "./dist/middleware.d.ts",
|
|
13
|
+
"import": "./dist/middleware.js"
|
|
14
|
+
},
|
|
15
|
+
"./types": {
|
|
16
|
+
"types": "./dist/types.d.ts",
|
|
17
|
+
"import": "./dist/types.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"hono": "^4.6.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^25.6.0",
|
|
34
|
+
"tsup": "^8.0.0",
|
|
35
|
+
"typescript": "^5.7.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/api-keys.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key management — create, list, verify, revoke.
|
|
3
|
+
*
|
|
4
|
+
* The gateway package provides:
|
|
5
|
+
* - Types and interfaces (ApiKeyStore)
|
|
6
|
+
* - A Hono router for CRUD (createApiKeyRoutes)
|
|
7
|
+
* - A verifyApiKey function that checks against the store
|
|
8
|
+
*
|
|
9
|
+
* Each agent implements ApiKeyStore against their own DB.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Hono } from 'hono'
|
|
13
|
+
|
|
14
|
+
// --- Types ---
|
|
15
|
+
|
|
16
|
+
export interface ApiKey {
|
|
17
|
+
id: string
|
|
18
|
+
userId: string
|
|
19
|
+
name: string
|
|
20
|
+
keyHash: string
|
|
21
|
+
keyPrefix: string
|
|
22
|
+
scopes: string[]
|
|
23
|
+
rateLimit: number // requests per minute
|
|
24
|
+
dailyLimit: number // requests per day
|
|
25
|
+
spendingLimitCents: number | null // max spend in cents (null = unlimited)
|
|
26
|
+
spentCents: number // running total spent
|
|
27
|
+
lastUsedAt: Date | null
|
|
28
|
+
expiresAt: Date | null
|
|
29
|
+
createdAt: Date
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ApiKeyCreateRequest {
|
|
33
|
+
name: string
|
|
34
|
+
scopes?: string[]
|
|
35
|
+
rateLimit?: number
|
|
36
|
+
dailyLimit?: number
|
|
37
|
+
spendingLimitCents?: number
|
|
38
|
+
expiresAt?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Each agent implements this against their DB */
|
|
42
|
+
export interface ApiKeyStore {
|
|
43
|
+
create(userId: string, data: {
|
|
44
|
+
name: string
|
|
45
|
+
keyHash: string
|
|
46
|
+
keyPrefix: string
|
|
47
|
+
scopes: string[]
|
|
48
|
+
rateLimit: number
|
|
49
|
+
dailyLimit: number
|
|
50
|
+
spendingLimitCents: number | null
|
|
51
|
+
expiresAt: Date | null
|
|
52
|
+
}): Promise<ApiKey>
|
|
53
|
+
|
|
54
|
+
list(userId: string): Promise<Omit<ApiKey, 'keyHash'>[]>
|
|
55
|
+
|
|
56
|
+
findByHash(keyHash: string): Promise<ApiKey | null>
|
|
57
|
+
|
|
58
|
+
delete(userId: string, keyId: string): Promise<boolean>
|
|
59
|
+
|
|
60
|
+
recordUsage(keyId: string, costCents: number): Promise<void>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Key generation ---
|
|
64
|
+
|
|
65
|
+
function generateRawKey(prefix: string): string {
|
|
66
|
+
const bytes = new Uint8Array(16)
|
|
67
|
+
crypto.getRandomValues(bytes)
|
|
68
|
+
const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
69
|
+
return `${prefix}${hex}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function hashKey(raw: string): Promise<string> {
|
|
73
|
+
const encoded = new TextEncoder().encode(raw)
|
|
74
|
+
const digest = await crypto.subtle.digest('SHA-256', encoded)
|
|
75
|
+
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Verification ---
|
|
79
|
+
|
|
80
|
+
export async function verifyApiKeyFromStore(
|
|
81
|
+
authHeader: string,
|
|
82
|
+
store: ApiKeyStore,
|
|
83
|
+
prefix = 'ak_',
|
|
84
|
+
): Promise<{ key: ApiKey; keyId: string; consumerId: string; scopes: string[]; rateLimitPerMinute: number; dailyLimit: number } | null> {
|
|
85
|
+
const bearerPrefix = `Bearer ${prefix}`
|
|
86
|
+
if (!authHeader.startsWith(bearerPrefix)) return null
|
|
87
|
+
|
|
88
|
+
const rawKey = authHeader.slice(7) // strip "Bearer "
|
|
89
|
+
const keyHash = await hashKey(rawKey)
|
|
90
|
+
const key = await store.findByHash(keyHash)
|
|
91
|
+
if (!key) return null
|
|
92
|
+
|
|
93
|
+
// Check expiry
|
|
94
|
+
if (key.expiresAt && key.expiresAt < new Date()) return null
|
|
95
|
+
|
|
96
|
+
// Check spending limit
|
|
97
|
+
if (key.spendingLimitCents !== null && key.spentCents >= key.spendingLimitCents) return null
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
key,
|
|
101
|
+
consumerId: `apikey:${key.id}`,
|
|
102
|
+
keyId: key.id,
|
|
103
|
+
scopes: key.scopes,
|
|
104
|
+
rateLimitPerMinute: key.rateLimit,
|
|
105
|
+
dailyLimit: key.dailyLimit,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- CRUD Routes ---
|
|
110
|
+
|
|
111
|
+
export interface ApiKeyRoutesConfig {
|
|
112
|
+
store: ApiKeyStore
|
|
113
|
+
/** Get the authenticated user ID from the request. Return null if not authenticated. */
|
|
114
|
+
getAuthUserId: (request: Request) => Promise<string | null>
|
|
115
|
+
/** Key prefix (default: "ak_") */
|
|
116
|
+
prefix?: string
|
|
117
|
+
/** Valid scopes for this agent (default: ["chat"]) */
|
|
118
|
+
validScopes?: string[]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createApiKeyRoutes(config: ApiKeyRoutesConfig) {
|
|
122
|
+
const router = new Hono()
|
|
123
|
+
const prefix = config.prefix ?? 'ak_'
|
|
124
|
+
const validScopes = config.validScopes ?? ['chat']
|
|
125
|
+
|
|
126
|
+
// List keys
|
|
127
|
+
router.get('/', async (c) => {
|
|
128
|
+
const userId = await config.getAuthUserId(c.req.raw)
|
|
129
|
+
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
|
130
|
+
|
|
131
|
+
const keys = await config.store.list(userId)
|
|
132
|
+
return c.json({ keys })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
// Create key
|
|
136
|
+
router.post('/', async (c) => {
|
|
137
|
+
const userId = await config.getAuthUserId(c.req.raw)
|
|
138
|
+
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
|
139
|
+
|
|
140
|
+
const body = await c.req.json<ApiKeyCreateRequest>()
|
|
141
|
+
if (!body.name?.trim()) return c.json({ error: 'name is required' }, 400)
|
|
142
|
+
|
|
143
|
+
const scopes = (body.scopes ?? ['chat']).filter(s => validScopes.includes(s))
|
|
144
|
+
if (scopes.length === 0) scopes.push('chat')
|
|
145
|
+
|
|
146
|
+
const rawKey = generateRawKey(prefix)
|
|
147
|
+
const keyHash = await hashKey(rawKey)
|
|
148
|
+
const keyPrefix = rawKey.slice(0, prefix.length + 8)
|
|
149
|
+
|
|
150
|
+
const created = await config.store.create(userId, {
|
|
151
|
+
name: body.name.trim(),
|
|
152
|
+
keyHash,
|
|
153
|
+
keyPrefix,
|
|
154
|
+
scopes,
|
|
155
|
+
rateLimit: body.rateLimit ?? 60,
|
|
156
|
+
dailyLimit: body.dailyLimit ?? 1000,
|
|
157
|
+
spendingLimitCents: body.spendingLimitCents ?? null,
|
|
158
|
+
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
return c.json({
|
|
162
|
+
key: rawKey,
|
|
163
|
+
id: created.id,
|
|
164
|
+
name: created.name,
|
|
165
|
+
keyPrefix: created.keyPrefix,
|
|
166
|
+
scopes: created.scopes,
|
|
167
|
+
rateLimit: created.rateLimit,
|
|
168
|
+
dailyLimit: created.dailyLimit,
|
|
169
|
+
spendingLimitCents: created.spendingLimitCents,
|
|
170
|
+
expiresAt: created.expiresAt,
|
|
171
|
+
_notice: 'Store this key securely. It will not be shown again.',
|
|
172
|
+
}, 201)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Delete key
|
|
176
|
+
router.delete('/:keyId', async (c) => {
|
|
177
|
+
const userId = await config.getAuthUserId(c.req.raw)
|
|
178
|
+
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
|
179
|
+
|
|
180
|
+
const deleted = await config.store.delete(userId, c.req.param('keyId'))
|
|
181
|
+
if (!deleted) return c.json({ error: 'API key not found' }, 404)
|
|
182
|
+
|
|
183
|
+
return c.json({ deleted: true })
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return router
|
|
187
|
+
}
|
package/src/filter.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { ChatMessage } from './types'
|
|
2
|
+
|
|
3
|
+
// --- Injection detection patterns ---
|
|
4
|
+
|
|
5
|
+
const INJECTION_PATTERNS = [
|
|
6
|
+
// Direct instruction override
|
|
7
|
+
/ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|prompts?|rules?|directives?)/i,
|
|
8
|
+
/disregard\s+(all\s+)?(previous|prior|system)/i,
|
|
9
|
+
/forget\s+(everything|all|your)\s+(previous|instructions?|training)/i,
|
|
10
|
+
// Role assumption
|
|
11
|
+
/you\s+are\s+now\s+(a|an|the)\s+/i,
|
|
12
|
+
/pretend\s+(you\s+are|to\s+be)\s+/i,
|
|
13
|
+
/act\s+as\s+(if\s+you\s+are|a|an|the)\s+/i,
|
|
14
|
+
/new\s+instructions?:/i,
|
|
15
|
+
/\[system\]/i,
|
|
16
|
+
/\[INST\]/i,
|
|
17
|
+
// Prompt extraction
|
|
18
|
+
/what\s+(is|are)\s+your\s+(system\s+)?(prompt|instructions?|rules?|directives?)/i,
|
|
19
|
+
/repeat\s+(your|the)\s+(system\s+)?(prompt|instructions?)/i,
|
|
20
|
+
/output\s+(your|the)\s+(system\s+)?(prompt|instructions?)/i,
|
|
21
|
+
/show\s+me\s+(your|the)\s+(system|hidden|secret)\s+(prompt|instructions?|message)/i,
|
|
22
|
+
// Data exfiltration
|
|
23
|
+
/read\s+(the\s+)?(vault|workspace|config|secret|\.env)/i,
|
|
24
|
+
/cat\s+\/home\/agent\/(vault|config|\.env|secrets?)/i,
|
|
25
|
+
/list\s+(all\s+)?(vault|workspace|secret)\s+(files?|contents?|data)/i,
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
// Unicode normalization — collapse homoglyphs and zero-width chars
|
|
29
|
+
function normalizeUnicode(text: string): string {
|
|
30
|
+
return text
|
|
31
|
+
// Remove zero-width chars (ZWJ, ZWNJ, ZWS, ZWSP)
|
|
32
|
+
.replace(/[\u200B-\u200F\u2028-\u202F\u2060\uFEFF]/g, '')
|
|
33
|
+
// Normalize to NFKC (collapses homoglyphs like а→a, е→e)
|
|
34
|
+
.normalize('NFKC')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect prompt injection attempts.
|
|
39
|
+
* Returns array of matched pattern descriptions, empty if clean.
|
|
40
|
+
*/
|
|
41
|
+
export function detectInjection(content: string): string[] {
|
|
42
|
+
const normalized = normalizeUnicode(content)
|
|
43
|
+
const matches: string[] = []
|
|
44
|
+
|
|
45
|
+
for (const pattern of INJECTION_PATTERNS) {
|
|
46
|
+
if (pattern.test(normalized)) {
|
|
47
|
+
matches.push(pattern.source.slice(0, 60))
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check for base64-encoded injection attempts
|
|
52
|
+
const b64Matches = normalized.match(/[A-Za-z0-9+/]{40,}={0,2}/g)
|
|
53
|
+
if (b64Matches) {
|
|
54
|
+
for (const b64 of b64Matches) {
|
|
55
|
+
try {
|
|
56
|
+
const decoded = atob(b64)
|
|
57
|
+
if (INJECTION_PATTERNS.some(p => p.test(decoded))) {
|
|
58
|
+
matches.push('base64-encoded injection')
|
|
59
|
+
}
|
|
60
|
+
} catch { /* not valid b64 */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return matches
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Security boundary — filter consumer messages before forwarding to agent.
|
|
69
|
+
*
|
|
70
|
+
* Defense in depth:
|
|
71
|
+
* 1. Strip system messages (consumers cannot set system prompt)
|
|
72
|
+
* 2. Normalize Unicode (collapse homoglyphs, remove zero-width chars)
|
|
73
|
+
* 3. Detect injection patterns (instruction override, prompt extraction, data exfil)
|
|
74
|
+
* 4. Redact sensitive keywords
|
|
75
|
+
* 5. Cap message length
|
|
76
|
+
*
|
|
77
|
+
* Returns filtered messages and any injection warnings detected.
|
|
78
|
+
*/
|
|
79
|
+
export function filterConsumerMessages(
|
|
80
|
+
messages: ChatMessage[],
|
|
81
|
+
maxLength = 8000,
|
|
82
|
+
): ChatMessage[] {
|
|
83
|
+
return messages
|
|
84
|
+
.filter((m) => m.role !== 'system')
|
|
85
|
+
.map((m) => {
|
|
86
|
+
const normalized = normalizeUnicode(m.content)
|
|
87
|
+
const redacted = normalized
|
|
88
|
+
.replace(/\b(vault|workspace|owner|admin|secret|\.env|config\.json)[\s/:][^\s]*/gi, '[REDACTED]')
|
|
89
|
+
.slice(0, maxLength)
|
|
90
|
+
return { role: m.role, content: redacted }
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Filter consumer messages with injection detection.
|
|
96
|
+
* Returns { messages, injectionWarnings }.
|
|
97
|
+
* If injectionWarnings is non-empty, the gateway should log and optionally reject.
|
|
98
|
+
*/
|
|
99
|
+
export function filterConsumerMessagesStrict(
|
|
100
|
+
messages: ChatMessage[],
|
|
101
|
+
maxLength = 8000,
|
|
102
|
+
): { messages: ChatMessage[]; injectionWarnings: string[] } {
|
|
103
|
+
const filtered = filterConsumerMessages(messages, maxLength)
|
|
104
|
+
const allContent = filtered.map(m => m.content).join(' ')
|
|
105
|
+
const injectionWarnings = detectInjection(allContent)
|
|
106
|
+
return { messages: filtered, injectionWarnings }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Redact system prompt content from agent output.
|
|
111
|
+
* Prevents the agent from leaking its own instructions in responses.
|
|
112
|
+
*
|
|
113
|
+
* Strategy: if any chunk of the system prompt appears verbatim (>40 chars)
|
|
114
|
+
* in the output, replace it with [REDACTED].
|
|
115
|
+
*/
|
|
116
|
+
export function redactSystemPromptFromOutput(
|
|
117
|
+
output: string,
|
|
118
|
+
systemPrompt: string | undefined,
|
|
119
|
+
): string {
|
|
120
|
+
if (!systemPrompt || systemPrompt.length < 40) return output
|
|
121
|
+
|
|
122
|
+
// Split system prompt into meaningful chunks (sentences or lines)
|
|
123
|
+
const chunks = systemPrompt
|
|
124
|
+
.split(/[.\n]/)
|
|
125
|
+
.map(s => s.trim())
|
|
126
|
+
.filter(s => s.length >= 40)
|
|
127
|
+
|
|
128
|
+
let redacted = output
|
|
129
|
+
for (const chunk of chunks) {
|
|
130
|
+
// Case-insensitive substring match
|
|
131
|
+
const idx = redacted.toLowerCase().indexOf(chunk.toLowerCase())
|
|
132
|
+
if (idx >= 0) {
|
|
133
|
+
redacted = redacted.slice(0, idx) + '[REDACTED — system instructions]' + redacted.slice(idx + chunk.length)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return redacted
|
|
138
|
+
}
|