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
@@ -111,7 +111,20 @@ describe('keys', () => {
111
111
  expect(write.status).toBe(200)
112
112
  })
113
113
 
114
- test('lets management keys manage org credentials without delegating issuance', async () => {
114
+ test('zone scopes are never self-serve', async () => {
115
+ const app = createApp()
116
+ const owner = await session(app)
117
+ const { org, project } = await fixture(app, owner)
118
+ const base = `/v1/orgs/${org.id}/projects/${project.id}/api-keys`
119
+
120
+ // Even the org owner cannot self-mint zone access: membership is
121
+ // controlled zone-side, so zone scopes are super-admin-issued.
122
+ const denied = await app.request(base, json('POST', owner, { scopes: ['zone:421700001:read'] }))
123
+ expect(denied.status).toBe(403)
124
+ expect(await denied.json()).toMatchObject({ error: { code: 'scope_not_issuable' } })
125
+ })
126
+
127
+ test('restricts project management keys without delegating issuance', async () => {
115
128
  const app = createApp()
116
129
  const cookie = await session(app)
117
130
  const { org, project } = await fixture(app, cookie)
@@ -135,10 +148,28 @@ describe('keys', () => {
135
148
  Keys.schema.createApiKey.Response,
136
149
  )
137
150
 
151
+ const own = await app.request(base, {
152
+ headers: { 'tempo-api-key': manager.token },
153
+ })
154
+ expect(own.status).toMatchInlineSnapshot(`200`)
155
+
138
156
  const listed = await app.request(siblingBase, {
139
157
  headers: { 'tempo-api-key': manager.token },
140
158
  })
141
- expect(listed.status).toBe(200)
159
+ const { requestId: listedRequestId, ...listedBody } = (await listed.json()) as {
160
+ requestId?: string
161
+ }
162
+
163
+ expect(listed.status).toMatchInlineSnapshot(`404`)
164
+ expect(listedRequestId).toBeDefined()
165
+ expect(listedBody).toMatchInlineSnapshot(`
166
+ {
167
+ "error": {
168
+ "code": "project_not_found",
169
+ "message": "Project not found",
170
+ },
171
+ }
172
+ `)
142
173
 
143
174
  const delegated = await app.request(base, {
144
175
  body: JSON.stringify({ scopes: ['management:read'] }),
@@ -151,7 +182,20 @@ describe('keys', () => {
151
182
  headers: { 'tempo-api-key': manager.token },
152
183
  method: 'DELETE',
153
184
  })
154
- expect(revoked.status).toBe(200)
185
+ const { requestId: revokedRequestId, ...revokedBody } = (await revoked.json()) as {
186
+ requestId?: string
187
+ }
188
+
189
+ expect(revoked.status).toMatchInlineSnapshot(`404`)
190
+ expect(revokedRequestId).toBeDefined()
191
+ expect(revokedBody).toMatchInlineSnapshot(`
192
+ {
193
+ "error": {
194
+ "code": "project_not_found",
195
+ "message": "Project not found",
196
+ },
197
+ }
198
+ `)
155
199
  })
156
200
 
157
201
  test('mints, lists, authenticates, and revokes a project key', async () => {
@@ -60,7 +60,7 @@ export namespace schema {
60
60
  .array(z.string().check(z.minLength(1)))
61
61
  .check(
62
62
  z.describe(
63
- "Granted scopes. '*' (grant-all) appears only on keys minted by the super admin.",
63
+ "Granted scopes. '*' and non-self-serve scopes appear only on keys minted by the super admin.",
64
64
  ),
65
65
  z.meta({ examples: [['data:read']] }),
66
66
  ),
@@ -218,7 +218,7 @@ export namespace schema {
218
218
  }
219
219
 
220
220
  /**
221
- * Mounts project-attributed API key mint, list, and revoke routes plus the org-wide list. Access follows the caller's organization ownership.
221
+ * Mounts project-attributed API key mint, list, and revoke routes plus the org-wide list. Project routes enforce caller organization and project attribution.
222
222
  */
223
223
  export function apiKeys(options: apiKeys.Options = {}) {
224
224
  const hidden = !options.enabled
@@ -0,0 +1,385 @@
1
+ import { testClient } from 'hono/testing'
2
+ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
3
+ import type * as z from 'zod/mini'
4
+
5
+ import * as TestApp from '../../../../test/App.js'
6
+ import type * as Db from '../../../db/Db.js'
7
+ import * as Memberships from '../../../db/tables/memberships.js'
8
+ import * as Users from '../../../db/tables/users.js'
9
+ import * as InviteLinks from './invite-links.js'
10
+
11
+ /** Origin pinned for SIWE domain binding; Hono test requests use this host. */
12
+ const origin = 'http://localhost'
13
+
14
+ /** RPC request options carrying a session cookie. */
15
+ function as(cookie: string) {
16
+ return { headers: { cookie } } as const
17
+ }
18
+
19
+ function createApp(db: Db.Source = TestApp.database()) {
20
+ const app = TestApp.create({ db, session: { wallet: { origin } } })
21
+ return { app, client: testClient(app) }
22
+ }
23
+
24
+ /** Signs in a fresh scripted account and resolves its user id. */
25
+ async function session({ app, client }: ReturnType<typeof createApp>) {
26
+ const { cookie } = await TestApp.signIn(app, privateKeyToAccount(generatePrivateKey()))
27
+ if (!cookie) throw new Error('Sign-in did not establish a session')
28
+ const me = await client.v1.me.$get(undefined, as(cookie))
29
+ expect(me.status).toBe(200)
30
+ if (me.status !== 200) throw new Error(`Failed to resolve session user: ${me.status}`)
31
+ const { id: userId } = await me.json()
32
+ return { cookie, userId }
33
+ }
34
+
35
+ /** Creates an owner and organization through their public write boundaries. */
36
+ async function setup(context: ReturnType<typeof createApp>) {
37
+ const owner = await session(context)
38
+ const response = await context.client.v1.orgs.$post({ json: { name: 'Acme' } }, as(owner.cookie))
39
+ expect(response.status).toBe(200)
40
+ if (response.status !== 200) throw new Error(`Failed to create organization: ${response.status}`)
41
+ const org = await response.json()
42
+ return { org, owner }
43
+ }
44
+
45
+ /** Creates an invite link through the management route. */
46
+ async function createLink(
47
+ { client }: ReturnType<typeof createApp>,
48
+ orgId: string,
49
+ cookie: string,
50
+ body: z.input<typeof InviteLinks.schema.CreateBody> = { name: 'Community' },
51
+ ) {
52
+ const response = await client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links'].$post(
53
+ { json: body, param: { orgId } },
54
+ as(cookie),
55
+ )
56
+ expect(response.status).toBe(200)
57
+ if (response.status !== 200) throw new Error(`Failed to create invite link: ${response.status}`)
58
+ return response.json()
59
+ }
60
+
61
+ const unavailable = {
62
+ error: { code: 'invite_link_not_found', message: 'Invite link not found' },
63
+ }
64
+
65
+ describe('inviteLinks', () => {
66
+ test('admins manage org-scoped links that outlive their creator', async () => {
67
+ const db = TestApp.database()
68
+ const context = createApp(db)
69
+ const { client } = context
70
+ const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links']
71
+ const { org, owner } = await setup(context)
72
+ const admin = await session(context)
73
+ const member = await session(context)
74
+ await Memberships.create(db, { orgId: org.id, role: 'admin', userId: admin.userId })
75
+ await Memberships.create(db, { orgId: org.id, role: 'member', userId: member.userId })
76
+
77
+ const forbidden = await orgLinks.$post(
78
+ { json: { name: 'No access' }, param: { orgId: org.id } },
79
+ as(member.cookie),
80
+ )
81
+ expect(forbidden.status).toBe(403)
82
+
83
+ const link = await createLink(context, org.id, admin.cookie, { role: 'member' })
84
+ expect(link).toMatchObject({
85
+ enabled: true,
86
+ lastUsedAt: null,
87
+ maxUses: null,
88
+ name: 'Untitled link',
89
+ role: 'member',
90
+ status: 'active',
91
+ useCount: 0,
92
+ })
93
+ expect(link.id).toMatch(/^iln_/)
94
+ expect(link.token).toMatch(/^lnk_/)
95
+ expect(Date.parse(link.expiresAt!) - Date.now()).toBeGreaterThan(6 * 86_400_000)
96
+
97
+ const updated = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$patch(
98
+ {
99
+ json: { enabled: false },
100
+ param: { inviteLinkId: link.id, orgId: org.id },
101
+ },
102
+ as(owner.cookie),
103
+ )
104
+ expect(updated.status).toBe(200)
105
+ if (updated.status !== 200) throw new Error(`Failed to update invite link: ${updated.status}`)
106
+ expect(await updated.json()).toMatchObject({
107
+ enabled: false,
108
+ expiresAt: link.expiresAt,
109
+ maxUses: null,
110
+ name: 'Untitled link',
111
+ status: 'disabled',
112
+ })
113
+ const enabled = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$patch(
114
+ { json: { enabled: true }, param: { inviteLinkId: link.id, orgId: org.id } },
115
+ as(owner.cookie),
116
+ )
117
+ expect(enabled.status).toBe(200)
118
+ if (enabled.status !== 200) throw new Error(`Failed to enable invite link: ${enabled.status}`)
119
+ expect((await enabled.json()).status).toBe('active')
120
+
121
+ expect(await Memberships.remove(db, org.id, admin.userId)).toBe('removed')
122
+ const resolved = await client.v1['invite-links'].resolve.$post({ json: { token: link.token } })
123
+ expect(resolved.status).toBe(200)
124
+
125
+ const foreign = await setup(context)
126
+ const crossOrg = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$patch(
127
+ {
128
+ json: { enabled: false },
129
+ param: { inviteLinkId: link.id, orgId: foreign.org.id },
130
+ },
131
+ as(foreign.owner.cookie),
132
+ )
133
+ expect(crossOrg.status).toBe(404)
134
+
135
+ const deleted = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$delete(
136
+ { param: { inviteLinkId: link.id, orgId: org.id } },
137
+ as(owner.cookie),
138
+ )
139
+ expect(deleted.status).toBe(200)
140
+ const listed = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie))
141
+ expect(listed.status).toBe(200)
142
+ if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`)
143
+ expect((await listed.json()).data).toEqual([])
144
+ })
145
+
146
+ test('resolve reveals only a summary and hides every unavailable reason', async () => {
147
+ const db = TestApp.database()
148
+ const context = createApp(db)
149
+ const { client } = context
150
+ const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links']
151
+ const publicLinks = client.v1['invite-links']
152
+ const { org, owner } = await setup(context)
153
+ const active = await createLink(context, org.id, owner.cookie, {
154
+ expiresAt: '2099-01-01T05:00:00+05:00',
155
+ name: 'Active',
156
+ })
157
+ expect(active.expiresAt).toBe('2099-01-01T00:00:00.000Z')
158
+ const resolved = await publicLinks.resolve.$post({ json: { token: active.token } })
159
+ expect(resolved.status).toBe(200)
160
+ if (resolved.status !== 200)
161
+ throw new Error(`Failed to resolve invite link: ${resolved.status}`)
162
+ expect(await resolved.json()).toEqual({
163
+ orgName: 'Acme',
164
+ role: 'member',
165
+ })
166
+ expect(resolved.headers.get('set-cookie')).toBeNull()
167
+
168
+ const disabled = await createLink(context, org.id, owner.cookie, { name: 'Disabled' })
169
+ await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$patch(
170
+ { json: { enabled: false }, param: { inviteLinkId: disabled.id, orgId: org.id } },
171
+ as(owner.cookie),
172
+ )
173
+ const expired = await createLink(context, org.id, owner.cookie, {
174
+ expiresAt: new Date(Date.now() - 1_000).toISOString(),
175
+ name: 'Expired',
176
+ })
177
+ const exhausted = await createLink(context, org.id, owner.cookie, {
178
+ expiresAt: null,
179
+ maxUses: 1,
180
+ name: 'Exhausted',
181
+ })
182
+ const invitee = await session(context)
183
+ await Users.setEmail(db, invitee.userId, 'exhausted@example.com')
184
+ const accepted = await publicLinks.accept.$post(
185
+ { json: { token: exhausted.token } },
186
+ as(invitee.cookie),
187
+ )
188
+ expect(accepted.status).toBe(200)
189
+ const deleted = await createLink(context, org.id, owner.cookie, { name: 'Deleted' })
190
+ await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$delete(
191
+ { param: { inviteLinkId: deleted.id, orgId: org.id } },
192
+ as(owner.cookie),
193
+ )
194
+
195
+ for (const token of [
196
+ 'lnk_missing',
197
+ deleted.token,
198
+ disabled.token,
199
+ exhausted.token,
200
+ expired.token,
201
+ ]) {
202
+ const response = await publicLinks.resolve.$post({ json: { token } })
203
+ expect(response.status).toBe(404)
204
+ if (response.status !== 404)
205
+ throw new Error(`Expected unavailable invite link, received ${response.status}`)
206
+ const { requestId: _, ...body } = await response.json()
207
+ expect(body).toEqual(unavailable)
208
+ }
209
+ })
210
+
211
+ test('accept consumes only new memberships and preserves audit after deletion', async () => {
212
+ const db = TestApp.database()
213
+ const context = createApp(db)
214
+ const { client } = context
215
+ const orgRoutes = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']
216
+ const orgLinks = orgRoutes['invite-links']
217
+ const publicLinks = client.v1['invite-links']
218
+ const { org, owner } = await setup(context)
219
+ const link = await createLink(context, org.id, owner.cookie, {
220
+ expiresAt: null,
221
+ name: 'Join',
222
+ })
223
+ const invitee = await session(context)
224
+
225
+ const unverified = await publicLinks.accept.$post(
226
+ { json: { token: link.token } },
227
+ as(invitee.cookie),
228
+ )
229
+ expect(unverified.status).toBe(403)
230
+ await Users.setEmail(db, invitee.userId, 'join@example.com')
231
+
232
+ const firstJoin = await publicLinks.accept.$post(
233
+ { json: { token: link.token } },
234
+ as(invitee.cookie),
235
+ )
236
+ const repeat = await publicLinks.accept.$post(
237
+ { json: { token: link.token } },
238
+ as(invitee.cookie),
239
+ )
240
+ expect(firstJoin.status).toBe(200)
241
+ expect(repeat.status).toBe(200)
242
+
243
+ const audits = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get(
244
+ { param: { inviteLinkId: link.id, orgId: org.id } },
245
+ as(owner.cookie),
246
+ )
247
+ expect(audits.status).toBe(200)
248
+ if (audits.status !== 200) throw new Error(`Failed to list redemptions: ${audits.status}`)
249
+ const firstAudit = await audits.json()
250
+ expect(firstAudit.data).toHaveLength(1)
251
+ expect(firstAudit.data[0]).not.toHaveProperty('token')
252
+ expect(firstAudit.data[0]).toMatchObject({
253
+ email: 'join@example.com',
254
+ inviteLinkId: link.id,
255
+ inviteLinkName: 'Join',
256
+ userId: invitee.userId,
257
+ })
258
+
259
+ expect(await Memberships.remove(db, org.id, invitee.userId)).toBe('removed')
260
+ const rejoined = await publicLinks.accept.$post(
261
+ { json: { token: link.token } },
262
+ as(invitee.cookie),
263
+ )
264
+ expect(rejoined.status).toBe(200)
265
+ const auditsAfterRejoin = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get(
266
+ { param: { inviteLinkId: link.id, orgId: org.id } },
267
+ as(owner.cookie),
268
+ )
269
+ expect(auditsAfterRejoin.status).toBe(200)
270
+ if (auditsAfterRejoin.status !== 200)
271
+ throw new Error(`Failed to list redemptions: ${auditsAfterRejoin.status}`)
272
+ expect((await auditsAfterRejoin.json()).data).toHaveLength(2)
273
+
274
+ const listed = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie))
275
+ expect(listed.status).toBe(200)
276
+ if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`)
277
+ const managed = (await listed.json()).data[0]!
278
+ expect(managed.lastUsedAt).not.toBeNull()
279
+ expect(managed.useCount).toBe(2)
280
+
281
+ await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].$delete(
282
+ { param: { inviteLinkId: link.id, orgId: org.id } },
283
+ as(owner.cookie),
284
+ )
285
+ const auditsAfterDeletion = await orgLinks[
286
+ ':inviteLinkId{iln_[A-Za-z0-9_-]+}'
287
+ ].redemptions.$get({ param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie))
288
+ expect(auditsAfterDeletion.status).toBe(200)
289
+ if (auditsAfterDeletion.status !== 200)
290
+ throw new Error(`Failed to list preserved redemptions: ${auditsAfterDeletion.status}`)
291
+ expect((await auditsAfterDeletion.json()).data).toHaveLength(2)
292
+ })
293
+
294
+ test('serializes concurrent attempts for the finite final use', async () => {
295
+ const factory = TestApp.databaseFactory()
296
+ const db = factory()
297
+ const context = createApp(factory)
298
+ const { client } = context
299
+ const orgRoutes = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']
300
+ const publicLinks = client.v1['invite-links']
301
+ const { org, owner } = await setup(context)
302
+ const link = await createLink(context, org.id, owner.cookie, {
303
+ expiresAt: null,
304
+ maxUses: 1,
305
+ name: 'One use',
306
+ })
307
+ const invitees = await Promise.all([session(context), session(context)])
308
+ await Promise.all(
309
+ invitees.map((invitee, index) =>
310
+ Users.setEmail(db, invitee.userId, `user${index}@example.com`),
311
+ ),
312
+ )
313
+
314
+ const responses = await Promise.all(
315
+ invitees.map((invitee) =>
316
+ publicLinks.accept.$post({ json: { token: link.token } }, as(invitee.cookie)),
317
+ ),
318
+ )
319
+ expect(responses.map(({ status }) => status).sort((a, b) => a - b)).toEqual([200, 404])
320
+ const winner = invitees[responses.findIndex(({ status }) => status === 200)]!
321
+ const retry = await publicLinks.accept.$post({ json: { token: link.token } }, as(winner.cookie))
322
+ expect(retry.status).toBe(200)
323
+
324
+ const audits = await orgRoutes['invite-links'][
325
+ ':inviteLinkId{iln_[A-Za-z0-9_-]+}'
326
+ ].redemptions.$get({ param: { inviteLinkId: link.id, orgId: org.id } }, as(owner.cookie))
327
+ expect(audits.status).toBe(200)
328
+ if (audits.status !== 200) throw new Error(`Failed to list redemptions: ${audits.status}`)
329
+ expect((await audits.json()).data).toHaveLength(1)
330
+ const listed = await orgRoutes['invite-links'].$get(
331
+ { param: { orgId: org.id } },
332
+ as(owner.cookie),
333
+ )
334
+ expect(listed.status).toBe(200)
335
+ if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`)
336
+ expect((await listed.json()).data[0]).toMatchObject({
337
+ status: 'exhausted',
338
+ useCount: 1,
339
+ })
340
+ })
341
+
342
+ test('keeps privileged email invitations independent from member links', async () => {
343
+ const db = TestApp.database()
344
+ const context = createApp(db)
345
+ const { client } = context
346
+ const orgRoutes = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']
347
+ const { org, owner } = await setup(context)
348
+ const link = await createLink(context, org.id, owner.cookie, {
349
+ expiresAt: null,
350
+ name: 'Join',
351
+ })
352
+ const invitee = await session(context)
353
+ await Users.setEmail(db, invitee.userId, 'admin@example.com')
354
+ const invitationResponse = await orgRoutes.invitations.$post(
355
+ {
356
+ json: { email: 'admin@example.com', role: 'admin' },
357
+ param: { orgId: org.id },
358
+ },
359
+ as(owner.cookie),
360
+ )
361
+ expect(invitationResponse.status).toBe(200)
362
+ if (invitationResponse.status !== 200)
363
+ throw new Error(`Failed to create email invitation: ${invitationResponse.status}`)
364
+ const invitation = await invitationResponse.json()
365
+
366
+ const joined = await client.v1['invite-links'].accept.$post(
367
+ { json: { token: link.token } },
368
+ as(invitee.cookie),
369
+ )
370
+ expect(joined.status).toBe(200)
371
+ expect((await Memberships.get(db, org.id, invitee.userId))?.role).toBe('member')
372
+
373
+ const pending = await client.v1.invitations.$get(undefined, as(invitee.cookie))
374
+ expect(pending.status).toBe(200)
375
+ if (pending.status !== 200)
376
+ throw new Error(`Failed to list email invitations: ${pending.status}`)
377
+ expect((await pending.json()).data).toHaveLength(1)
378
+ const elevated = await client.v1.invitations[':invitationId{inv_[A-Za-z0-9_-]+}'].accept.$post(
379
+ { param: { invitationId: invitation.id } },
380
+ as(invitee.cookie),
381
+ )
382
+ expect(elevated.status).toBe(200)
383
+ expect((await Memberships.get(db, org.id, invitee.userId))?.role).toBe('admin')
384
+ })
385
+ })