tapimo 0.4.4 → 0.5.1
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/dist/App.d.ts +4701 -3416
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +24 -2
- package/dist/App.js.map +1 -1
- package/dist/Assets.js +3 -3
- package/dist/Assets.js.map +1 -1
- package/dist/Scope.d.ts +18 -1
- package/dist/Scope.d.ts.map +1 -1
- package/dist/Scope.js +23 -3
- package/dist/Scope.js.map +1 -1
- package/dist/Webhooks.d.ts +1 -1
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +5 -10
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +6 -34
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +3 -3
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +4 -1
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/scopes.d.ts +6 -34
- package/dist/admin/apps/scopes.d.ts.map +1 -1
- package/dist/admin/apps/scopes.js +2 -2
- package/dist/admin/apps/scopes.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +57 -57
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +33 -33
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/apps/data/App.d.ts +4574 -4277
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +155 -77
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.d.ts +109 -0
- package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -0
- package/dist/apps/data/FundingQuoteProviders.js +464 -0
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -0
- package/dist/apps/data/index.d.ts +1 -1
- package/dist/apps/data/index.js +1 -1
- package/dist/apps/data/routes/activities.d.ts +26 -26
- package/dist/apps/data/routes/addresses.d.ts +38 -38
- package/dist/apps/data/routes/balances.d.ts +45 -45
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +67 -149
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +2 -3
- package/dist/apps/data/routes/blocks.d.ts.map +1 -1
- package/dist/apps/data/routes/blocks.js +3 -5
- package/dist/apps/data/routes/blocks.js.map +1 -1
- package/dist/apps/data/routes/coingecko.d.ts +177 -176
- package/dist/apps/data/routes/coingecko.d.ts.map +1 -1
- package/dist/apps/data/routes/coingecko.js +3 -8
- package/dist/apps/data/routes/coingecko.js.map +1 -1
- package/dist/apps/data/routes/funding.d.ts +131 -228
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +152 -219
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/indexer.d.ts +34 -34
- package/dist/apps/data/routes/indexer.d.ts.map +1 -1
- package/dist/apps/data/routes/indexer.js +9 -2
- package/dist/apps/data/routes/indexer.js.map +1 -1
- package/dist/apps/data/routes/receipts.d.ts +228 -228
- package/dist/apps/data/routes/rpc.d.ts.map +1 -1
- package/dist/apps/data/routes/rpc.js +86 -16
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/tokenlist.d.ts +12 -12
- package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
- package/dist/apps/data/routes/webhooks.d.ts +340 -0
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +13 -1
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/index.d.ts +1 -1
- package/dist/apps/index.js +1 -1
- package/dist/apps/management/App.d.ts +1833 -851
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/App.js +4 -0
- package/dist/apps/management/App.js.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +41 -41
- package/dist/apps/management/routes/api-keys.js +2 -2
- package/dist/apps/management/routes/api-keys.js.map +1 -1
- package/dist/apps/management/routes/billing.d.ts +48 -48
- package/dist/apps/management/routes/invitations.d.ts +132 -132
- package/dist/apps/management/routes/invite-links.d.ts +1100 -0
- package/dist/apps/management/routes/invite-links.d.ts.map +1 -0
- package/dist/apps/management/routes/invite-links.js +411 -0
- package/dist/apps/management/routes/invite-links.js.map +1 -0
- package/dist/apps/management/routes/members.d.ts +41 -41
- package/dist/apps/management/routes/orgs.d.ts +92 -92
- package/dist/apps/management/routes/projects.d.ts +9 -9
- package/dist/apps/management/routes/scopes.d.ts.map +1 -1
- package/dist/apps/management/routes/scopes.js +2 -2
- package/dist/apps/management/routes/scopes.js.map +1 -1
- package/dist/apps/management/routes/usage.d.ts +24 -24
- package/dist/apps/mcp.js +2 -2
- package/dist/apps/mcp.js.map +1 -1
- package/dist/apps/relay/App.d.ts +2 -2
- package/dist/apps/relay/App.d.ts.map +1 -1
- package/dist/apps/relay/App.js +44 -6
- package/dist/apps/relay/App.js.map +1 -1
- package/dist/db/Db.d.ts +3 -0
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +57 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +31 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +31 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/inviteLinks.d.ts +42 -0
- package/dist/db/tables/inviteLinks.d.ts.map +1 -0
- package/dist/db/tables/inviteLinks.js +148 -0
- package/dist/db/tables/inviteLinks.js.map +1 -0
- package/dist/db/tables/organizations.d.ts.map +1 -1
- package/dist/db/tables/organizations.js +2 -0
- package/dist/db/tables/organizations.js.map +1 -1
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +6 -2
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/handlers/relay.d.ts.map +1 -1
- package/dist/handlers/relay.js +13 -3
- package/dist/handlers/relay.js.map +1 -1
- package/dist/internal/Auth.d.ts +9 -1
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +138 -10
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/EdgeCache.d.ts +3 -1
- package/dist/internal/EdgeCache.d.ts.map +1 -1
- package/dist/internal/EdgeCache.js +17 -2
- package/dist/internal/EdgeCache.js.map +1 -1
- package/dist/internal/{FundingRoutes.d.ts → FundingQuotes.d.ts} +240 -369
- package/dist/internal/FundingQuotes.d.ts.map +1 -0
- package/dist/internal/{FundingRoutes.js → FundingQuotes.js} +144 -183
- package/dist/internal/FundingQuotes.js.map +1 -0
- package/dist/internal/Log.d.ts +18 -12
- package/dist/internal/Log.d.ts.map +1 -1
- package/dist/internal/Log.js +2 -0
- package/dist/internal/Log.js.map +1 -1
- package/dist/internal/Tidx.d.ts +9 -3
- package/dist/internal/Tidx.d.ts.map +1 -1
- package/dist/internal/Tidx.js +13 -7
- package/dist/internal/Tidx.js.map +1 -1
- package/dist/internal/Viem.d.ts +17 -2
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +30 -3
- package/dist/internal/Viem.js.map +1 -1
- package/dist/internal/index.d.ts +1 -1
- package/dist/internal/index.js +1 -1
- package/package.json +2 -2
- package/src/ApiKeys.test.ts +25 -0
- package/src/App.test-d.ts +2 -0
- package/src/App.test.ts +13 -2
- package/src/App.ts +37 -5
- package/src/Assets.ts +3 -3
- package/src/Client.test-d.ts +21 -11
- package/src/Scope.test.ts +1 -0
- package/src/Scope.ts +32 -4
- package/src/Webhooks.ts +5 -10
- package/src/admin/apps/api-keys.test.ts +27 -0
- package/src/admin/apps/api-keys.ts +8 -1
- package/src/admin/apps/scopes.test.ts +7 -2
- package/src/admin/apps/scopes.ts +2 -2
- package/src/admin/ui/src/components/Dialog.tsx +8 -2
- package/src/admin/ui/src/lib/scopes.test.ts +19 -0
- package/src/admin/ui/src/lib/scopes.ts +12 -0
- package/src/admin/ui/src/routes/api-keys.tsx +128 -27
- package/src/admin/ui.generated.ts +33 -33
- package/src/apps/composition.test.ts +77 -0
- package/src/apps/data/App.test.ts +354 -0
- package/src/apps/data/App.ts +178 -91
- package/src/apps/data/FundingQuoteProviders.test.ts +577 -0
- package/src/apps/data/FundingQuoteProviders.ts +633 -0
- package/src/apps/data/index.ts +1 -1
- package/src/apps/data/routes/balances.test.ts +64 -18
- package/src/apps/data/routes/balances.ts +76 -177
- package/src/apps/data/routes/blocks.ts +3 -5
- package/src/apps/data/routes/coingecko.ts +3 -8
- package/src/apps/data/routes/funding.test.ts +304 -192
- package/src/apps/data/routes/funding.ts +166 -286
- package/src/apps/data/routes/indexer.test.ts +173 -15
- package/src/apps/data/routes/indexer.ts +8 -2
- package/src/apps/data/routes/rpc.test.ts +219 -0
- package/src/apps/data/routes/rpc.ts +92 -17
- package/src/apps/data/routes/webhooks.test.ts +21 -0
- package/src/apps/data/routes/webhooks.ts +13 -1
- package/src/apps/index.ts +1 -1
- package/src/apps/management/App.ts +4 -0
- package/src/apps/management/Billing.test.ts +2 -2
- package/src/apps/management/routes/api-keys.test.ts +47 -3
- package/src/apps/management/routes/api-keys.ts +2 -2
- package/src/apps/management/routes/invite-links.test.ts +385 -0
- package/src/apps/management/routes/invite-links.ts +570 -0
- package/src/apps/management/routes/scopes.test.ts +3 -2
- package/src/apps/management/routes/scopes.ts +4 -2
- package/src/apps/mcp.test.ts +18 -0
- package/src/apps/mcp.ts +2 -2
- package/src/apps/relay/App.test.ts +328 -7
- package/src/apps/relay/App.ts +42 -6
- package/src/apps/relay/Sponsorships.test.ts +2 -2
- package/src/db/Db.test.ts +5 -5
- package/src/db/Db.ts +65 -0
- package/src/db/Schema.ts +39 -0
- package/src/db/tables/billingSettings.test.ts +2 -2
- package/src/db/tables/inviteLinks.ts +187 -0
- package/src/db/tables/organizations.test.ts +2 -2
- package/src/db/tables/organizations.ts +2 -0
- package/src/db/tables/sponsoredTransactions.test.ts +19 -2
- package/src/db/tables/sponsoredTransactions.ts +21 -14
- package/src/db/tables/stripeCustomers.test.ts +2 -2
- package/src/handlers/relay.ts +16 -4
- package/src/internal/Auth.test.ts +132 -22
- package/src/internal/Auth.ts +181 -14
- package/src/internal/EdgeCache.test.ts +24 -3
- package/src/internal/EdgeCache.ts +18 -2
- package/src/internal/FundingQuotes.test.ts +268 -0
- package/src/internal/{FundingRoutes.ts → FundingQuotes.ts} +216 -277
- package/src/internal/Log.ts +21 -14
- package/src/internal/Tidx.test.ts +48 -0
- package/src/internal/Tidx.ts +21 -7
- package/src/internal/Viem.test.ts +46 -0
- package/src/internal/Viem.ts +48 -6
- package/src/internal/index.test.ts +1 -1
- package/src/internal/index.ts +1 -1
- package/dist/apps/data/FundingRouteProviders.d.ts +0 -36
- package/dist/apps/data/FundingRouteProviders.d.ts.map +0 -1
- package/dist/apps/data/FundingRouteProviders.js +0 -245
- package/dist/apps/data/FundingRouteProviders.js.map +0 -1
- package/dist/internal/FundingRoutes.d.ts.map +0 -1
- package/dist/internal/FundingRoutes.js.map +0 -1
- package/src/apps/data/FundingRouteProviders.test.ts +0 -225
- package/src/apps/data/FundingRouteProviders.ts +0 -312
- package/src/internal/FundingRoutes.test.ts +0 -213
|
@@ -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
|
+
})
|