@tangle-network/agent-gateway 0.7.0 → 0.8.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 +108 -6
- package/dist/chunk-GITV7CPT.js +84 -0
- package/dist/chunk-GITV7CPT.js.map +1 -0
- package/dist/chunk-J5SDVHOL.js +104 -0
- package/dist/chunk-J5SDVHOL.js.map +1 -0
- package/dist/chunk-MP6IIAIA.js +5651 -0
- package/dist/chunk-MP6IIAIA.js.map +1 -0
- package/dist/index.d.ts +76 -12
- package/dist/index.js +307 -21
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +7 -2
- package/dist/middleware.js +3 -2
- package/dist/nonce-store.d.ts +47 -11
- package/dist/nonce-store.js +9 -3
- package/dist/observer-types-A0RtA8uL.d.ts +95 -0
- package/dist/observer.d.ts +79 -0
- package/dist/observer.js +11 -0
- package/dist/observer.js.map +1 -0
- package/dist/{types-CX2V06cN.d.ts → types-BHISsm7D.d.ts} +423 -166
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/src/a2a/agent-card.ts +4 -3
- package/src/a2a/execution-fence.ts +162 -0
- package/src/a2a/handler.ts +507 -562
- package/src/a2a/message-send-execution.ts +241 -0
- package/src/a2a/message-stream-execution.ts +392 -0
- package/src/a2a/payment-recovery.ts +431 -0
- package/src/a2a/push-config-methods.ts +158 -0
- package/src/a2a/push-notifications.ts +172 -22
- package/src/a2a/task-cancellation.ts +50 -0
- package/src/a2a/task-finalization.ts +451 -0
- package/src/a2a/task-lifecycle.ts +54 -0
- package/src/a2a/task-methods.ts +163 -0
- package/src/a2a/task-push-delivery.ts +119 -0
- package/src/a2a/task-recovery.ts +11 -0
- package/src/a2a/task-state.ts +99 -0
- package/src/a2a/task-store-sql.ts +222 -24
- package/src/a2a/task-store.ts +58 -1
- package/src/a2a/task-submission-recovery.ts +178 -0
- package/src/a2a/types.ts +1 -0
- package/src/dispatch-authorization.ts +437 -0
- package/src/dispatch-payment-recovery.ts +248 -0
- package/src/dispatch-payment.ts +425 -0
- package/src/dispatch-pricing.ts +108 -0
- package/src/dispatch-sandbox.ts +422 -0
- package/src/dispatch-settlement.ts +139 -0
- package/src/dispatch-types.ts +81 -0
- package/src/dispatch.ts +35 -462
- package/src/index.ts +64 -2
- package/src/middleware.ts +313 -32
- package/src/mpp-payment.ts +117 -0
- package/src/nonce-store.ts +122 -20
- package/src/observer-types.ts +63 -0
- package/src/observer.ts +3 -63
- package/src/payment-operations.ts +485 -0
- package/src/payment-recovery-sql.ts +108 -0
- package/src/payment-recovery-worker.ts +488 -0
- package/src/payment-recovery.ts +331 -0
- package/src/payment-types.ts +48 -0
- package/src/types.ts +153 -42
- package/src/verify.ts +265 -36
- package/dist/chunk-3IKQWFKX.js +0 -1703
- package/dist/chunk-3IKQWFKX.js.map +0 -1
- package/dist/chunk-M7ZJAK4K.js +0 -53
- package/dist/chunk-M7ZJAK4K.js.map +0 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Task } from './types'
|
|
2
|
+
import {
|
|
3
|
+
compareAndSetTask,
|
|
4
|
+
cryptoRandomId,
|
|
5
|
+
type TaskStateStore,
|
|
6
|
+
withStatus,
|
|
7
|
+
} from './task-state'
|
|
8
|
+
|
|
9
|
+
const TASK_ORIGIN_METADATA_KEY = 'gatewayOrigin'
|
|
10
|
+
const TASK_SUBMISSION_METADATA_KEY = 'gatewaySubmission'
|
|
11
|
+
const TASK_SUBMISSION_RECOVERY_METADATA_KEY = 'gatewaySubmissionRecovery'
|
|
12
|
+
const TASK_SUBMISSION_LEASE_MS = 5 * 60 * 1000
|
|
13
|
+
|
|
14
|
+
export interface TaskOriginAgent {
|
|
15
|
+
id: string
|
|
16
|
+
slug: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TaskSubmissionIdentity {
|
|
20
|
+
agent: TaskOriginAgent
|
|
21
|
+
requestId: string
|
|
22
|
+
consumerId: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TaskOriginBinding {
|
|
26
|
+
version: 1
|
|
27
|
+
agentId: string
|
|
28
|
+
agentSlug: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TaskSubmissionRecord {
|
|
32
|
+
version: 1
|
|
33
|
+
lease: { id: string; expiresAt: number }
|
|
34
|
+
agentId: string
|
|
35
|
+
agentSlug: string
|
|
36
|
+
requestId: string
|
|
37
|
+
consumerId: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SubmissionRecoveryDependencies {
|
|
41
|
+
taskStore: TaskStateStore
|
|
42
|
+
deliverPush: (task: Task) => Promise<void>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function withTaskOrigin(
|
|
46
|
+
metadata: Record<string, unknown> | undefined,
|
|
47
|
+
agent: TaskOriginAgent,
|
|
48
|
+
): Record<string, unknown> {
|
|
49
|
+
return {
|
|
50
|
+
...(metadata ?? {}),
|
|
51
|
+
[TASK_ORIGIN_METADATA_KEY]: {
|
|
52
|
+
version: 1,
|
|
53
|
+
agentId: agent.id,
|
|
54
|
+
agentSlug: agent.slug,
|
|
55
|
+
} satisfies TaskOriginBinding,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function withTaskSubmission(
|
|
60
|
+
metadata: Record<string, unknown> | undefined,
|
|
61
|
+
identity: TaskSubmissionIdentity,
|
|
62
|
+
): Record<string, unknown> {
|
|
63
|
+
const origin = metadata?.[TASK_ORIGIN_METADATA_KEY]
|
|
64
|
+
return {
|
|
65
|
+
...(metadata ?? {}),
|
|
66
|
+
...(origin === undefined
|
|
67
|
+
? {
|
|
68
|
+
[TASK_ORIGIN_METADATA_KEY]: {
|
|
69
|
+
version: 1,
|
|
70
|
+
agentId: identity.agent.id,
|
|
71
|
+
agentSlug: identity.agent.slug,
|
|
72
|
+
} satisfies TaskOriginBinding,
|
|
73
|
+
}
|
|
74
|
+
: {}),
|
|
75
|
+
[TASK_SUBMISSION_METADATA_KEY]: {
|
|
76
|
+
version: 1,
|
|
77
|
+
lease: { id: cryptoRandomId(), expiresAt: Date.now() + TASK_SUBMISSION_LEASE_MS },
|
|
78
|
+
agentId: identity.agent.id,
|
|
79
|
+
agentSlug: identity.agent.slug,
|
|
80
|
+
requestId: identity.requestId,
|
|
81
|
+
consumerId: identity.consumerId,
|
|
82
|
+
} satisfies TaskSubmissionRecord,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readTaskOrigin(task: Task): TaskOriginBinding | undefined {
|
|
87
|
+
const raw = task.metadata?.[TASK_ORIGIN_METADATA_KEY]
|
|
88
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
89
|
+
const origin = raw as Partial<TaskOriginBinding>
|
|
90
|
+
if (
|
|
91
|
+
origin.version !== 1 ||
|
|
92
|
+
typeof origin.agentId !== 'string' ||
|
|
93
|
+
origin.agentId.length === 0 ||
|
|
94
|
+
typeof origin.agentSlug !== 'string' ||
|
|
95
|
+
origin.agentSlug.length === 0
|
|
96
|
+
) {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
return origin as TaskOriginBinding
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readTaskSubmission(task: Task): TaskSubmissionRecord | undefined {
|
|
103
|
+
const raw = task.metadata?.[TASK_SUBMISSION_METADATA_KEY]
|
|
104
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
105
|
+
const submission = raw as Partial<TaskSubmissionRecord>
|
|
106
|
+
if (
|
|
107
|
+
submission.version !== 1 ||
|
|
108
|
+
!submission.lease ||
|
|
109
|
+
typeof submission.lease.id !== 'string' ||
|
|
110
|
+
submission.lease.id.length === 0 ||
|
|
111
|
+
typeof submission.lease.expiresAt !== 'number' ||
|
|
112
|
+
!Number.isFinite(submission.lease.expiresAt) ||
|
|
113
|
+
typeof submission.agentId !== 'string' ||
|
|
114
|
+
submission.agentId.length === 0 ||
|
|
115
|
+
typeof submission.agentSlug !== 'string' ||
|
|
116
|
+
submission.agentSlug.length === 0 ||
|
|
117
|
+
typeof submission.requestId !== 'string' ||
|
|
118
|
+
submission.requestId.length === 0 ||
|
|
119
|
+
typeof submission.consumerId !== 'string'
|
|
120
|
+
) {
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
return submission as TaskSubmissionRecord
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function clearTaskSubmission(task: Task): Task {
|
|
127
|
+
if (!task.metadata || !(TASK_SUBMISSION_METADATA_KEY in task.metadata)) return task
|
|
128
|
+
const metadata = { ...task.metadata }
|
|
129
|
+
delete metadata[TASK_SUBMISSION_METADATA_KEY]
|
|
130
|
+
if (Object.keys(metadata).length > 0) return { ...task, metadata }
|
|
131
|
+
const { metadata: _metadata, ...withoutMetadata } = task
|
|
132
|
+
return withoutMetadata
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function recoverSubmissionIfNeeded(
|
|
136
|
+
task: Task,
|
|
137
|
+
deps: SubmissionRecoveryDependencies,
|
|
138
|
+
): Promise<Task> {
|
|
139
|
+
const raw = task.metadata?.[TASK_SUBMISSION_METADATA_KEY]
|
|
140
|
+
if (raw === undefined) return task
|
|
141
|
+
const submission = readTaskSubmission(task)
|
|
142
|
+
if (submission && submission.lease.expiresAt > Date.now()) return task
|
|
143
|
+
if (task.status.state !== 'submitted') {
|
|
144
|
+
return (await clearTaskSubmissionMarker(deps.taskStore, task)).task
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cleanTask = clearTaskSubmission(task)
|
|
148
|
+
const failed: Task = {
|
|
149
|
+
...withStatus(cleanTask, 'failed'),
|
|
150
|
+
metadata: {
|
|
151
|
+
...(cleanTask.metadata ?? {}),
|
|
152
|
+
[TASK_SUBMISSION_RECOVERY_METADATA_KEY]: {
|
|
153
|
+
error: submission
|
|
154
|
+
? 'A2A task submission lease expired before payment authorization completed'
|
|
155
|
+
: 'A2A task submission lease is invalid',
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
if (await compareAndSetTask(deps.taskStore, task, failed)) {
|
|
160
|
+
await deps.deliverPush(failed)
|
|
161
|
+
return failed
|
|
162
|
+
}
|
|
163
|
+
return await deps.taskStore.get(task.id) ?? task
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function clearTaskSubmissionMarker(
|
|
167
|
+
taskStore: TaskStateStore,
|
|
168
|
+
expected: Task,
|
|
169
|
+
): Promise<{ task: Task; applied: boolean }> {
|
|
170
|
+
const current = await taskStore.get(expected.id)
|
|
171
|
+
if (!current || JSON.stringify(current) !== JSON.stringify(expected)) {
|
|
172
|
+
return { task: current ?? expected, applied: false }
|
|
173
|
+
}
|
|
174
|
+
const cleared = clearTaskSubmission(current)
|
|
175
|
+
if (cleared === current) return { task: current, applied: true }
|
|
176
|
+
if (await compareAndSetTask(taskStore, current, cleared)) return { task: cleared, applied: true }
|
|
177
|
+
return { task: await taskStore.get(expected.id) ?? expected, applied: false }
|
|
178
|
+
}
|
package/src/a2a/types.ts
CHANGED
|
@@ -51,6 +51,7 @@ export const A2A_ERROR_CODES = {
|
|
|
51
51
|
CONTENT_TYPE_NOT_SUPPORTED: -32005,
|
|
52
52
|
INVALID_AGENT_RESPONSE: -32006,
|
|
53
53
|
AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED: -32007,
|
|
54
|
+
TASK_ACCESS_DENIED: -32008,
|
|
54
55
|
} as const
|
|
55
56
|
|
|
56
57
|
// ── Message parts ────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
import { filterConsumerMessagesStrict } from './filter'
|
|
4
|
+
import { type RequestContext, generateRequestId } from './observer'
|
|
5
|
+
import { type GatewayState, type AuthorizedRequest } from './dispatch-types'
|
|
6
|
+
import { checkRateLimit } from './rate-limit'
|
|
7
|
+
import {
|
|
8
|
+
maximumBillableInputTokens,
|
|
9
|
+
requiredX402Amount,
|
|
10
|
+
} from './dispatch-pricing'
|
|
11
|
+
import type {
|
|
12
|
+
ApiKeyInfo,
|
|
13
|
+
ChatMessage,
|
|
14
|
+
GatewayConfig,
|
|
15
|
+
PaymentMethod,
|
|
16
|
+
SandboxExecutionBudget,
|
|
17
|
+
} from './types'
|
|
18
|
+
import {
|
|
19
|
+
defaultVerifyApiKey,
|
|
20
|
+
isApiKeyAuthEnabled,
|
|
21
|
+
isMppAuthEnabled,
|
|
22
|
+
mppPaymentPayload,
|
|
23
|
+
mppPaymentCredential,
|
|
24
|
+
verifyMppCredential,
|
|
25
|
+
verifyX402,
|
|
26
|
+
} from './verify'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the agent, then run the full pre-dispatch pipeline: payment +
|
|
30
|
+
* rate-limit + injection filter + user-message extraction + optional
|
|
31
|
+
* `authorizeConsumer` hook. Returns the success record on the happy path
|
|
32
|
+
* or a fully-formed `Response` (402/404/429/400/403) on any short-circuit.
|
|
33
|
+
*
|
|
34
|
+
* Body parsing is the caller's responsibility — different wire formats
|
|
35
|
+
* (OpenAI chat completions vs A2A JSON-RPC) have different envelopes; both
|
|
36
|
+
* still ultimately produce a `ChatMessage[]`.
|
|
37
|
+
*/
|
|
38
|
+
export async function authenticateAndGuard(
|
|
39
|
+
c: Context,
|
|
40
|
+
slug: string,
|
|
41
|
+
messages: ChatMessage[],
|
|
42
|
+
config: GatewayConfig,
|
|
43
|
+
state: GatewayState,
|
|
44
|
+
requestedMaxOutputTokens?: number,
|
|
45
|
+
): Promise<AuthorizedRequest | Response> {
|
|
46
|
+
const startMs = Date.now()
|
|
47
|
+
const requestId = generateRequestId()
|
|
48
|
+
const ctx: RequestContext = { requestId, agentSlug: slug, startMs }
|
|
49
|
+
await state.obs?.onRequestStart?.(ctx)
|
|
50
|
+
|
|
51
|
+
const agent = await config.resolveAgent(slug)
|
|
52
|
+
if (!agent || !agent.enabled) {
|
|
53
|
+
return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)
|
|
54
|
+
}
|
|
55
|
+
if (!messages?.length) {
|
|
56
|
+
return c.json(
|
|
57
|
+
{ error: { message: 'messages array required', type: 'invalid_request' } },
|
|
58
|
+
400,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const maxOutputTokens = requestedMaxOutputTokens ?? state.defaultOutputTokens
|
|
63
|
+
if (
|
|
64
|
+
!Number.isInteger(maxOutputTokens) ||
|
|
65
|
+
maxOutputTokens <= 0 ||
|
|
66
|
+
maxOutputTokens > state.maxOutputTokens
|
|
67
|
+
) {
|
|
68
|
+
return c.json(
|
|
69
|
+
{
|
|
70
|
+
error: {
|
|
71
|
+
message: `max_tokens must be an integer between 1 and ${state.maxOutputTokens}`,
|
|
72
|
+
type: 'invalid_request',
|
|
73
|
+
code: 'invalid_max_tokens',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
400,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Quote the maximum UTF-8 input plus every hidden provider cost before
|
|
81
|
+
// verification. The verifier must remain read-only at this point.
|
|
82
|
+
const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(
|
|
83
|
+
messages,
|
|
84
|
+
state.maxLen,
|
|
85
|
+
)
|
|
86
|
+
const userMessage = filtered
|
|
87
|
+
.filter((m) => m.role === 'user')
|
|
88
|
+
.map((m) => m.content)
|
|
89
|
+
.join('\n\n')
|
|
90
|
+
if (!userMessage) {
|
|
91
|
+
return c.json(
|
|
92
|
+
{ error: { message: 'No user message provided', type: 'invalid_request' } },
|
|
93
|
+
400,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let requiredPaymentAmount: bigint
|
|
98
|
+
const messageInputBound = maximumBillableInputTokens(agent, filtered)
|
|
99
|
+
let maxInputTokens = messageInputBound
|
|
100
|
+
if (config.inputTokenBound) {
|
|
101
|
+
let configuredBound: number
|
|
102
|
+
try {
|
|
103
|
+
configuredBound = config.inputTokenBound({ agent, messages: filtered })
|
|
104
|
+
} catch {
|
|
105
|
+
return c.json(
|
|
106
|
+
{
|
|
107
|
+
error: {
|
|
108
|
+
message: 'Agent input token bound is unavailable',
|
|
109
|
+
type: 'server_error',
|
|
110
|
+
code: 'input_token_bound_unavailable',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
503,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
if (!Number.isSafeInteger(configuredBound) || configuredBound < messageInputBound) {
|
|
117
|
+
return c.json(
|
|
118
|
+
{
|
|
119
|
+
error: {
|
|
120
|
+
message: 'Agent input token bound is invalid',
|
|
121
|
+
type: 'server_error',
|
|
122
|
+
code: 'invalid_input_token_bound',
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
503,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
maxInputTokens = configuredBound
|
|
129
|
+
}
|
|
130
|
+
const maxReasoningTokens = state.maxReasoningTokens
|
|
131
|
+
const maxToolTokens = state.maxToolTokens
|
|
132
|
+
const maxToolCalls = state.maxToolCalls
|
|
133
|
+
const maxProviderCostUsd = state.maxProviderCostUsd ??
|
|
134
|
+
(maxInputTokens + maxOutputTokens + maxReasoningTokens + maxToolTokens) * agent.pricePerTokenUsd
|
|
135
|
+
const executionBudget: SandboxExecutionBudget = {
|
|
136
|
+
maxInputTokens,
|
|
137
|
+
maxOutputTokens,
|
|
138
|
+
maxReasoningTokens,
|
|
139
|
+
maxToolTokens,
|
|
140
|
+
maxToolCalls,
|
|
141
|
+
maxProviderCostUsd,
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
requiredPaymentAmount = requiredX402Amount(
|
|
145
|
+
agent.pricePerTokenUsd,
|
|
146
|
+
maxInputTokens,
|
|
147
|
+
maxOutputTokens,
|
|
148
|
+
config.x402.currencyDecimals,
|
|
149
|
+
maxReasoningTokens,
|
|
150
|
+
maxToolTokens,
|
|
151
|
+
maxProviderCostUsd,
|
|
152
|
+
)
|
|
153
|
+
} catch {
|
|
154
|
+
return c.json(
|
|
155
|
+
{
|
|
156
|
+
error: {
|
|
157
|
+
message: 'Agent payment configuration is invalid',
|
|
158
|
+
type: 'server_error',
|
|
159
|
+
code: 'invalid_payment_configuration',
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
503,
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Payment / auth.
|
|
167
|
+
const spendAuthHeader = c.req.header('X-Payment-Signature')
|
|
168
|
+
const authHeader = c.req.header('Authorization') ?? ''
|
|
169
|
+
let consumerId: string | null = null
|
|
170
|
+
let paymentMethod: PaymentMethod = 'none'
|
|
171
|
+
let keyInfo: ApiKeyInfo | null = null
|
|
172
|
+
let x402Payload: Record<string, unknown> | null = null
|
|
173
|
+
let paymentNonceKey: string | undefined
|
|
174
|
+
let mppMethod: string | undefined
|
|
175
|
+
let mppCredential: string | undefined
|
|
176
|
+
let mppPaymentIdentity: string | undefined
|
|
177
|
+
|
|
178
|
+
if (spendAuthHeader) {
|
|
179
|
+
const signer = await verifyX402(
|
|
180
|
+
spendAuthHeader,
|
|
181
|
+
config.x402,
|
|
182
|
+
state.nonceStore,
|
|
183
|
+
requiredPaymentAmount,
|
|
184
|
+
false,
|
|
185
|
+
)
|
|
186
|
+
if (!signer) {
|
|
187
|
+
await state.obs?.onAuthFailure?.(ctx, {
|
|
188
|
+
method: 'x402',
|
|
189
|
+
code: 'invalid_spend_auth',
|
|
190
|
+
httpStatus: 402,
|
|
191
|
+
})
|
|
192
|
+
return c.json(
|
|
193
|
+
{
|
|
194
|
+
error: {
|
|
195
|
+
message: 'Invalid X-Payment-Signature',
|
|
196
|
+
type: 'authentication_error',
|
|
197
|
+
code: 'invalid_spend_auth',
|
|
198
|
+
required_amount: requiredPaymentAmount.toString(),
|
|
199
|
+
currency_decimals: config.x402.currencyDecimals ?? 6,
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
status: 402,
|
|
204
|
+
headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': requestId },
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
x402Payload = JSON.parse(spendAuthHeader) as Record<string, unknown>
|
|
209
|
+
paymentNonceKey = `${String(x402Payload.commitment).toLowerCase()}:${BigInt(String(x402Payload.nonce)).toString()}`
|
|
210
|
+
consumerId = signer
|
|
211
|
+
paymentMethod = 'x402'
|
|
212
|
+
} else if (isMppAuthEnabled(config) && authHeader.toLowerCase().startsWith('payment ')) {
|
|
213
|
+
const authenticated = await verifyMppCredential(
|
|
214
|
+
authHeader,
|
|
215
|
+
config.mpp!,
|
|
216
|
+
config.x402,
|
|
217
|
+
state.nonceStore,
|
|
218
|
+
requiredPaymentAmount,
|
|
219
|
+
false,
|
|
220
|
+
)
|
|
221
|
+
if (!authenticated) {
|
|
222
|
+
const realm = config.mpp!.realm
|
|
223
|
+
const method = config.mpp!.method ?? 'blueprintevm'
|
|
224
|
+
await state.obs?.onAuthFailure?.(ctx, {
|
|
225
|
+
method: 'mpp',
|
|
226
|
+
code: 'invalid_mpp_credential',
|
|
227
|
+
httpStatus: 401,
|
|
228
|
+
})
|
|
229
|
+
return c.json(
|
|
230
|
+
{
|
|
231
|
+
error: {
|
|
232
|
+
message: 'Invalid Payment credential',
|
|
233
|
+
type: 'authentication_error',
|
|
234
|
+
code: 'invalid_mpp_credential',
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
status: 401,
|
|
239
|
+
headers: {
|
|
240
|
+
'WWW-Authenticate': `Payment realm="${realm}", method="${method}"`,
|
|
241
|
+
'X-Request-Id': requestId,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
consumerId = authenticated.consumerId
|
|
247
|
+
paymentMethod = 'mpp'
|
|
248
|
+
mppMethod = authHeader.match(/^Payment\s+(\S+)\s+/i)?.[1]?.toLowerCase()
|
|
249
|
+
mppCredential = mppPaymentCredential(authHeader)
|
|
250
|
+
mppPaymentIdentity = authenticated.paymentIdentity
|
|
251
|
+
x402Payload = mppPaymentPayload(authHeader) ?? null
|
|
252
|
+
paymentNonceKey = authenticated.replayKey
|
|
253
|
+
} else if (authHeader.startsWith('Bearer ')) {
|
|
254
|
+
const verify = config.verifyApiKey ?? (config.x402.demoMode ? defaultVerifyApiKey : null)
|
|
255
|
+
if (!verify || !isApiKeyAuthEnabled(config)) {
|
|
256
|
+
await state.obs?.onAuthFailure?.(ctx, {
|
|
257
|
+
method: 'apikey',
|
|
258
|
+
code: 'api_keys_not_configured',
|
|
259
|
+
httpStatus: 401,
|
|
260
|
+
})
|
|
261
|
+
return c.json(
|
|
262
|
+
{ error: { message: 'API key authentication is not configured', type: 'authentication_error' } },
|
|
263
|
+
{ status: 401, headers: { 'X-Request-Id': requestId } },
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
const key = await verify(authHeader)
|
|
267
|
+
if (!key) {
|
|
268
|
+
await state.obs?.onAuthFailure?.(ctx, {
|
|
269
|
+
method: 'apikey',
|
|
270
|
+
code: 'invalid_api_key',
|
|
271
|
+
httpStatus: 401,
|
|
272
|
+
})
|
|
273
|
+
return c.json(
|
|
274
|
+
{ error: { message: 'Invalid API key', type: 'authentication_error' } },
|
|
275
|
+
{ status: 401, headers: { 'X-Request-Id': requestId } },
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(state.requiredScope)) {
|
|
279
|
+
await state.obs?.onAuthFailure?.(ctx, {
|
|
280
|
+
method: 'apikey',
|
|
281
|
+
code: 'insufficient_scope',
|
|
282
|
+
httpStatus: 403,
|
|
283
|
+
})
|
|
284
|
+
return c.json(
|
|
285
|
+
{
|
|
286
|
+
error: {
|
|
287
|
+
message: `API key missing required scope: ${state.requiredScope}`,
|
|
288
|
+
type: 'forbidden',
|
|
289
|
+
code: 'insufficient_scope',
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{ status: 403, headers: { 'X-Request-Id': requestId } },
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
consumerId = key.consumerId
|
|
296
|
+
paymentMethod = 'apikey'
|
|
297
|
+
keyInfo = key
|
|
298
|
+
} else {
|
|
299
|
+
await state.obs?.onAuthFailure?.(ctx, {
|
|
300
|
+
method: 'none',
|
|
301
|
+
code: 'payment_required',
|
|
302
|
+
httpStatus: 402,
|
|
303
|
+
})
|
|
304
|
+
const methods: string[] = ['x402']
|
|
305
|
+
if (isMppAuthEnabled(config)) methods.push('mpp')
|
|
306
|
+
if (isApiKeyAuthEnabled(config)) methods.push('api_key')
|
|
307
|
+
const headers: Record<string, string> = {
|
|
308
|
+
'X-Payment-Required': methods.join(', '),
|
|
309
|
+
'X-Request-Id': requestId,
|
|
310
|
+
}
|
|
311
|
+
if (isMppAuthEnabled(config) && config.mpp) {
|
|
312
|
+
headers['WWW-Authenticate'] =
|
|
313
|
+
`Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
|
|
314
|
+
}
|
|
315
|
+
return c.json(
|
|
316
|
+
{
|
|
317
|
+
error: {
|
|
318
|
+
message: 'Payment required',
|
|
319
|
+
type: 'payment_required',
|
|
320
|
+
payment_methods: methods,
|
|
321
|
+
x402: {
|
|
322
|
+
operator: config.x402.operatorAddress,
|
|
323
|
+
chain_id: config.x402.chainId,
|
|
324
|
+
credits_address: config.x402.creditsAddress,
|
|
325
|
+
required_amount: requiredPaymentAmount.toString(),
|
|
326
|
+
currency_decimals: config.x402.currencyDecimals ?? 6,
|
|
327
|
+
max_output_tokens: maxOutputTokens,
|
|
328
|
+
},
|
|
329
|
+
...(isMppAuthEnabled(config) && config.mpp
|
|
330
|
+
? { mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' } }
|
|
331
|
+
: {}),
|
|
332
|
+
...(isApiKeyAuthEnabled(config)
|
|
333
|
+
? {
|
|
334
|
+
api_key: {
|
|
335
|
+
purchase_url: config.baseUrl
|
|
336
|
+
? `${config.baseUrl}/agents/${slug}/api-keys`
|
|
337
|
+
: undefined,
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
: {}),
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
{ status: 402, headers },
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Rate limit.
|
|
348
|
+
const effectiveRateLimit = keyInfo?.rateLimitPerMinute
|
|
349
|
+
? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }
|
|
350
|
+
: state.globalRateLimit
|
|
351
|
+
const rl = await checkRateLimit(consumerId, effectiveRateLimit, state.rateLimitStore)
|
|
352
|
+
if (!rl.allowed) {
|
|
353
|
+
await state.obs?.onRateLimited?.(ctx, {
|
|
354
|
+
consumerId: consumerId,
|
|
355
|
+
retryAfterSeconds: rl.retryAfterSeconds ?? 60,
|
|
356
|
+
})
|
|
357
|
+
return c.json(
|
|
358
|
+
{
|
|
359
|
+
error: {
|
|
360
|
+
message: 'Rate limit exceeded',
|
|
361
|
+
type: 'rate_limit_error',
|
|
362
|
+
retry_after: rl.retryAfterSeconds,
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
status: 429,
|
|
367
|
+
headers: {
|
|
368
|
+
'Retry-After': String(rl.retryAfterSeconds ?? 60),
|
|
369
|
+
'X-Request-Id': requestId,
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Reject or report injection only after authentication so observer events
|
|
376
|
+
// retain the authenticated consumer identity.
|
|
377
|
+
if (injectionWarnings.length > 0) {
|
|
378
|
+
await state.obs?.onInjectionDetected?.(ctx, {
|
|
379
|
+
consumerId: consumerId,
|
|
380
|
+
patterns: injectionWarnings,
|
|
381
|
+
blocked: !!config.blockInjection,
|
|
382
|
+
})
|
|
383
|
+
if (config.blockInjection) {
|
|
384
|
+
return c.json(
|
|
385
|
+
{
|
|
386
|
+
error: {
|
|
387
|
+
message: 'Request rejected: potential prompt injection detected',
|
|
388
|
+
type: 'content_policy_violation',
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
{ status: 400, headers: { 'X-Request-Id': requestId } },
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (config.authorizeConsumer) {
|
|
397
|
+
const authz = await config.authorizeConsumer(agent, {
|
|
398
|
+
method: paymentMethod,
|
|
399
|
+
consumerId: consumerId,
|
|
400
|
+
keyId: keyInfo?.keyId,
|
|
401
|
+
requestId,
|
|
402
|
+
})
|
|
403
|
+
if (!authz.allow) {
|
|
404
|
+
return c.json(
|
|
405
|
+
{
|
|
406
|
+
error: {
|
|
407
|
+
message: authz.reason,
|
|
408
|
+
type: 'authorization_denied',
|
|
409
|
+
code: authz.code,
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
{ status: 403, headers: { 'X-Request-Id': requestId } },
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
agent,
|
|
419
|
+
consumerId,
|
|
420
|
+
paymentMethod,
|
|
421
|
+
keyInfo,
|
|
422
|
+
userMessage,
|
|
423
|
+
rateLimitRemaining: rl.remaining,
|
|
424
|
+
requestId,
|
|
425
|
+
startMs,
|
|
426
|
+
maxOutputTokens,
|
|
427
|
+
executionBudget,
|
|
428
|
+
requiredPaymentAmount,
|
|
429
|
+
paymentPayload: x402Payload,
|
|
430
|
+
paymentNonceKey,
|
|
431
|
+
mppMethod,
|
|
432
|
+
mppCredential,
|
|
433
|
+
mppPaymentIdentity,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export type { AuthorizedRequest, GatewayState }
|