@structured-id/ui-core 1.2.0 → 1.3.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 (101) hide show
  1. package/README.md +9 -5
  2. package/dist/browser-vp/index.d.cts +0 -11
  3. package/dist/browser-vp/index.d.ts +0 -11
  4. package/dist/index.cjs +13721 -9514
  5. package/dist/index.d.cts +4362 -2375
  6. package/dist/index.d.ts +4362 -2375
  7. package/dist/index.js +13937 -9730
  8. package/package.json +3 -3
  9. package/src/browser-vp/crypto.ts +0 -1
  10. package/src/browser-vp/index.ts +0 -2
  11. package/src/browser-vp/prf.ts +0 -2
  12. package/src/browser-vp/storage.ts +0 -2
  13. package/src/browser-vp/types.ts +0 -2
  14. package/src/browser-vp/vp.ts +0 -2
  15. package/src/composables/index.ts +6 -1
  16. package/src/composables/useAccountTransport.test.ts +121 -0
  17. package/src/composables/useAccountTransport.ts +69 -0
  18. package/src/composables/useBffAuth.ts +44 -34
  19. package/src/generated/google/api/httpbody.ts +159 -0
  20. package/src/generated/google/protobuf/any.ts +326 -0
  21. package/src/generated/google/rpc/error_details.ts +1386 -0
  22. package/src/generated/google/rpc/status.ts +127 -0
  23. package/src/generated/sid/v1/account/account.ts +1 -1
  24. package/src/generated/sid/v1/admin/admin.client.ts +15 -12
  25. package/src/generated/sid/v1/admin/admin.ts +24 -89
  26. package/src/generated/sid/v1/admin/enrollment.client.ts +24 -12
  27. package/src/generated/sid/v1/admin/enrollment.ts +65 -3
  28. package/src/generated/sid/v1/admin/events.client.ts +8 -8
  29. package/src/generated/sid/v1/admin/flow.ts +1 -1
  30. package/src/generated/sid/v1/admin/keys.ts +1 -1
  31. package/src/generated/sid/v1/admin/notifications.client.ts +12 -6
  32. package/src/generated/sid/v1/admin/notifications.ts +11 -70
  33. package/src/generated/sid/v1/admin/operations.ts +20 -35
  34. package/src/generated/sid/v1/admin/organizations.client.ts +2 -2
  35. package/src/generated/sid/v1/admin/organizations.ts +15 -32
  36. package/src/generated/sid/v1/admin/provisioning.client.ts +232 -0
  37. package/src/generated/sid/v1/admin/provisioning.ts +1383 -0
  38. package/src/generated/sid/v1/admin/scopes.ts +1 -1
  39. package/src/generated/sid/v1/admin/security.client.ts +22 -24
  40. package/src/generated/sid/v1/admin/security.ts +13 -22
  41. package/src/generated/sid/v1/admin/settings.client.ts +29 -18
  42. package/src/generated/sid/v1/admin/settings.ts +2 -82
  43. package/src/generated/sid/v1/attestation/attestation.client.ts +6 -6
  44. package/src/generated/sid/v1/attestation/attestation.ts +8 -8
  45. package/src/generated/sid/v1/authn/auth.client.ts +101 -58
  46. package/src/generated/sid/v1/authn/auth.ts +842 -414
  47. package/src/generated/sid/v1/authn/flows.ts +0 -12
  48. package/src/generated/sid/v1/authn/issuer.client.ts +489 -0
  49. package/src/generated/sid/v1/authn/issuer.ts +1600 -0
  50. package/src/generated/sid/v1/authn/password_history.client.ts +57 -0
  51. package/src/generated/sid/v1/authn/password_history.ts +578 -0
  52. package/src/generated/sid/v1/authn/system_integration.client.ts +73 -0
  53. package/src/generated/sid/v1/authn/system_integration.ts +218 -0
  54. package/src/generated/sid/v1/authz/authz.ts +1498 -317
  55. package/src/generated/sid/v1/authz/forward_auth.client.ts +97 -0
  56. package/src/generated/sid/v1/authz/forward_auth.ts +79 -0
  57. package/src/generated/sid/v1/authz/governance.client.ts +2 -2
  58. package/src/generated/sid/v1/authz/governance.ts +61 -4
  59. package/src/generated/sid/v1/common/errors.ts +532 -6
  60. package/src/generated/sid/v1/events/event_stream.client.ts +2 -2
  61. package/src/generated/sid/v1/events/event_stream.ts +2 -1
  62. package/src/generated/sid/v1/identity/identity.ts +98 -58
  63. package/src/generated/sid/v1/ids/ids.ts +395 -0
  64. package/src/generated/sid/v1/machine/machine_user.ts +0 -36
  65. package/src/generated/sid/v1/machine/pat.client.ts +2 -19
  66. package/src/generated/sid/v1/machine/pat.ts +1 -186
  67. package/src/generated/sid/v1/projects/projects.client.ts +137 -19
  68. package/src/generated/sid/v1/projects/projects.ts +2105 -405
  69. package/src/generated/sid/v1/scim/scim.client.ts +248 -2
  70. package/src/generated/sid/v1/scim/scim.ts +153 -14
  71. package/src/generated/sid/v1/test/test.ts +1 -1
  72. package/src/i18n/index.ts +0 -11
  73. package/src/index.ts +36 -2
  74. package/src/package-exports.test.ts +18 -0
  75. package/src/profile/components/SidLoginForm.vue +5 -2
  76. package/src/profile/components/SidRegistrationForm.test.ts +58 -1
  77. package/src/profile/components/SidRegistrationForm.vue +110 -12
  78. package/src/profile/components/index.ts +4 -0
  79. package/src/profile/composables/index.ts +2 -1
  80. package/src/profile/composables/useAccountApi.ts +20 -7
  81. package/src/profile/composables/useAuth.test.ts +453 -0
  82. package/src/profile/composables/useAuth.ts +414 -0
  83. package/src/profile/composables/useAuthApi.opaque.test.ts +18 -0
  84. package/src/profile/composables/useAuthApi.ts +188 -20
  85. package/src/profile/composables/useEnrollmentApi.test.ts +33 -0
  86. package/src/profile/composables/useEnrollmentApi.ts +26 -0
  87. package/src/profile/composables/useIdentityApi.ts +17 -7
  88. package/src/profile/composables/useLoginSuccess.test.ts +97 -3
  89. package/src/profile/composables/useLoginSuccess.ts +80 -22
  90. package/src/profile/composables/useSecurityLevel.test.ts +10 -3
  91. package/src/profile/composables/useSecurityLevel.ts +12 -3
  92. package/src/quasar/SidPrincipalInput.vue +4 -7
  93. package/src/utils/normalizePrincipal.test.ts +19 -5
  94. package/src/utils/normalizePrincipal.ts +7 -9
  95. package/src/utils/rpcRefusal.test.ts +151 -0
  96. package/src/utils/rpcRefusal.ts +252 -0
  97. package/src/utils/validators.test.ts +9 -0
  98. package/src/utils/validators.ts +14 -2
  99. package/src/composables/useAuth.ts +0 -40
  100. package/src/profile/composables/useOpaqueAuth.test.ts +0 -290
  101. package/src/profile/composables/useOpaqueAuth.ts +0 -191
@@ -1,22 +1,14 @@
1
1
  /**
2
2
  * Shared post-login success handler.
3
3
  *
4
- * After BFF migration (#536i-3): tokens are set server-side via BFF session cookie.
5
- * The SPA no longer stores tokens — it only calls checkBffSession() to populate
6
- * the auth store from the BFF /auth/userinfo endpoint.
4
+ * A sign-in that an authorization request sent the user to (`rd`, the
5
+ * continuation of that request at the issuer) returns the browser there: the
6
+ * ceremony has set the issuer's IdP session cookie, and the authorization
7
+ * endpoint completes the request with it. Any other `rd` is ignored, so the
8
+ * sign-in page never redirects to an address it did not validate.
7
9
  *
8
- * The BFF login flow:
9
- * 1. App redirects to /auth/login (PKCE initiation)
10
- * 2. User authenticates on the IdP
11
- * 3. BFF callback sets httpOnly session cookie
12
- * 4. BFF redirects back to SPA
13
- * 5. SPA calls checkBffSession() → setBffSession() → navigates to dashboard
14
- *
15
- * For apps where the SPA IS the auth UI (personal-ui OPAQUE flow):
16
- * The OPAQUE login completes, sid-auth sets the server-side session,
17
- * and this handler fetches userinfo from BFF to restore session state.
18
- *
19
- * Used by all apps (personal-ui, admin-ui, frontend) for consistent behavior.
10
+ * Otherwise the app's own session comes from its BFF: the handler reads it
11
+ * from the BFF's /auth/userinfo and navigates within the app.
20
12
  */
21
13
  import { useRouter } from "vue-router";
22
14
  import { useAuthStore } from "../../index";
@@ -26,29 +18,95 @@ export interface LoginSuccessResult {
26
18
  sessionId?: string;
27
19
  }
28
20
 
21
+ /** Options of {@link useLoginSuccess}. */
22
+ export interface LoginSuccessOptions {
23
+ /**
24
+ * Origin of the SID installation whose issuers may send users to this
25
+ * sign-in page (e.g. `https://sid.example.com`). Without it, `rd` is never
26
+ * followed.
27
+ */
28
+ issuerOrigin?: string;
29
+ /** Leave the app for an absolute URL; defaults to `window.location.assign`. */
30
+ leaveTo?: (url: string) => void;
31
+ }
32
+
33
+ /** Path of an issuer's authorization endpoint: `/i/<32 hex>/oauth2/authorize`. */
34
+ const AUTHORIZE_PATH = /^\/i\/[0-9a-f]{32}\/oauth2\/authorize$/;
35
+
36
+ /** Prefix of the reference a kept authorization request is continued by. */
37
+ const REQUEST_URI_PREFIX = "urn:ietf:params:oauth:request_uri:";
38
+
29
39
  /**
30
- * Create a login success handler that restores BFF session state and navigates.
31
- *
32
- * @param redirectTo - Route to navigate after successful login (e.g., "/account", "/admin")
40
+ * `rd` when it is exactly the continuation of an authorization request at an
41
+ * issuer of `issuerOrigin`: that origin, an issuer's authorization endpoint,
42
+ * and no parameter but `client_id` and `request_uri`. Anything else is null.
43
+ */
44
+ export function authorizationContinuation(
45
+ rd: unknown,
46
+ issuerOrigin: string,
47
+ ): string | null {
48
+ if (typeof rd !== "string") return null;
49
+ let url: URL;
50
+ let expected: string;
51
+ try {
52
+ url = new URL(rd);
53
+ expected = new URL(issuerOrigin).origin;
54
+ } catch {
55
+ return null;
56
+ }
57
+ if (url.origin !== expected || url.username || url.password || url.hash) {
58
+ return null;
59
+ }
60
+ if (!AUTHORIZE_PATH.test(url.pathname)) return null;
61
+ const names = [...url.searchParams.keys()].sort();
62
+ if (
63
+ names.length !== 2 ||
64
+ names[0] !== "client_id" ||
65
+ names[1] !== "request_uri"
66
+ ) {
67
+ return null;
68
+ }
69
+ if (!url.searchParams.get("request_uri")?.startsWith(REQUEST_URI_PREFIX)) {
70
+ return null;
71
+ }
72
+ if (!url.searchParams.get("client_id")) return null;
73
+ return url.toString();
74
+ }
75
+
76
+ /**
77
+ * Create a login success handler: back to the authorization request the
78
+ * user signs in for, or into the app at `redirectTo`.
33
79
  *
34
80
  * @example
35
81
  * ```vue
36
82
  * <script setup>
37
83
  * import { useLoginSuccess } from "@structured-id/ui-core/profile";
38
- * const { onLoginSuccess } = useLoginSuccess("/account");
84
+ * const { onLoginSuccess } = useLoginSuccess("/account", { issuerOrigin });
39
85
  * </script>
40
86
  * <template>
41
87
  * <SidLoginForm :login-fn="login" @success="onLoginSuccess" />
42
88
  * </template>
43
89
  * ```
44
90
  */
45
- export function useLoginSuccess(redirectTo: string) {
91
+ export function useLoginSuccess(
92
+ redirectTo: string,
93
+ options: LoginSuccessOptions = {},
94
+ ) {
46
95
  const auth = useAuthStore();
47
96
  const router = useRouter();
48
97
 
49
98
  async function onLoginSuccess(_result?: LoginSuccessResult) {
50
- // Restore BFF session from /auth/userinfo — the BFF set the httpOnly cookie
51
- // during the login flow. We fetch userinfo to populate the auth store.
99
+ const back = options.issuerOrigin
100
+ ? authorizationContinuation(
101
+ router.currentRoute.value.query.rd,
102
+ options.issuerOrigin,
103
+ )
104
+ : null;
105
+ if (back) {
106
+ (options.leaveTo ?? ((url: string) => window.location.assign(url)))(back);
107
+ return;
108
+ }
109
+
52
110
  try {
53
111
  const userInfo = await checkBffSession();
54
112
  if (userInfo) {
@@ -12,7 +12,6 @@ import {
12
12
  type Credential,
13
13
  type Principal,
14
14
  } from "../../index";
15
- import { PrincipalSubjectType } from "../../generated/sid/v1/identity/identity";
16
15
 
17
16
  // ── Helpers ──
18
17
 
@@ -50,8 +49,7 @@ function makePrincipal(type: PrincipalType, verified: boolean): Principal {
50
49
  value: "test",
51
50
  verified,
52
51
  isPrimary: false,
53
- subjectType: PrincipalSubjectType.PROFILE,
54
- subjectId: "",
52
+ needsAddressConfirmation: false,
55
53
  };
56
54
  }
57
55
 
@@ -162,6 +160,15 @@ describe("recoverability", () => {
162
160
  const { recoveryMethods } = useSecurityLevel(ref([]), principals);
163
161
  expect(recoveryMethods.value).not.toContain("recovery_email");
164
162
  });
163
+
164
+ // An email whose address awaits confirmation receives no reset, so it is
165
+ // no recovery channel even when it was once marked verified.
166
+ it("email awaiting address confirmation not counted", () => {
167
+ const pending = makePrincipal(PrincipalType.EMAIL, true);
168
+ pending.needsAddressConfirmation = true;
169
+ const { recoveryMethods } = useSecurityLevel(ref([]), ref([pending]));
170
+ expect(recoveryMethods.value).not.toContain("recovery_email");
171
+ });
165
172
  });
166
173
 
167
174
  // ── Nudges ──
@@ -180,8 +180,12 @@ export function useSecurityLevel(
180
180
  methods.push("synced_passkey");
181
181
  }
182
182
 
183
+ // An email awaiting address confirmation receives no reset.
183
184
  const emails = identifiers.value.filter(
184
- (i) => i.type === PrincipalType.EMAIL && i.verified,
185
+ (i) =>
186
+ i.type === PrincipalType.EMAIL &&
187
+ i.verified &&
188
+ !i.needsAddressConfirmation,
185
189
  );
186
190
  if (emails.length > 0) {
187
191
  methods.push("recovery_email");
@@ -235,10 +239,15 @@ export function useSecurityLevel(
235
239
  (c) => c.type === CredentialType.RECOVERY,
236
240
  );
237
241
  const verifiedEmails = identifiers.value.filter(
238
- (i) => i.type === PrincipalType.EMAIL && i.verified,
242
+ (i) =>
243
+ i.type === PrincipalType.EMAIL &&
244
+ i.verified &&
245
+ !i.needsAddressConfirmation,
239
246
  );
240
247
  const unverifiedEmails = identifiers.value.filter(
241
- (i) => i.type === PrincipalType.EMAIL && !i.verified,
248
+ (i) =>
249
+ i.type === PrincipalType.EMAIL &&
250
+ (!i.verified || i.needsAddressConfirmation),
242
251
  );
243
252
 
244
253
  if (!hasRecovery) {
@@ -33,6 +33,7 @@
33
33
  <script setup lang="ts">
34
34
  import { ref, computed, watch } from "vue";
35
35
  import { isValidPhoneNumber } from "libphonenumber-js";
36
+ import { isValidEmail } from "../utils/validators";
36
37
 
37
38
  export type PrincipalType = "email" | "phone" | "username" | "unknown";
38
39
 
@@ -47,8 +48,7 @@ interface Props {
47
48
 
48
49
  const props = withDefaults(defineProps<Props>(), {
49
50
  modelValue: "",
50
- // Per arch/identity/identity-model.md §Principal: username regex is
51
- // ^[a-z0-9_]{3,32}$ (display form ^[a-zA-Z0-9_]{3,32}$).
51
+ // Username rule: ^[a-z0-9_]{3,32}$ (display form ^[a-zA-Z0-9_]{3,32}$).
52
52
  minUsernameLength: 3,
53
53
  required: true,
54
54
  label: "Email, phone, or username",
@@ -80,8 +80,7 @@ function detectType(value: string): PrincipalType {
80
80
  // Email: contains @ (checked after phone to exclude edge cases)
81
81
  if (trimmed.includes("@")) return "email";
82
82
 
83
- // Username per arch/identity/identity-model.md:
84
- // ^[a-z0-9_]{3,32}$, no leading/trailing _, no __
83
+ // Username: ^[a-z0-9_]{3,32}$, no leading/trailing _, no __
85
84
  // Detection is relaxed: accepts uppercase (will normalize), checks min length
86
85
  const re = new RegExp(
87
86
  `^[a-zA-Z0-9_]{${props.minUsernameLength},${USERNAME_MAX_LENGTH}}$`,
@@ -110,15 +109,13 @@ function onInput(value: string | number | null) {
110
109
  const requiredRule = (val: string) =>
111
110
  !props.required || !!val || "This field is required";
112
111
 
113
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
114
-
115
112
  const typeRule = (val: string): string | true => {
116
113
  if (!val) return true; // required rule handles empty
117
114
  const type = detectType(val);
118
115
 
119
116
  switch (type) {
120
117
  case "email":
121
- return emailRegex.test(val) || "Invalid email address";
118
+ return isValidEmail(val.trim()) || "Invalid email address";
122
119
  case "phone":
123
120
  return isValidPhoneNumber(val) || "Invalid phone number";
124
121
  case "username": {
@@ -99,11 +99,24 @@ describe("detectLoginInputType", () => {
99
99
  });
100
100
 
101
101
  describe("normalizePrincipal", () => {
102
- it("normalizes email: lowercase + strip dots + strip alias", () => {
103
- const result = normalizePrincipal("Alice.Bob+tag@Example.COM");
102
+ // Regression: the client folded the address (case, dots, +tag) before
103
+ // sending it, so the server never saw the mailbox the user typed and
104
+ // stored the folded key as the address to mail. The server derives the
105
+ // lookup key and keeps the spelling; the client sends it as typed.
106
+ it("sends an email as typed, only trimmed", () => {
107
+ const result = normalizePrincipal(" Alice.Bob+tag@Example.COM ");
104
108
  expect(result.type).toBe("email");
105
- expect(result.value).toBe("alicebob@example.com");
106
- expect(result.display).toBe("alicebob@example.com");
109
+ expect(result.value).toBe("Alice.Bob+tag@Example.COM");
110
+ expect(result.display).toBe("Alice.Bob+tag@Example.COM");
111
+ });
112
+
113
+ // Admission is the server's policy: an installation's internal mail
114
+ // domain and a quoted local part are not refused by the client.
115
+ it("leaves email admission to the server", () => {
116
+ expect(normalizePrincipal("admin@printer").value).toBe("admin@printer");
117
+ expect(normalizePrincipal('"a b"@example.com').value).toBe(
118
+ '"a b"@example.com',
119
+ );
107
120
  });
108
121
 
109
122
  it("normalizes phone to E.164", () => {
@@ -137,8 +150,9 @@ describe("normalizePrincipal", () => {
137
150
  expect(() => normalizePrincipal("alice", 6)).toThrow("Cannot determine");
138
151
  });
139
152
 
140
- it("throws on invalid email", () => {
153
+ it("throws on an email with no local part or domain", () => {
141
154
  expect(() => normalizePrincipal("alice@")).toThrow("Invalid email");
155
+ expect(() => normalizePrincipal("@example.com")).toThrow("Invalid email");
142
156
  });
143
157
 
144
158
  it("throws on invalid phone", () => {
@@ -1,6 +1,7 @@
1
1
  import parsePhoneNumberFromString, {
2
2
  isValidPhoneNumber,
3
3
  } from "libphonenumber-js";
4
+ import { isValidEmail } from "./validators";
4
5
 
5
6
  export type LoginInputType = "email" | "phone" | "username" | "unknown";
6
7
 
@@ -13,7 +14,7 @@ export interface NormalizedPrincipal {
13
14
  }
14
15
 
15
16
  /**
16
- * Global username rules per arch/identity/identity-model.md:
17
+ * Global username rules:
17
18
  * Display: ^[a-zA-Z0-9_]{3,32}$ — user's original input, mixed case preserved
18
19
  * Normalized: ^[a-z0-9_]{3,32}$ — lowercase, for storage + uniqueness + login
19
20
  * - cannot start or end with underscore
@@ -76,7 +77,7 @@ export function detectLoginInputType(
76
77
  /**
77
78
  * Normalize principal value for server submission.
78
79
  *
79
- * - Email: lowercase, strip dots before @, strip +aliases
80
+ * - Email: as typed (trimmed); the server applies the email policy
80
81
  * - Phone: E.164 format via libphonenumber-js
81
82
  * - Username: lowercase, validate per arch rules
82
83
  *
@@ -92,15 +93,12 @@ export function normalizePrincipal(
92
93
 
93
94
  switch (type) {
94
95
  case "email": {
95
- const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
96
- if (!emailRegex.test(trimmed)) {
96
+ // Sent as typed: the server validates it, derives the lookup key and
97
+ // keeps this spelling as the address to mail.
98
+ if (!isValidEmail(trimmed)) {
97
99
  throw new Error("Invalid email address");
98
100
  }
99
- // Remove dots before @, strip +alias, lowercase
100
- const [localRaw, domain] = trimmed.split("@");
101
- const local = localRaw.replace(/\./g, "").replace(/\+.*$/, "");
102
- const normalized = `${local}@${domain}`.toLowerCase();
103
- return { value: normalized, display: normalized, type: "email" };
101
+ return { value: trimmed, display: trimmed, type: "email" };
104
102
  }
105
103
 
106
104
  case "phone": {
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { RpcError } from "@protobuf-ts/runtime-rpc";
3
+ import { base64encode } from "@protobuf-ts/runtime";
4
+ import { Any } from "../generated/google/protobuf/any";
5
+ import { Status } from "../generated/google/rpc/status";
6
+ import {
7
+ ErrorInfo,
8
+ LocalizedMessage,
9
+ } from "../generated/google/rpc/error_details";
10
+ import { ErrorReason } from "../generated/sid/v1/common/errors";
11
+ import {
12
+ REFUSAL_TEXTS,
13
+ refusalMessage,
14
+ rpcReason,
15
+ rpcRefusal,
16
+ } from "./rpcRefusal";
17
+
18
+ /** A refused call as the gRPC-Web transport raises it: the status in the trailer. */
19
+ function refused(
20
+ details: Any[],
21
+ message = "this%20password%20was%20used%20before",
22
+ ): RpcError {
23
+ const status = Status.create({ code: 9, message, details });
24
+ return new RpcError(message, "FAILED_PRECONDITION", {
25
+ "grpc-status-details-bin": base64encode(Status.toBinary(status)),
26
+ });
27
+ }
28
+
29
+ const info = Any.pack(
30
+ ErrorInfo.create({
31
+ reason: "PASSWORD_REUSED",
32
+ domain: "structured.id",
33
+ metadata: {},
34
+ }),
35
+ ErrorInfo,
36
+ );
37
+
38
+ describe("rpcRefusal", () => {
39
+ // The contract a page branches on is the (domain, reason) pair of ErrorInfo.
40
+ it("reads the domain and reason from the status details", () => {
41
+ expect(rpcRefusal(refused([info]))).toMatchObject({
42
+ code: "FAILED_PRECONDITION",
43
+ domain: "structured.id",
44
+ reason: "PASSWORD_REUSED",
45
+ });
46
+ expect(rpcReason(refused([info]))).toBe("PASSWORD_REUSED");
47
+ });
48
+
49
+ // A reason is defined only within its domain: the same name from another
50
+ // service (a proxied upstream, another edition) is not SID's reason, so a
51
+ // page must not branch on it as if it were.
52
+ it("reports a reason only for the domain asked about", () => {
53
+ const foreign = Any.pack(
54
+ ErrorInfo.create({
55
+ reason: "PASSWORD_REUSED",
56
+ domain: "upstream.example.com",
57
+ metadata: {},
58
+ }),
59
+ ErrorInfo,
60
+ );
61
+ expect(rpcReason(refused([foreign]))).toBeNull();
62
+ expect(rpcReason(refused([foreign]), "upstream.example.com")).toBe(
63
+ "PASSWORD_REUSED",
64
+ );
65
+ });
66
+
67
+ // gRPC percent-encodes the status message on the wire; people read it decoded.
68
+ it("decodes the percent-encoded status message", () => {
69
+ expect(rpcRefusal(refused([]))?.message).toBe(
70
+ "this password was used before",
71
+ );
72
+ });
73
+
74
+ it("prefers the server's text for people", () => {
75
+ const localized = Any.pack(
76
+ LocalizedMessage.create({ locale: "en", message: "Choose another." }),
77
+ LocalizedMessage,
78
+ );
79
+ expect(refusalMessage(refused([info, localized]))).toBe("Choose another.");
80
+ });
81
+
82
+ // The status message is written for developers: people see the client's
83
+ // text for the reason, never the message.
84
+ it("shows the client's text for the reason", () => {
85
+ expect(refusalMessage(refused([info]))).toBe(
86
+ REFUSAL_TEXTS[ErrorReason.PASSWORD_REUSED],
87
+ );
88
+ expect(rpcRefusal(refused([info]))?.reasonCode).toBe(
89
+ ErrorReason.PASSWORD_REUSED,
90
+ );
91
+ });
92
+
93
+ // A refusal without SID's reason (a transport failure, another domain, a
94
+ // reason this client does not know) has nothing for people but the
95
+ // caller's own context.
96
+ it("falls back to the caller's text without a known SID reason", () => {
97
+ expect(refusalMessage(refused([]), "Failed to load")).toBe(
98
+ "Failed to load",
99
+ );
100
+ const foreign = Any.pack(
101
+ ErrorInfo.create({
102
+ reason: "PASSWORD_REUSED",
103
+ domain: "upstream.example.com",
104
+ metadata: {},
105
+ }),
106
+ ErrorInfo,
107
+ );
108
+ expect(refusalMessage(refused([foreign]), "Failed to load")).toBe(
109
+ "Failed to load",
110
+ );
111
+ const unknown = Any.pack(
112
+ ErrorInfo.create({
113
+ reason: "NOT_YET_DEFINED",
114
+ domain: "structured.id",
115
+ metadata: {},
116
+ }),
117
+ ErrorInfo,
118
+ );
119
+ expect(rpcRefusal(refused([unknown]))?.reasonCode).toBeNull();
120
+ expect(refusalMessage(refused([unknown]), "Failed to load")).toBe(
121
+ "Failed to load",
122
+ );
123
+ });
124
+
125
+ // Every reason of the proto has a text: a reason added without one would
126
+ // reach people as the bare fallback.
127
+ it("has a text for every reason", () => {
128
+ const texts: Partial<Record<ErrorReason, string>> = REFUSAL_TEXTS;
129
+ for (const value of Object.values(ErrorReason)) {
130
+ if (typeof value !== "number") continue;
131
+ if (value === ErrorReason.ERROR_REASON_UNSPECIFIED) continue;
132
+ expect(texts[value]).toBeTruthy();
133
+ }
134
+ });
135
+
136
+ it("leaves any other error alone", () => {
137
+ expect(rpcRefusal(new Error("boom"))).toBeNull();
138
+ expect(rpcReason(new Error("boom"))).toBeNull();
139
+ expect(refusalMessage(new Error("boom"))).toBe("boom");
140
+ expect(refusalMessage("not an error", "Request failed")).toBe(
141
+ "Request failed",
142
+ );
143
+ });
144
+
145
+ it("survives an unreadable trailer", () => {
146
+ const err = new RpcError("m", "INTERNAL", {
147
+ "grpc-status-details-bin": "!!!",
148
+ });
149
+ expect(rpcRefusal(err)).toMatchObject({ reason: null, message: "m" });
150
+ });
151
+ });