@csntgao/uni-base 0.1.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 (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
  7. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
  8. package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
  9. package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
@@ -0,0 +1,418 @@
1
+ import type {
2
+ ApplicationEntry,
3
+ ApplicationSwitcherTransport,
4
+ OrganizationEntry,
5
+ OrganizationListResult,
6
+ } from './application-switcher-transport'
7
+ import { resolveUniplatBaseApplicationEntryUrl } from './uniplat-base-application-ticket'
8
+ import { createIdentityTicketLaunchResult } from './uniplat-base-identity-ticket'
9
+
10
+ export interface UniplatBaseApplicationSwitcherTransportOptions {
11
+ serviceBaseUrl: string
12
+ /** @deprecated Application Switcher ticket navigation does not replace the current Host JWT. */
13
+ setAccessToken?(accessToken: string): void | Promise<void>
14
+ fetch?: typeof fetch
15
+ }
16
+
17
+ interface UniplatBaseIdentityTarget {
18
+ id: string
19
+ xid: string
20
+ applicationName: string
21
+ }
22
+
23
+ interface UniplatBaseApplicationTarget extends UniplatBaseIdentityTarget {
24
+ organizationId: string
25
+ applicationScope: 'open' | 'organization'
26
+ launchTarget:
27
+ { kind: 'local' } | { kind: 'external'; targetSystem: string; targetApplicationName: string }
28
+ entry: ApplicationEntry
29
+ }
30
+
31
+ interface UniplatBaseApplicationSnapshot {
32
+ currentOrganizationId: string
33
+ applications: readonly UniplatBaseApplicationTarget[]
34
+ }
35
+
36
+ const LIST_PATH = 'application.identity/list_available_applications'
37
+ const ORGANIZATIONS_PATH = 'application.identity/list_available_organizations'
38
+ const ISSUE_TICKET_PATH = 'application.identity/issue_ticket'
39
+
40
+ export function createUniplatBaseApplicationSwitcherTransport(
41
+ options: UniplatBaseApplicationSwitcherTransportOptions,
42
+ ): ApplicationSwitcherTransport {
43
+ if (!options) throw new TypeError('UniplatBase transport options are required')
44
+ const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
45
+ const listEndpoint = `${serviceBaseUrl}/${LIST_PATH}`
46
+ const organizationsEndpoint = `${serviceBaseUrl}/${ORGANIZATIONS_PATH}`
47
+ const issueTicketEndpoint = `${serviceBaseUrl}/${ISSUE_TICKET_PATH}`
48
+ const fetchImplementation = options.fetch ?? globalThis.fetch
49
+ let pendingApplicationAccessToken = ''
50
+ let pendingApplications: Promise<UniplatBaseApplicationSnapshot> | null = null
51
+ let pendingOrganizationAccessToken = ''
52
+ let pendingOrganizations: Promise<readonly OrganizationEntry[]> | null = null
53
+ let selectedOrganizationId = ''
54
+ let selectedCredentialMarker = ''
55
+
56
+ const loadApplications = (accessToken: string): Promise<UniplatBaseApplicationSnapshot> => {
57
+ const token = requireAccessToken(accessToken)
58
+ if (pendingApplications && pendingApplicationAccessToken === token) return pendingApplications
59
+
60
+ pendingApplicationAccessToken = token
61
+ const request = requestData(fetchImplementation, listEndpoint, token, {}).then(
62
+ parseApplicationsSnapshot,
63
+ )
64
+ pendingApplications = request
65
+ void request.then(clearPendingApplications, clearPendingApplications)
66
+ return request
67
+
68
+ function clearPendingApplications(): void {
69
+ if (pendingApplications === request) {
70
+ pendingApplications = null
71
+ pendingApplicationAccessToken = ''
72
+ }
73
+ }
74
+ }
75
+
76
+ const loadOrganizations = (accessToken: string): Promise<readonly OrganizationEntry[]> => {
77
+ const token = requireAccessToken(accessToken)
78
+ if (pendingOrganizations && pendingOrganizationAccessToken === token)
79
+ return pendingOrganizations
80
+
81
+ pendingOrganizationAccessToken = token
82
+ const request = requestData(fetchImplementation, organizationsEndpoint, token, {}).then(
83
+ parseOrganizations,
84
+ )
85
+ pendingOrganizations = request
86
+ void request.then(clearPendingOrganizations, clearPendingOrganizations)
87
+ return request
88
+
89
+ function clearPendingOrganizations(): void {
90
+ if (pendingOrganizations === request) {
91
+ pendingOrganizations = null
92
+ pendingOrganizationAccessToken = ''
93
+ }
94
+ }
95
+ }
96
+
97
+ const createApplicationTicket = async (
98
+ accessToken: string,
99
+ target: UniplatBaseApplicationTarget,
100
+ ) => {
101
+ const token = requireAccessToken(accessToken)
102
+ const entryUrl = resolveUniplatBaseApplicationEntryUrl(target.entry.url)
103
+ const data = await requestData(fetchImplementation, issueTicketEndpoint, token, {})
104
+ return createIdentityTicketLaunchResult(
105
+ entryUrl,
106
+ data.ticket,
107
+ data.expires_in,
108
+ data.source_system,
109
+ )
110
+ }
111
+
112
+ const resolveSelectedOrganizationId = (
113
+ snapshot: UniplatBaseApplicationSnapshot,
114
+ accessToken: string,
115
+ ): string => {
116
+ const credentialMarker = markCredentialRevision(accessToken)
117
+ if (credentialMarker !== selectedCredentialMarker) {
118
+ selectedCredentialMarker = credentialMarker
119
+ selectedOrganizationId =
120
+ snapshot.currentOrganizationId === '0' ? '' : snapshot.currentOrganizationId
121
+ return selectedOrganizationId
122
+ }
123
+ if (selectedOrganizationId) return selectedOrganizationId
124
+ // oid=0 means the open-application identity is not bound to an organization.
125
+ // Do not silently select the first available organization: that would expose
126
+ // organization-scoped applications before the user chooses an organization.
127
+ selectedOrganizationId =
128
+ snapshot.currentOrganizationId === '0' ? '' : snapshot.currentOrganizationId
129
+ return selectedOrganizationId
130
+ }
131
+
132
+ return {
133
+ async listApplications(accessToken) {
134
+ const snapshot = await loadApplications(accessToken)
135
+ const organizationId = resolveSelectedOrganizationId(snapshot, accessToken)
136
+ return snapshot.applications
137
+ .filter(
138
+ (target) =>
139
+ target.organizationId === organizationId || target.applicationScope === 'open',
140
+ )
141
+ .map((target) => ({ ...target.entry }))
142
+ },
143
+ async listOrganizations(accessToken): Promise<OrganizationListResult> {
144
+ const [snapshot, organizations] = await Promise.all([
145
+ loadApplications(accessToken),
146
+ loadOrganizations(accessToken),
147
+ ])
148
+ const currentOrganizationId = resolveSelectedOrganizationId(snapshot, accessToken)
149
+ return {
150
+ organizations: organizations.map((organization) => ({ ...organization })),
151
+ currentOrganizationId,
152
+ }
153
+ },
154
+ async switchOrganization(accessToken, organizationId) {
155
+ const token = requireAccessToken(accessToken)
156
+ const organizations = await loadOrganizations(accessToken)
157
+ const normalizedOrganizationId = organizationId.trim()
158
+ const available = organizations.some((candidate) => candidate.id === normalizedOrganizationId)
159
+ if (!available) throw new Error('Organization is unavailable')
160
+ selectedCredentialMarker = markCredentialRevision(token)
161
+ selectedOrganizationId = normalizedOrganizationId
162
+ },
163
+ async switchApplication(accessToken, applicationId) {
164
+ const snapshot = await loadApplications(accessToken)
165
+ const organizationId = resolveSelectedOrganizationId(snapshot, accessToken)
166
+ const selectedTarget = snapshot.applications.find(
167
+ (candidate) =>
168
+ candidate.id === applicationId.trim() &&
169
+ (candidate.organizationId === organizationId || candidate.applicationScope === 'open'),
170
+ )
171
+ if (!selectedTarget) throw new Error('Application is unavailable')
172
+ return createApplicationTicket(accessToken, selectedTarget)
173
+ },
174
+ }
175
+ }
176
+
177
+ function markCredentialRevision(accessToken: string): string {
178
+ let first = 0x811c9dc5
179
+ let second = 0x9e3779b9
180
+ for (let index = 0; index < accessToken.length; index += 1) {
181
+ const code = accessToken.charCodeAt(index)
182
+ first = Math.imul(first ^ code, 0x01000193)
183
+ second = Math.imul(second ^ (code + index), 0x85ebca6b)
184
+ }
185
+ return `${accessToken.length}:${(first >>> 0).toString(16)}:${(second >>> 0).toString(16)}`
186
+ }
187
+
188
+ async function requestData(
189
+ fetchImplementation: typeof fetch | undefined,
190
+ endpoint: string,
191
+ accessToken: string,
192
+ body: Record<string, unknown>,
193
+ ): Promise<Record<string, unknown>> {
194
+ if (!fetchImplementation) throw new Error('Fetch is unavailable')
195
+ const response = await fetchImplementation(endpoint, {
196
+ method: 'POST',
197
+ credentials: 'include',
198
+ headers: {
199
+ accept: 'application/json',
200
+ authorization: `Bearer ${accessToken}`,
201
+ 'content-type': 'application/json',
202
+ },
203
+ body: JSON.stringify(body),
204
+ })
205
+ if (!response.ok) throw new Error('UniplatBase application request failed')
206
+
207
+ const envelope = asRecord(await response.json())
208
+ if (number(envelope.rescode) !== 0) throw new Error('UniplatBase application request failed')
209
+ const data = asRecord(envelope.data)
210
+ if (Object.keys(data).length === 0) throw new Error('UniplatBase application response is invalid')
211
+ return data
212
+ }
213
+
214
+ function parseApplicationsSnapshot(
215
+ applicationsData: Record<string, unknown>,
216
+ ): UniplatBaseApplicationSnapshot {
217
+ const currentIdentity = asRecord(applicationsData.current_identity)
218
+ const currentOrganizationId = identifier(currentIdentity.oid)
219
+ const currentApplicationName = text(currentIdentity.application_name).trim()
220
+ if (
221
+ !applicationIdentifier(currentIdentity.xid) ||
222
+ currentOrganizationId === '' ||
223
+ !currentApplicationName
224
+ ) {
225
+ throw new Error('UniplatBase current identity is invalid')
226
+ }
227
+
228
+ const availableApplications = applicationsData.available_applications
229
+ if (!Array.isArray(availableApplications)) {
230
+ throw new Error('UniplatBase application response is invalid')
231
+ }
232
+
233
+ const applicationEntryKeys = new Set<string>()
234
+ const parsedApplications: Array<{
235
+ target: UniplatBaseApplicationTarget
236
+ baseId: string
237
+ entryName: string
238
+ }> = []
239
+ for (const candidate of availableApplications) {
240
+ const record = asRecord(candidate)
241
+ const xid = applicationIdentifier(record.xid)
242
+ const applicationName = text(record.application_name).trim()
243
+ const organizationId = identifier(record.oid)
244
+ const applicationScope = applicationScopeValue(record.application_scope, organizationId)
245
+ const name = text(record.application_title).trim()
246
+ const url = text(record.entry_url).trim()
247
+ const entryName = text(record.entry_name).trim()
248
+ if (!xid || !applicationName || organizationId === '' || !name || !url) {
249
+ continue
250
+ }
251
+
252
+ // An application identity can expose multiple entries. In particular,
253
+ // OfficialWebsite has the shared open identity (xid=0) for its main,
254
+ // personal and organizational entries. Deduplicating only by identity
255
+ // would silently collapse those entries into one tile.
256
+ const identityKey = `${applicationName}:${xid}`
257
+ const entryKey = JSON.stringify([applicationName, xid, entryName, url])
258
+ if (applicationEntryKeys.has(entryKey)) continue
259
+ const entry: ApplicationEntry = { id: '', name, url }
260
+ const launchTarget = parseApplicationLaunchTarget(record)
261
+ const iconUrl = text(record.icon_url).trim()
262
+ const description = text(record.description).trim()
263
+ if (iconUrl) entry.iconUrl = iconUrl
264
+ if (description) entry.description = description
265
+ if (record.open_mode === 'blank') entry.openMode = 'blank'
266
+ parsedApplications.push({
267
+ target: {
268
+ id: '',
269
+ xid,
270
+ applicationName,
271
+ organizationId,
272
+ applicationScope,
273
+ launchTarget,
274
+ entry,
275
+ },
276
+ baseId: xid === '0' ? identityKey : xid,
277
+ entryName,
278
+ })
279
+ applicationEntryKeys.add(entryKey)
280
+ }
281
+
282
+ const baseIdCounts = new Map<string, number>()
283
+ for (const { baseId } of parsedApplications) {
284
+ baseIdCounts.set(baseId, (baseIdCounts.get(baseId) ?? 0) + 1)
285
+ }
286
+ const usedApplicationIds = new Set<string>()
287
+ const applications: UniplatBaseApplicationTarget[] = []
288
+ for (const [index, parsed] of parsedApplications.entries()) {
289
+ let id = parsed.baseId
290
+ if ((baseIdCounts.get(id) ?? 0) > 1) {
291
+ const normalizedEntryName = parsed.entryName
292
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
293
+ .replace(/^-+|-+$/g, '')
294
+ .slice(0, 48)
295
+ const baseEntryId = `${id}:${normalizedEntryName || `entry-${index + 1}`}`
296
+ id = baseEntryId
297
+ let collision = 2
298
+ while (usedApplicationIds.has(id)) {
299
+ id = `${baseEntryId}-${collision}`
300
+ collision += 1
301
+ }
302
+ }
303
+ parsed.target.id = id
304
+ parsed.target.entry.id = id
305
+ usedApplicationIds.add(id)
306
+ applications.push(parsed.target)
307
+ }
308
+
309
+ return { currentOrganizationId, applications }
310
+ }
311
+
312
+ function parseApplicationLaunchTarget(
313
+ application: Record<string, unknown>,
314
+ ): UniplatBaseApplicationTarget['launchTarget'] {
315
+ if (!Object.prototype.hasOwnProperty.call(application, 'launch_target')) {
316
+ return { kind: 'local' }
317
+ }
318
+ const launchTarget = asRecord(application.launch_target)
319
+ const keys = Object.keys(launchTarget).sort()
320
+ const expectedKeys = ['application_name', 'target_system', 'type']
321
+ const targetSystem = text(launchTarget.target_system).trim()
322
+ const targetApplicationName = text(launchTarget.application_name).trim()
323
+ if (
324
+ keys.length !== expectedKeys.length ||
325
+ keys.some((key, index) => key !== expectedKeys[index]) ||
326
+ launchTarget.type !== 'external' ||
327
+ !targetSystem ||
328
+ targetSystem.length > 128 ||
329
+ !targetApplicationName ||
330
+ targetApplicationName.length > 255
331
+ ) {
332
+ throw new Error('UniplatBase external application target is invalid')
333
+ }
334
+ return { kind: 'external', targetSystem, targetApplicationName }
335
+ }
336
+
337
+ function parseOrganizations(
338
+ organizationsData: Record<string, unknown>,
339
+ ): readonly OrganizationEntry[] {
340
+ if (!Array.isArray(organizationsData.organizations)) {
341
+ throw new Error('UniplatBase organization response is invalid')
342
+ }
343
+ const organizationIds = new Set<string>()
344
+ const organizations: OrganizationEntry[] = []
345
+ for (const candidate of organizationsData.organizations) {
346
+ const record = asRecord(candidate)
347
+ const id = identifier(record.oid)
348
+ if (id === '' || organizationIds.has(id)) {
349
+ continue
350
+ }
351
+ const name = text(record.team_name).trim() || `组织 ${id}`
352
+ organizations.push({ id, name })
353
+ organizationIds.add(id)
354
+ }
355
+
356
+ return organizations
357
+ }
358
+
359
+ function validateServiceBaseUrl(rawValue: string): string {
360
+ const value = typeof rawValue === 'string' ? rawValue.trim() : ''
361
+ if (!value) throw new TypeError('UniplatBase serviceBaseUrl is required')
362
+ const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
363
+ let url: URL
364
+ try {
365
+ url = new URL(value, documentBase)
366
+ } catch {
367
+ throw new TypeError('UniplatBase serviceBaseUrl is invalid')
368
+ }
369
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
370
+ throw new TypeError('UniplatBase serviceBaseUrl must use HTTP(S) without user credentials')
371
+ }
372
+ if (url.search || url.hash) {
373
+ throw new TypeError('UniplatBase serviceBaseUrl must not contain query or hash')
374
+ }
375
+ return url.href.replace(/\/+$/, '')
376
+ }
377
+
378
+ function requireAccessToken(accessToken: string): string {
379
+ const token = accessToken.trim()
380
+ if (!token) throw new Error('Access token is required')
381
+ return token
382
+ }
383
+
384
+ function applicationIdentifier(value: unknown): string {
385
+ const normalized = identifier(value)
386
+ // xid=0 is the valid global instance identity for an open application.
387
+ if (!normalized || !/^\d+$/.test(normalized)) return ''
388
+ return normalized
389
+ }
390
+
391
+ function identifier(value: unknown): string {
392
+ const normalized =
393
+ typeof value === 'string'
394
+ ? value.trim()
395
+ : typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
396
+ ? String(value)
397
+ : ''
398
+ return /^\d+$/.test(normalized) ? normalized : ''
399
+ }
400
+
401
+ function applicationScopeValue(value: unknown, organizationId: string): 'open' | 'organization' {
402
+ if (value === 'open' || value === 'organization') return value
403
+ return organizationId === '0' ? 'open' : 'organization'
404
+ }
405
+
406
+ function asRecord(value: unknown): Record<string, unknown> {
407
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
408
+ ? (value as Record<string, unknown>)
409
+ : {}
410
+ }
411
+
412
+ function text(value: unknown): string {
413
+ return typeof value === 'string' ? value : ''
414
+ }
415
+
416
+ function number(value: unknown): number {
417
+ return typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
418
+ }
@@ -0,0 +1,254 @@
1
+ import type { ApplicationLaunchResult } from './application-switcher-transport'
2
+
3
+ export const UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY = 'uniplat_application_ticket'
4
+ export const UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY = 'source_system'
5
+ export const UNIPLAT_APPLICATION_TICKET_APPLICATION_NAME_FRAGMENT_KEY = 'application_name'
6
+
7
+ export interface UniplatBaseApplicationTicketExchangeOptions {
8
+ serviceBaseUrl: string
9
+ applicationName: string
10
+ setAccessToken(accessToken: string): void | Promise<void>
11
+ fetch?: typeof fetch
12
+ }
13
+
14
+ const EXCHANGE_PATH = 'anonymous/application.identity/exchange_application_ticket'
15
+ const MIN_TICKET_LENGTH = 22
16
+ const MAX_TICKET_LENGTH = 512
17
+ const MAX_TICKET_LIFETIME_SECONDS = 60
18
+
19
+ export async function exchangeUniplatBaseApplicationTicket(
20
+ options: UniplatBaseApplicationTicketExchangeOptions,
21
+ ): Promise<boolean> {
22
+ if (!options || typeof options.setAccessToken !== 'function') {
23
+ throw new TypeError('Application ticket exchange requires setAccessToken()')
24
+ }
25
+ const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
26
+ const applicationName = requireText(options.applicationName, 'Application name')
27
+ if (typeof window === 'undefined') return false
28
+
29
+ const fragment = readTicketFragment(window.location.hash)
30
+ let ticket = fragment.ticket
31
+ if (!ticket) return false
32
+ clearTicketFragment()
33
+ ticket = requireTicket(ticket)
34
+ const sourceSystem = fragment.sourceSystem
35
+
36
+ const fetchImplementation = options.fetch ?? globalThis.fetch
37
+ const endpoint = `${serviceBaseUrl}/${EXCHANGE_PATH}`
38
+ let accessToken = ''
39
+ let publicErrorMessage = ''
40
+ try {
41
+ if (!fetchImplementation) throw new Error('Fetch is unavailable')
42
+ const body: Record<string, string> = { ticket }
43
+ if (fragment.hasSourceSystem) {
44
+ body.source_system = sourceSystem
45
+ body.application_name = applicationName
46
+ }
47
+ const response = await fetchImplementation(endpoint, {
48
+ method: 'POST',
49
+ credentials: 'omit',
50
+ headers: {
51
+ accept: 'application/json',
52
+ 'content-type': 'application/json',
53
+ },
54
+ body: JSON.stringify(body),
55
+ })
56
+ if (!response.ok) throw new Error('Application ticket exchange failed')
57
+ const envelope = asRecord(await response.json())
58
+ if (number(envelope.rescode) !== 0) {
59
+ publicErrorMessage = federatedPublicErrorMessage(envelope)
60
+ throw new Error('Application ticket exchange failed')
61
+ }
62
+ const data = asRecord(envelope.data)
63
+ accessToken = text(data.token).trim()
64
+ const responseApplicationName = text(data.application_name).trim()
65
+ if (!accessToken || responseApplicationName !== applicationName) {
66
+ throw new Error('Application ticket exchange failed')
67
+ }
68
+ await options.setAccessToken(accessToken)
69
+ return true
70
+ } catch {
71
+ throw new Error(publicErrorMessage || 'Application ticket exchange failed')
72
+ } finally {
73
+ accessToken = ''
74
+ ticket = ''
75
+ }
76
+ }
77
+
78
+ export function createApplicationTicketLaunchResult(
79
+ entryUrl: string,
80
+ rawTicket: unknown,
81
+ rawExpiresIn: unknown,
82
+ ): ApplicationLaunchResult {
83
+ const ticket = requireTicket(rawTicket)
84
+ validateTicketLifetime(rawExpiresIn)
85
+
86
+ const url = new URL(resolveUniplatBaseApplicationEntryUrl(entryUrl))
87
+ url.hash = new URLSearchParams([[UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY, ticket]]).toString()
88
+ return { url: url.href }
89
+ }
90
+
91
+ export function createExternalApplicationTicketLaunchResult(
92
+ rawRedirectUrl: unknown,
93
+ rawTicket: unknown,
94
+ rawExpiresIn: unknown,
95
+ targetApplicationName: string,
96
+ ): ApplicationLaunchResult {
97
+ const ticket = requireTicket(rawTicket)
98
+ validateTicketLifetime(rawExpiresIn)
99
+ const expectedApplicationName = requireText(targetApplicationName, 'Target application name')
100
+ const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
101
+ let url: URL
102
+ try {
103
+ url = new URL(text(rawRedirectUrl).trim(), documentBase)
104
+ } catch {
105
+ throw new Error('External application redirect URL is invalid')
106
+ }
107
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
108
+ throw new Error('External application redirect URL is invalid')
109
+ }
110
+ const params = new URLSearchParams(url.hash.slice(1))
111
+ const expectedKeys = [
112
+ UNIPLAT_APPLICATION_TICKET_APPLICATION_NAME_FRAGMENT_KEY,
113
+ UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY,
114
+ UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY,
115
+ ]
116
+ const actualKeys = [...params.keys()].sort()
117
+ if (
118
+ actualKeys.length !== expectedKeys.length ||
119
+ actualKeys.some((key, index) => key !== expectedKeys[index]) ||
120
+ params.get(UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY) !== ticket ||
121
+ !params.get(UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY)?.trim() ||
122
+ params.get(UNIPLAT_APPLICATION_TICKET_APPLICATION_NAME_FRAGMENT_KEY) !== expectedApplicationName
123
+ ) {
124
+ throw new Error('External application redirect URL is invalid')
125
+ }
126
+ return { url: url.href }
127
+ }
128
+
129
+ /**
130
+ * Resolves a launcher entry before a ticket is requested, so the URL sent to
131
+ * UniplatBase is exactly the URL that will subsequently receive the ticket.
132
+ */
133
+ export function resolveUniplatBaseApplicationEntryUrl(entryUrl: string): string {
134
+ const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
135
+ let url: URL
136
+ try {
137
+ url = new URL(entryUrl, documentBase)
138
+ } catch {
139
+ throw new Error('Application entry URL is invalid')
140
+ }
141
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
142
+ throw new Error('Application entry URL is invalid')
143
+ }
144
+ return url.href
145
+ }
146
+
147
+ function readTicketFragment(hash: string): {
148
+ ticket: string
149
+ sourceSystem: string
150
+ hasSourceSystem: boolean
151
+ } {
152
+ const fragment = hash.startsWith('#') ? hash.slice(1) : hash
153
+ const params = new URLSearchParams(fragment)
154
+ return {
155
+ ticket: params.get(UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY)?.trim() ?? '',
156
+ sourceSystem: params.get(UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY) ?? '',
157
+ hasSourceSystem: params.has(UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY),
158
+ }
159
+ }
160
+
161
+ function federatedPublicErrorMessage(envelope: Record<string, unknown>): string {
162
+ const code = text(envelope.error_code)
163
+ const allowedCodes = new Set([
164
+ 'FEDERATED_SYSTEM_UNAVAILABLE',
165
+ 'FEDERATED_TICKET_INVALID',
166
+ 'FEDERATED_SOURCE_UNAVAILABLE',
167
+ 'FEDERATED_IDENTITY_NOT_READY',
168
+ 'FEDERATED_APPLICATION_ACCESS_DENIED',
169
+ 'FEDERATED_LOGIN_FAILED',
170
+ ])
171
+ const message = text(envelope.msg)
172
+ if (
173
+ !allowedCodes.has(code) ||
174
+ !message ||
175
+ message.length > 200 ||
176
+ [...message].some((character) => {
177
+ const codePoint = character.charCodeAt(0)
178
+ return codePoint <= 31 || codePoint === 127
179
+ })
180
+ ) {
181
+ return ''
182
+ }
183
+ return message
184
+ }
185
+
186
+ function clearTicketFragment(): void {
187
+ const fragment = window.location.hash.startsWith('#')
188
+ ? window.location.hash.slice(1)
189
+ : window.location.hash
190
+ const params = new URLSearchParams(fragment)
191
+ params.delete(UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY)
192
+ params.delete(UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY)
193
+ const remainingFragment = params.toString()
194
+ const cleanUrl = `${window.location.pathname}${window.location.search}${remainingFragment ? `#${remainingFragment}` : ''}`
195
+ window.history.replaceState(window.history.state, '', cleanUrl)
196
+ }
197
+
198
+ function requireTicket(rawValue: unknown): string {
199
+ const ticket = text(rawValue).trim()
200
+ if (
201
+ ticket.length < MIN_TICKET_LENGTH ||
202
+ ticket.length > MAX_TICKET_LENGTH ||
203
+ !/^[A-Za-z0-9_-]+$/.test(ticket)
204
+ ) {
205
+ throw new Error('Application ticket response is invalid')
206
+ }
207
+ return ticket
208
+ }
209
+
210
+ function validateTicketLifetime(rawExpiresIn: unknown): void {
211
+ const expiresIn = number(rawExpiresIn)
212
+ if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > MAX_TICKET_LIFETIME_SECONDS) {
213
+ throw new Error('Application ticket response is invalid')
214
+ }
215
+ }
216
+
217
+ function validateServiceBaseUrl(rawValue: string): string {
218
+ const value = typeof rawValue === 'string' ? rawValue.trim() : ''
219
+ if (!value) throw new TypeError('UniplatBase serviceBaseUrl is required')
220
+ const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
221
+ let url: URL
222
+ try {
223
+ url = new URL(value, documentBase)
224
+ } catch {
225
+ throw new TypeError('UniplatBase serviceBaseUrl is invalid')
226
+ }
227
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
228
+ throw new TypeError('UniplatBase serviceBaseUrl must use HTTP(S) without user credentials')
229
+ }
230
+ if (url.search || url.hash) {
231
+ throw new TypeError('UniplatBase serviceBaseUrl must not contain query or hash')
232
+ }
233
+ return url.href.replace(/\/+$/, '')
234
+ }
235
+
236
+ function requireText(rawValue: unknown, label: string): string {
237
+ const value = text(rawValue).trim()
238
+ if (!value) throw new TypeError(`${label} is required`)
239
+ return value
240
+ }
241
+
242
+ function asRecord(value: unknown): Record<string, unknown> {
243
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
244
+ ? (value as Record<string, unknown>)
245
+ : {}
246
+ }
247
+
248
+ function text(value: unknown): string {
249
+ return typeof value === 'string' ? value : ''
250
+ }
251
+
252
+ function number(value: unknown): number {
253
+ return typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
254
+ }