tapimo 0.4.3 → 0.5.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.
Files changed (192) hide show
  1. package/dist/App.d.ts +7222 -6316
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js +4 -0
  4. package/dist/App.js.map +1 -1
  5. package/dist/Scope.d.ts +18 -1
  6. package/dist/Scope.d.ts.map +1 -1
  7. package/dist/Scope.js +24 -3
  8. package/dist/Scope.js.map +1 -1
  9. package/dist/admin/apps/verified-tokens.d.ts +116 -116
  10. package/dist/apps/data/App.d.ts +2541 -2607
  11. package/dist/apps/data/App.d.ts.map +1 -1
  12. package/dist/apps/data/App.js +135 -44
  13. package/dist/apps/data/App.js.map +1 -1
  14. package/dist/apps/data/FundingQuoteProviders.d.ts +36 -0
  15. package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -0
  16. package/dist/apps/data/FundingQuoteProviders.js +190 -0
  17. package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
  18. package/dist/apps/data/index.d.ts +1 -1
  19. package/dist/apps/data/index.js +1 -1
  20. package/dist/apps/data/routes/activities.d.ts +242 -242
  21. package/dist/apps/data/routes/addresses.d.ts +227 -227
  22. package/dist/apps/data/routes/balances.d.ts +12 -12
  23. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  24. package/dist/apps/data/routes/balances.js +67 -149
  25. package/dist/apps/data/routes/balances.js.map +1 -1
  26. package/dist/apps/data/routes/blocks.d.ts +125 -126
  27. package/dist/apps/data/routes/blocks.d.ts.map +1 -1
  28. package/dist/apps/data/routes/blocks.js +3 -5
  29. package/dist/apps/data/routes/blocks.js.map +1 -1
  30. package/dist/apps/data/routes/coingecko.d.ts +55 -54
  31. package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
  32. package/dist/apps/data/routes/coingecko.js +3 -8
  33. package/dist/apps/data/routes/coingecko.js.map +1 -1
  34. package/dist/apps/data/routes/exchanges.d.ts +455 -455
  35. package/dist/apps/data/routes/fee-amm.d.ts +92 -92
  36. package/dist/apps/data/routes/funding.d.ts +91 -864
  37. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  38. package/dist/apps/data/routes/funding.js +138 -450
  39. package/dist/apps/data/routes/funding.js.map +1 -1
  40. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  41. package/dist/apps/data/routes/rpc.js +86 -16
  42. package/dist/apps/data/routes/rpc.js.map +1 -1
  43. package/dist/apps/data/routes/tokenlist.d.ts +36 -36
  44. package/dist/apps/data/routes/webhooks.d.ts +340 -0
  45. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  46. package/dist/apps/data/routes/webhooks.js +13 -1
  47. package/dist/apps/data/routes/webhooks.js.map +1 -1
  48. package/dist/apps/index.d.ts +1 -1
  49. package/dist/apps/index.js +1 -1
  50. package/dist/apps/management/App.d.ts +1843 -861
  51. package/dist/apps/management/App.d.ts.map +1 -1
  52. package/dist/apps/management/App.js +4 -0
  53. package/dist/apps/management/App.js.map +1 -1
  54. package/dist/apps/management/routes/api-keys.d.ts +121 -121
  55. package/dist/apps/management/routes/api-keys.js +2 -2
  56. package/dist/apps/management/routes/api-keys.js.map +1 -1
  57. package/dist/apps/management/routes/billing.d.ts +2 -2
  58. package/dist/apps/management/routes/invite-links.d.ts +1100 -0
  59. package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
  60. package/dist/apps/management/routes/invite-links.js +411 -0
  61. package/dist/apps/management/routes/invite-links.js.map +1 -0
  62. package/dist/apps/management/routes/me.d.ts +13 -13
  63. package/dist/apps/management/routes/members.d.ts +77 -77
  64. package/dist/apps/management/routes/orgs.d.ts +96 -96
  65. package/dist/apps/management/routes/projects.d.ts +62 -62
  66. package/dist/apps/management/routes/scopes.d.ts +11 -11
  67. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  68. package/dist/apps/management/routes/scopes.js +2 -2
  69. package/dist/apps/management/routes/scopes.js.map +1 -1
  70. package/dist/apps/management/routes/usage.d.ts +79 -79
  71. package/dist/apps/mcp.d.ts.map +1 -1
  72. package/dist/apps/mcp.js +3 -1
  73. package/dist/apps/mcp.js.map +1 -1
  74. package/dist/apps/relay/App.d.ts.map +1 -1
  75. package/dist/apps/relay/App.js +42 -4
  76. package/dist/apps/relay/App.js.map +1 -1
  77. package/dist/cloudflare.d.ts +2 -0
  78. package/dist/cloudflare.d.ts.map +1 -1
  79. package/dist/cloudflare.js +4 -0
  80. package/dist/cloudflare.js.map +1 -1
  81. package/dist/db/Db.d.ts +3 -0
  82. package/dist/db/Db.d.ts.map +1 -1
  83. package/dist/db/Db.js +57 -0
  84. package/dist/db/Db.js.map +1 -1
  85. package/dist/db/Schema.d.ts +31 -0
  86. package/dist/db/Schema.d.ts.map +1 -1
  87. package/dist/db/Schema.js +31 -0
  88. package/dist/db/Schema.js.map +1 -1
  89. package/dist/db/tables/inviteLinks.d.ts +42 -0
  90. package/dist/db/tables/inviteLinks.d.ts.map +1 -0
  91. package/dist/db/tables/inviteLinks.js +148 -0
  92. package/dist/db/tables/inviteLinks.js.map +1 -0
  93. package/dist/db/tables/organizations.d.ts.map +1 -1
  94. package/dist/db/tables/organizations.js +2 -0
  95. package/dist/db/tables/organizations.js.map +1 -1
  96. package/dist/handlers/relay.d.ts.map +1 -1
  97. package/dist/handlers/relay.js +13 -3
  98. package/dist/handlers/relay.js.map +1 -1
  99. package/dist/internal/Auth.d.ts +42 -23
  100. package/dist/internal/Auth.d.ts.map +1 -1
  101. package/dist/internal/Auth.js +169 -20
  102. package/dist/internal/Auth.js.map +1 -1
  103. package/dist/internal/EdgeCache.d.ts +3 -1
  104. package/dist/internal/EdgeCache.d.ts.map +1 -1
  105. package/dist/internal/EdgeCache.js +17 -2
  106. package/dist/internal/EdgeCache.js.map +1 -1
  107. package/dist/internal/Errors.d.ts +6 -0
  108. package/dist/internal/Errors.d.ts.map +1 -0
  109. package/dist/internal/Errors.js +16 -0
  110. package/dist/internal/Errors.js.map +1 -0
  111. package/dist/internal/FundingQuotes.d.ts +644 -0
  112. package/dist/internal/FundingQuotes.d.ts.map +1 -0
  113. package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -289
  114. package/dist/internal/FundingQuotes.js.map +1 -0
  115. package/dist/internal/Store.d.ts +61 -16
  116. package/dist/internal/Store.d.ts.map +1 -1
  117. package/dist/internal/Store.js +72 -17
  118. package/dist/internal/Store.js.map +1 -1
  119. package/dist/internal/Viem.d.ts +2 -0
  120. package/dist/internal/Viem.d.ts.map +1 -1
  121. package/dist/internal/Viem.js +11 -2
  122. package/dist/internal/Viem.js.map +1 -1
  123. package/dist/internal/index.d.ts +27 -0
  124. package/dist/internal/index.d.ts.map +1 -0
  125. package/dist/internal/index.js +27 -0
  126. package/dist/internal/index.js.map +1 -0
  127. package/package.json +8 -3
  128. package/src/ApiKeys.test.ts +23 -0
  129. package/src/App.test-d.ts +2 -0
  130. package/src/App.test.ts +60 -4
  131. package/src/App.ts +6 -0
  132. package/src/Client.test-d.ts +16 -54
  133. package/src/Scope.test.ts +1 -0
  134. package/src/Scope.ts +30 -4
  135. package/src/apps/data/App.test.ts +354 -0
  136. package/src/apps/data/App.ts +159 -52
  137. package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
  138. package/src/apps/data/FundingQuoteProviders.ts +251 -0
  139. package/src/apps/data/index.ts +1 -1
  140. package/src/apps/data/routes/balances.test.ts +64 -18
  141. package/src/apps/data/routes/balances.ts +76 -177
  142. package/src/apps/data/routes/blocks.ts +3 -5
  143. package/src/apps/data/routes/coingecko.ts +3 -8
  144. package/src/apps/data/routes/funding.test.ts +149 -306
  145. package/src/apps/data/routes/funding.ts +154 -604
  146. package/src/apps/data/routes/indexer.test.ts +99 -3
  147. package/src/apps/data/routes/rpc.test.ts +219 -0
  148. package/src/apps/data/routes/rpc.ts +92 -17
  149. package/src/apps/data/routes/webhooks.test.ts +23 -2
  150. package/src/apps/data/routes/webhooks.ts +13 -1
  151. package/src/apps/index.ts +1 -1
  152. package/src/apps/management/App.ts +4 -0
  153. package/src/apps/management/routes/api-keys.test.ts +47 -3
  154. package/src/apps/management/routes/api-keys.ts +2 -2
  155. package/src/apps/management/routes/invite-links.test.ts +385 -0
  156. package/src/apps/management/routes/invite-links.ts +570 -0
  157. package/src/apps/management/routes/scopes.test.ts +3 -2
  158. package/src/apps/management/routes/scopes.ts +4 -2
  159. package/src/apps/mcp.test.ts +55 -14
  160. package/src/apps/mcp.ts +3 -1
  161. package/src/apps/relay/App.test.ts +324 -4
  162. package/src/apps/relay/App.ts +40 -4
  163. package/src/cloudflare.ts +5 -0
  164. package/src/db/Db.ts +65 -0
  165. package/src/db/Schema.ts +39 -0
  166. package/src/db/tables/inviteLinks.ts +187 -0
  167. package/src/db/tables/organizations.ts +2 -0
  168. package/src/handlers/relay.ts +16 -4
  169. package/src/internal/Auth.test.ts +528 -37
  170. package/src/internal/Auth.ts +246 -45
  171. package/src/internal/EdgeCache.test.ts +24 -3
  172. package/src/internal/EdgeCache.ts +18 -2
  173. package/src/internal/FundingQuotes.test.ts +174 -0
  174. package/src/internal/FundingQuotes.ts +735 -0
  175. package/src/internal/Log.test.ts +4 -4
  176. package/src/internal/Store.test-d.ts +15 -0
  177. package/src/internal/Store.test.ts +153 -2
  178. package/src/internal/Store.ts +157 -41
  179. package/src/internal/Viem.ts +14 -5
  180. package/src/internal/index.test.ts +34 -0
  181. package/src/internal/index.ts +26 -0
  182. package/dist/apps/data/FundingRouteProviders.d.ts +0 -143
  183. package/dist/apps/data/FundingRouteProviders.d.ts.map +0 -1
  184. package/dist/apps/data/FundingRouteProviders.js +0 -546
  185. package/dist/apps/data/FundingRouteProviders.js.map +0 -1
  186. package/dist/internal/FundingRoutes.d.ts +0 -1085
  187. package/dist/internal/FundingRoutes.d.ts.map +0 -1
  188. package/dist/internal/FundingRoutes.js.map +0 -1
  189. package/src/apps/data/FundingRouteProviders.test.ts +0 -485
  190. package/src/apps/data/FundingRouteProviders.ts +0 -744
  191. package/src/internal/FundingRoutes.test.ts +0 -239
  192. package/src/internal/FundingRoutes.ts +0 -1067
@@ -91,6 +91,46 @@ async function toolsCall(
91
91
  }
92
92
  }
93
93
 
94
+ /** Searches every page of the progressive tool catalog. */
95
+ async function toolsSearch(host: Host) {
96
+ const tools: { annotations?: { readOnlyHint?: boolean }; name: string }[] = []
97
+ let offset = 0
98
+ while (true) {
99
+ const result = resultJson(await toolsCall(host, 'search_tools', { limit: 20, offset })) as {
100
+ nextOffset?: number
101
+ tools: typeof tools
102
+ }
103
+ tools.push(...result.tools)
104
+ if (result.nextOffset === undefined) return tools
105
+ offset = result.nextOffset
106
+ }
107
+ }
108
+
109
+ /** Loads the complete schema and metadata for one catalog tool. */
110
+ async function toolDetails(host: Host, name: string) {
111
+ return resultJson(await toolsCall(host, 'get_tool_details', { name })) as {
112
+ annotations?: { readOnlyHint?: boolean }
113
+ inputSchema: { properties?: Record<string, { description?: string }> }
114
+ name: string
115
+ }
116
+ }
117
+
118
+ /** Inspects and executes a generated tool through its matching read or write gate. */
119
+ async function generatedToolCall(
120
+ host: Host,
121
+ name: string,
122
+ args: Record<string, unknown>,
123
+ headers: Record<string, string> = {},
124
+ ) {
125
+ const details = await toolDetails(host, name)
126
+ return toolsCall(
127
+ host,
128
+ details.annotations?.readOnlyHint === true ? 'call_read_tool' : 'call_write_tool',
129
+ { arguments: args, name },
130
+ headers,
131
+ )
132
+ }
133
+
94
134
  /** Parses a tool result: structured content when present, else the JSON text item. */
95
135
  function resultJson(result: Awaited<ReturnType<typeof toolsCall>>) {
96
136
  return result.structuredContent ?? JSON.parse(result.content[0]!.text)
@@ -108,7 +148,13 @@ test('scopes tools to the data domain', async () => {
108
148
  expect(Object.keys(spec.paths).some((path) => path.startsWith('/v1/orgs'))).toBe(true)
109
149
 
110
150
  await initialize(host)
111
- const tools = await toolsList(host)
151
+ expect(await toolsList(host)).toEqual([
152
+ 'search_tools',
153
+ 'get_tool_details',
154
+ 'call_read_tool',
155
+ 'call_write_tool',
156
+ ])
157
+ const tools = (await toolsSearch(host)).map((tool) => tool.name)
112
158
 
113
159
  expect(tools).toContain('v1_tokens_get')
114
160
  expect(tools).toContain('docs_search')
@@ -125,13 +171,8 @@ test('keeps tool input schemas compact', async () => {
125
171
  const { host } = createHost()
126
172
  await initialize(host)
127
173
 
128
- const body = await request(host, { id: 2, jsonrpc: '2.0', method: 'tools/list', params: {} })
129
- const tools = body.result.tools as {
130
- name: string
131
- inputSchema: { properties?: Record<string, { description?: string }> }
132
- }[]
133
- const tool = tools.find((entry) => entry.name === 'v1_tokens_get')
134
- const properties = tool?.inputSchema.properties ?? {}
174
+ const tool = await toolDetails(host, 'v1_tokens_get')
175
+ const properties = tool.inputSchema.properties ?? {}
135
176
 
136
177
  // Credential inputs stay off the schema; auth rides the forwarded header.
137
178
  expect(properties['authorization']).toBeUndefined()
@@ -142,7 +183,7 @@ test('keeps tool input schemas compact', async () => {
142
183
  'Keyset cursor from a previous response `nextCursor`; omit for the first page.',
143
184
  )
144
185
  // `compact` strips examples from generated schemas.
145
- expect(JSON.stringify(tool?.inputSchema)).not.toContain('"examples"')
186
+ expect(JSON.stringify(tool.inputSchema)).not.toContain('"examples"')
146
187
  })
147
188
 
148
189
  test('executes generated tools against the composed app', async () => {
@@ -152,7 +193,7 @@ test('executes generated tools against the composed app', async () => {
152
193
  const { host } = createHost({ app: TestApp.create({ db }) })
153
194
 
154
195
  await initialize(host)
155
- const result = await toolsCall(host, 'v1_tokens_get', { verified: true })
196
+ const result = await generatedToolCall(host, 'v1_tokens_get', { verified: true })
156
197
 
157
198
  expect(result.isError).toBeFalsy()
158
199
  const body = resultJson(result) as { data: { verified: boolean }[]; nextCursor: unknown }
@@ -164,10 +205,10 @@ test('forwards the caller Authorization header to authenticated tools', async ()
164
205
  const { host } = createHost({ app: TestApp.create({ auth: { keys: [reader] } }) })
165
206
  await initialize(host)
166
207
 
167
- const denied = await toolsCall(host, 'v1_webhooks_get', {})
208
+ const denied = await generatedToolCall(host, 'v1_webhooks_get', {})
168
209
  expect(denied.isError).toBe(true)
169
210
 
170
- const allowed = await toolsCall(
211
+ const allowed = await generatedToolCall(
171
212
  host,
172
213
  'v1_webhooks_get',
173
214
  {},
@@ -181,7 +222,7 @@ test('proxies docs tool calls to the remote MCP source', async () => {
181
222
  const { host } = createHost({ docs: docsStub() })
182
223
  await initialize(host)
183
224
 
184
- const result = await toolsCall(host, 'docs_search', { query: 'fees' })
225
+ const result = await generatedToolCall(host, 'docs_search', { query: 'fees' })
185
226
 
186
227
  expect(result.isError).toBeFalsy()
187
228
  expect(resultJson(result)).toMatchObject({ hits: ['fees'] })
@@ -210,7 +251,7 @@ test('documents POST /mcp and keeps it open under auth enforcement', async () =>
210
251
  // Documenting the route makes it auth-enforced; the public lane keeps it
211
252
  // anonymously reachable, and the endpoint never surfaces itself as a tool.
212
253
  await initialize(app)
213
- const tools = await toolsList(app)
254
+ const tools = (await toolsSearch(app)).map((tool) => tool.name)
214
255
  expect(tools.length).toBeGreaterThan(0)
215
256
  expect(tools.some((name) => name.startsWith('mcp'))).toBe(false)
216
257
 
package/src/apps/mcp.ts CHANGED
@@ -147,7 +147,9 @@ export function mcp(options: mcp.Options) {
147
147
  example: {
148
148
  jsonrpc: '2.0',
149
149
  id: 1,
150
- result: { tools: [{ name: 'v1_tokens_get', description: 'List tokens' }] },
150
+ result: {
151
+ tools: [{ name: 'search_tools', description: 'Search available tools' }],
152
+ },
151
153
  },
152
154
  schema: OpenApi.resolver(schema.Response),
153
155
  },
@@ -1,11 +1,12 @@
1
1
  import { nanoid } from 'nanoid'
2
- import { Hash, Secp256k1 } from 'ox'
2
+ import { Hash, Hex, Secp256k1 } from 'ox'
3
+ import { ZoneRpcAuthentication } from 'ox/tempo'
3
4
  import { createClient, http } from 'viem'
4
5
  import { prepareTransactionRequest, sendRawTransactionSync, signTransaction } from 'viem/actions'
5
6
  import { Account as TempoAccount, Actions, Transaction, withRelay } from 'viem/tempo'
6
7
 
7
8
  import * as TestApp from '../../../test/App.js'
8
- import { startTempo } from '../../../test/containers.js'
9
+ import * as Containers from '../../../test/containers.js'
9
10
  import * as Relay from '../../../test/Relay.js'
10
11
  import * as Runtime from '../../../test/runtime.js'
11
12
  import * as Tempo from '../../../test/Tempo.js'
@@ -25,6 +26,12 @@ const runtime = Runtime.get()
25
26
 
26
27
  const feePayerAccount = Tempo.accounts[10]!
27
28
  const senderAccount = Tempo.accounts[9]!
29
+ const zone = runtime.zone.chainId
30
+ const zoneScope = `zone:${zone}:read` as const
31
+ const zoneOptions = {
32
+ chainId: zone,
33
+ forwardRpcUrl: runtime.zone.forwardRpcUrl,
34
+ }
28
35
 
29
36
  /** Super admin secret configured on the end-to-end billing app. */
30
37
  const superAdminSecret = 'tempo:sk:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1'
@@ -71,6 +78,22 @@ const keys = [
71
78
  scopes: ['rpc-relay:read'],
72
79
  token: 'secret_unscoped',
73
80
  },
81
+ {
82
+ id: 'key_zone_relay',
83
+ orgId: 'org_1',
84
+ projectId: 'prj_1',
85
+ environment: 'sandbox',
86
+ scopes: ['rpc-relay:read', zoneScope],
87
+ token: 'secret_zone_relay',
88
+ },
89
+ {
90
+ id: 'key_zone_sponsor',
91
+ orgId: 'org_1',
92
+ projectId: 'prj_1',
93
+ environment: 'sandbox',
94
+ scopes: ['rpc-relay:sponsor', zoneScope],
95
+ token: 'secret_zone_sponsor',
96
+ },
74
97
  {
75
98
  id: 'key_unattributed',
76
99
  orgId: 'org_1',
@@ -80,6 +103,303 @@ const keys = [
80
103
  },
81
104
  ] as const satisfies readonly TestApp.kvStore.Key[]
82
105
 
106
+ describe('behavior: Zone chain routing', () => {
107
+ const zoneSelectors = [
108
+ ['relay', 'path'],
109
+ ['relay', 'body'],
110
+ ['sponsor', 'path'],
111
+ ['sponsor', 'body'],
112
+ ] as const
113
+ // Prool's default Zone dev key.
114
+ const privateKey = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'
115
+
116
+ function app() {
117
+ return TestApp.create({
118
+ auth: { keys },
119
+ zones: [zoneOptions],
120
+ })
121
+ }
122
+
123
+ function token() {
124
+ const issuedAt = Math.floor(Date.now() / 1000)
125
+ const authentication = ZoneRpcAuthentication.from({
126
+ chainId: zone,
127
+ expiresAt: issuedAt + 300,
128
+ issuedAt,
129
+ zoneId: 1,
130
+ })
131
+ return ZoneRpcAuthentication.serialize(authentication, {
132
+ signature: Secp256k1.sign({
133
+ payload: ZoneRpcAuthentication.getSignPayload(authentication),
134
+ privateKey,
135
+ }),
136
+ })
137
+ }
138
+
139
+ async function request(
140
+ app: ReturnType<typeof TestApp.create>,
141
+ options: {
142
+ apiKey?: string | undefined
143
+ basePath?: string | undefined
144
+ body: object | readonly object[]
145
+ mount: 'relay' | 'sponsor'
146
+ pathChainId?: number | undefined
147
+ zoneToken?: string | undefined
148
+ },
149
+ ) {
150
+ const apiKey =
151
+ options.apiKey ?? (options.mount === 'relay' ? 'secret_unscoped' : 'secret_sponsor')
152
+ const path = `${options.basePath ?? ''}/rpc/${options.mount}${options.pathChainId ? `/${options.pathChainId}` : ''}`
153
+ const response = await app.fetch(
154
+ new Request(`http://tempo-api.test${path}`, {
155
+ body: JSON.stringify(options.body),
156
+ headers: {
157
+ 'content-type': 'application/json',
158
+ 'tempo-api-key': apiKey,
159
+ ...(options.zoneToken ? { [ZoneRpcAuthentication.headerName]: options.zoneToken } : {}),
160
+ },
161
+ method: 'POST',
162
+ }),
163
+ )
164
+ return await response.json()
165
+ }
166
+
167
+ test.each(zoneSelectors)('rejects an unscoped Zone on %s via %s', async (mount, selector) => {
168
+ const body = await request(app(), {
169
+ body: {
170
+ id: 1,
171
+ jsonrpc: '2.0',
172
+ method: 'eth_chainId',
173
+ params: selector === 'body' ? [{ chainId: zone }] : [],
174
+ },
175
+ mount,
176
+ ...(selector === 'path' ? { pathChainId: zone } : {}),
177
+ })
178
+ expect(body).toMatchObject({
179
+ error: {
180
+ code: -32602,
181
+ data: { code: 'api_key_forbidden' },
182
+ message: expect.stringContaining(zoneScope),
183
+ },
184
+ })
185
+ })
186
+
187
+ test.each(zoneSelectors)('allows a Zone token on %s via %s', async (mount, selector) => {
188
+ const body = await request(app(), {
189
+ body: {
190
+ id: 1,
191
+ jsonrpc: '2.0',
192
+ method: 'eth_chainId',
193
+ params: selector === 'body' ? [{ chainId: zone }] : [],
194
+ },
195
+ mount,
196
+ ...(selector === 'path' ? { pathChainId: zone } : {}),
197
+ zoneToken: token(),
198
+ })
199
+
200
+ expect(body).toMatchObject({ result: `0x${zone.toString(16)}` })
201
+ })
202
+
203
+ test('allows a Zone token on the default chain', async () => {
204
+ const api = TestApp.create({
205
+ auth: { keys },
206
+ defaultChainId: zone,
207
+ zones: [zoneOptions],
208
+ })
209
+ const body = await request(api, {
210
+ body: { id: 1, jsonrpc: '2.0', method: 'eth_chainId' },
211
+ mount: 'relay',
212
+ zoneToken: token(),
213
+ })
214
+
215
+ expect(body).toMatchObject({ result: `0x${zone.toString(16)}` })
216
+ })
217
+
218
+ test('allows a Zone token in a batch', async () => {
219
+ const body = (await request(app(), {
220
+ body: [
221
+ { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [{ chainId: zone }] },
222
+ { id: 2, jsonrpc: '2.0', method: 'eth_chainId', params: [{ chainId: zone }] },
223
+ ],
224
+ mount: 'relay',
225
+ zoneToken: token(),
226
+ })) as { result?: string }[]
227
+
228
+ expect(body.map((entry) => entry.result)).toEqual([
229
+ `0x${zone.toString(16)}`,
230
+ `0x${zone.toString(16)}`,
231
+ ])
232
+ })
233
+
234
+ test.each([
235
+ ['relay', 'secret_zone_relay'],
236
+ ['sponsor', 'secret_zone_sponsor'],
237
+ ] as const)('allows a Zone-scoped key on %s', async (mount, apiKey) => {
238
+ const body = await request(app(), {
239
+ apiKey,
240
+ body: { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [{ chainId: zone }] },
241
+ mount,
242
+ })
243
+
244
+ expect(body).toMatchObject({ result: `0x${zone.toString(16)}` })
245
+ })
246
+
247
+ test('forwards RPC basic auth to a Zone internal RPC', async () => {
248
+ const authorization: (string | null)[] = []
249
+ const server = await Relay.createServer(
250
+ RequestListener.fromFetchHandler(async (request) => {
251
+ authorization.push(request.headers.get('authorization'))
252
+ return Response.json({ id: 1, jsonrpc: '2.0', result: Hex.fromNumber(zone) })
253
+ }),
254
+ )
255
+ try {
256
+ const api = TestApp.create({
257
+ auth: { keys },
258
+ rpc: ({ chainId }) =>
259
+ chainId === zone ? { basicAuth: 'rpc-secret', url: server.url } : {},
260
+ zones: [zoneOptions],
261
+ })
262
+ const body = await request(api, {
263
+ apiKey: 'secret_zone_relay',
264
+ body: { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [{ chainId: zone }] },
265
+ mount: 'relay',
266
+ })
267
+
268
+ expect(body).toMatchObject({ result: Hex.fromNumber(zone) })
269
+ expect(authorization).toEqual(['Basic cnBjLXNlY3JldA=='])
270
+ } finally {
271
+ await server.closeAsync()
272
+ }
273
+ })
274
+
275
+ test('rejects a blank Zone token', async () => {
276
+ const body = await request(app(), {
277
+ body: { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [{ chainId: zone }] },
278
+ mount: 'relay',
279
+ zoneToken: ' ',
280
+ })
281
+
282
+ expect(body).toMatchObject({ error: { data: { code: 'api_key_forbidden' } } })
283
+ })
284
+
285
+ test('does not forward a Zone token to a non-Zone RPC', async () => {
286
+ const tokens: (string | null)[] = []
287
+ const server = await Relay.createServer(
288
+ RequestListener.fromFetchHandler(async (request) => {
289
+ tokens.push(request.headers.get(ZoneRpcAuthentication.headerName))
290
+ return Response.json({ id: 1, jsonrpc: '2.0', result: Hex.fromNumber(runtime.chainId) })
291
+ }),
292
+ )
293
+ try {
294
+ const api = TestApp.create({
295
+ auth: { keys },
296
+ rpc: { url: () => server.url },
297
+ zones: [zoneOptions],
298
+ })
299
+ const body = await request(api, {
300
+ body: { id: 1, jsonrpc: '2.0', method: 'eth_chainId' },
301
+ mount: 'relay',
302
+ pathChainId: runtime.chainId,
303
+ zoneToken: token(),
304
+ })
305
+
306
+ expect(body).toMatchObject({ result: Hex.fromNumber(runtime.chainId) })
307
+ expect(tokens).toEqual([null])
308
+ } finally {
309
+ await server.closeAsync()
310
+ }
311
+ })
312
+
313
+ test('rejects conflicting path and body chain ids', async () => {
314
+ const body = await request(app(), {
315
+ body: {
316
+ id: 1,
317
+ jsonrpc: '2.0',
318
+ method: 'eth_chainId',
319
+ params: [{ chainId: zone }],
320
+ },
321
+ mount: 'relay',
322
+ pathChainId: runtime.chainId,
323
+ })
324
+ expect(body).toMatchObject({ error: { code: -32602, message: 'Conflicting chain ids.' } })
325
+ })
326
+
327
+ test('rejects every Zone request in a batch', async () => {
328
+ const body = (await request(app(), {
329
+ body: [
330
+ { id: 1, jsonrpc: '2.0', method: 'eth_chainId', params: [{ chainId: zone }] },
331
+ { id: 2, jsonrpc: '2.0', method: 'eth_chainId', params: [{ chainId: zone }] },
332
+ ],
333
+ mount: 'relay',
334
+ })) as { error?: { data?: { code?: string } } }[]
335
+
336
+ expect(body).toHaveLength(2)
337
+ expect(body.every((entry) => entry.error?.data?.code === 'api_key_forbidden')).toBe(true)
338
+ })
339
+
340
+ test('rejects a Zone configured as the default chain', async () => {
341
+ const api = TestApp.create({
342
+ auth: { keys },
343
+ defaultChainId: zone,
344
+ zones: [zoneOptions],
345
+ })
346
+ const body = await request(api, {
347
+ body: { id: 1, jsonrpc: '2.0', method: 'eth_chainId' },
348
+ mount: 'relay',
349
+ })
350
+
351
+ expect(body).toMatchObject({ error: { data: { code: 'api_key_forbidden' } } })
352
+ })
353
+
354
+ test('rejects Zone selectors under a base path', async () => {
355
+ const api = TestApp.create({
356
+ auth: { keys },
357
+ path: '/api',
358
+ zones: [zoneOptions],
359
+ })
360
+ const body = await request(api, {
361
+ basePath: '/api',
362
+ body: { id: 1, jsonrpc: '2.0', method: 'eth_chainId' },
363
+ mount: 'relay',
364
+ pathChainId: zone,
365
+ })
366
+
367
+ expect(body).toMatchObject({ error: { data: { code: 'api_key_forbidden' } } })
368
+ })
369
+
370
+ test('requires Zone authorization for an embedded signed transaction', async () => {
371
+ const api = TestApp.create({
372
+ auth: { keys },
373
+ relay: { feePayer: { account: feePayerAccount } },
374
+ zones: [zoneOptions],
375
+ })
376
+ const serialized = await senderAccount.signTransaction({
377
+ chainId: zone,
378
+ feePayer: true,
379
+ gas: 100_000n,
380
+ maxFeePerGas: 1_000_000n,
381
+ maxPriorityFeePerGas: 0n,
382
+ nonce: 0,
383
+ to: Tempo.accounts[8]!.address,
384
+ value: 0n,
385
+ })
386
+ const rpc = {
387
+ body: {
388
+ id: 1,
389
+ jsonrpc: '2.0',
390
+ method: 'eth_signRawTransaction',
391
+ params: [serialized],
392
+ },
393
+ mount: 'sponsor',
394
+ } as const
395
+ const refused = await request(api, rpc)
396
+ const authorized = await request(api, { ...rpc, zoneToken: token() })
397
+
398
+ expect(refused).toMatchObject({ error: { data: { code: 'api_key_forbidden' } } })
399
+ expect(authorized).toMatchObject({ result: expect.stringMatching(/^0x/) })
400
+ })
401
+ })
402
+
83
403
  describe.skipIf(runtime.mode !== 'localnet')('behavior: sponsorship', () => {
84
404
  test('behavior: scoped keys sponsor, recorded with attribution', async () => {
85
405
  const db = TestApp.database()
@@ -821,10 +1141,10 @@ describe.runIf(TestStripe.secretKey)(
821
1141
  () => {
822
1142
  // prettier-ignore
823
1143
  const webhookSecret = `whsec_${'c'.repeat(32)}`
824
- let node: Awaited<ReturnType<typeof startTempo>> | undefined
1144
+ let node: Awaited<ReturnType<typeof Containers.startTempo>> | undefined
825
1145
 
826
1146
  beforeAll(async () => {
827
- node = await startTempo({ chainId: Viem.chainId.mainnet })
1147
+ node = await Containers.startTempo({ chainId: Viem.chainId.mainnet })
828
1148
  }, 240_000)
829
1149
 
830
1150
  afterAll(async () => {
@@ -1,6 +1,7 @@
1
1
  import { Hono } from 'hono'
2
2
  import { createMiddleware } from 'hono/factory'
3
3
  import { RpcResponse } from 'ox'
4
+ import { ZoneRpcAuthentication } from 'ox/tempo'
4
5
  import { Addresses, TokenId, Transaction } from 'viem/tempo'
5
6
 
6
7
  import type * as App from '../../App.js'
@@ -79,11 +80,13 @@ function middleware(options: middleware.Options) {
79
80
  ? Billing.toBaseUnits(options.feePayer.txFeeLimit ?? '1.00')
80
81
  : 0n
81
82
  return createMiddleware<App.Environment>(async (c) => {
83
+ const principal = Auth.getPrincipal(c)
82
84
  const verifiedTokens = c.get('verifiedTokens')
85
+ const zoneToken_header = c.req.header(ZoneRpcAuthentication.headerName)
86
+ const zoneToken = zoneToken_header?.trim() ? zoneToken_header : undefined
83
87
 
84
88
  const feePayer = (() => {
85
89
  if (!options.feePayer) return undefined
86
- const principal = Auth.getPrincipal(c)
87
90
  const attribution =
88
91
  principal?.type === 'api_key' && principal.projectId !== undefined
89
92
  ? {
@@ -224,13 +227,46 @@ function middleware(options: middleware.Options) {
224
227
  } satisfies Handler.relay.Options['feePayer']
225
228
  })()
226
229
 
230
+ const authorizeChainId = (chainId: number | undefined) => {
231
+ const resolved = chainId ?? c.get('chainId')
232
+ if (!c.get('supportedChainIds').has(resolved))
233
+ throw new RpcResponse.InvalidParamsError({ message: 'Unsupported chain id.' })
234
+ if (c.get('zones').has(resolved)) {
235
+ const scopes = principal?.type === 'api_key' ? principal.apiKey.scopes : undefined
236
+ const allowed =
237
+ scopes?.includes(Scope.wildcard) ||
238
+ scopes?.includes(`zone:${resolved}:read`) ||
239
+ zoneToken !== undefined
240
+ if (allowed) return resolved
241
+ throw new RpcResponse.InvalidParamsError({
242
+ data: { code: 'api_key_forbidden' },
243
+ message: `Chain id ${resolved} is a zone. Use an API key granting \`zone:${resolved}:read\`, or pass a Zone token via \`X-Authorization-Token\`.`,
244
+ })
245
+ }
246
+ return resolved
247
+ }
248
+ const zoneClients = new Map<number, Viem.getClient.ReturnType>()
249
+ const getClient = (chainId: number | undefined) => {
250
+ const resolved = authorizeChainId(chainId) as Viem.ChainId
251
+ const zone = c.get('zones').get(resolved)
252
+ if (!zone || !zoneToken) return c.get('getClient')(resolved)
253
+ const existing = zoneClients.get(resolved)
254
+ if (existing) return existing
255
+ const client = Viem.getClient({
256
+ chainId: resolved,
257
+ principal,
258
+ rpc: { url: () => zone.forwardRpcUrl },
259
+ zoneToken,
260
+ })
261
+ zoneClients.set(resolved, client)
262
+ return client
263
+ }
227
264
  // Dispatching a standalone Hono via `fetch(c.req.raw)` bypasses Hono's
228
- // mount-path stripping, so its internal routes register at the request's
229
- // full external path (`basePath` + the RPC mount).
265
+ // mount-path stripping, so its routes use the full external path.
230
266
  const handler = Handler.relay({
231
267
  cache: c.get('store'),
232
268
  ...(options.features ? { features: options.features } : {}),
233
- getClient: (chainId) => c.get('getClient')(chainId as Viem.ChainId | undefined),
269
+ getClient,
234
270
  path: Path.join(c.get('basePath'), options.path),
235
271
  ...(feePayer ? { feePayer } : {}),
236
272
  ...(verifiedTokens
package/src/cloudflare.ts CHANGED
@@ -26,6 +26,11 @@ export class DurableObject extends CloudflareDurableObject {
26
26
  await this.#store.delete(key)
27
27
  }
28
28
 
29
+ /** Deletes a key only when its value matches `expected`. */
30
+ async deleteIf(key: string, expected: null | string) {
31
+ return this.#store.deleteIf!(key, expected)
32
+ }
33
+
29
34
  /** Gets a string value. Returns `null` for missing or expired keys. */
30
35
  async get(key: string) {
31
36
  return this.#store.get(key)
package/src/db/Db.ts CHANGED
@@ -13,6 +13,7 @@ import * as pg from 'pg'
13
13
  import type * as AdminAuditLogs from './tables/adminAuditLogs.js'
14
14
  import type * as BillingSettings from './tables/billingSettings.js'
15
15
  import type * as Invitations from './tables/invitations.js'
16
+ import type * as InviteLinks from './tables/inviteLinks.js'
16
17
  import type * as Memberships from './tables/memberships.js'
17
18
  import type * as Organizations from './tables/organizations.js'
18
19
  import type * as Projects from './tables/projects.js'
@@ -32,6 +33,8 @@ export type Database = {
32
33
  admin_audit_logs: AdminAuditLogs.Table
33
34
  billing_settings: BillingSettings.Table
34
35
  invitations: Invitations.Table
36
+ invite_link_redemptions: InviteLinks.RedemptionTable
37
+ invite_links: InviteLinks.Table
35
38
  memberships: Memberships.Table
36
39
  migrations: {
37
40
  appliedAt: string
@@ -576,6 +579,68 @@ export const migrations: readonly Migration[] = [
576
579
  .on('adminAuditLogs')
577
580
  .columns(['createdAt', 'id']),
578
581
  },
582
+ {
583
+ name: '0053_invite_links',
584
+ up: (db) =>
585
+ db.schema
586
+ .createTable('inviteLinks')
587
+ .ifNotExists()
588
+ .addColumn('id', 'text', (c) => c.primaryKey())
589
+ .addColumn('token', 'text', (c) => c.notNull().unique())
590
+ .addColumn('orgId', 'text', (c) => c.notNull())
591
+ .addColumn('name', 'text', (c) => c.notNull())
592
+ .addColumn('role', 'text', (c) => c.notNull())
593
+ .addColumn('maxUses', 'integer')
594
+ .addColumn('useCount', 'integer', (c) => c.notNull().defaultTo(0))
595
+ .addColumn('expiresAt', 'text')
596
+ .addColumn('enabled', 'boolean', (c) => c.notNull().defaultTo(true))
597
+ .addColumn('createdBy', 'text', (c) => c.notNull())
598
+ .addColumn('createdAt', 'text', (c) => c.notNull())
599
+ .addColumn('lastUsedAt', 'text')
600
+ .addColumn('updatedAt', 'text', (c) => c.notNull())
601
+ .addColumn('deletedAt', 'text'),
602
+ },
603
+ {
604
+ name: '0054_invite_links_org_index',
605
+ up: (db) =>
606
+ db.schema
607
+ .createIndex('invite_links_org_idx')
608
+ .ifNotExists()
609
+ .on('inviteLinks')
610
+ .columns(['orgId', 'createdAt']),
611
+ },
612
+ {
613
+ name: '0055_invite_link_redemptions',
614
+ up: (db) =>
615
+ db.schema
616
+ .createTable('inviteLinkRedemptions')
617
+ .ifNotExists()
618
+ .addColumn('id', 'text', (c) => c.primaryKey())
619
+ .addColumn('inviteLinkId', 'text', (c) => c.notNull())
620
+ .addColumn('inviteLinkName', 'text', (c) => c.notNull())
621
+ .addColumn('orgId', 'text', (c) => c.notNull())
622
+ .addColumn('userId', 'text', (c) => c.notNull())
623
+ .addColumn('email', 'text', (c) => c.notNull())
624
+ .addColumn('createdAt', 'text', (c) => c.notNull()),
625
+ },
626
+ {
627
+ name: '0056_invite_link_redemptions_org_index',
628
+ up: (db) =>
629
+ db.schema
630
+ .createIndex('invite_link_redemptions_org_idx')
631
+ .ifNotExists()
632
+ .on('inviteLinkRedemptions')
633
+ .columns(['orgId', 'createdAt']),
634
+ },
635
+ {
636
+ name: '0057_invite_link_redemptions_link_index',
637
+ up: (db) =>
638
+ db.schema
639
+ .createIndex('invite_link_redemptions_link_idx')
640
+ .ifNotExists()
641
+ .on('inviteLinkRedemptions')
642
+ .columns(['inviteLinkId', 'createdAt']),
643
+ },
579
644
  ]
580
645
 
581
646
  /**