@codingfactory/socialkit-vue 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 (56) hide show
  1. package/dist/composables/useAuth.d.ts +27 -0
  2. package/dist/composables/useAuth.d.ts.map +1 -0
  3. package/dist/composables/useAuth.js +137 -0
  4. package/dist/composables/useAuth.js.map +1 -0
  5. package/dist/index.d.ts +25 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +16 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/plugins/terminology/index.d.ts +11 -0
  10. package/dist/plugins/terminology/index.d.ts.map +1 -0
  11. package/dist/plugins/terminology/index.js +91 -0
  12. package/dist/plugins/terminology/index.js.map +1 -0
  13. package/dist/plugins/terminology/terms.d.ts +15 -0
  14. package/dist/plugins/terminology/terms.d.ts.map +1 -0
  15. package/dist/plugins/terminology/terms.js +72 -0
  16. package/dist/plugins/terminology/terms.js.map +1 -0
  17. package/dist/plugins/terminology/types.d.ts +32 -0
  18. package/dist/plugins/terminology/types.d.ts.map +1 -0
  19. package/dist/plugins/terminology/types.js +2 -0
  20. package/dist/plugins/terminology/types.js.map +1 -0
  21. package/dist/services/api.d.ts +50 -0
  22. package/dist/services/api.d.ts.map +1 -0
  23. package/dist/services/api.js +305 -0
  24. package/dist/services/api.js.map +1 -0
  25. package/dist/services/auth.d.ts +127 -0
  26. package/dist/services/auth.d.ts.map +1 -0
  27. package/dist/services/auth.js +562 -0
  28. package/dist/services/auth.js.map +1 -0
  29. package/dist/stores/auth.d.ts +174 -0
  30. package/dist/stores/auth.d.ts.map +1 -0
  31. package/dist/stores/auth.js +262 -0
  32. package/dist/stores/auth.js.map +1 -0
  33. package/dist/types/api.d.ts +52 -0
  34. package/dist/types/api.d.ts.map +1 -0
  35. package/dist/types/api.js +7 -0
  36. package/dist/types/api.js.map +1 -0
  37. package/dist/types/user.d.ts +42 -0
  38. package/dist/types/user.d.ts.map +1 -0
  39. package/dist/types/user.js +45 -0
  40. package/dist/types/user.js.map +1 -0
  41. package/dist/utils/tokenStorage.d.ts +41 -0
  42. package/dist/utils/tokenStorage.d.ts.map +1 -0
  43. package/dist/utils/tokenStorage.js +300 -0
  44. package/dist/utils/tokenStorage.js.map +1 -0
  45. package/package.json +40 -0
  46. package/src/composables/useAuth.ts +164 -0
  47. package/src/index.ts +118 -0
  48. package/src/plugins/terminology/index.ts +114 -0
  49. package/src/plugins/terminology/terms.ts +104 -0
  50. package/src/plugins/terminology/types.ts +28 -0
  51. package/src/services/api.ts +472 -0
  52. package/src/services/auth.ts +874 -0
  53. package/src/stores/auth.ts +400 -0
  54. package/src/types/api.ts +56 -0
  55. package/src/types/user.ts +94 -0
  56. package/src/utils/tokenStorage.ts +394 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Configurable API service for SocialKit-powered frontends.
3
+ */
4
+
5
+ import axios from 'axios'
6
+ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
7
+ import type { ApiResponse, RequestConfig } from '../types/api.js'
8
+ import type { TokenStorage } from '../utils/tokenStorage.js'
9
+
10
+ /** Configuration for creating an API service instance. */
11
+ export interface ApiServiceConfig {
12
+ /** Base URL for API requests (e.g. '/api' or 'https://api.example.com/api'). */
13
+ baseURL: string
14
+ /** Token storage instance for auth headers and refresh logic. */
15
+ tokenStorage: TokenStorage
16
+ /** Optional existing Axios client to configure instead of creating a new one. */
17
+ client?: AxiosInstance
18
+ /** Request timeout in milliseconds (default: 30000). */
19
+ timeout?: number
20
+ /** Paths that are considered auth pages and won't be redirected to (default: ['/login', '/register']). */
21
+ authPagePaths?: string[]
22
+ /** Path to redirect to when auth fails (default: '/login'). */
23
+ loginPath?: string
24
+ /** Delay in ms before redirecting on auth failure (default: 1500). */
25
+ authFailureRedirectDelayMs?: number
26
+ /** Optional error handler called on every response error. */
27
+ onResponseError?: (error: unknown) => void
28
+ /** Optional callback invoked just before local auth state is cleared. */
29
+ onAuthInvalidated?: () => void
30
+ /** Override the redirect target used when auth teardown occurs. */
31
+ buildLoginRedirectUrl?: () => string
32
+ /** Max retries for transient 502/503/504 idempotent requests (default: 2). */
33
+ maxServerRetries?: number
34
+ /** Base delay in ms for transient server retries (default: 500). */
35
+ serverRetryBaseDelayMs?: number
36
+ }
37
+
38
+ interface RetriableRequestConfig extends AxiosRequestConfig {
39
+ _retry?: boolean
40
+ _policyRetry?: boolean
41
+ _serverRetryCount?: number
42
+ }
43
+
44
+ interface MissingPolicy {
45
+ policy_id: string
46
+ version: number
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === 'object' && value !== null
51
+ }
52
+
53
+ function isTransientServerError(status: number | undefined): boolean {
54
+ return status === 502 || status === 503 || status === 504
55
+ }
56
+
57
+ function isIdempotentMethod(method: string | undefined): boolean {
58
+ const normalizedMethod = (method ?? 'get').toLowerCase()
59
+ return normalizedMethod === 'get' || normalizedMethod === 'head' || normalizedMethod === 'options'
60
+ }
61
+
62
+ function delay(ms: number): Promise<void> {
63
+ return new Promise((resolve) => { window.setTimeout(resolve, ms) })
64
+ }
65
+
66
+ function parseMissingPolicies(payload: unknown): MissingPolicy[] {
67
+ if (!isRecord(payload)) {
68
+ return []
69
+ }
70
+
71
+ const errors = payload.errors
72
+ if (!isRecord(errors)) {
73
+ return []
74
+ }
75
+
76
+ const missing = errors.missing
77
+ if (!Array.isArray(missing)) {
78
+ return []
79
+ }
80
+
81
+ const parsed: MissingPolicy[] = []
82
+
83
+ for (const item of missing) {
84
+ if (!isRecord(item)) {
85
+ continue
86
+ }
87
+
88
+ const policyId = item.policy_id
89
+ const version = item.version
90
+ if (typeof policyId !== 'string') {
91
+ continue
92
+ }
93
+
94
+ const parsedVersion = typeof version === 'number'
95
+ ? version
96
+ : typeof version === 'string'
97
+ ? Number.parseInt(version, 10)
98
+ : Number.NaN
99
+
100
+ if (!Number.isInteger(parsedVersion)) {
101
+ continue
102
+ }
103
+
104
+ parsed.push({ policy_id: policyId, version: parsedVersion })
105
+ }
106
+
107
+ return parsed
108
+ }
109
+
110
+ function isPoliciesPendingErrorPayload(payload: unknown): boolean {
111
+ return isRecord(payload) && payload.code === 'POLICIES_PENDING'
112
+ }
113
+
114
+ function dedupeMissingPolicies(policies: MissingPolicy[]): MissingPolicy[] {
115
+ const deduped = new Map<string, MissingPolicy>()
116
+
117
+ for (const policy of policies) {
118
+ const existing = deduped.get(policy.policy_id)
119
+ if (!existing || policy.version > existing.version) {
120
+ deduped.set(policy.policy_id, policy)
121
+ }
122
+ }
123
+
124
+ return [...deduped.values()]
125
+ }
126
+
127
+ function cloneRequestConfig(config: RetriableRequestConfig): RetriableRequestConfig {
128
+ return {
129
+ ...config,
130
+ headers: { ...(config.headers as Record<string, string> | undefined) }
131
+ }
132
+ }
133
+
134
+ function withAuthorizationHeader(
135
+ config: RetriableRequestConfig,
136
+ token: string
137
+ ): RetriableRequestConfig {
138
+ return {
139
+ ...config,
140
+ headers: {
141
+ ...(config.headers as Record<string, string> | undefined),
142
+ Authorization: `Bearer ${token}`
143
+ }
144
+ }
145
+ }
146
+
147
+ function createConfiguredClient(
148
+ baseURL: string,
149
+ timeout: number,
150
+ client?: AxiosInstance
151
+ ): AxiosInstance {
152
+ if (client) {
153
+ client.defaults.baseURL = baseURL
154
+ client.defaults.timeout = timeout
155
+ client.defaults.headers.common.Accept = 'application/json'
156
+ client.defaults.headers.common['Content-Type'] = 'application/json'
157
+ client.defaults.withCredentials = false
158
+ return client
159
+ }
160
+
161
+ return axios.create({
162
+ baseURL,
163
+ timeout,
164
+ headers: {
165
+ 'Content-Type': 'application/json',
166
+ Accept: 'application/json'
167
+ },
168
+ withCredentials: false
169
+ })
170
+ }
171
+
172
+ /** Public API service interface. */
173
+ export interface ApiService {
174
+ get<T = unknown>(url: string, config?: RequestConfig): Promise<ApiResponse<T>>
175
+ post<T = unknown>(url: string, data?: unknown, config?: RequestConfig): Promise<ApiResponse<T>>
176
+ put<T = unknown>(url: string, data?: unknown, config?: RequestConfig): Promise<ApiResponse<T>>
177
+ patch<T = unknown>(url: string, data?: unknown, config?: RequestConfig): Promise<ApiResponse<T>>
178
+ delete<T = unknown>(url: string, config?: RequestConfig): Promise<ApiResponse<T>>
179
+ postForm<T = unknown>(url: string, form: FormData, config?: RequestConfig): Promise<T>
180
+ postFormRaw<T = unknown>(url: string, form: FormData, config?: RequestConfig): Promise<AxiosResponse<T>>
181
+ markAuthRecoveryHandled(): void
182
+ hasPendingAuthRequest(): boolean
183
+ clearPendingAuthRequest(): void
184
+ replayPendingAuthRequest(token: string): Promise<void>
185
+ }
186
+
187
+ /** Create a configured API service instance. */
188
+ export function createApiService(config: ApiServiceConfig): ApiService {
189
+ const {
190
+ baseURL,
191
+ tokenStorage,
192
+ client,
193
+ timeout = 30000,
194
+ authPagePaths = ['/login', '/register'],
195
+ loginPath = '/login',
196
+ authFailureRedirectDelayMs = 1500,
197
+ onResponseError,
198
+ onAuthInvalidated,
199
+ buildLoginRedirectUrl,
200
+ maxServerRetries = 2,
201
+ serverRetryBaseDelayMs = 500
202
+ } = config
203
+
204
+ const clientInstance = createConfiguredClient(baseURL, timeout, client)
205
+
206
+ let pendingAuthRequests: RetriableRequestConfig[] = []
207
+ let authFailureRedirectTimeoutId: number | null = null
208
+ let authFailureQueuedToken: string | null = null
209
+ let policyAcceptancePromise: Promise<boolean> | null = null
210
+
211
+ function isPolicyAcceptRequest(url: unknown): boolean {
212
+ return typeof url === 'string' && url.includes('/v1/policy/accept')
213
+ }
214
+
215
+ async function acceptMissingPolicies(missingPolicies: MissingPolicy[]): Promise<boolean> {
216
+ const policiesToAccept = dedupeMissingPolicies(missingPolicies)
217
+ if (policiesToAccept.length === 0) {
218
+ return false
219
+ }
220
+
221
+ if (policyAcceptancePromise) {
222
+ return policyAcceptancePromise
223
+ }
224
+
225
+ policyAcceptancePromise = (async () => {
226
+ try {
227
+ for (const policy of policiesToAccept) {
228
+ await clientInstance.post('/v1/policy/accept', {
229
+ policy_id: policy.policy_id,
230
+ version: policy.version
231
+ })
232
+ }
233
+
234
+ return true
235
+ } catch {
236
+ return false
237
+ } finally {
238
+ policyAcceptancePromise = null
239
+ }
240
+ })()
241
+
242
+ return policyAcceptancePromise
243
+ }
244
+
245
+ function capturePendingAuthRequest(reqConfig: RetriableRequestConfig): void {
246
+ pendingAuthRequests.push(cloneRequestConfig(reqConfig))
247
+ }
248
+
249
+ function cancelQueuedAuthFailureRedirect(): void {
250
+ if (authFailureRedirectTimeoutId === null) {
251
+ return
252
+ }
253
+
254
+ window.clearTimeout(authFailureRedirectTimeoutId)
255
+ authFailureRedirectTimeoutId = null
256
+ authFailureQueuedToken = null
257
+ }
258
+
259
+ function queueAuthFailureRedirect(): void {
260
+ if (authFailureRedirectTimeoutId !== null) {
261
+ return
262
+ }
263
+
264
+ authFailureQueuedToken = tokenStorage.getToken()
265
+
266
+ authFailureRedirectTimeoutId = window.setTimeout(() => {
267
+ authFailureRedirectTimeoutId = null
268
+ const queuedToken = authFailureQueuedToken
269
+ authFailureQueuedToken = null
270
+ const currentToken = tokenStorage.getToken()
271
+
272
+ if (
273
+ queuedToken !== null &&
274
+ currentToken !== null &&
275
+ currentToken !== queuedToken
276
+ ) {
277
+ return
278
+ }
279
+
280
+ onAuthInvalidated?.()
281
+ tokenStorage.removeToken()
282
+
283
+ const currentPath = window.location.pathname
284
+ if (authPagePaths.includes(currentPath)) {
285
+ return
286
+ }
287
+
288
+ const redirectUrl = buildLoginRedirectUrl?.() ?? loginPath
289
+ window.location.assign(redirectUrl)
290
+ }, authFailureRedirectDelayMs)
291
+ }
292
+
293
+ clientInstance.interceptors.request.use(
294
+ (reqConfig) => {
295
+ const token = tokenStorage.getToken()
296
+ if (token) {
297
+ reqConfig.headers.Authorization = `Bearer ${token}`
298
+ }
299
+
300
+ const isFormData = typeof FormData !== 'undefined' && reqConfig.data instanceof FormData
301
+ if (isFormData) {
302
+ delete reqConfig.headers['Content-Type']
303
+ delete reqConfig.headers['content-type']
304
+ reqConfig.transformRequest = [(data) => data]
305
+ }
306
+
307
+ return reqConfig
308
+ },
309
+ (error) => Promise.reject(error)
310
+ )
311
+
312
+ clientInstance.interceptors.response.use(
313
+ (response) => response,
314
+ async (error) => {
315
+ if (isTransientServerError(error.response?.status)) {
316
+ const originalRequest = error.config as RetriableRequestConfig | undefined
317
+ const retryCount = originalRequest?._serverRetryCount ?? 0
318
+
319
+ if (originalRequest && retryCount < maxServerRetries && isIdempotentMethod(originalRequest.method)) {
320
+ originalRequest._serverRetryCount = retryCount + 1
321
+ await delay(serverRetryBaseDelayMs * Math.pow(2, retryCount))
322
+ return clientInstance(originalRequest)
323
+ }
324
+ }
325
+
326
+ if (error.response?.status === 428) {
327
+ const originalRequest = error.config as RetriableRequestConfig | undefined
328
+ const requestUrl = originalRequest?.url
329
+ const responsePayload = error.response?.data
330
+ const missingPolicies = parseMissingPolicies(responsePayload)
331
+ const canAttemptPolicyAcceptance = Boolean(
332
+ originalRequest &&
333
+ !originalRequest._policyRetry &&
334
+ !isPolicyAcceptRequest(requestUrl) &&
335
+ isPoliciesPendingErrorPayload(responsePayload) &&
336
+ missingPolicies.length > 0
337
+ )
338
+
339
+ if (canAttemptPolicyAcceptance && originalRequest) {
340
+ originalRequest._policyRetry = true
341
+ const accepted = await acceptMissingPolicies(missingPolicies)
342
+ if (accepted) {
343
+ return clientInstance(originalRequest)
344
+ }
345
+ }
346
+ }
347
+
348
+ if (error.response?.status === 401) {
349
+ const originalRequest = error.config as RetriableRequestConfig | undefined
350
+ const requestUrl = originalRequest?.url
351
+ const canAttemptRefresh = Boolean(
352
+ originalRequest &&
353
+ !originalRequest._retry &&
354
+ !tokenStorage.shouldSkipAuth(requestUrl) &&
355
+ !tokenStorage.isRefreshTokenEndpoint(requestUrl)
356
+ )
357
+
358
+ if (canAttemptRefresh && originalRequest) {
359
+ originalRequest._retry = true
360
+ const refreshedToken = await tokenStorage.tryRefreshToken()
361
+
362
+ if (refreshedToken) {
363
+ const retryConfig = withAuthorizationHeader(originalRequest, refreshedToken)
364
+ cancelQueuedAuthFailureRedirect()
365
+ pendingAuthRequests = []
366
+ return clientInstance(retryConfig)
367
+ }
368
+ }
369
+
370
+ if (tokenStorage.wasTokenRotatedSince(originalRequest?.headers)) {
371
+ onResponseError?.(error)
372
+ return Promise.reject(error)
373
+ }
374
+
375
+ const shouldCapturePendingRequest = Boolean(
376
+ originalRequest &&
377
+ !tokenStorage.shouldSkipAuth(requestUrl) &&
378
+ !tokenStorage.isRefreshTokenEndpoint(requestUrl)
379
+ )
380
+
381
+ if (shouldCapturePendingRequest && originalRequest) {
382
+ capturePendingAuthRequest(originalRequest)
383
+ }
384
+
385
+ queueAuthFailureRedirect()
386
+ }
387
+
388
+ onResponseError?.(error)
389
+
390
+ return Promise.reject(error)
391
+ }
392
+ )
393
+
394
+ return {
395
+ async get<T = unknown>(url: string, reqConfig?: RequestConfig): Promise<ApiResponse<T>> {
396
+ const response = await clientInstance.get<ApiResponse<T>>(url, reqConfig as AxiosRequestConfig)
397
+ return response.data
398
+ },
399
+
400
+ async post<T = unknown>(url: string, data?: unknown, reqConfig?: RequestConfig): Promise<ApiResponse<T>> {
401
+ const response = await clientInstance.post<ApiResponse<T>>(url, data, reqConfig as AxiosRequestConfig)
402
+ return response.data
403
+ },
404
+
405
+ async put<T = unknown>(url: string, data?: unknown, reqConfig?: RequestConfig): Promise<ApiResponse<T>> {
406
+ const response = await clientInstance.put<ApiResponse<T>>(url, data, reqConfig as AxiosRequestConfig)
407
+ return response.data
408
+ },
409
+
410
+ async patch<T = unknown>(url: string, data?: unknown, reqConfig?: RequestConfig): Promise<ApiResponse<T>> {
411
+ const response = await clientInstance.patch<ApiResponse<T>>(url, data, reqConfig as AxiosRequestConfig)
412
+ return response.data
413
+ },
414
+
415
+ async delete<T = unknown>(url: string, reqConfig?: RequestConfig): Promise<ApiResponse<T>> {
416
+ const response = await clientInstance.delete<ApiResponse<T>>(url, reqConfig as AxiosRequestConfig)
417
+ return response.data
418
+ },
419
+
420
+ async postForm<T = unknown>(url: string, form: FormData, reqConfig?: RequestConfig): Promise<T> {
421
+ const response = await clientInstance.post<T>(url, form, reqConfig as AxiosRequestConfig)
422
+ return response.data
423
+ },
424
+
425
+ async postFormRaw<T = unknown>(url: string, form: FormData, reqConfig?: RequestConfig): Promise<AxiosResponse<T>> {
426
+ const response = await clientInstance.post<T>(url, form, reqConfig as AxiosRequestConfig)
427
+ return response
428
+ },
429
+
430
+ markAuthRecoveryHandled(): void {
431
+ cancelQueuedAuthFailureRedirect()
432
+ },
433
+
434
+ hasPendingAuthRequest(): boolean {
435
+ return pendingAuthRequests.length > 0
436
+ },
437
+
438
+ clearPendingAuthRequest(): void {
439
+ pendingAuthRequests = []
440
+ },
441
+
442
+ async replayPendingAuthRequest(token: string): Promise<void> {
443
+ if (pendingAuthRequests.length === 0) {
444
+ return
445
+ }
446
+
447
+ cancelQueuedAuthFailureRedirect()
448
+
449
+ const requestsToReplay = pendingAuthRequests.map(cloneRequestConfig)
450
+ pendingAuthRequests = []
451
+
452
+ const results = await Promise.allSettled(
453
+ requestsToReplay.map((request) => {
454
+ const retryConfig = withAuthorizationHeader({
455
+ ...request,
456
+ _retry: true
457
+ }, token)
458
+
459
+ return clientInstance(retryConfig)
460
+ })
461
+ )
462
+
463
+ const firstRejection = results.find(
464
+ (result): result is PromiseRejectedResult => result.status === 'rejected'
465
+ )
466
+
467
+ if (firstRejection) {
468
+ throw firstRejection.reason as Error
469
+ }
470
+ }
471
+ }
472
+ }