@structured-id/ui-core 0.1.5

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 (156) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +47 -0
  3. package/dist/browser-vp/index.cjs +349 -0
  4. package/dist/browser-vp/index.d.cts +287 -0
  5. package/dist/browser-vp/index.d.ts +287 -0
  6. package/dist/browser-vp/index.js +349 -0
  7. package/dist/chunk-C3Q766CY.cjs +125 -0
  8. package/dist/chunk-J5YXRXB5.js +125 -0
  9. package/dist/index.cjs +19360 -0
  10. package/dist/index.d.cts +9536 -0
  11. package/dist/index.d.ts +9536 -0
  12. package/dist/index.js +19360 -0
  13. package/dist/storage-4CB2JTLW.cjs +22 -0
  14. package/dist/storage-THRWREZP.js +22 -0
  15. package/package.json +93 -0
  16. package/src/browser-vp/crypto.ts +254 -0
  17. package/src/browser-vp/index.test.ts +10 -0
  18. package/src/browser-vp/index.ts +118 -0
  19. package/src/browser-vp/prf.ts +181 -0
  20. package/src/browser-vp/storage.test.ts +9 -0
  21. package/src/browser-vp/storage.ts +165 -0
  22. package/src/browser-vp/types.ts +97 -0
  23. package/src/browser-vp/vp.test.ts +43 -0
  24. package/src/browser-vp/vp.ts +113 -0
  25. package/src/components/SidAvatar.vue +106 -0
  26. package/src/components/SidConfirmDialog.vue +125 -0
  27. package/src/components/SidStatusBadge.vue +71 -0
  28. package/src/components/index.ts +5 -0
  29. package/src/composables/index.ts +10 -0
  30. package/src/composables/useAuth.ts +40 -0
  31. package/src/composables/useBffAuth.ts +147 -0
  32. package/src/composables/useGrpc.test.ts +196 -0
  33. package/src/composables/useGrpc.ts +120 -0
  34. package/src/composables/useSession.ts +80 -0
  35. package/src/generated/google/api/http.ts +687 -0
  36. package/src/generated/google/protobuf/descriptor.ts +4865 -0
  37. package/src/generated/google/protobuf/duration.ts +230 -0
  38. package/src/generated/google/protobuf/empty.ts +94 -0
  39. package/src/generated/google/protobuf/struct.ts +484 -0
  40. package/src/generated/google/protobuf/timestamp.ts +287 -0
  41. package/src/generated/sid/v1/account/account.client.ts +527 -0
  42. package/src/generated/sid/v1/account/account.ts +3261 -0
  43. package/src/generated/sid/v1/admin/admin.client.ts +136 -0
  44. package/src/generated/sid/v1/admin/admin.ts +846 -0
  45. package/src/generated/sid/v1/admin/admin_roles.client.ts +181 -0
  46. package/src/generated/sid/v1/admin/admin_roles.ts +1606 -0
  47. package/src/generated/sid/v1/admin/analytics.client.ts +106 -0
  48. package/src/generated/sid/v1/admin/analytics.ts +1017 -0
  49. package/src/generated/sid/v1/admin/branding.client.ts +166 -0
  50. package/src/generated/sid/v1/admin/branding.ts +1906 -0
  51. package/src/generated/sid/v1/admin/enrollment.client.ts +165 -0
  52. package/src/generated/sid/v1/admin/enrollment.ts +1704 -0
  53. package/src/generated/sid/v1/admin/events.client.ts +195 -0
  54. package/src/generated/sid/v1/admin/events.ts +1900 -0
  55. package/src/generated/sid/v1/admin/flow.client.ts +183 -0
  56. package/src/generated/sid/v1/admin/flow.ts +1552 -0
  57. package/src/generated/sid/v1/admin/keys.client.ts +107 -0
  58. package/src/generated/sid/v1/admin/keys.ts +931 -0
  59. package/src/generated/sid/v1/admin/login_experience.client.ts +160 -0
  60. package/src/generated/sid/v1/admin/login_experience.ts +1061 -0
  61. package/src/generated/sid/v1/admin/migration.client.ts +127 -0
  62. package/src/generated/sid/v1/admin/migration.ts +1948 -0
  63. package/src/generated/sid/v1/admin/notifications.client.ts +128 -0
  64. package/src/generated/sid/v1/admin/notifications.ts +1332 -0
  65. package/src/generated/sid/v1/admin/operations.client.ts +241 -0
  66. package/src/generated/sid/v1/admin/operations.ts +2648 -0
  67. package/src/generated/sid/v1/admin/organizations.client.ts +57 -0
  68. package/src/generated/sid/v1/admin/organizations.ts +349 -0
  69. package/src/generated/sid/v1/admin/scopes.client.ts +171 -0
  70. package/src/generated/sid/v1/admin/scopes.ts +1324 -0
  71. package/src/generated/sid/v1/admin/security.client.ts +412 -0
  72. package/src/generated/sid/v1/admin/security.ts +4086 -0
  73. package/src/generated/sid/v1/admin/sessions.client.ts +114 -0
  74. package/src/generated/sid/v1/admin/sessions.ts +923 -0
  75. package/src/generated/sid/v1/admin/settings.client.ts +340 -0
  76. package/src/generated/sid/v1/admin/settings.ts +2395 -0
  77. package/src/generated/sid/v1/admin/user_profile.client.ts +57 -0
  78. package/src/generated/sid/v1/admin/user_profile.ts +730 -0
  79. package/src/generated/sid/v1/admin/webhooks.client.ts +269 -0
  80. package/src/generated/sid/v1/admin/webhooks.ts +2804 -0
  81. package/src/generated/sid/v1/attestation/attestation.client.ts +115 -0
  82. package/src/generated/sid/v1/attestation/attestation.ts +813 -0
  83. package/src/generated/sid/v1/authn/auth.client.ts +900 -0
  84. package/src/generated/sid/v1/authn/auth.ts +9287 -0
  85. package/src/generated/sid/v1/authn/flows.client.ts +413 -0
  86. package/src/generated/sid/v1/authn/flows.ts +3217 -0
  87. package/src/generated/sid/v1/authz/authz.client.ts +459 -0
  88. package/src/generated/sid/v1/authz/authz.ts +5343 -0
  89. package/src/generated/sid/v1/authz/governance.client.ts +102 -0
  90. package/src/generated/sid/v1/authz/governance.ts +818 -0
  91. package/src/generated/sid/v1/common/errors.ts +184 -0
  92. package/src/generated/sid/v1/common/pagination.ts +152 -0
  93. package/src/generated/sid/v1/common/transport.ts +227 -0
  94. package/src/generated/sid/v1/common/types.ts +151 -0
  95. package/src/generated/sid/v1/events/event_stream.client.ts +67 -0
  96. package/src/generated/sid/v1/events/event_stream.ts +374 -0
  97. package/src/generated/sid/v1/federation/upstream.client.ts +142 -0
  98. package/src/generated/sid/v1/federation/upstream.ts +1736 -0
  99. package/src/generated/sid/v1/identity/identity.client.ts +467 -0
  100. package/src/generated/sid/v1/identity/identity.ts +5613 -0
  101. package/src/generated/sid/v1/machine/machine_user.client.ts +219 -0
  102. package/src/generated/sid/v1/machine/machine_user.ts +2400 -0
  103. package/src/generated/sid/v1/machine/pat.client.ts +147 -0
  104. package/src/generated/sid/v1/machine/pat.ts +1268 -0
  105. package/src/generated/sid/v1/projects/projects.client.ts +286 -0
  106. package/src/generated/sid/v1/projects/projects.ts +2714 -0
  107. package/src/generated/sid/v1/scim/scim.client.ts +274 -0
  108. package/src/generated/sid/v1/scim/scim.ts +3043 -0
  109. package/src/generated/sid/v1/test/test.client.ts +232 -0
  110. package/src/generated/sid/v1/test/test.ts +1136 -0
  111. package/src/i18n/index.ts +40 -0
  112. package/src/index.ts +157 -0
  113. package/src/profile/auth.ts +15 -0
  114. package/src/profile/components/ConsentManager.vue +88 -0
  115. package/src/profile/components/ProfileEditor.vue +146 -0
  116. package/src/profile/components/SecuritySettings.vue +170 -0
  117. package/src/profile/components/SessionList.vue +100 -0
  118. package/src/profile/components/SidLoginForm.test.ts +80 -0
  119. package/src/profile/components/SidLoginForm.vue +230 -0
  120. package/src/profile/components/SidMfaChallenge.test.ts +127 -0
  121. package/src/profile/components/SidMfaChallenge.vue +243 -0
  122. package/src/profile/components/SidRegistrationForm.test.ts +79 -0
  123. package/src/profile/components/SidRegistrationForm.vue +317 -0
  124. package/src/profile/components/index.ts +11 -0
  125. package/src/profile/composables/index.ts +6 -0
  126. package/src/profile/composables/useAccountApi.ts +203 -0
  127. package/src/profile/composables/useAuthApi.opaque.test.ts +140 -0
  128. package/src/profile/composables/useAuthApi.ts +617 -0
  129. package/src/profile/composables/useIdentityApi.ts +123 -0
  130. package/src/profile/composables/useLoginSuccess.test.ts +112 -0
  131. package/src/profile/composables/useLoginSuccess.ts +65 -0
  132. package/src/profile/composables/useOpaqueAuth.test.ts +290 -0
  133. package/src/profile/composables/useOpaqueAuth.ts +191 -0
  134. package/src/profile/composables/useSecurityLevel.test.ts +198 -0
  135. package/src/profile/composables/useSecurityLevel.ts +324 -0
  136. package/src/profile/index.ts +18 -0
  137. package/src/quasar/SidPrincipalInput.vue +209 -0
  138. package/src/quasar/index.ts +8 -0
  139. package/src/stores/auth.test.ts +83 -0
  140. package/src/stores/auth.ts +92 -0
  141. package/src/stores/index.ts +1 -0
  142. package/src/transport/adaptive.test.ts +728 -0
  143. package/src/transport/adaptive.ts +308 -0
  144. package/src/transport/framing.test.ts +227 -0
  145. package/src/transport/framing.ts +98 -0
  146. package/src/transport/webtransport.test.ts +728 -0
  147. package/src/transport/webtransport.ts +323 -0
  148. package/src/types/auth.ts +58 -0
  149. package/src/types/index.ts +1 -0
  150. package/src/utils/formatters.test.ts +26 -0
  151. package/src/utils/formatters.ts +69 -0
  152. package/src/utils/index.ts +16 -0
  153. package/src/utils/normalizePrincipal.test.ts +188 -0
  154. package/src/utils/normalizePrincipal.ts +159 -0
  155. package/src/utils/validators.test.ts +75 -0
  156. package/src/utils/validators.ts +36 -0
@@ -0,0 +1,323 @@
1
+ /**
2
+ * WebTransport RPC transport for StructuredID.
3
+ *
4
+ * Implements the @protobuf-ts/runtime-rpc RpcTransport interface using
5
+ * the browser WebTransport API over HTTP/3 (QUIC).
6
+ *
7
+ * Each unary RPC opens one bidirectional stream:
8
+ * Client → Server: [4B len][RequestHeader] [4B len][request body]
9
+ * Server → Client: [4B len][ResponseHeader] [4B len][response body]
10
+ */
11
+
12
+ import {
13
+ Deferred,
14
+ RpcError,
15
+ mergeRpcOptions,
16
+ UnaryCall,
17
+ } from "@protobuf-ts/runtime-rpc";
18
+ import type {
19
+ RpcTransport,
20
+ MethodInfo,
21
+ RpcOptions,
22
+ RpcMetadata,
23
+ RpcStatus,
24
+ ServerStreamingCall,
25
+ ClientStreamingCall,
26
+ DuplexStreamingCall,
27
+ } from "@protobuf-ts/runtime-rpc";
28
+ import {
29
+ RequestHeader,
30
+ ResponseHeader,
31
+ } from "../generated/sid/v1/common/transport";
32
+ import { FrameReader, writeFrame } from "./framing";
33
+
34
+ export interface WebTransportConnectOptions {
35
+ /** WebTransport server URL, e.g. "https://localhost:4433" */
36
+ url: string;
37
+ /**
38
+ * SHA-256 certificate hash (hex string) for dev self-signed certs.
39
+ * Required for connecting to servers with self-signed certificates.
40
+ */
41
+ certHash?: string;
42
+ }
43
+
44
+ /**
45
+ * Managed WebTransport connection with lazy connect and auto-reconnect detection.
46
+ */
47
+ export class WebTransportConnection {
48
+ private transport: WebTransport | null = null;
49
+ private connecting: Promise<WebTransport> | null = null;
50
+ private readonly opts: WebTransportConnectOptions;
51
+
52
+ constructor(opts: WebTransportConnectOptions) {
53
+ this.opts = opts;
54
+ }
55
+
56
+ get connected(): boolean {
57
+ return this.transport !== null;
58
+ }
59
+
60
+ async connect(): Promise<WebTransport> {
61
+ if (this.transport) return this.transport;
62
+ if (this.connecting) return this.connecting;
63
+
64
+ this.connecting = this.doConnect();
65
+ return this.connecting;
66
+ }
67
+
68
+ close(): void {
69
+ if (this.transport) {
70
+ this.transport.close();
71
+ this.transport = null;
72
+ }
73
+ this.connecting = null;
74
+ }
75
+
76
+ private async doConnect(): Promise<WebTransport> {
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ const initOpts: Record<string, any> = {};
79
+
80
+ if (this.opts.certHash) {
81
+ const hashBytes = hexToBytes(this.opts.certHash);
82
+ initOpts.serverCertificateHashes = [
83
+ { algorithm: "sha-256", value: hashBytes.buffer },
84
+ ];
85
+ }
86
+
87
+ try {
88
+ const wt = new WebTransport(this.opts.url, initOpts);
89
+ await wt.ready;
90
+
91
+ this.transport = wt;
92
+ this.connecting = null;
93
+
94
+ wt.closed.then(() => {
95
+ if (this.transport === wt) {
96
+ this.transport = null;
97
+ }
98
+ });
99
+
100
+ return wt;
101
+ } catch (err) {
102
+ // Reset so that subsequent connect() calls retry rather than returning
103
+ // the stale rejected promise.
104
+ this.connecting = null;
105
+ throw err;
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * RpcTransport implementation using WebTransport bidirectional streams.
112
+ *
113
+ * Requires an established WebTransportConnection — does NOT manage
114
+ * the connection lifecycle. Use AdaptiveRpcTransport for automatic
115
+ * failover and reconnect.
116
+ */
117
+ export class WebTransportRpcTransport implements RpcTransport {
118
+ private readonly defaultOptions: RpcOptions;
119
+ private readonly connection: WebTransportConnection;
120
+
121
+ constructor(connection: WebTransportConnection, defaultOptions?: RpcOptions) {
122
+ this.connection = connection;
123
+ this.defaultOptions = defaultOptions ?? {};
124
+ }
125
+
126
+ mergeOptions(options?: Partial<RpcOptions>): RpcOptions {
127
+ return mergeRpcOptions(this.defaultOptions, options);
128
+ }
129
+
130
+ unary<I extends object, O extends object>(
131
+ method: MethodInfo<I, O>,
132
+ input: I,
133
+ options: RpcOptions,
134
+ ): UnaryCall<I, O> {
135
+ const defHeader = new Deferred<RpcMetadata>();
136
+ const defMessage = new Deferred<O>();
137
+ const defStatus = new Deferred<RpcStatus>();
138
+ const defTrailer = new Deferred<RpcMetadata>();
139
+
140
+ this.executeUnary(
141
+ method,
142
+ input,
143
+ options,
144
+ defHeader,
145
+ defMessage,
146
+ defStatus,
147
+ defTrailer,
148
+ );
149
+
150
+ return new UnaryCall(
151
+ method,
152
+ options.meta ?? {},
153
+ input,
154
+ defHeader.promise,
155
+ defMessage.promise,
156
+ defStatus.promise,
157
+ defTrailer.promise,
158
+ );
159
+ }
160
+
161
+ serverStreaming<I extends object, O extends object>(
162
+ _method: MethodInfo<I, O>,
163
+ _input: I,
164
+ _options: RpcOptions,
165
+ ): ServerStreamingCall<I, O> {
166
+ throw new RpcError(
167
+ "Server streaming not supported over WebTransport transport",
168
+ );
169
+ }
170
+
171
+ clientStreaming<I extends object, O extends object>(
172
+ _method: MethodInfo<I, O>,
173
+ _options: RpcOptions,
174
+ ): ClientStreamingCall<I, O> {
175
+ throw new RpcError(
176
+ "Client streaming not supported over WebTransport transport",
177
+ );
178
+ }
179
+
180
+ duplex<I extends object, O extends object>(
181
+ _method: MethodInfo<I, O>,
182
+ _options: RpcOptions,
183
+ ): DuplexStreamingCall<I, O> {
184
+ throw new RpcError(
185
+ "Duplex streaming not supported over WebTransport transport",
186
+ );
187
+ }
188
+
189
+ private async executeUnary<I extends object, O extends object>(
190
+ method: MethodInfo<I, O>,
191
+ input: I,
192
+ options: RpcOptions,
193
+ defHeader: Deferred<RpcMetadata>,
194
+ defMessage: Deferred<O>,
195
+ defStatus: Deferred<RpcStatus>,
196
+ defTrailer: Deferred<RpcMetadata>,
197
+ ): Promise<void> {
198
+ try {
199
+ if (!this.connection.connected) {
200
+ throw new RpcError(
201
+ "WebTransport connection not established",
202
+ "UNAVAILABLE",
203
+ );
204
+ }
205
+
206
+ const wt = await this.connection.connect();
207
+ const stream = await wt.createBidirectionalStream();
208
+ const writer = stream.writable.getWriter();
209
+ const reader = stream.readable.getReader();
210
+
211
+ try {
212
+ // Build RequestHeader
213
+ const methodPath = `${method.service.typeName}/${method.name}`;
214
+ const metadata: Record<string, string> = {};
215
+ if (options.meta) {
216
+ for (const [key, value] of Object.entries(options.meta)) {
217
+ if (typeof value === "string") {
218
+ metadata[key] = value;
219
+ } else if (Array.isArray(value)) {
220
+ metadata[key] = value.join(",");
221
+ }
222
+ }
223
+ }
224
+
225
+ let timeoutMs = 0;
226
+ if (options.timeout) {
227
+ timeoutMs =
228
+ options.timeout instanceof Date
229
+ ? Math.max(0, options.timeout.getTime() - Date.now())
230
+ : options.timeout;
231
+ }
232
+
233
+ const reqHeader: RequestHeader = {
234
+ method: methodPath,
235
+ metadata,
236
+ timeoutMs,
237
+ };
238
+
239
+ // Send RequestHeader + request body
240
+ const headerBytes = RequestHeader.toBinary(reqHeader);
241
+ await writeFrame(writer, headerBytes);
242
+
243
+ const bodyBytes = method.I.toBinary(input, options.binaryOptions);
244
+ await writeFrame(writer, bodyBytes);
245
+
246
+ // Read ResponseHeader + response body via FrameReader so that excess
247
+ // bytes from one read() are not lost when reading the next frame.
248
+ const frameReader = new FrameReader(reader);
249
+ const respHeaderBytes = await frameReader.readFrame();
250
+ const respHeader = ResponseHeader.fromBinary(respHeaderBytes);
251
+
252
+ const responseHeaders: RpcMetadata = { ...respHeader.metadata };
253
+ defHeader.resolve(responseHeaders);
254
+
255
+ if (respHeader.statusCode !== 0) {
256
+ throw new RpcError(
257
+ respHeader.statusMessage || "RPC error",
258
+ grpcCodeName(respHeader.statusCode),
259
+ );
260
+ }
261
+
262
+ // Read response body
263
+ const respBodyBytes = await frameReader.readFrame();
264
+ const message = method.O.fromBinary(
265
+ respBodyBytes,
266
+ options.binaryOptions,
267
+ );
268
+
269
+ defMessage.resolve(message);
270
+ defStatus.resolve({ code: "OK", detail: "" });
271
+ defTrailer.resolve({});
272
+ } finally {
273
+ writer.releaseLock();
274
+ reader.releaseLock();
275
+ }
276
+ } catch (err) {
277
+ const rpcErr =
278
+ err instanceof RpcError
279
+ ? err
280
+ : new RpcError(
281
+ err instanceof Error ? err.message : String(err),
282
+ "INTERNAL",
283
+ );
284
+ defHeader.rejectPending(rpcErr);
285
+ defMessage.rejectPending(rpcErr);
286
+ defStatus.rejectPending(rpcErr);
287
+ defTrailer.rejectPending(rpcErr);
288
+ }
289
+ }
290
+ }
291
+
292
+ /** Convert hex string to Uint8Array. */
293
+ function hexToBytes(hex: string): Uint8Array {
294
+ const bytes = new Uint8Array(hex.length / 2);
295
+ for (let i = 0; i < hex.length; i += 2) {
296
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
297
+ }
298
+ return bytes;
299
+ }
300
+
301
+ /** Map gRPC numeric status code to string name. */
302
+ function grpcCodeName(code: number): string {
303
+ const names: Record<number, string> = {
304
+ 0: "OK",
305
+ 1: "CANCELLED",
306
+ 2: "UNKNOWN",
307
+ 3: "INVALID_ARGUMENT",
308
+ 4: "DEADLINE_EXCEEDED",
309
+ 5: "NOT_FOUND",
310
+ 6: "ALREADY_EXISTS",
311
+ 7: "PERMISSION_DENIED",
312
+ 8: "RESOURCE_EXHAUSTED",
313
+ 9: "FAILED_PRECONDITION",
314
+ 10: "ABORTED",
315
+ 11: "OUT_OF_RANGE",
316
+ 12: "UNIMPLEMENTED",
317
+ 13: "INTERNAL",
318
+ 14: "UNAVAILABLE",
319
+ 15: "DATA_LOSS",
320
+ 16: "UNAUTHENTICATED",
321
+ };
322
+ return names[code] ?? "UNKNOWN";
323
+ }
@@ -0,0 +1,58 @@
1
+ /** Authentication state for the current user session (BFF cookie mode). */
2
+ export interface AuthState {
3
+ /** Whether a valid BFF session exists. */
4
+ sessionValid: boolean;
5
+ /** Server-side session identifier (for step-up, session management). */
6
+ sessionId: string | null;
7
+ /** Profile ID of the authenticated user (UUID). */
8
+ profileId: string | null;
9
+ /** Display name of the authenticated user. */
10
+ displayName: string | null;
11
+ /** Email address (may be null if not in session claims). */
12
+ email: string | null;
13
+ /** Preferred username or email — display identifier. */
14
+ preferredUsername: string | null;
15
+ }
16
+
17
+ /** Session information as displayed in the dashboard. */
18
+ export interface SessionInfo {
19
+ id: string;
20
+ deviceName: string;
21
+ ipAddress: string;
22
+ location: string | null;
23
+ lastActive: string;
24
+ createdAt: string;
25
+ current: boolean;
26
+ }
27
+
28
+ /** gRPC connection configuration. */
29
+ export interface GrpcConfig {
30
+ /** Base URL for gRPC-web transport (e.g., "https://api.example.com"). */
31
+ baseUrl: string;
32
+ /**
33
+ * @deprecated BFF mode always uses credentials: "include".
34
+ * This field is kept for API compatibility but has no effect.
35
+ */
36
+ withCredentials?: boolean;
37
+ /**
38
+ * WebTransport server URL (e.g., "https://wt.api.example.com").
39
+ * When provided, AdaptiveRpcTransport is used with WebTransport as primary
40
+ * and gRPC-web as fallback. Omit to use gRPC-web only.
41
+ */
42
+ webTransportUrl?: string;
43
+ /**
44
+ * SHA-256 certificate hash (hex string) for dev self-signed WebTransport certs.
45
+ * Only needed when connecting to servers with self-signed certificates.
46
+ */
47
+ webTransportCertHash?: string;
48
+ /**
49
+ * Delay in milliseconds before attempting to reconnect WebTransport after failover.
50
+ * Defaults to 5000ms.
51
+ */
52
+ webTransportReconnectDelay?: number;
53
+ /**
54
+ * Callback fired when the active transport changes between "webtransport" and "grpc-web".
55
+ * Use this to show transport status indicators in the UI.
56
+ */
57
+ onTransportChange?: (transport: "webtransport" | "grpc-web") => void;
58
+ }
@@ -0,0 +1 @@
1
+ export type { AuthState, SessionInfo, GrpcConfig } from "./auth";
@@ -0,0 +1,26 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { truncate, maskEmail } from "./formatters";
3
+
4
+ describe("truncate", () => {
5
+ it("returns original if under max", () => {
6
+ expect(truncate("hello", 10)).toBe("hello");
7
+ });
8
+ it("truncates with ellipsis", () => {
9
+ expect(truncate("hello world", 6)).toBe("hello\u2026");
10
+ });
11
+ it("handles exact length", () => {
12
+ expect(truncate("hello", 5)).toBe("hello");
13
+ });
14
+ });
15
+
16
+ describe("maskEmail", () => {
17
+ it("masks middle of local part", () => {
18
+ expect(maskEmail("alice@example.com")).toBe("a***e@example.com");
19
+ });
20
+ it("handles short local part", () => {
21
+ expect(maskEmail("ab@example.com")).toBe("ab@example.com");
22
+ });
23
+ it("limits mask to 3 stars", () => {
24
+ expect(maskEmail("alexander@example.com")).toBe("a***r@example.com");
25
+ });
26
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Format a date string or timestamp to locale-aware display.
3
+ * Falls back to ISO string if Intl is not available.
4
+ */
5
+ export function formatDate(
6
+ value: string | number | Date,
7
+ locale = "en",
8
+ ): string {
9
+ const date = value instanceof Date ? value : new Date(value);
10
+ if (isNaN(date.getTime())) return String(value);
11
+
12
+ try {
13
+ return new Intl.DateTimeFormat(locale, {
14
+ year: "numeric",
15
+ month: "short",
16
+ day: "numeric",
17
+ hour: "2-digit",
18
+ minute: "2-digit",
19
+ }).format(date);
20
+ } catch {
21
+ return date.toISOString();
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Format a relative time (e.g., "5 minutes ago").
27
+ * Uses Intl.RelativeTimeFormat when available.
28
+ */
29
+ export function formatRelativeTime(
30
+ date: string | number | Date,
31
+ locale = "en",
32
+ ): string {
33
+ const now = Date.now();
34
+ const then = date instanceof Date ? date.getTime() : new Date(date).getTime();
35
+ const diffSeconds = Math.floor((now - then) / 1000);
36
+
37
+ if (diffSeconds < 60) return locale === "uk" ? "Щойно" : "Just now";
38
+
39
+ try {
40
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
41
+
42
+ if (diffSeconds < 3600) {
43
+ return rtf.format(-Math.floor(diffSeconds / 60), "minute");
44
+ }
45
+ if (diffSeconds < 86400) {
46
+ return rtf.format(-Math.floor(diffSeconds / 3600), "hour");
47
+ }
48
+ return rtf.format(-Math.floor(diffSeconds / 86400), "day");
49
+ } catch {
50
+ const minutes = Math.floor(diffSeconds / 60);
51
+ if (minutes < 60) return `${minutes}m ago`;
52
+ const hours = Math.floor(minutes / 60);
53
+ if (hours < 24) return `${hours}h ago`;
54
+ return `${Math.floor(hours / 24)}d ago`;
55
+ }
56
+ }
57
+
58
+ /** Truncate a string with ellipsis. */
59
+ export function truncate(value: string, maxLen: number): string {
60
+ if (value.length <= maxLen) return value;
61
+ return value.slice(0, maxLen - 1) + "\u2026";
62
+ }
63
+
64
+ /** Mask an email address for display (e.g., "a***e@example.com"). */
65
+ export function maskEmail(email: string): string {
66
+ const [local, domain] = email.split("@");
67
+ if (!domain || local.length <= 2) return email;
68
+ return `${local[0]}${"*".repeat(Math.min(local.length - 2, 3))}${local[local.length - 1]}@${domain}`;
69
+ }
@@ -0,0 +1,16 @@
1
+ export {
2
+ isValidEmail,
3
+ isValidPhone,
4
+ isValidUuid,
5
+ isValidUsername,
6
+ minLength,
7
+ maxLength,
8
+ isRequired,
9
+ } from "./validators";
10
+
11
+ export {
12
+ formatDate,
13
+ formatRelativeTime,
14
+ truncate,
15
+ maskEmail,
16
+ } from "./formatters";
@@ -0,0 +1,188 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { detectLoginInputType, normalizePrincipal } from "./normalizePrincipal";
3
+
4
+ describe("detectLoginInputType", () => {
5
+ it("detects email", () => {
6
+ expect(detectLoginInputType("alice@example.com")).toBe("email");
7
+ expect(detectLoginInputType("user+tag@mail.co")).toBe("email");
8
+ });
9
+
10
+ it("detects phone", () => {
11
+ expect(detectLoginInputType("+380501234567")).toBe("phone");
12
+ expect(detectLoginInputType("+1555")).toBe("phone");
13
+ });
14
+
15
+ it("detects username (default minLength=3)", () => {
16
+ expect(detectLoginInputType("sid")).toBe("username");
17
+ expect(detectLoginInputType("alice")).toBe("username");
18
+ expect(detectLoginInputType("bob_smith")).toBe("username");
19
+ expect(detectLoginInputType("dev42")).toBe("username");
20
+ // Uppercase accepted in detection (will be lowercased on normalize)
21
+ expect(detectLoginInputType("Admin123")).toBe("username");
22
+ });
23
+
24
+ it("detects username with minLength=6 (registration)", () => {
25
+ expect(detectLoginInputType("alice", 6)).toBe("unknown"); // too short
26
+ expect(detectLoginInputType("alicew", 6)).toBe("username"); // exactly 6
27
+ expect(detectLoginInputType("alice_wonder", 6)).toBe("username");
28
+ });
29
+
30
+ it("rejects usernames starting/ending with underscore", () => {
31
+ expect(detectLoginInputType("_alice")).toBe("unknown");
32
+ expect(detectLoginInputType("alice_")).toBe("unknown");
33
+ });
34
+
35
+ it("accepts digits in username (arch allows ^[a-z0-9_])", () => {
36
+ expect(detectLoginInputType("1abc")).toBe("username"); // digit start is OK per arch
37
+ expect(detectLoginInputType("42dev")).toBe("username");
38
+ });
39
+
40
+ it("rejects username exceeding max length (32)", () => {
41
+ const long = "a".repeat(33);
42
+ expect(detectLoginInputType(long)).toBe("unknown");
43
+ // Exactly 32 is OK
44
+ expect(detectLoginInputType("a".repeat(32))).toBe("username");
45
+ });
46
+
47
+ it("returns unknown for empty or whitespace input", () => {
48
+ expect(detectLoginInputType("")).toBe("unknown");
49
+ expect(detectLoginInputType(" ")).toBe("unknown");
50
+ expect(detectLoginInputType("ab")).toBe("unknown"); // too short (default min=3)
51
+ });
52
+
53
+ it("phone takes priority over username for +", () => {
54
+ expect(detectLoginInputType("+abc")).toBe("phone");
55
+ });
56
+
57
+ it("email takes priority for @", () => {
58
+ expect(detectLoginInputType("a@b")).toBe("email");
59
+ });
60
+
61
+ // ── Federated username (user#domain) ──
62
+
63
+ it("detects federated username", () => {
64
+ expect(detectLoginInputType("alice#acme.corp")).toBe("username");
65
+ expect(detectLoginInputType("bob#example.com")).toBe("username");
66
+ expect(detectLoginInputType("dev42#internal.net")).toBe("username");
67
+ });
68
+
69
+ it("detects federated username with uppercase", () => {
70
+ expect(detectLoginInputType("Alice#Acme.Corp")).toBe("username");
71
+ });
72
+
73
+ it("accepts short login part in federated username (org admin decides)", () => {
74
+ expect(detectLoginInputType("ab#acme.corp")).toBe("username"); // 2 chars OK
75
+ expect(detectLoginInputType("a#acme.corp")).toBe("username"); // 1 char OK
76
+ });
77
+
78
+ it("rejects federated username with empty login part", () => {
79
+ expect(detectLoginInputType("#acme.corp")).toBe("unknown");
80
+ });
81
+
82
+ it("rejects federated username with empty domain", () => {
83
+ expect(detectLoginInputType("alice#")).toBe("unknown");
84
+ });
85
+
86
+ it("rejects federated username with leading underscore in login part", () => {
87
+ expect(detectLoginInputType("_alice#acme.corp")).toBe("unknown");
88
+ });
89
+
90
+ it("# takes priority over plain username detection", () => {
91
+ expect(detectLoginInputType("alice#domain")).toBe("username");
92
+ });
93
+
94
+ it("ignores minUsernameLength for federated username", () => {
95
+ // minUsernameLength=6 only applies to global usernames, not federated
96
+ expect(detectLoginInputType("alice#acme.corp", 6)).toBe("username"); // 5 chars OK
97
+ expect(detectLoginInputType("ab#acme.corp", 6)).toBe("username"); // 2 chars OK
98
+ });
99
+ });
100
+
101
+ describe("normalizePrincipal", () => {
102
+ it("normalizes email: lowercase + strip dots + strip alias", () => {
103
+ const result = normalizePrincipal("Alice.Bob+tag@Example.COM");
104
+ expect(result.type).toBe("email");
105
+ expect(result.value).toBe("alicebob@example.com");
106
+ expect(result.display).toBe("alicebob@example.com");
107
+ });
108
+
109
+ it("normalizes phone to E.164", () => {
110
+ const result = normalizePrincipal("+380 50 123 45 67");
111
+ expect(result.type).toBe("phone");
112
+ expect(result.value).toBe("+380501234567");
113
+ expect(result.display).toBe("+380501234567");
114
+ });
115
+
116
+ it("normalizes username: value=lowercase, display=original casing", () => {
117
+ const result = normalizePrincipal("Alice_Bob");
118
+ expect(result.type).toBe("username");
119
+ expect(result.value).toBe("alice_bob");
120
+ expect(result.display).toBe("Alice_Bob");
121
+ });
122
+
123
+ it("rejects username with leading/trailing underscore (detected as unknown)", () => {
124
+ // Leading/trailing _ blocked at detection level → "Cannot determine"
125
+ expect(() => normalizePrincipal("_alice_bob")).toThrow("Cannot determine");
126
+ expect(() => normalizePrincipal("alice_")).toThrow("Cannot determine");
127
+ });
128
+
129
+ it("rejects username with consecutive underscores", () => {
130
+ // Detected as username (passes detection regex), but fails validation
131
+ expect(() => normalizePrincipal("alice__bob")).toThrow("Invalid username");
132
+ });
133
+
134
+ it("respects minUsernameLength for registration (6)", () => {
135
+ // 5-char username OK with default (3) but fails with minLength=6
136
+ expect(normalizePrincipal("alice").type).toBe("username");
137
+ expect(() => normalizePrincipal("alice", 6)).toThrow("Cannot determine");
138
+ });
139
+
140
+ it("throws on invalid email", () => {
141
+ expect(() => normalizePrincipal("alice@")).toThrow("Invalid email");
142
+ });
143
+
144
+ it("throws on invalid phone", () => {
145
+ expect(() => normalizePrincipal("+000")).toThrow("Invalid phone");
146
+ });
147
+
148
+ it("throws on unknown type", () => {
149
+ expect(() => normalizePrincipal("ab")).toThrow("Cannot determine");
150
+ });
151
+
152
+ // ── Federated username normalization ──
153
+
154
+ it("normalizes federated username: lowercase both parts", () => {
155
+ const result = normalizePrincipal("Alice#Acme.Corp");
156
+ expect(result.type).toBe("username");
157
+ expect(result.value).toBe("alice#acme.corp");
158
+ expect(result.display).toBe("Alice#Acme.Corp");
159
+ });
160
+
161
+ it("normalizes federated username with numbers", () => {
162
+ const result = normalizePrincipal("dev42#internal.net");
163
+ expect(result.type).toBe("username");
164
+ expect(result.value).toBe("dev42#internal.net");
165
+ });
166
+
167
+ it("rejects federated username with consecutive underscores in login part", () => {
168
+ expect(() => normalizePrincipal("alice__bob#acme.corp")).toThrow(
169
+ "Invalid username",
170
+ );
171
+ });
172
+
173
+ it("rejects federated username with invalid domain chars", () => {
174
+ expect(() => normalizePrincipal("alice#acme corp")).toThrow(
175
+ "Invalid federated domain",
176
+ );
177
+ });
178
+
179
+ it("rejects federated username with empty domain", () => {
180
+ expect(() => normalizePrincipal("alice#")).toThrow("Cannot determine");
181
+ });
182
+
183
+ it("preserves display form for federated username", () => {
184
+ const result = normalizePrincipal("Bob_Smith#MyCompany.com");
185
+ expect(result.display).toBe("Bob_Smith#MyCompany.com");
186
+ expect(result.value).toBe("bob_smith#mycompany.com");
187
+ });
188
+ });