tapimo 0.4.4 → 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 (162) hide show
  1. package/dist/App.d.ts +7403 -6127
  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 +115 -115
  10. package/dist/apps/data/App.d.ts +1838 -1534
  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/{FundingRouteProviders.d.ts → FundingQuoteProviders.d.ts} +7 -7
  15. package/dist/apps/data/{FundingRouteProviders.d.ts.map → FundingQuoteProviders.d.ts.map} +1 -1
  16. package/dist/apps/data/{FundingRouteProviders.js → FundingQuoteProviders.js} +36 -91
  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/funding.d.ts +207 -388
  35. package/dist/apps/data/routes/funding.d.ts.map +1 -1
  36. package/dist/apps/data/routes/funding.js +143 -221
  37. package/dist/apps/data/routes/funding.js.map +1 -1
  38. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  39. package/dist/apps/data/routes/rpc.js +86 -16
  40. package/dist/apps/data/routes/rpc.js.map +1 -1
  41. package/dist/apps/data/routes/tokenlist.d.ts +33 -33
  42. package/dist/apps/data/routes/transactions.d.ts +281 -281
  43. package/dist/apps/data/routes/transfers.d.ts +58 -58
  44. package/dist/apps/data/routes/verified-tokens.d.ts +86 -86
  45. package/dist/apps/data/routes/webhooks.d.ts +340 -0
  46. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  47. package/dist/apps/data/routes/webhooks.js +13 -1
  48. package/dist/apps/data/routes/webhooks.js.map +1 -1
  49. package/dist/apps/index.d.ts +1 -1
  50. package/dist/apps/index.js +1 -1
  51. package/dist/apps/management/App.d.ts +1090 -108
  52. package/dist/apps/management/App.d.ts.map +1 -1
  53. package/dist/apps/management/App.js +4 -0
  54. package/dist/apps/management/App.js.map +1 -1
  55. package/dist/apps/management/routes/api-keys.d.ts +118 -118
  56. package/dist/apps/management/routes/api-keys.js +2 -2
  57. package/dist/apps/management/routes/api-keys.js.map +1 -1
  58. package/dist/apps/management/routes/billing.d.ts +207 -207
  59. package/dist/apps/management/routes/invite-links.d.ts +1100 -0
  60. package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
  61. package/dist/apps/management/routes/invite-links.js +411 -0
  62. package/dist/apps/management/routes/invite-links.js.map +1 -0
  63. package/dist/apps/management/routes/me.d.ts +17 -17
  64. package/dist/apps/management/routes/members.d.ts +71 -71
  65. package/dist/apps/management/routes/orgs.d.ts +12 -12
  66. package/dist/apps/management/routes/projects.d.ts +70 -70
  67. package/dist/apps/management/routes/scopes.d.ts +11 -11
  68. package/dist/apps/management/routes/scopes.d.ts.map +1 -1
  69. package/dist/apps/management/routes/scopes.js +2 -2
  70. package/dist/apps/management/routes/scopes.js.map +1 -1
  71. package/dist/apps/management/routes/usage.d.ts +75 -75
  72. package/dist/apps/relay/App.d.ts.map +1 -1
  73. package/dist/apps/relay/App.js +42 -4
  74. package/dist/apps/relay/App.js.map +1 -1
  75. package/dist/db/Db.d.ts +3 -0
  76. package/dist/db/Db.d.ts.map +1 -1
  77. package/dist/db/Db.js +57 -0
  78. package/dist/db/Db.js.map +1 -1
  79. package/dist/db/Schema.d.ts +31 -0
  80. package/dist/db/Schema.d.ts.map +1 -1
  81. package/dist/db/Schema.js +31 -0
  82. package/dist/db/Schema.js.map +1 -1
  83. package/dist/db/tables/inviteLinks.d.ts +42 -0
  84. package/dist/db/tables/inviteLinks.d.ts.map +1 -0
  85. package/dist/db/tables/inviteLinks.js +148 -0
  86. package/dist/db/tables/inviteLinks.js.map +1 -0
  87. package/dist/db/tables/organizations.d.ts.map +1 -1
  88. package/dist/db/tables/organizations.js +2 -0
  89. package/dist/db/tables/organizations.js.map +1 -1
  90. package/dist/handlers/relay.d.ts.map +1 -1
  91. package/dist/handlers/relay.js +13 -3
  92. package/dist/handlers/relay.js.map +1 -1
  93. package/dist/internal/Auth.d.ts +9 -1
  94. package/dist/internal/Auth.d.ts.map +1 -1
  95. package/dist/internal/Auth.js +130 -10
  96. package/dist/internal/Auth.js.map +1 -1
  97. package/dist/internal/EdgeCache.d.ts +3 -1
  98. package/dist/internal/EdgeCache.d.ts.map +1 -1
  99. package/dist/internal/EdgeCache.js +17 -2
  100. package/dist/internal/EdgeCache.js.map +1 -1
  101. package/dist/internal/{FundingRoutes.d.ts → FundingQuotes.d.ts} +185 -428
  102. package/dist/internal/FundingQuotes.d.ts.map +1 -0
  103. package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +93 -218
  104. package/dist/internal/FundingQuotes.js.map +1 -0
  105. package/dist/internal/Viem.d.ts +3 -1
  106. package/dist/internal/Viem.d.ts.map +1 -1
  107. package/dist/internal/Viem.js +11 -2
  108. package/dist/internal/Viem.js.map +1 -1
  109. package/dist/internal/index.d.ts +1 -1
  110. package/dist/internal/index.js +1 -1
  111. package/package.json +1 -1
  112. package/src/ApiKeys.test.ts +23 -0
  113. package/src/App.test-d.ts +2 -0
  114. package/src/App.ts +6 -0
  115. package/src/Client.test-d.ts +20 -11
  116. package/src/Scope.test.ts +1 -0
  117. package/src/Scope.ts +30 -4
  118. package/src/apps/data/App.test.ts +354 -0
  119. package/src/apps/data/App.ts +159 -52
  120. package/src/apps/data/FundingQuoteProviders.test.ts +151 -0
  121. package/src/apps/data/{FundingRouteProviders.ts → FundingQuoteProviders.ts} +40 -101
  122. package/src/apps/data/index.ts +1 -1
  123. package/src/apps/data/routes/balances.test.ts +64 -18
  124. package/src/apps/data/routes/balances.ts +76 -177
  125. package/src/apps/data/routes/blocks.ts +3 -5
  126. package/src/apps/data/routes/coingecko.ts +3 -8
  127. package/src/apps/data/routes/funding.test.ts +151 -206
  128. package/src/apps/data/routes/funding.ts +156 -291
  129. package/src/apps/data/routes/indexer.test.ts +97 -1
  130. package/src/apps/data/routes/rpc.test.ts +219 -0
  131. package/src/apps/data/routes/rpc.ts +92 -17
  132. package/src/apps/data/routes/webhooks.test.ts +21 -0
  133. package/src/apps/data/routes/webhooks.ts +13 -1
  134. package/src/apps/index.ts +1 -1
  135. package/src/apps/management/App.ts +4 -0
  136. package/src/apps/management/routes/api-keys.test.ts +47 -3
  137. package/src/apps/management/routes/api-keys.ts +2 -2
  138. package/src/apps/management/routes/invite-links.test.ts +385 -0
  139. package/src/apps/management/routes/invite-links.ts +570 -0
  140. package/src/apps/management/routes/scopes.test.ts +3 -2
  141. package/src/apps/management/routes/scopes.ts +4 -2
  142. package/src/apps/relay/App.test.ts +324 -4
  143. package/src/apps/relay/App.ts +40 -4
  144. package/src/db/Db.ts +65 -0
  145. package/src/db/Schema.ts +39 -0
  146. package/src/db/tables/inviteLinks.ts +187 -0
  147. package/src/db/tables/organizations.ts +2 -0
  148. package/src/handlers/relay.ts +16 -4
  149. package/src/internal/Auth.test.ts +132 -22
  150. package/src/internal/Auth.ts +171 -14
  151. package/src/internal/EdgeCache.test.ts +24 -3
  152. package/src/internal/EdgeCache.ts +18 -2
  153. package/src/internal/FundingQuotes.test.ts +174 -0
  154. package/src/internal/{FundingRoutes.ts → FundingQuotes.ts} +153 -312
  155. package/src/internal/Viem.ts +14 -5
  156. package/src/internal/index.test.ts +1 -1
  157. package/src/internal/index.ts +1 -1
  158. package/dist/apps/data/FundingRouteProviders.js.map +0 -1
  159. package/dist/internal/FundingRoutes.d.ts.map +0 -1
  160. package/dist/internal/FundingRoutes.js.map +0 -1
  161. package/src/apps/data/FundingRouteProviders.test.ts +0 -225
  162. package/src/internal/FundingRoutes.test.ts +0 -213
@@ -0,0 +1,187 @@
1
+ import type { Selectable } from 'kysely'
2
+
3
+ import * as Id from '../../internal/Id.js'
4
+ import type * as Db from '../Db.js'
5
+ import type * as db_Schema from '../Schema.js'
6
+
7
+ /** Columns of the `invite_links` table. */
8
+ export type Table = db_Schema.InviteLink
9
+ /** Columns of the `invite_link_redemptions` table. */
10
+ export type RedemptionTable = db_Schema.InviteLinkRedemption
11
+ /** A stored invite link. */
12
+ export type Record = Selectable<Table>
13
+ /** A stored redemption. */
14
+ export type Redemption = Selectable<RedemptionTable>
15
+ /** Derived link availability. */
16
+ export type Status = 'active' | 'deleted' | 'disabled' | 'exhausted' | 'expired'
17
+
18
+ /** Derives current availability from persisted controls and count. */
19
+ export function status(record: Record, now = new Date().toISOString()): Status {
20
+ if (record.deletedAt) return 'deleted'
21
+ if (!record.enabled) return 'disabled'
22
+ if (record.expiresAt && record.expiresAt <= now) return 'expired'
23
+ if (record.maxUses !== null && record.useCount >= record.maxUses) return 'exhausted'
24
+ return 'active'
25
+ }
26
+
27
+ /** Creates a reusable invite link. */
28
+ export function create(
29
+ db: Db.Db,
30
+ input: {
31
+ createdBy: string
32
+ expiresAt: string | null
33
+ maxUses: number | null
34
+ name?: string | undefined
35
+ orgId: string
36
+ role: 'member'
37
+ },
38
+ ): Promise<Record> {
39
+ const now = new Date().toISOString()
40
+ return db.kysely
41
+ .insertInto('invite_links')
42
+ .values({
43
+ createdAt: now,
44
+ createdBy: input.createdBy,
45
+ deletedAt: null,
46
+ enabled: true,
47
+ expiresAt: input.expiresAt,
48
+ id: Id.generate('iln'),
49
+ lastUsedAt: null,
50
+ maxUses: input.maxUses,
51
+ name: input.name ?? 'Untitled link',
52
+ orgId: input.orgId,
53
+ role: input.role,
54
+ token: Id.generate('lnk'),
55
+ updatedAt: now,
56
+ useCount: 0,
57
+ })
58
+ .returningAll()
59
+ .executeTakeFirstOrThrow()
60
+ }
61
+
62
+ /** Lists non-deleted links in an organization. */
63
+ export function list(db: Db.Db, orgId: string): Promise<Record[]> {
64
+ return db.kysely
65
+ .selectFrom('invite_links')
66
+ .selectAll()
67
+ .where('orgId', '=', orgId)
68
+ .where('deletedAt', 'is', null)
69
+ .orderBy('createdAt', 'desc')
70
+ .execute()
71
+ }
72
+
73
+ /** Gets an invite link by its management id, including deleted links. */
74
+ export function get(db: Db.Db, id: string): Promise<Record | undefined> {
75
+ return db.kysely.selectFrom('invite_links').selectAll().where('id', '=', id).executeTakeFirst()
76
+ }
77
+
78
+ /** Gets an invite link by its bearer token, including deleted links. */
79
+ export function getByToken(db: Db.Db, token: string): Promise<Record | undefined> {
80
+ return db.kysely
81
+ .selectFrom('invite_links')
82
+ .selectAll()
83
+ .where('token', '=', token)
84
+ .executeTakeFirst()
85
+ }
86
+
87
+ /** Updates an org-scoped non-deleted link. */
88
+ export function update(
89
+ db: Db.Db,
90
+ orgId: string,
91
+ id: string,
92
+ enabled: boolean,
93
+ ): Promise<Record | undefined> {
94
+ return db.kysely
95
+ .updateTable('invite_links')
96
+ .set({ enabled, updatedAt: new Date().toISOString() })
97
+ .where('id', '=', id)
98
+ .where('orgId', '=', orgId)
99
+ .where('deletedAt', 'is', null)
100
+ .returningAll()
101
+ .executeTakeFirst()
102
+ }
103
+
104
+ /** Soft deletes an org-scoped link. */
105
+ export function remove(db: Db.Db, orgId: string, id: string): Promise<Record | undefined> {
106
+ const now = new Date().toISOString()
107
+ return db.kysely
108
+ .updateTable('invite_links')
109
+ .set({ deletedAt: now, updatedAt: now })
110
+ .where('id', '=', id)
111
+ .where('orgId', '=', orgId)
112
+ .where('deletedAt', 'is', null)
113
+ .returningAll()
114
+ .executeTakeFirst()
115
+ }
116
+
117
+ /** Lists redemption audit records, newest first. */
118
+ export function listRedemptions(
119
+ db: Db.Db,
120
+ orgId: string,
121
+ inviteLinkId: string,
122
+ ): Promise<Redemption[]> {
123
+ return db.kysely
124
+ .selectFrom('invite_link_redemptions')
125
+ .selectAll()
126
+ .where('orgId', '=', orgId)
127
+ .where('inviteLinkId', '=', inviteLinkId)
128
+ .orderBy('createdAt', 'desc')
129
+ .execute()
130
+ }
131
+
132
+ /** Atomically joins through an active link, consuming only a newly inserted membership. */
133
+ export function accept(
134
+ db: Db.Db,
135
+ token: string,
136
+ userId: string,
137
+ email: string,
138
+ ): Promise<{ link: Record; consumed: boolean } | undefined> {
139
+ return db.kysely.transaction().execute(async (trx) => {
140
+ const now = new Date().toISOString()
141
+ const link = await trx
142
+ .selectFrom('invite_links')
143
+ .selectAll()
144
+ .where('token', '=', token)
145
+ .forUpdate()
146
+ .executeTakeFirst()
147
+ if (!link) return undefined
148
+ // Existing membership is already the desired end state, so retries remain
149
+ // successful after the link becomes unavailable and never consume capacity.
150
+ const existing = await trx
151
+ .selectFrom('memberships')
152
+ .select('userId')
153
+ .where('orgId', '=', link.orgId)
154
+ .where('userId', '=', userId)
155
+ .executeTakeFirst()
156
+ if (existing) return { consumed: false, link }
157
+ if (status(link, now) !== 'active') return undefined
158
+ const inserted = await trx
159
+ .insertInto('memberships')
160
+ .values({ createdAt: now, orgId: link.orgId, role: 'member', updatedAt: now, userId })
161
+ .onConflict((oc) => oc.columns(['orgId', 'userId']).doNothing())
162
+ .returning('userId')
163
+ .executeTakeFirst()
164
+ if (!inserted) return { consumed: false, link }
165
+ await trx
166
+ .updateTable('invite_links')
167
+ .set({ lastUsedAt: now, updatedAt: now, useCount: link.useCount + 1 })
168
+ .where('id', '=', link.id)
169
+ .execute()
170
+ await trx
171
+ .insertInto('invite_link_redemptions')
172
+ .values({
173
+ createdAt: now,
174
+ email: email.toLowerCase(),
175
+ id: Id.generate('ilr'),
176
+ inviteLinkId: link.id,
177
+ inviteLinkName: link.name,
178
+ orgId: link.orgId,
179
+ userId,
180
+ })
181
+ .execute()
182
+ return {
183
+ consumed: true,
184
+ link: { ...link, lastUsedAt: now, updatedAt: now, useCount: link.useCount + 1 },
185
+ }
186
+ })
187
+ }
@@ -112,6 +112,8 @@ export function deleteOrganization(db: Db.Db, id: string): Promise<boolean> {
112
112
  .execute()
113
113
  await trx.deleteFrom('billing_settings').where('orgId', '=', id).execute()
114
114
  await trx.deleteFrom('invitations').where('orgId', '=', id).execute()
115
+ await trx.deleteFrom('invite_link_redemptions').where('orgId', '=', id).execute()
116
+ await trx.deleteFrom('invite_links').where('orgId', '=', id).execute()
115
117
  await trx.deleteFrom('memberships').where('orgId', '=', id).execute()
116
118
  await trx.deleteFrom('projects').where('orgId', '=', id).execute()
117
119
  await trx.deleteFrom('request_usage_buckets').where('orgId', '=', id).execute()
@@ -116,15 +116,27 @@ export function relay(options: relay.Options = {}): Handler {
116
116
  ) {
117
117
  await onRequest?.(request)
118
118
 
119
- // Resolve chainId from: 1) explicit option (URL path), 2) first param object, 3) default chain.
120
119
  const params = 'params' in request && Array.isArray(request.params) ? request.params : []
121
120
  const first =
122
121
  typeof params[0] === 'object' && params[0]
123
122
  ? (params[0] as Record<string, unknown>)
124
123
  : undefined
125
- const chainId =
126
- Utils.resolveChainId(first?.['chainId']) ?? options?.chainId ?? getClient().chain?.id
127
- const client = getClient(chainId)
124
+ const chainId_body = Utils.resolveChainId(first?.['chainId'])
125
+ const chainId_path = options?.chainId
126
+ if (chainId_body !== undefined && chainId_path !== undefined && chainId_body !== chainId_path)
127
+ return Utils.rpcErrorJson(
128
+ request,
129
+ new RpcResponse.InvalidParamsError({ message: 'Conflicting chain ids.' }),
130
+ )
131
+
132
+ let chainId: number | undefined
133
+ let client: Client
134
+ try {
135
+ chainId = chainId_path ?? chainId_body ?? getClient().chain?.id
136
+ client = getClient(chainId)
137
+ } catch (error) {
138
+ return Utils.rpcErrorJson(request, error)
139
+ }
128
140
 
129
141
  switch (request.method) {
130
142
  case 'eth_fillTransaction': {
@@ -1010,6 +1010,38 @@ describe('require', () => {
1010
1010
  })
1011
1011
  })
1012
1012
 
1013
+ test('session challenge uses the explicit request chain', async () => {
1014
+ const mpp = createMppServer({
1015
+ chainId: Viem.chainId.mainnet,
1016
+ getClient: ({ chainId = Viem.chainId.mainnet }) => Viem.getClient({ chainId }),
1017
+ })
1018
+ const app = createApp({
1019
+ auth: {
1020
+ apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) },
1021
+ mpp,
1022
+ rateLimit: overQuota(),
1023
+ },
1024
+ policy: {
1025
+ mpp: { session: { amount: '1' } },
1026
+ public: { rateLimit: { perMinute: 1 } },
1027
+ },
1028
+ })
1029
+
1030
+ const defaultResponse = await app.request('/protected')
1031
+ const testnetResponse = await app.request('/protected?chainId=testnet')
1032
+
1033
+ expect(Challenge.fromHeaders(defaultResponse.headers).request).toMatchObject({
1034
+ methodDetails: {
1035
+ chainId: Viem.chainId.mainnet,
1036
+ },
1037
+ })
1038
+ expect(Challenge.fromHeaders(testnetResponse.headers).request).toMatchObject({
1039
+ methodDetails: {
1040
+ chainId: Viem.chainId.testnet,
1041
+ },
1042
+ })
1043
+ })
1044
+
1013
1045
  test('route session overrides propagate to the challenge', async () => {
1014
1046
  const mpp = createMppServer()
1015
1047
  const app = createApp({
@@ -1657,29 +1689,74 @@ describe('require', () => {
1657
1689
  })
1658
1690
 
1659
1691
  describe('ensureProject', () => {
1660
- test('authorizes a flat project route against the API key organization', async () => {
1692
+ test('authorizes an organization-attributed API key', async () => {
1661
1693
  const db = TestApp.database()
1662
1694
  const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1663
1695
  const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1664
- const response = await createProjectGuardApp(db, apiKeyPrincipal(organization.id)).request(
1665
- `/v1/projects/${project.id}/resource`,
1666
- )
1696
+ const response = await createProjectGuardApp(
1697
+ db,
1698
+ apiKeyPrincipal({ orgId: organization.id }),
1699
+ ).request(`/v1/projects/${project.id}/resource`)
1700
+ const { orgId, projectId, ...body } = await response.json()
1667
1701
 
1668
1702
  expect(response.status).toMatchInlineSnapshot(`200`)
1669
- expect(await response.json()).toEqual({
1670
- membership: null,
1671
- orgId: organization.id,
1672
- projectId: project.id,
1673
- })
1703
+ expect(orgId).toBe(organization.id)
1704
+ expect(projectId).toBe(project.id)
1705
+ expect(body).toMatchInlineSnapshot(`
1706
+ {
1707
+ "membership": null,
1708
+ }
1709
+ `)
1710
+ })
1711
+
1712
+ test('authorizes a project-attributed API key for its project', async () => {
1713
+ const db = TestApp.database()
1714
+ const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1715
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1716
+ const response = await createProjectGuardApp(
1717
+ db,
1718
+ apiKeyPrincipal({ orgId: organization.id, projectId: project.id }),
1719
+ ).request(`/v1/projects/${project.id}/resource`)
1720
+ const { orgId, projectId, ...body } = await response.json()
1721
+
1722
+ expect(response.status).toMatchInlineSnapshot(`200`)
1723
+ expect(orgId).toBe(organization.id)
1724
+ expect(projectId).toBe(project.id)
1725
+ expect(body).toMatchInlineSnapshot(`
1726
+ {
1727
+ "membership": null,
1728
+ }
1729
+ `)
1730
+ })
1731
+
1732
+ test('hides another project from a project-attributed API key', async () => {
1733
+ const db = TestApp.database()
1734
+ const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1735
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1736
+ const response = await createProjectGuardApp(
1737
+ db,
1738
+ apiKeyPrincipal({ orgId: organization.id, projectId: 'prj_other' }),
1739
+ ).request(`/v1/projects/${project.id}/resource`)
1740
+
1741
+ expect(response.status).toMatchInlineSnapshot(`404`)
1742
+ expect(await response.json()).toMatchInlineSnapshot(`
1743
+ {
1744
+ "error": {
1745
+ "code": "project_not_found",
1746
+ "message": "Project not found",
1747
+ },
1748
+ }
1749
+ `)
1674
1750
  })
1675
1751
 
1676
1752
  test('hides a flat project from an API key in another organization', async () => {
1677
1753
  const db = TestApp.database()
1678
1754
  const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1679
1755
  const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1680
- const response = await createProjectGuardApp(db, apiKeyPrincipal('org_foreign')).request(
1681
- `/v1/projects/${project.id}/resource`,
1682
- )
1756
+ const response = await createProjectGuardApp(
1757
+ db,
1758
+ apiKeyPrincipal({ orgId: 'org_foreign' }),
1759
+ ).request(`/v1/projects/${project.id}/resource`)
1683
1760
 
1684
1761
  expect(response.status).toMatchInlineSnapshot(`404`)
1685
1762
  expect(await response.json()).toMatchInlineSnapshot(`
@@ -1703,14 +1780,16 @@ describe('ensureProject', () => {
1703
1780
  const response = await createProjectGuardApp(db, sessionPrincipal(user.id)).request(
1704
1781
  `/v1/projects/${project.id}/resource`,
1705
1782
  )
1706
- const body = (await response.json()) as Record<string, unknown>
1783
+ const { orgId, projectId, ...body } = await response.json()
1707
1784
 
1708
1785
  expect(response.status).toMatchInlineSnapshot(`200`)
1709
- expect(body).toEqual({
1710
- membership: 'member',
1711
- orgId: organization.id,
1712
- projectId: project.id,
1713
- })
1786
+ expect(orgId).toBe(organization.id)
1787
+ expect(projectId).toBe(project.id)
1788
+ expect(body).toMatchInlineSnapshot(`
1789
+ {
1790
+ "membership": "member",
1791
+ }
1792
+ `)
1714
1793
  })
1715
1794
 
1716
1795
  test('preserves nested organization role authorization', async () => {
@@ -1735,6 +1814,26 @@ describe('ensureProject', () => {
1735
1814
  }
1736
1815
  `)
1737
1816
  })
1817
+
1818
+ test('hides another nested project from a project-attributed API key', async () => {
1819
+ const db = TestApp.database()
1820
+ const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' })
1821
+ const project = await Projects.create(db, { name: 'Test project', orgId: organization.id })
1822
+ const response = await createNestedProjectGuardApp(
1823
+ db,
1824
+ apiKeyPrincipal({ orgId: organization.id, projectId: 'prj_other' }),
1825
+ ).request(`/v1/orgs/${organization.id}/projects/${project.id}/resource`)
1826
+
1827
+ expect(response.status).toMatchInlineSnapshot(`404`)
1828
+ expect(await response.json()).toMatchInlineSnapshot(`
1829
+ {
1830
+ "error": {
1831
+ "code": "project_not_found",
1832
+ "message": "Project not found",
1833
+ },
1834
+ }
1835
+ `)
1836
+ })
1738
1837
  })
1739
1838
 
1740
1839
  function createScopedApp(options: {
@@ -1800,13 +1899,21 @@ function projectGuardHandler(c: Context<GuardEnvironment>) {
1800
1899
  })
1801
1900
  }
1802
1901
 
1803
- function apiKeyPrincipal(orgId: string): Auth.Principal {
1804
- const apiKey = { ...key, orgId }
1902
+ function apiKeyPrincipal(options: {
1903
+ orgId: string
1904
+ projectId?: string | undefined
1905
+ }): Auth.Principal {
1906
+ const apiKey = {
1907
+ ...key,
1908
+ orgId: options.orgId,
1909
+ ...(options.projectId === undefined ? {} : { projectId: options.projectId }),
1910
+ }
1805
1911
  return {
1806
1912
  apiKey,
1807
1913
  environment: apiKey.environment,
1808
1914
  id: apiKey.id,
1809
- orgId,
1915
+ orgId: options.orgId,
1916
+ ...(options.projectId === undefined ? {} : { projectId: options.projectId }),
1810
1917
  type: 'api_key',
1811
1918
  }
1812
1919
  }
@@ -1869,7 +1976,10 @@ function createMppServer(options: createMppServer.Options = {}) {
1869
1976
  }
1870
1977
 
1871
1978
  declare namespace createMppServer {
1872
- type Options = Pick<NonNullable<Parameters<typeof tempo.session>[0]>, 'store'>
1979
+ type Options = Pick<
1980
+ NonNullable<Parameters<typeof tempo.session>[0]>,
1981
+ 'chainId' | 'getClient' | 'store'
1982
+ >
1873
1983
  }
1874
1984
 
1875
1985
  function createMppHandler(