@tellescope/sdk 1.255.16 → 1.255.17

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 (35) hide show
  1. package/lib/cjs/sdk.d.ts +1 -0
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js.map +1 -1
  4. package/lib/cjs/tests/api_tests/organization_api_keys.test.js +2 -2
  5. package/lib/cjs/tests/api_tests/organization_api_keys.test.js.map +1 -1
  6. package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts +6 -0
  7. package/lib/cjs/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -0
  8. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js +314 -0
  9. package/lib/cjs/tests/api_tests/scoped_api_keys.test.js.map +1 -0
  10. package/lib/cjs/tests/tests.d.ts.map +1 -1
  11. package/lib/cjs/tests/tests.js +147 -143
  12. package/lib/cjs/tests/tests.js.map +1 -1
  13. package/lib/esm/enduser.d.ts +0 -1
  14. package/lib/esm/enduser.d.ts.map +1 -1
  15. package/lib/esm/sdk.d.ts +3 -3
  16. package/lib/esm/sdk.d.ts.map +1 -1
  17. package/lib/esm/sdk.js.map +1 -1
  18. package/lib/esm/session.d.ts +0 -1
  19. package/lib/esm/session.d.ts.map +1 -1
  20. package/lib/esm/tests/api_tests/organization_api_keys.test.js +2 -2
  21. package/lib/esm/tests/api_tests/organization_api_keys.test.js.map +1 -1
  22. package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts +6 -0
  23. package/lib/esm/tests/api_tests/scoped_api_keys.test.d.ts.map +1 -0
  24. package/lib/esm/tests/api_tests/scoped_api_keys.test.js +307 -0
  25. package/lib/esm/tests/api_tests/scoped_api_keys.test.js.map +1 -0
  26. package/lib/esm/tests/tests.d.ts.map +1 -1
  27. package/lib/esm/tests/tests.js +147 -143
  28. package/lib/esm/tests/tests.js.map +1 -1
  29. package/lib/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +8 -8
  31. package/src/sdk.ts +3 -0
  32. package/src/tests/api_tests/organization_api_keys.test.ts +2 -2
  33. package/src/tests/api_tests/scoped_api_keys.test.ts +237 -0
  34. package/src/tests/tests.ts +2 -0
  35. package/test_generated.pdf +0 -0
@@ -12,7 +12,7 @@ import { setup_tests } from "../setup"
12
12
  const host = process.env.API_URL || 'http://localhost:8080' as const
13
13
 
14
14
  // fields that the admin org-wide list endpoint is allowed to return
15
- const ALLOWED_FIELDS = ['id', 'creator', 'businessId', 'updatedAt'].sort()
15
+ const ALLOWED_FIELDS = ['id', 'creator', 'businessId', 'updatedAt', 'scopes'].sort()
16
16
 
17
17
  export const organization_api_keys_tests = async (
18
18
  { sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }
@@ -104,7 +104,7 @@ export const organization_api_keys_tests = async (
104
104
  assert(
105
105
  offending === undefined,
106
106
  `a returned record exposed non-allowlisted fields: ${JSON.stringify(offending)}`,
107
- "returned records contain only allowlisted fields (subset of id, creator, businessId, updatedAt)"
107
+ "returned records contain only allowlisted fields (subset of id, creator, businessId, updatedAt, scopes)"
108
108
  )
109
109
 
110
110
  // =============================================
@@ -0,0 +1,237 @@
1
+ require('source-map-support').install();
2
+
3
+ import axios from "axios"
4
+ import { Session } from "../../sdk"
5
+ import {
6
+ assert,
7
+ async_test,
8
+ log_header,
9
+ wait,
10
+ } from "@tellescope/testing"
11
+ import { setup_tests } from "../setup"
12
+ import { FORM_INGESTION_API_KEY_SCOPE } from "@tellescope/types-models"
13
+
14
+ const host = process.env.API_URL || "http://localhost:8080"
15
+
16
+ const TEST_EMAIL = "scoped-api-keys@tellescope.com"
17
+
18
+ export const scoped_api_keys_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
19
+ log_header("Scoped API Keys (form-ingestion)")
20
+
21
+ const form = await sdk.api.forms.createOne({ title: "Scoped API Key Test Form" })
22
+
23
+ const buildUrl = (pathSegment: string) => {
24
+ const url = new URL(`${host}/v1/webhooks/form-ingestion/${pathSegment}`)
25
+ url.searchParams.set('formId', form.id)
26
+ url.searchParams.set('returnJSON', 'true')
27
+ return url.toString()
28
+ }
29
+
30
+ const bodyFor = (responder_uuid: string) => ({
31
+ answers: [{ key: 'email', value: TEST_EMAIL }],
32
+ responder_uuid,
33
+ finalized: true,
34
+ })
35
+
36
+ const keyIds: string[] = []
37
+ const formResponseIds: string[] = []
38
+ let throwawayUserId: string | undefined
39
+ let throwawayRoleId: string | undefined
40
+
41
+ try {
42
+ // =============================================
43
+ // (a) Admin creates a scoped key
44
+ // =============================================
45
+ const scoped = await sdk.api.api_keys.createOne({ scopes: [FORM_INGESTION_API_KEY_SCOPE] })
46
+ keyIds.push(scoped.id)
47
+ assert(!!scoped.id && !!scoped.key, "scoped key create returned no id/key", "admin creates a form-ingestion-scoped key")
48
+
49
+ // record reflects the scope for its creator (CREATOR_ONLY_ACCESS read)
50
+ await async_test(
51
+ "scoped key record stores scopes",
52
+ () => sdk.api.api_keys.getOne(scoped.id),
53
+ { onResult: k => JSON.stringify(k.scopes) === JSON.stringify([FORM_INGESTION_API_KEY_SCOPE]) },
54
+ )
55
+
56
+ // =============================================
57
+ // (b) Scoped key is rejected on all general /v1 auth
58
+ // =============================================
59
+ await async_test(
60
+ "scoped key rejected on /v1/test-authenticated",
61
+ () => new Session({ host, apiKey: scoped.key }).test_authenticated(),
62
+ // SDK parseError surfaces a raw 401 body as the string 'Unauthenticated'
63
+ { shouldError: true, onError: (e: any) => e === 'Unauthenticated' || e?.statusCode === 401 || /unauthenticated/i.test(e?.message || '') },
64
+ )
65
+ await async_test(
66
+ "scoped key rejected on GET /v1/endusers",
67
+ () => axios.get(`${host}/v1/endusers`, { headers: { Authorization: `API_KEY ${scoped.key}` } }),
68
+ { shouldError: true, onError: (e: any) => e.response?.status === 401 },
69
+ )
70
+ await async_test(
71
+ "scoped key rejected on cross-org targeting (x-tellescope-organization)",
72
+ () => axios.get(`${host}/v1/endusers`, {
73
+ headers: {
74
+ Authorization: `API_KEY ${scoped.key}`,
75
+ 'x-tellescope-organization': sdk.userInfo.businessId,
76
+ },
77
+ }),
78
+ { shouldError: true, onError: (e: any) => e.response?.status === 401 },
79
+ )
80
+
81
+ // =============================================
82
+ // (c) Scoped key IS accepted by the form-ingestion webhook
83
+ // =============================================
84
+ const headerResult = await axios.post(
85
+ buildUrl('key-in-header'),
86
+ bodyFor("scoped-1"),
87
+ { headers: { Authorization: `API_KEY ${scoped.key}` } },
88
+ )
89
+ assert(
90
+ headerResult.status === 200 && !!headerResult.data?.formResponseId,
91
+ `scoped key header auth failed: ${headerResult.status} ${JSON.stringify(headerResult.data)}`,
92
+ "scoped key authenticates the form-ingestion webhook via header",
93
+ )
94
+ if (headerResult.data?.formResponseId) formResponseIds.push(headerResult.data.formResponseId)
95
+
96
+ const pathResult = await axios.post(buildUrl(scoped.key), bodyFor("scoped-2"))
97
+ assert(
98
+ pathResult.status === 200 && !!pathResult.data?.formResponseId,
99
+ `scoped key path auth failed: ${pathResult.status} ${JSON.stringify(pathResult.data)}`,
100
+ "scoped key authenticates the form-ingestion webhook via path segment",
101
+ )
102
+ if (pathResult.data?.formResponseId) formResponseIds.push(pathResult.data.formResponseId)
103
+
104
+ // =============================================
105
+ // (d) Non-admin cannot create a scoped key (throwaway user, never mutate existing roles)
106
+ // =============================================
107
+ const apiKeysRole = await sdk.api.role_based_access_permissions.createOne({
108
+ role: 'scoped-api-keys-test-role',
109
+ permissions: { api_keys: { create: 'All', read: 'All', update: 'All', delete: 'All' } },
110
+ })
111
+ throwawayRoleId = apiKeysRole.id
112
+
113
+ const throwawayUser = (
114
+ await sdk.api.users.getOne({ email: 'scoped.api.keys.user@tellescope.com' }).catch(() => null)
115
+ ) || (
116
+ await sdk.api.users.createOne({ email: 'scoped.api.keys.user@tellescope.com', notificationEmailsDisabled: true, verifiedEmail: true })
117
+ )
118
+ throwawayUserId = throwawayUser.id
119
+ await sdk.api.users.updateOne(throwawayUser.id, { roles: [apiKeysRole.role] }, { replaceObjectFields: true })
120
+ await wait(undefined, 2000) // wait for the role change to propagate before authenticating
121
+
122
+ const otherSdk = new Session({
123
+ host,
124
+ authToken: (await sdk.api.users.generate_auth_token({ id: throwawayUser.id })).authToken,
125
+ })
126
+
127
+ await async_test(
128
+ "non-admin cannot create a scoped key (401)",
129
+ () => otherSdk.api.api_keys.createOne({ scopes: [FORM_INGESTION_API_KEY_SCOPE] }),
130
+ { shouldError: true, onError: (e: any) => e === 'Unauthenticated' || e?.statusCode === 401 || /admin/i.test(e?.message || '') },
131
+ )
132
+
133
+ // ...but CAN still create an unscoped key; an empty scopes list is rejected by validation (fail closed)
134
+ const nonAdminKey = await otherSdk.api.api_keys.createOne({})
135
+ await async_test(
136
+ "empty scopes list rejected by validation",
137
+ () => otherSdk.api.api_keys.createOne({ scopes: [] }),
138
+ { shouldError: true, onError: (e: any) => e?.statusCode === 400 || /empty|not valid|invalid/i.test(e?.message || '') },
139
+ )
140
+ // creator-only cleanup for the throwaway user's key before the user is deleted
141
+ await otherSdk.api.api_keys.deleteOne(nonAdminKey.id).catch(() => {})
142
+
143
+ // =============================================
144
+ // (e) Unscoped keys are unchanged (no regression)
145
+ // =============================================
146
+ const unscoped = await sdk.api.api_keys.createOne({})
147
+ const unscopedKeyValue = (unscoped as any).key as string
148
+ keyIds.push(unscoped.id)
149
+
150
+ await async_test(
151
+ "unscoped key still authenticates general /v1",
152
+ () => new Session({ host, apiKey: unscopedKeyValue }).test_authenticated(),
153
+ { onResult: r => !!r },
154
+ )
155
+ // Frozen pre-existing behavior: unscoped /v1-created keys store a utf8-encoded hashedKey, which the
156
+ // webhook's hex lookup never matches — only legacy plaintext keys and hex-stored scoped keys work there.
157
+ // (Legacy-key webhook auth is covered by formsort_header_auth.test.ts.)
158
+ await async_test(
159
+ "unscoped /v1-created key still does NOT authenticate the webhook (frozen encoding quirk)",
160
+ () => axios.post(
161
+ buildUrl('key-in-header'),
162
+ bodyFor("scoped-3"),
163
+ { headers: { Authorization: `API_KEY ${unscopedKeyValue}` } },
164
+ ),
165
+ { shouldError: true, onError: (e: any) => e.response?.status === 401 },
166
+ )
167
+
168
+ // =============================================
169
+ // (f) Admin org-wide list includes scopes (and still never leaks secrets)
170
+ // =============================================
171
+ const { apiKeys } = await sdk.api.api_keys.get_organization_api_keys()
172
+ assert(
173
+ !!apiKeys.find(k => k.id === scoped.id && JSON.stringify(k.scopes) === JSON.stringify([FORM_INGESTION_API_KEY_SCOPE])),
174
+ "org list did not report scopes on the scoped key",
175
+ "get_organization_api_keys reports scopes on scoped keys",
176
+ )
177
+ assert(
178
+ !!apiKeys.find(k => k.id === unscoped.id && k.scopes === undefined),
179
+ "org list reported scopes on an unscoped key",
180
+ "get_organization_api_keys omits scopes on unscoped keys",
181
+ )
182
+ assert(
183
+ apiKeys.every(k => (k as any).key === undefined && (k as any).hashedKey === undefined),
184
+ "org list leaked key material",
185
+ "get_organization_api_keys still never returns key/hashedKey",
186
+ )
187
+
188
+ // =============================================
189
+ // Validation: unknown scope values are rejected
190
+ // =============================================
191
+ await async_test(
192
+ "unknown scope value rejected by validation",
193
+ () => sdk.api.api_keys.createOne({ scopes: ['not-a-real-scope'] as any }),
194
+ { shouldError: true, onError: (e: any) => e?.statusCode === 400 || /must match|not valid|invalid/i.test(e?.message || '') },
195
+ )
196
+ } finally {
197
+ for (const id of keyIds) {
198
+ await sdk.api.api_keys.deleteOne(id).catch(() => {})
199
+ }
200
+ for (const id of formResponseIds) {
201
+ await sdk.api.form_responses.deleteOne(id).catch(() => {})
202
+ }
203
+ try {
204
+ const enduser = await sdk.api.endusers.getOne({ email: TEST_EMAIL })
205
+ await sdk.api.endusers.deleteOne(enduser.id)
206
+ } catch (err) { /* may not exist */ }
207
+ await sdk.api.forms.deleteOne(form.id).catch(() => {})
208
+ if (throwawayUserId) {
209
+ await sdk.api.users.deleteOne(throwawayUserId).catch(() => {})
210
+ }
211
+ if (throwawayRoleId) {
212
+ await sdk.api.role_based_access_permissions.deleteOne(throwawayRoleId).catch(() => {})
213
+ }
214
+ }
215
+ }
216
+
217
+ // Allow running this test file independently
218
+ if (require.main === module) {
219
+ console.log(`🌐 Using API URL: ${host}`)
220
+ const sdk = new Session({ host })
221
+ const sdkNonAdmin = new Session({ host })
222
+
223
+ const runTests = async () => {
224
+ await setup_tests(sdk, sdkNonAdmin)
225
+ await scoped_api_keys_tests({ sdk, sdkNonAdmin })
226
+ }
227
+
228
+ runTests()
229
+ .then(() => {
230
+ console.log("✅ Scoped API keys test suite completed successfully")
231
+ process.exit(0)
232
+ })
233
+ .catch((error) => {
234
+ console.error("❌ Scoped API keys test suite failed:", error)
235
+ process.exit(1)
236
+ })
237
+ }
@@ -87,6 +87,7 @@ import { enduser_login_rate_limits_tests } from "./api_tests/enduser_login_rate_
87
87
  import { eom_procedure_codes_tests } from "./api_tests/eom_procedure_codes.test";
88
88
  import { cross_org_api_key_tests } from "./api_tests/cross_org_api_key.test";
89
89
  import { organization_api_keys_tests } from "./api_tests/organization_api_keys.test";
90
+ import { scoped_api_keys_tests } from "./api_tests/scoped_api_keys.test";
90
91
  import { custom_dashboards_tests } from "./api_tests/custom_dashboards.test";
91
92
  import { message_assignment_trigger_tests } from "./api_tests/message_assignment_trigger.test";
92
93
  import { outbound_chat_sent_trigger_tests } from "./api_tests/outbound_chat_sent_trigger.test";
@@ -15084,6 +15085,7 @@ const ip_address_form_tests = async () => {
15084
15085
  await eom_procedure_codes_tests({ sdk, sdkNonAdmin })
15085
15086
  await cross_org_api_key_tests({ sdk, sdkNonAdmin })
15086
15087
  await organization_api_keys_tests({ sdk, sdkNonAdmin })
15088
+ await scoped_api_keys_tests({ sdk, sdkNonAdmin })
15087
15089
  await organization_settings_duplicates_tests({ sdk, sdkNonAdmin })
15088
15090
  await enduser_session_invalidation_tests({ sdk, sdkNonAdmin })
15089
15091
  await chats_analytics_tests({ sdk, sdkNonAdmin })
Binary file