tapimo 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/App.d.ts +13987 -5755
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js.map +1 -1
- package/dist/admin/App.d.ts +910 -412
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/App.js +4 -0
- package/dist/admin/App.js.map +1 -1
- package/dist/admin/apps/early-access.d.ts +148 -0
- package/dist/admin/apps/early-access.d.ts.map +1 -0
- package/dist/admin/apps/early-access.js +133 -0
- package/dist/admin/apps/early-access.js.map +1 -0
- package/dist/admin/apps/verified-tokens.d.ts +643 -118
- package/dist/admin/apps/verified-tokens.d.ts.map +1 -1
- package/dist/admin/apps/verified-tokens.js +214 -1
- package/dist/admin/apps/verified-tokens.js.map +1 -1
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.d.ts.map +1 -1
- package/dist/admin/ui.generated.js +45 -33
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/apps/data/App.d.ts +3733 -2428
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +11 -6
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.d.ts +34 -1
- package/dist/apps/data/FundingQuoteProviders.d.ts.map +1 -1
- package/dist/apps/data/FundingQuoteProviders.js +193 -2
- package/dist/apps/data/FundingQuoteProviders.js.map +1 -1
- package/dist/apps/data/FxOracle.d.ts +71 -0
- package/dist/apps/data/FxOracle.d.ts.map +1 -0
- package/dist/apps/data/FxOracle.js +97 -0
- package/dist/apps/data/FxOracle.js.map +1 -0
- package/dist/apps/data/index.d.ts +1 -0
- package/dist/apps/data/index.d.ts.map +1 -1
- package/dist/apps/data/index.js +1 -0
- package/dist/apps/data/index.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +3575 -1358
- package/dist/apps/data/routes/activities.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.js +230 -88
- package/dist/apps/data/routes/activities.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +963 -342
- package/dist/apps/data/routes/addresses.d.ts.map +1 -1
- package/dist/apps/data/routes/addresses.js +11 -9
- package/dist/apps/data/routes/addresses.js.map +1 -1
- package/dist/apps/data/routes/balances.d.ts +108 -57
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +88 -45
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/coingecko.d.ts +169 -169
- package/dist/apps/data/routes/exchanges.d.ts +952 -201
- package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
- package/dist/apps/data/routes/exchanges.js +518 -76
- package/dist/apps/data/routes/exchanges.js.map +1 -1
- package/dist/apps/data/routes/fee-amm.d.ts +24 -24
- package/dist/apps/data/routes/funding.d.ts +8 -2
- package/dist/apps/data/routes/funding.d.ts.map +1 -1
- package/dist/apps/data/routes/funding.js +32 -6
- package/dist/apps/data/routes/funding.js.map +1 -1
- package/dist/apps/data/routes/indexer.d.ts +5 -5
- package/dist/apps/data/routes/receipts.d.ts +392 -315
- package/dist/apps/data/routes/receipts.d.ts.map +1 -1
- package/dist/apps/data/routes/receipts.js +152 -77
- package/dist/apps/data/routes/receipts.js.map +1 -1
- package/dist/apps/data/routes/tokens.d.ts +166 -173
- package/dist/apps/data/routes/tokens.d.ts.map +1 -1
- package/dist/apps/data/routes/tokens.js +14 -12
- package/dist/apps/data/routes/tokens.js.map +1 -1
- package/dist/apps/data/routes/transactions.d.ts +277 -536
- package/dist/apps/data/routes/transactions.d.ts.map +1 -1
- package/dist/apps/data/routes/transactions.js +96 -77
- package/dist/apps/data/routes/transactions.js.map +1 -1
- package/dist/apps/data/routes/transfers.d.ts +82 -17
- package/dist/apps/data/routes/transfers.d.ts.map +1 -1
- package/dist/apps/data/routes/transfers.js +70 -13
- package/dist/apps/data/routes/transfers.js.map +1 -1
- package/dist/apps/data/routes/valuation.d.ts +307 -0
- package/dist/apps/data/routes/valuation.d.ts.map +1 -0
- package/dist/apps/data/routes/valuation.js +292 -0
- package/dist/apps/data/routes/valuation.js.map +1 -0
- package/dist/apps/data/routes/verified-tokens.d.ts.map +1 -1
- package/dist/apps/data/routes/verified-tokens.js +7 -5
- package/dist/apps/data/routes/verified-tokens.js.map +1 -1
- package/dist/apps/data/routes/webhooks.d.ts +459 -479
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +124 -25
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/index.d.ts +1 -1
- package/dist/apps/index.d.ts.map +1 -1
- package/dist/apps/index.js +1 -1
- package/dist/apps/index.js.map +1 -1
- package/dist/apps/management/App.d.ts +8606 -1681
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/App.js +12 -1
- package/dist/apps/management/App.js.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +23 -0
- package/dist/apps/management/routes/api-keys.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.js +5 -3
- package/dist/apps/management/routes/api-keys.js.map +1 -1
- package/dist/apps/management/routes/billing.d.ts +88 -48
- package/dist/apps/management/routes/billing.d.ts.map +1 -1
- package/dist/apps/management/routes/billing.js +10 -2
- package/dist/apps/management/routes/billing.js.map +1 -1
- package/dist/apps/management/routes/faucet.d.ts +218 -0
- package/dist/apps/management/routes/faucet.d.ts.map +1 -0
- package/dist/apps/management/routes/faucet.js +139 -0
- package/dist/apps/management/routes/faucet.js.map +1 -0
- package/dist/apps/management/routes/invitations.d.ts +121 -121
- package/dist/apps/management/routes/invitations.d.ts.map +1 -1
- package/dist/apps/management/routes/invitations.js +3 -0
- package/dist/apps/management/routes/invitations.js.map +1 -1
- package/dist/apps/management/routes/invite-links.d.ts +197 -168
- package/dist/apps/management/routes/invite-links.d.ts.map +1 -1
- package/dist/apps/management/routes/invite-links.js +26 -1
- package/dist/apps/management/routes/invite-links.js.map +1 -1
- package/dist/apps/management/routes/me.d.ts +2 -0
- package/dist/apps/management/routes/me.d.ts.map +1 -1
- package/dist/apps/management/routes/me.js +4 -0
- package/dist/apps/management/routes/me.js.map +1 -1
- package/dist/apps/management/routes/members.d.ts +49 -49
- package/dist/apps/management/routes/orgs.d.ts +91 -91
- package/dist/apps/management/routes/projects.d.ts +115 -115
- package/dist/apps/management/routes/usage.d.ts +35 -32
- package/dist/apps/management/routes/usage.d.ts.map +1 -1
- package/dist/apps/management/routes/usage.js +4 -0
- package/dist/apps/management/routes/usage.js.map +1 -1
- package/dist/apps/management/routes/verified-token-requests.d.ts +697 -0
- package/dist/apps/management/routes/verified-token-requests.d.ts.map +1 -0
- package/dist/apps/management/routes/verified-token-requests.js +271 -0
- package/dist/apps/management/routes/verified-token-requests.js.map +1 -0
- package/dist/apps/management/routes/webhooks.d.ts +6977 -0
- package/dist/apps/management/routes/webhooks.d.ts.map +1 -0
- package/dist/apps/management/routes/webhooks.js +573 -0
- package/dist/apps/management/routes/webhooks.js.map +1 -0
- package/dist/apps/mcp.d.ts +9 -2
- package/dist/apps/mcp.d.ts.map +1 -1
- package/dist/apps/mcp.js +9 -4
- package/dist/apps/mcp.js.map +1 -1
- package/dist/cli/Cli.d.ts.map +1 -1
- package/dist/cli/Cli.js +7 -2
- package/dist/cli/Cli.js.map +1 -1
- package/dist/db/Db.d.ts +4 -0
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +83 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +41 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +45 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/earlyAccess.d.ts +50 -0
- package/dist/db/tables/earlyAccess.d.ts.map +1 -0
- package/dist/db/tables/earlyAccess.js +63 -0
- package/dist/db/tables/earlyAccess.js.map +1 -0
- package/dist/db/tables/invitations.d.ts +12 -1
- package/dist/db/tables/invitations.d.ts.map +1 -1
- package/dist/db/tables/invitations.js +23 -0
- package/dist/db/tables/invitations.js.map +1 -1
- package/dist/db/tables/inviteLinks.d.ts +10 -2
- package/dist/db/tables/inviteLinks.d.ts.map +1 -1
- package/dist/db/tables/inviteLinks.js +10 -1
- package/dist/db/tables/inviteLinks.js.map +1 -1
- package/dist/db/tables/sponsoredTransactions.d.ts +2 -0
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +2 -0
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/db/tables/verifiedTokenRequests.d.ts +94 -0
- package/dist/db/tables/verifiedTokenRequests.d.ts.map +1 -0
- package/dist/db/tables/verifiedTokenRequests.js +120 -0
- package/dist/db/tables/verifiedTokenRequests.js.map +1 -0
- package/dist/db/tables/webhookSubscriptions.d.ts +14 -0
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
- package/dist/db/tables/webhookSubscriptions.js +16 -0
- package/dist/db/tables/webhookSubscriptions.js.map +1 -1
- package/dist/internal/Auth.d.ts +43 -2
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +60 -45
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/FundingQuotes.d.ts +69 -15
- package/dist/internal/FundingQuotes.d.ts.map +1 -1
- package/dist/internal/FundingQuotes.js +74 -25
- package/dist/internal/FundingQuotes.js.map +1 -1
- package/dist/internal/Log.d.ts +19 -1
- 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/MetricSink.d.ts.map +1 -1
- package/dist/internal/MetricSink.js +9 -0
- package/dist/internal/MetricSink.js.map +1 -1
- package/dist/internal/OpenApi.d.ts +2 -2
- package/dist/internal/OpenApi.d.ts.map +1 -1
- package/dist/internal/OpenApi.js +3 -0
- package/dist/internal/OpenApi.js.map +1 -1
- package/dist/internal/Schema.d.ts +14 -5
- package/dist/internal/Schema.d.ts.map +1 -1
- package/dist/internal/Schema.js +23 -5
- package/dist/internal/Schema.js.map +1 -1
- package/dist/internal/Value.d.ts +18 -0
- package/dist/internal/Value.d.ts.map +1 -1
- package/dist/internal/Value.js +11 -1
- package/dist/internal/Value.js.map +1 -1
- package/dist/internal/VerifiedTokens.d.ts.map +1 -1
- package/dist/internal/VerifiedTokens.js +10 -1
- package/dist/internal/VerifiedTokens.js.map +1 -1
- package/dist/internal/Viem.d.ts +1 -1
- package/dist/internal/Webhooks.d.ts +24 -3
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +34 -8
- package/dist/internal/Webhooks.js.map +1 -1
- package/package.json +2 -2
- package/src/App.ts +9 -1
- package/src/Client.test-d.ts +638 -6
- package/src/admin/App.ts +4 -0
- package/src/admin/apps/early-access.test.ts +125 -0
- package/src/admin/apps/early-access.ts +175 -0
- package/src/admin/apps/verified-tokens.test.ts +201 -0
- package/src/admin/apps/verified-tokens.ts +532 -286
- package/src/admin/ui/plugin.ts +2 -1
- package/src/admin/ui/src/lib/early-access.ts +63 -0
- package/src/admin/ui/src/lib/verified-tokens.ts +79 -0
- package/src/admin/ui/src/routeTree.gen.ts +33 -2
- package/src/admin/ui/src/routes/__root.tsx +2 -1
- package/src/admin/ui/src/routes/early-access.tsx +157 -0
- package/src/admin/ui/src/routes/verified-tokens.tsx +230 -19
- package/src/admin/ui.generated.ts +48 -33
- package/src/apps/data/App.ts +22 -14
- package/src/apps/data/FundingQuoteProviders.test.ts +298 -0
- package/src/apps/data/FundingQuoteProviders.ts +257 -4
- package/src/apps/data/FxOracle.test.ts +118 -0
- package/src/apps/data/FxOracle.ts +153 -0
- package/src/apps/data/index.ts +1 -0
- package/src/apps/data/routes/activities.test.ts +173 -3
- package/src/apps/data/routes/activities.ts +403 -132
- package/src/apps/data/routes/addresses.ts +19 -9
- package/src/apps/data/routes/balances.test.ts +144 -11
- package/src/apps/data/routes/balances.ts +140 -51
- package/src/apps/data/routes/exchanges.test.ts +540 -9
- package/src/apps/data/routes/exchanges.ts +690 -103
- package/src/apps/data/routes/funding.test.ts +84 -0
- package/src/apps/data/routes/funding.ts +38 -6
- package/src/apps/data/routes/receipts.test.ts +125 -23
- package/src/apps/data/routes/receipts.ts +205 -100
- package/src/apps/data/routes/tokens.test.ts +43 -0
- package/src/apps/data/routes/tokens.ts +27 -12
- package/src/apps/data/routes/transactions.test.ts +72 -19
- package/src/apps/data/routes/transactions.ts +162 -92
- package/src/apps/data/routes/transfers.test.ts +146 -4
- package/src/apps/data/routes/transfers.ts +91 -13
- package/src/apps/data/routes/valuation.test.ts +422 -0
- package/src/apps/data/routes/valuation.ts +423 -0
- package/src/apps/data/routes/verified-tokens.ts +14 -2
- package/src/apps/data/routes/webhooks.test.ts +56 -8
- package/src/apps/data/routes/webhooks.ts +144 -27
- package/src/apps/index.ts +1 -1
- package/src/apps/management/App.ts +13 -0
- package/src/apps/management/routes/api-keys.test.ts +87 -0
- package/src/apps/management/routes/api-keys.ts +4 -3
- package/src/apps/management/routes/billing.test.ts +112 -1
- package/src/apps/management/routes/billing.ts +7 -2
- package/src/apps/management/routes/faucet.test.ts +79 -0
- package/src/apps/management/routes/faucet.ts +185 -0
- package/src/apps/management/routes/invitations.test.ts +172 -0
- package/src/apps/management/routes/invitations.ts +3 -0
- package/src/apps/management/routes/invite-links.test.ts +117 -0
- package/src/apps/management/routes/invite-links.ts +43 -1
- package/src/apps/management/routes/me.test.ts +140 -88
- package/src/apps/management/routes/me.ts +6 -0
- package/src/apps/management/routes/usage.test.ts +6 -0
- package/src/apps/management/routes/usage.ts +7 -0
- package/src/apps/management/routes/verified-token-requests.test.ts +377 -0
- package/src/apps/management/routes/verified-token-requests.ts +397 -0
- package/src/apps/management/routes/webhooks.test.ts +426 -0
- package/src/apps/management/routes/webhooks.ts +672 -0
- package/src/apps/mcp.test.ts +13 -0
- package/src/apps/mcp.ts +18 -5
- package/src/cli/Cli.test.ts +36 -0
- package/src/cli/Cli.ts +7 -2
- package/src/db/Db.test.ts +19 -0
- package/src/db/Db.ts +95 -0
- package/src/db/Schema.ts +53 -0
- package/src/db/tables/earlyAccess.ts +86 -0
- package/src/db/tables/invitations.ts +26 -1
- package/src/db/tables/inviteLinks.ts +22 -3
- package/src/db/tables/sponsoredTransactions.ts +4 -0
- package/src/db/tables/verifiedTokenRequests.test.ts +200 -0
- package/src/db/tables/verifiedTokenRequests.ts +182 -0
- package/src/db/tables/webhookSubscriptions.ts +31 -0
- package/src/internal/Auth.ts +73 -45
- package/src/internal/FundingQuotes.test.ts +143 -1
- package/src/internal/FundingQuotes.ts +95 -28
- package/src/internal/Log.test-d.ts +21 -0
- package/src/internal/Log.test.ts +50 -0
- package/src/internal/Log.ts +24 -1
- package/src/internal/MetricSink.test.ts +160 -0
- package/src/internal/MetricSink.ts +10 -0
- package/src/internal/OpenApi.ts +3 -0
- package/src/internal/Schema.ts +50 -5
- package/src/internal/Value.test.ts +20 -0
- package/src/internal/Value.ts +24 -1
- package/src/internal/VerifiedTokens.ts +9 -1
- package/src/internal/Webhooks.test.ts +140 -0
- package/src/internal/Webhooks.ts +53 -9
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
|
|
2
|
+
|
|
3
|
+
import * as TestApp from '../../../../test/App.js'
|
|
4
|
+
import * as Faucet from './faucet.js'
|
|
5
|
+
import * as Orgs from './orgs.js'
|
|
6
|
+
|
|
7
|
+
const origin = 'http://localhost'
|
|
8
|
+
|
|
9
|
+
function createApp() {
|
|
10
|
+
return TestApp.create({
|
|
11
|
+
rateLimit: { now: () => new Date('2026-01-01T00:00:00.000Z') },
|
|
12
|
+
session: { wallet: { origin } },
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function session(app: TestApp.signIn.App) {
|
|
17
|
+
const account = privateKeyToAccount(generatePrivateKey())
|
|
18
|
+
const { cookie } = await TestApp.signIn(app, account)
|
|
19
|
+
return cookie!
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function organization(app: ReturnType<typeof createApp>, cookie: string) {
|
|
23
|
+
const response = await app.request('/v1/orgs', {
|
|
24
|
+
body: JSON.stringify({ name: 'Acme' }),
|
|
25
|
+
headers: { 'content-type': 'application/json', cookie },
|
|
26
|
+
method: 'POST',
|
|
27
|
+
})
|
|
28
|
+
return TestApp.json(response, Orgs.schema.Organization)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('faucet', () => {
|
|
32
|
+
test('funds a member account and rate limits repeated funding', async () => {
|
|
33
|
+
const app = createApp()
|
|
34
|
+
const cookie = await session(app)
|
|
35
|
+
const org = await organization(app, cookie)
|
|
36
|
+
const recipient = privateKeyToAccount(generatePrivateKey()).address
|
|
37
|
+
|
|
38
|
+
const funded = await app.request(`/v1/orgs/${org.id}/faucet`, {
|
|
39
|
+
body: JSON.stringify({ address: recipient }),
|
|
40
|
+
headers: { 'content-type': 'application/json', cookie },
|
|
41
|
+
method: 'POST',
|
|
42
|
+
})
|
|
43
|
+
expect(funded.status).toBe(200)
|
|
44
|
+
const result = await TestApp.json(funded, Faucet.schema.FundResponse)
|
|
45
|
+
expect(result.tokens.length).toBeGreaterThan(0)
|
|
46
|
+
expect(result.tokens.every((token) => token.symbol.length > 0)).toBe(true)
|
|
47
|
+
expect(result.tokens.every((token) => BigInt(token.amount) > 0n)).toBe(true)
|
|
48
|
+
expect(result.transactionHashes).toEqual(result.tokens.map((token) => token.transactionHash))
|
|
49
|
+
|
|
50
|
+
const repeated = await app.request(`/v1/orgs/${org.id}/faucet`, {
|
|
51
|
+
body: JSON.stringify({ address: recipient }),
|
|
52
|
+
headers: { 'content-type': 'application/json', cookie },
|
|
53
|
+
method: 'POST',
|
|
54
|
+
})
|
|
55
|
+
expect(repeated.status).toBe(429)
|
|
56
|
+
expect(repeated.headers.get('ratelimit-scope')).toBe('faucet-address')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('requires membership and a valid address', async () => {
|
|
60
|
+
const app = createApp()
|
|
61
|
+
const owner = await session(app)
|
|
62
|
+
const org = await organization(app, owner)
|
|
63
|
+
const stranger = await session(app)
|
|
64
|
+
|
|
65
|
+
const inaccessible = await app.request(`/v1/orgs/${org.id}/faucet`, {
|
|
66
|
+
body: JSON.stringify({ address: privateKeyToAccount(generatePrivateKey()).address }),
|
|
67
|
+
headers: { 'content-type': 'application/json', cookie: stranger },
|
|
68
|
+
method: 'POST',
|
|
69
|
+
})
|
|
70
|
+
expect(inaccessible.status).toBe(404)
|
|
71
|
+
|
|
72
|
+
const invalid = await app.request(`/v1/orgs/${org.id}/faucet`, {
|
|
73
|
+
body: JSON.stringify({ address: 'not-an-address' }),
|
|
74
|
+
headers: { 'content-type': 'application/json', cookie: owner },
|
|
75
|
+
method: 'POST',
|
|
76
|
+
})
|
|
77
|
+
expect(invalid.status).toBe(400)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Hono, type Context } from 'hono'
|
|
2
|
+
import * as z from 'zod/mini'
|
|
3
|
+
|
|
4
|
+
import * as Auth from '../../../internal/Auth.js'
|
|
5
|
+
import * as OpenApi from '../../../internal/OpenApi.js'
|
|
6
|
+
import * as RateLimit from '../../../internal/RateLimit.js'
|
|
7
|
+
import * as Response from '../../../internal/Response.js'
|
|
8
|
+
import * as Schema from '../../../internal/Schema.js'
|
|
9
|
+
import * as Viem from '../../../internal/Viem.js'
|
|
10
|
+
import type { Environment } from '../App.js'
|
|
11
|
+
|
|
12
|
+
const addressRateLimit = { perMinute: 1 } satisfies RateLimit.Limit
|
|
13
|
+
const userRateLimit = { perMinute: 3 } satisfies RateLimit.Limit
|
|
14
|
+
|
|
15
|
+
/** OpenAPI schemas owned by the sandbox faucet. */
|
|
16
|
+
export namespace schema {
|
|
17
|
+
/** Faucet request body. */
|
|
18
|
+
export const FundBody = Schema.describe(
|
|
19
|
+
z.object({ address: Schema.Address }),
|
|
20
|
+
'A Tempo testnet account to fund.',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
/** Faucet response. */
|
|
24
|
+
export const FundResponse = Schema.describe(
|
|
25
|
+
z.object({
|
|
26
|
+
tokens: z
|
|
27
|
+
.array(
|
|
28
|
+
z.object({
|
|
29
|
+
address: Schema.TokenAddress.check(
|
|
30
|
+
z.describe('The test token contract that minted funds.'),
|
|
31
|
+
),
|
|
32
|
+
amount: Schema.DecimalString.check(
|
|
33
|
+
z.describe('The minted amount in the token’s smallest units.'),
|
|
34
|
+
),
|
|
35
|
+
currency: z.string().check(z.describe('The token’s currency label.')),
|
|
36
|
+
decimals: z
|
|
37
|
+
.number()
|
|
38
|
+
.check(
|
|
39
|
+
z.int(),
|
|
40
|
+
z.nonnegative(),
|
|
41
|
+
z.describe('The number of decimal places the token uses.'),
|
|
42
|
+
),
|
|
43
|
+
name: z.string().check(z.describe('The token’s human-readable name.')),
|
|
44
|
+
symbol: z.string().check(z.describe('The token’s symbol.')),
|
|
45
|
+
transactionHash: Schema.Hash.check(
|
|
46
|
+
z.describe('The transaction that minted this token.'),
|
|
47
|
+
),
|
|
48
|
+
}),
|
|
49
|
+
)
|
|
50
|
+
.check(z.describe('The configured test tokens minted to the account.')),
|
|
51
|
+
transactionHashes: z
|
|
52
|
+
.array(Schema.Hash)
|
|
53
|
+
.check(z.describe('Transactions that minted the configured test tokens.')),
|
|
54
|
+
}),
|
|
55
|
+
'Test tokens minted while funding the account.',
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
/** Faucet path parameters. */
|
|
59
|
+
export const Params = Schema.describe(
|
|
60
|
+
z.object({
|
|
61
|
+
orgId: z
|
|
62
|
+
.string()
|
|
63
|
+
.check(
|
|
64
|
+
z.describe('The organization id (`org_…`).'),
|
|
65
|
+
z.meta({ examples: ['org_1a2b3c4d5e6f7g8h9j0k1m2n'] }),
|
|
66
|
+
),
|
|
67
|
+
}),
|
|
68
|
+
'Path parameters for the organization faucet.',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const bodyValidation = {
|
|
73
|
+
code: 'body_invalid',
|
|
74
|
+
message: 'Enter a valid Tempo address.',
|
|
75
|
+
} as const
|
|
76
|
+
const paramValidation = {
|
|
77
|
+
code: 'param_invalid',
|
|
78
|
+
message: 'Check the path parameters and try again.',
|
|
79
|
+
} as const
|
|
80
|
+
|
|
81
|
+
/** Mounts the session-authenticated Tempo testnet faucet. */
|
|
82
|
+
export function faucet() {
|
|
83
|
+
return new Hono<Environment>().post(
|
|
84
|
+
'/v1/orgs/:orgId{org_[A-Za-z0-9_-]+}/faucet',
|
|
85
|
+
Auth.policy({ session: true }),
|
|
86
|
+
Auth.ensureOrg(),
|
|
87
|
+
OpenApi.validate('param', schema.Params, paramValidation),
|
|
88
|
+
OpenApi.validate('json', schema.FundBody, bodyValidation),
|
|
89
|
+
OpenApi.describeRoute({
|
|
90
|
+
description:
|
|
91
|
+
'Funds an account with the test tokens configured on Tempo testnet. This operation is available to signed-in organization members and is rate limited.',
|
|
92
|
+
operationId: 'fundTestnetAccount',
|
|
93
|
+
responses: OpenApi.responses({
|
|
94
|
+
errors: {
|
|
95
|
+
400: {
|
|
96
|
+
codes: ['api_key_malformed', 'body_invalid', 'param_invalid'],
|
|
97
|
+
description: 'The path or account address is invalid.',
|
|
98
|
+
},
|
|
99
|
+
404: 'No accessible organization was found.',
|
|
100
|
+
},
|
|
101
|
+
success: {
|
|
102
|
+
description: 'The account was funded.',
|
|
103
|
+
schema: schema.FundResponse,
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
summary: 'Fund testnet account',
|
|
107
|
+
tags: ['Faucet'],
|
|
108
|
+
}),
|
|
109
|
+
async (c) => {
|
|
110
|
+
if (Auth.narrowAccess) return Auth.superAdminAccessError(c)
|
|
111
|
+
if (Auth.narrowScope) return Auth.ensureOrgError(c)
|
|
112
|
+
if (OpenApi.narrowValidation) return OpenApi.validationError(c, paramValidation)
|
|
113
|
+
if (OpenApi.narrowValidation) return OpenApi.validationError(c, bodyValidation)
|
|
114
|
+
|
|
115
|
+
const { address } = c.req.valid('json')
|
|
116
|
+
const limited = await consumeRateLimit(c, address)
|
|
117
|
+
if (limited) return rateLimitError(c, limited)
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const client = c.get('getClient')(Viem.chainId.testnet)
|
|
121
|
+
const receipts = await client.faucet.fundSync({ account: address, timeout: 30_000 })
|
|
122
|
+
const tokens = await Promise.all(
|
|
123
|
+
receipts.map(async (receipt) => {
|
|
124
|
+
const event = client.token.mint.extractEvent(receipt.logs)
|
|
125
|
+
const metadata = await client.token.getMetadata({ token: event.address })
|
|
126
|
+
return {
|
|
127
|
+
address: event.address,
|
|
128
|
+
amount: event.args.amount.toString(),
|
|
129
|
+
currency: metadata.currency,
|
|
130
|
+
decimals: metadata.decimals,
|
|
131
|
+
name: metadata.name,
|
|
132
|
+
symbol: metadata.symbol,
|
|
133
|
+
transactionHash: receipt.transactionHash,
|
|
134
|
+
}
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
const transactionHashes = tokens.map((token) => token.transactionHash)
|
|
138
|
+
return c.json(Response.validated(schema.FundResponse, { tokens, transactionHashes }), 200)
|
|
139
|
+
} catch (cause) {
|
|
140
|
+
return Response.upstream(c, cause)
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function consumeRateLimit(c: Context<Environment>, address: string) {
|
|
147
|
+
const principal = Auth.getPrincipal(c)
|
|
148
|
+
const store = c.get('auth').rateLimit
|
|
149
|
+
if (!principal || !store) return undefined
|
|
150
|
+
|
|
151
|
+
const user = await store.consume({
|
|
152
|
+
key: `faucet:user:${principal.id}`,
|
|
153
|
+
limit: userRateLimit,
|
|
154
|
+
})
|
|
155
|
+
if (!user.allowed) return { result: user, scope: 'faucet-user' }
|
|
156
|
+
|
|
157
|
+
const account = await store.consume({
|
|
158
|
+
key: `faucet:address:${address}`,
|
|
159
|
+
limit: addressRateLimit,
|
|
160
|
+
})
|
|
161
|
+
if (!account.allowed) return { result: account, scope: 'faucet-address' }
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function rateLimitError(
|
|
166
|
+
c: Context<Environment>,
|
|
167
|
+
limited: {
|
|
168
|
+
/** Denied quota result. */
|
|
169
|
+
result: RateLimit.Result
|
|
170
|
+
/** Quota dimension that denied the request. */
|
|
171
|
+
scope: string
|
|
172
|
+
},
|
|
173
|
+
) {
|
|
174
|
+
const { result, scope } = limited
|
|
175
|
+
c.header('RateLimit-Limit', String(result.limit))
|
|
176
|
+
c.header('RateLimit-Remaining', String(result.remaining))
|
|
177
|
+
c.header('RateLimit-Reset', String(result.reset))
|
|
178
|
+
c.header('RateLimit-Scope', scope)
|
|
179
|
+
c.header('Retry-After', String(Math.max(result.reset - Math.ceil(Date.now() / 1_000), 1)))
|
|
180
|
+
return Response.error(c, {
|
|
181
|
+
code: 'rate_limit_exceeded',
|
|
182
|
+
message: 'This account was funded recently. Try again after the cooldown.',
|
|
183
|
+
status: 429,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
@@ -2,6 +2,7 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
|
|
|
2
2
|
|
|
3
3
|
import * as App from '../App.js'
|
|
4
4
|
import * as TestApp from '../../../../test/App.js'
|
|
5
|
+
import * as EarlyAccess from '../../../db/tables/earlyAccess.js'
|
|
5
6
|
import * as Invitations from '../../../db/tables/invitations.js'
|
|
6
7
|
import * as InvitationsApp from './invitations.js'
|
|
7
8
|
import * as Memberships from '../../../db/tables/memberships.js'
|
|
@@ -50,6 +51,7 @@ function createApp(
|
|
|
50
51
|
db?: ReturnType<typeof TestApp.database>
|
|
51
52
|
email?: App.Email
|
|
52
53
|
kv?: { store: ReturnType<typeof TestApp.kvStore> }
|
|
54
|
+
tmp_earlyAccess?: boolean
|
|
53
55
|
} = {},
|
|
54
56
|
) {
|
|
55
57
|
return TestApp.create({
|
|
@@ -58,6 +60,7 @@ function createApp(
|
|
|
58
60
|
...(options.email ? { email: options.email } : {}),
|
|
59
61
|
...(options.kv ? { kv: options.kv } : {}),
|
|
60
62
|
session: { wallet: { origin } },
|
|
63
|
+
...(options.tmp_earlyAccess === undefined ? {} : { tmp_earlyAccess: options.tmp_earlyAccess }),
|
|
61
64
|
})
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -346,6 +349,7 @@ describe('invitations', () => {
|
|
|
346
349
|
const expired = await Invitations.create(db, {
|
|
347
350
|
email: 'dev@example.com',
|
|
348
351
|
expiresAt: '2020-01-01T00:00:00.000Z',
|
|
352
|
+
grantsEarlyAccess: false,
|
|
349
353
|
invitedBy: owner.userId,
|
|
350
354
|
orgId: org.id,
|
|
351
355
|
role: 'member',
|
|
@@ -508,3 +512,171 @@ describe('invitations', () => {
|
|
|
508
512
|
)
|
|
509
513
|
})
|
|
510
514
|
})
|
|
515
|
+
|
|
516
|
+
describe('early access stamping', () => {
|
|
517
|
+
test('does not persist a dormant gate bypass', async () => {
|
|
518
|
+
const db = TestApp.database()
|
|
519
|
+
const app = createApp({ db })
|
|
520
|
+
const owner = await session(app)
|
|
521
|
+
await Users.setEmail(db, owner.userId, 'owner@example.net')
|
|
522
|
+
const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' }))
|
|
523
|
+
const org = await TestApp.json(created, Orgs.schema.Organization)
|
|
524
|
+
|
|
525
|
+
const invited = await app.request(
|
|
526
|
+
`/v1/orgs/${org.id}/invitations`,
|
|
527
|
+
json('POST', owner.cookie, { email: 'owner@example.net', role: 'member' }),
|
|
528
|
+
)
|
|
529
|
+
const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation)
|
|
530
|
+
|
|
531
|
+
expect((await Invitations.get(db, invitation.id))?.grantsEarlyAccess).toBe(false)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
test('stamps grants_early_access from the session inviter', async () => {
|
|
535
|
+
const db = TestApp.database()
|
|
536
|
+
const app = createApp({ db, tmp_earlyAccess: true })
|
|
537
|
+
await EarlyAccess.add(db, { createdBy: 'test', entry: 'example.org' })
|
|
538
|
+
|
|
539
|
+
// A non-early-access inviter stamps no grant.
|
|
540
|
+
const plain = await session(app)
|
|
541
|
+
const createdPlain = await app.request('/v1/orgs', json('POST', plain.cookie, { name: 'Plain' })) // prettier-ignore
|
|
542
|
+
const orgPlain = await TestApp.json(createdPlain, Orgs.schema.Organization)
|
|
543
|
+
const invitedPlain = await app.request(
|
|
544
|
+
`/v1/orgs/${orgPlain.id}/invitations`,
|
|
545
|
+
json('POST', plain.cookie, { email: 'a@example.net', role: 'member' }),
|
|
546
|
+
)
|
|
547
|
+
const invitationPlain = await TestApp.json(invitedPlain, InvitationsApp.schema.Invitation)
|
|
548
|
+
expect((await Invitations.get(db, invitationPlain.id))?.grantsEarlyAccess).toBe(false)
|
|
549
|
+
|
|
550
|
+
// An early-access inviter stamps the grant.
|
|
551
|
+
const granted = await session(app)
|
|
552
|
+
await Users.setEmail(db, granted.userId, 'owner@example.org')
|
|
553
|
+
const createdGranted = await app.request('/v1/orgs', json('POST', granted.cookie, { name: 'Granted' })) // prettier-ignore
|
|
554
|
+
const orgGranted = await TestApp.json(createdGranted, Orgs.schema.Organization)
|
|
555
|
+
const invitedGranted = await app.request(
|
|
556
|
+
`/v1/orgs/${orgGranted.id}/invitations`,
|
|
557
|
+
json('POST', granted.cookie, { email: 'b@example.net', role: 'member' }),
|
|
558
|
+
)
|
|
559
|
+
const invitationGranted = await TestApp.json(invitedGranted, InvitationsApp.schema.Invitation)
|
|
560
|
+
expect((await Invitations.get(db, invitationGranted.id))?.grantsEarlyAccess).toBe(true)
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
test('stamps grants_early_access true for API-key and super-admin inviters', async () => {
|
|
564
|
+
const db = TestApp.database()
|
|
565
|
+
await Organizations.create(db, { id: management.orgId, name: 'Acme' })
|
|
566
|
+
const app = createApp({
|
|
567
|
+
db,
|
|
568
|
+
tmp_earlyAccess: true,
|
|
569
|
+
kv: { store: TestApp.kvStore({ keys: [management] }) },
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
const viaKey = await app.request(`/v1/orgs/${management.orgId}/invitations`, {
|
|
573
|
+
body: JSON.stringify({ email: 'key@example.net', role: 'member' }),
|
|
574
|
+
headers: { 'content-type': 'application/json', 'tempo-api-key': management.token },
|
|
575
|
+
method: 'POST',
|
|
576
|
+
})
|
|
577
|
+
expect(viaKey.status).toBe(200)
|
|
578
|
+
const keyInvitation = await TestApp.json(viaKey, InvitationsApp.schema.Invitation)
|
|
579
|
+
expect((await Invitations.get(db, keyInvitation.id))?.grantsEarlyAccess).toBe(true)
|
|
580
|
+
|
|
581
|
+
const created = await app.request('/v1/orgs', {
|
|
582
|
+
body: JSON.stringify({ name: 'Provisioned' }),
|
|
583
|
+
headers: { 'content-type': 'application/json', 'tempo-api-key': secret },
|
|
584
|
+
method: 'POST',
|
|
585
|
+
})
|
|
586
|
+
const org = await TestApp.json(created, Orgs.schema.Organization)
|
|
587
|
+
const viaAdmin = await app.request(`/v1/orgs/${org.id}/invitations`, {
|
|
588
|
+
body: JSON.stringify({ email: 'admin@example.net', role: 'owner' }),
|
|
589
|
+
headers: { 'content-type': 'application/json', 'tempo-api-key': secret },
|
|
590
|
+
method: 'POST',
|
|
591
|
+
})
|
|
592
|
+
expect(viaAdmin.status).toBe(200)
|
|
593
|
+
const adminInvitation = await TestApp.json(viaAdmin, InvitationsApp.schema.Invitation)
|
|
594
|
+
expect((await Invitations.get(db, adminInvitation.id))?.grantsEarlyAccess).toBe(true)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
test('re-invites upgrade the grant and never downgrade it', async () => {
|
|
598
|
+
const db = TestApp.database()
|
|
599
|
+
const app = createApp({ db, tmp_earlyAccess: true })
|
|
600
|
+
await EarlyAccess.add(db, { createdBy: 'test', entry: 'example.org' })
|
|
601
|
+
const owner = await session(app)
|
|
602
|
+
const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' }))
|
|
603
|
+
const org = await TestApp.json(created, Orgs.schema.Organization)
|
|
604
|
+
const admin = await session(app)
|
|
605
|
+
await Users.setEmail(db, admin.userId, 'admin@example.org')
|
|
606
|
+
await Memberships.create(db, { orgId: org.id, role: 'admin', userId: admin.userId })
|
|
607
|
+
|
|
608
|
+
const first = await app.request(
|
|
609
|
+
`/v1/orgs/${org.id}/invitations`,
|
|
610
|
+
json('POST', owner.cookie, { email: 'x@example.net', role: 'member' }),
|
|
611
|
+
)
|
|
612
|
+
const firstInvite = await TestApp.json(first, InvitationsApp.schema.Invitation)
|
|
613
|
+
expect((await Invitations.get(db, firstInvite.id))?.grantsEarlyAccess).toBe(false)
|
|
614
|
+
|
|
615
|
+
// An early-access re-invite upgrades the same pending row.
|
|
616
|
+
const second = await app.request(
|
|
617
|
+
`/v1/orgs/${org.id}/invitations`,
|
|
618
|
+
json('POST', admin.cookie, { email: 'x@example.net', role: 'member' }),
|
|
619
|
+
)
|
|
620
|
+
const secondInvite = await TestApp.json(second, InvitationsApp.schema.Invitation)
|
|
621
|
+
expect(secondInvite.id).toBe(firstInvite.id)
|
|
622
|
+
expect((await Invitations.get(db, firstInvite.id))?.grantsEarlyAccess).toBe(true)
|
|
623
|
+
|
|
624
|
+
// A later non-early-access re-invite never withdraws the grant.
|
|
625
|
+
const third = await app.request(
|
|
626
|
+
`/v1/orgs/${org.id}/invitations`,
|
|
627
|
+
json('POST', owner.cookie, { email: 'x@example.net', role: 'member' }),
|
|
628
|
+
)
|
|
629
|
+
expect(third.status).toBe(200)
|
|
630
|
+
expect((await Invitations.get(db, firstInvite.id))?.grantsEarlyAccess).toBe(true)
|
|
631
|
+
})
|
|
632
|
+
|
|
633
|
+
test('hasEarlyAccessGrant survives acceptance and expiry, dies on revoke', async () => {
|
|
634
|
+
const db = TestApp.database()
|
|
635
|
+
const app = createApp({ db, tmp_earlyAccess: true })
|
|
636
|
+
await EarlyAccess.add(db, { createdBy: 'test', entry: 'example.org' })
|
|
637
|
+
const owner = await session(app)
|
|
638
|
+
await Users.setEmail(db, owner.userId, 'owner@example.org')
|
|
639
|
+
const created = await app.request('/v1/orgs', json('POST', owner.cookie, { name: 'Acme' }))
|
|
640
|
+
const org = await TestApp.json(created, Orgs.schema.Organization)
|
|
641
|
+
|
|
642
|
+
// Acceptance settles the invitation but keeps the grant.
|
|
643
|
+
const invited = await app.request(
|
|
644
|
+
`/v1/orgs/${org.id}/invitations`,
|
|
645
|
+
json('POST', owner.cookie, { email: 'z@example.net', role: 'member' }),
|
|
646
|
+
)
|
|
647
|
+
const invitation = await TestApp.json(invited, InvitationsApp.schema.Invitation)
|
|
648
|
+
const invitee = await session(app)
|
|
649
|
+
await Users.setEmail(db, invitee.userId, 'z@example.net')
|
|
650
|
+
const accepted = await app.request(`/v1/invitations/${invitation.id}/accept`, {
|
|
651
|
+
headers: { cookie: invitee.cookie },
|
|
652
|
+
method: 'POST',
|
|
653
|
+
})
|
|
654
|
+
expect(accepted.status).toBe(200)
|
|
655
|
+
expect(await Invitations.hasEarlyAccessGrant(db, 'z@example.net')).toBe(true)
|
|
656
|
+
|
|
657
|
+
// Expiry is ignored by the grant lookup.
|
|
658
|
+
await Invitations.create(db, {
|
|
659
|
+
email: 'e@example.net',
|
|
660
|
+
expiresAt: '2020-01-01T00:00:00.000Z',
|
|
661
|
+
grantsEarlyAccess: true,
|
|
662
|
+
invitedBy: owner.userId,
|
|
663
|
+
orgId: org.id,
|
|
664
|
+
role: 'member',
|
|
665
|
+
})
|
|
666
|
+
expect(await Invitations.hasEarlyAccessGrant(db, 'e@example.net')).toBe(true)
|
|
667
|
+
|
|
668
|
+
// Revocation withdraws the grant.
|
|
669
|
+
const pending = await app.request(
|
|
670
|
+
`/v1/orgs/${org.id}/invitations`,
|
|
671
|
+
json('POST', owner.cookie, { email: 'r@example.net', role: 'member' }),
|
|
672
|
+
)
|
|
673
|
+
const pendingInvite = await TestApp.json(pending, InvitationsApp.schema.Invitation)
|
|
674
|
+
expect(await Invitations.hasEarlyAccessGrant(db, 'r@example.net')).toBe(true)
|
|
675
|
+
const revoked = await app.request(`/v1/orgs/${org.id}/invitations/${pendingInvite.id}`, {
|
|
676
|
+
headers: { cookie: owner.cookie },
|
|
677
|
+
method: 'DELETE',
|
|
678
|
+
})
|
|
679
|
+
expect(revoked.status).toBe(200)
|
|
680
|
+
expect(await Invitations.hasEarlyAccessGrant(db, 'r@example.net')).toBe(false)
|
|
681
|
+
})
|
|
682
|
+
})
|
|
@@ -261,12 +261,15 @@ export function invitations() {
|
|
|
261
261
|
const principal = Auth.getPrincipal(c)
|
|
262
262
|
const invitedBy =
|
|
263
263
|
principal?.type === 'api_key' ? principal.id : (caller?.userId ?? 'super_admin')
|
|
264
|
+
// Elevation transfers only from inviters who themselves have early access.
|
|
265
|
+
const grantsEarlyAccess = await Auth.tmp_earlyAccessGrant(c)
|
|
264
266
|
|
|
265
267
|
const db = Db.get(c.get('db'))
|
|
266
268
|
try {
|
|
267
269
|
const record = await Invitations.create(db, {
|
|
268
270
|
email: body.email,
|
|
269
271
|
expiresAt: new Date(Date.now() + ttlDays * 86_400_000).toISOString(),
|
|
272
|
+
grantsEarlyAccess,
|
|
270
273
|
invitedBy,
|
|
271
274
|
orgId: Auth.org(c).id,
|
|
272
275
|
role: body.role,
|
|
@@ -82,6 +82,7 @@ describe('inviteLinks', () => {
|
|
|
82
82
|
|
|
83
83
|
const link = await createLink(context, org.id, admin.cookie, { role: 'member' })
|
|
84
84
|
expect(link).toMatchObject({
|
|
85
|
+
allowedEmailDomains: null,
|
|
85
86
|
enabled: true,
|
|
86
87
|
lastUsedAt: null,
|
|
87
88
|
maxUses: null,
|
|
@@ -143,6 +144,122 @@ describe('inviteLinks', () => {
|
|
|
143
144
|
expect((await listed.json()).data).toEqual([])
|
|
144
145
|
})
|
|
145
146
|
|
|
147
|
+
test('normalizes and validates allowed email domains', async () => {
|
|
148
|
+
const context = createApp()
|
|
149
|
+
const { client } = context
|
|
150
|
+
const { org, owner } = await setup(context)
|
|
151
|
+
const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links']
|
|
152
|
+
|
|
153
|
+
const link = await createLink(context, org.id, owner.cookie, {
|
|
154
|
+
allowedEmailDomains: [' Example.COM ', 'example.com', 'TEMPO.XYZ'],
|
|
155
|
+
name: 'Restricted',
|
|
156
|
+
})
|
|
157
|
+
expect(link.allowedEmailDomains).toEqual(['example.com', 'tempo.xyz'])
|
|
158
|
+
|
|
159
|
+
const listed = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie))
|
|
160
|
+
expect(listed.status).toBe(200)
|
|
161
|
+
if (listed.status !== 200) throw new Error(`Failed to list invite links: ${listed.status}`)
|
|
162
|
+
expect((await listed.json()).data[0]?.allowedEmailDomains).toEqual(['example.com', 'tempo.xyz'])
|
|
163
|
+
|
|
164
|
+
for (const allowedEmailDomains of [
|
|
165
|
+
[],
|
|
166
|
+
['user@example.com'],
|
|
167
|
+
['https://example.com'],
|
|
168
|
+
['*.example.com'],
|
|
169
|
+
]) {
|
|
170
|
+
const response = await orgLinks.$post(
|
|
171
|
+
{
|
|
172
|
+
json: { allowedEmailDomains },
|
|
173
|
+
param: { orgId: org.id },
|
|
174
|
+
},
|
|
175
|
+
as(owner.cookie),
|
|
176
|
+
)
|
|
177
|
+
expect(response.status).toBe(400)
|
|
178
|
+
if (response.status !== 400)
|
|
179
|
+
throw new Error(`Expected invalid invite link body, received ${response.status}`)
|
|
180
|
+
expect((await response.json()).error.code).toBe('body_invalid')
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('restricts new memberships without consuming rejected attempts', async () => {
|
|
185
|
+
const db = TestApp.database()
|
|
186
|
+
const context = createApp(db)
|
|
187
|
+
const { client } = context
|
|
188
|
+
const { org, owner } = await setup(context)
|
|
189
|
+
const orgLinks = client.v1.orgs[':orgId{org_[A-Za-z0-9_-]+}']['invite-links']
|
|
190
|
+
const publicLinks = client.v1['invite-links']
|
|
191
|
+
const link = await createLink(context, org.id, owner.cookie, {
|
|
192
|
+
allowedEmailDomains: [' Example.COM '],
|
|
193
|
+
expiresAt: null,
|
|
194
|
+
maxUses: 1,
|
|
195
|
+
name: 'Restricted',
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
const member = await session(context)
|
|
199
|
+
await Users.setEmail(db, member.userId, 'member@sub.example.com')
|
|
200
|
+
await Memberships.create(db, { orgId: org.id, role: 'member', userId: member.userId })
|
|
201
|
+
const memberRetry = await publicLinks.accept.$post(
|
|
202
|
+
{ json: { token: link.token } },
|
|
203
|
+
as(member.cookie),
|
|
204
|
+
)
|
|
205
|
+
expect(memberRetry.status).toBe(200)
|
|
206
|
+
|
|
207
|
+
const subdomain = await session(context)
|
|
208
|
+
await Users.setEmail(db, subdomain.userId, 'dev@sub.example.com')
|
|
209
|
+
const rejected = await publicLinks.accept.$post(
|
|
210
|
+
{ json: { token: link.token } },
|
|
211
|
+
as(subdomain.cookie),
|
|
212
|
+
)
|
|
213
|
+
expect(rejected.status).toBe(403)
|
|
214
|
+
if (rejected.status !== 403)
|
|
215
|
+
throw new Error(`Expected forbidden invite link acceptance, received ${rejected.status}`)
|
|
216
|
+
expect((await rejected.json()).error.code).toBe('invite_link_email_forbidden')
|
|
217
|
+
expect(await Memberships.get(db, org.id, subdomain.userId)).toBeUndefined()
|
|
218
|
+
|
|
219
|
+
const audits = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get(
|
|
220
|
+
{ param: { inviteLinkId: link.id, orgId: org.id } },
|
|
221
|
+
as(owner.cookie),
|
|
222
|
+
)
|
|
223
|
+
expect(audits.status).toBe(200)
|
|
224
|
+
if (audits.status !== 200) throw new Error(`Failed to list redemptions: ${audits.status}`)
|
|
225
|
+
expect((await audits.json()).data).toEqual([])
|
|
226
|
+
|
|
227
|
+
const beforeJoin = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie))
|
|
228
|
+
expect(beforeJoin.status).toBe(200)
|
|
229
|
+
if (beforeJoin.status !== 200)
|
|
230
|
+
throw new Error(`Failed to list invite links: ${beforeJoin.status}`)
|
|
231
|
+
expect((await beforeJoin.json()).data[0]).toMatchObject({ status: 'active', useCount: 0 })
|
|
232
|
+
|
|
233
|
+
const exact = await session(context)
|
|
234
|
+
await Users.setEmail(db, exact.userId, 'Dev@Example.com')
|
|
235
|
+
const accepted = await publicLinks.accept.$post(
|
|
236
|
+
{ json: { token: link.token } },
|
|
237
|
+
as(exact.cookie),
|
|
238
|
+
)
|
|
239
|
+
expect(accepted.status).toBe(200)
|
|
240
|
+
|
|
241
|
+
const exhaustedRetry = await publicLinks.accept.$post(
|
|
242
|
+
{ json: { token: link.token } },
|
|
243
|
+
as(member.cookie),
|
|
244
|
+
)
|
|
245
|
+
expect(exhaustedRetry.status).toBe(200)
|
|
246
|
+
|
|
247
|
+
const afterJoin = await orgLinks.$get({ param: { orgId: org.id } }, as(owner.cookie))
|
|
248
|
+
expect(afterJoin.status).toBe(200)
|
|
249
|
+
if (afterJoin.status !== 200)
|
|
250
|
+
throw new Error(`Failed to list invite links: ${afterJoin.status}`)
|
|
251
|
+
expect((await afterJoin.json()).data[0]).toMatchObject({ status: 'exhausted', useCount: 1 })
|
|
252
|
+
|
|
253
|
+
const auditsAfterJoin = await orgLinks[':inviteLinkId{iln_[A-Za-z0-9_-]+}'].redemptions.$get(
|
|
254
|
+
{ param: { inviteLinkId: link.id, orgId: org.id } },
|
|
255
|
+
as(owner.cookie),
|
|
256
|
+
)
|
|
257
|
+
expect(auditsAfterJoin.status).toBe(200)
|
|
258
|
+
if (auditsAfterJoin.status !== 200)
|
|
259
|
+
throw new Error(`Failed to list redemptions: ${auditsAfterJoin.status}`)
|
|
260
|
+
expect((await auditsAfterJoin.json()).data).toHaveLength(1)
|
|
261
|
+
})
|
|
262
|
+
|
|
146
263
|
test('resolve reveals only a summary and hides every unavailable reason', async () => {
|
|
147
264
|
const db = TestApp.database()
|
|
148
265
|
const context = createApp(db)
|