@qlever-llc/trellis-svelte 0.10.18-rc.1 → 0.10.19
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.
- package/dist/components/TrellisProvider.svelte +41 -2
- package/dist/components/TrellisProvider.types.d.ts +2 -0
- package/dist/portal_flow.d.ts +7 -0
- package/dist/portal_flow.js +125 -0
- package/package.json +3 -3
- package/src/components/TrellisProvider.svelte +41 -2
- package/src/components/TrellisProvider.types.ts +2 -0
- package/src/portal_flow.svelte.ts +136 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts" generics="TContract extends TrellisContractLike">
|
|
2
2
|
import {
|
|
3
|
+
classifyBrowserAuthError,
|
|
3
4
|
ClientAuthHandledError,
|
|
4
5
|
TrellisClient,
|
|
5
6
|
type ClientAuthOptions,
|
|
@@ -17,12 +18,17 @@
|
|
|
17
18
|
client,
|
|
18
19
|
children,
|
|
19
20
|
loading,
|
|
21
|
+
recoveringAuth,
|
|
20
22
|
error: errorSnippet,
|
|
21
23
|
onAuthRequired,
|
|
24
|
+
onRecoverableAuthError,
|
|
22
25
|
}: TrellisProviderProps<TContract> = $props();
|
|
23
26
|
|
|
27
|
+
type ProviderState = "connecting" | "connected" | "auth_handled" | "failed";
|
|
28
|
+
|
|
24
29
|
let trellis = $state<ConnectedTrellisClient<TContract> | null>(null);
|
|
25
30
|
let connectError = $state<unknown>(null);
|
|
31
|
+
let providerState = $state<ProviderState>("connecting");
|
|
26
32
|
|
|
27
33
|
type SerializableTrellisError = {
|
|
28
34
|
message?: unknown;
|
|
@@ -98,6 +104,7 @@
|
|
|
98
104
|
connectError = new TypeError(
|
|
99
105
|
"Expected trellisApp to resolve a Trellis URL",
|
|
100
106
|
);
|
|
107
|
+
providerState = "failed";
|
|
101
108
|
return;
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -118,14 +125,43 @@
|
|
|
118
125
|
|
|
119
126
|
if (active) {
|
|
120
127
|
trellis = connected;
|
|
128
|
+
providerState = "connected";
|
|
121
129
|
} else {
|
|
122
130
|
await connected.connection.close();
|
|
123
131
|
}
|
|
124
132
|
} catch (error) {
|
|
125
133
|
if (!active) return;
|
|
126
|
-
if (error instanceof ClientAuthHandledError)
|
|
134
|
+
if (error instanceof ClientAuthHandledError) {
|
|
135
|
+
providerState = "auth_handled";
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const authRecovery = classifyBrowserAuthError(error);
|
|
139
|
+
if (authRecovery.recoverable) {
|
|
140
|
+
if (onRecoverableAuthError) {
|
|
141
|
+
providerState = "auth_handled";
|
|
142
|
+
try {
|
|
143
|
+
await onRecoverableAuthError(error);
|
|
144
|
+
return;
|
|
145
|
+
} catch (recoveryError) {
|
|
146
|
+
if (!active) return;
|
|
147
|
+
console.error("TrellisProvider auth recovery callback failed", {
|
|
148
|
+
recoveryError,
|
|
149
|
+
error,
|
|
150
|
+
});
|
|
151
|
+
logConnectionError(recoveryError);
|
|
152
|
+
connectError = recoveryError;
|
|
153
|
+
providerState = "failed";
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (recoveringAuth) {
|
|
158
|
+
providerState = "auth_handled";
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
127
162
|
logConnectionError(error);
|
|
128
163
|
connectError = error;
|
|
164
|
+
providerState = "failed";
|
|
129
165
|
}
|
|
130
166
|
})();
|
|
131
167
|
|
|
@@ -133,6 +169,7 @@
|
|
|
133
169
|
active = false;
|
|
134
170
|
const connected = trellis;
|
|
135
171
|
trellis = null;
|
|
172
|
+
providerState = "connecting";
|
|
136
173
|
if (connected) {
|
|
137
174
|
void connected.connection.close();
|
|
138
175
|
}
|
|
@@ -142,10 +179,12 @@
|
|
|
142
179
|
|
|
143
180
|
{#if trellis}
|
|
144
181
|
<TrellisContextProvider {trellisApp} {trellis} {children} />
|
|
145
|
-
{:else if connectError}
|
|
182
|
+
{:else if providerState === "failed" && connectError}
|
|
146
183
|
{#if errorSnippet}
|
|
147
184
|
{@render errorSnippet(connectError)}
|
|
148
185
|
{/if}
|
|
186
|
+
{:else if providerState === "auth_handled" && recoveringAuth}
|
|
187
|
+
{@render recoveringAuth()}
|
|
149
188
|
{:else if loading}
|
|
150
189
|
{@render loading()}
|
|
151
190
|
{/if}
|
|
@@ -8,6 +8,8 @@ export type TrellisProviderProps<TContract extends TrellisContractLike = Trellis
|
|
|
8
8
|
client?: ClientOpts;
|
|
9
9
|
children: Snippet;
|
|
10
10
|
loading?: Snippet;
|
|
11
|
+
recoveringAuth?: Snippet;
|
|
11
12
|
error?: Snippet<[unknown]>;
|
|
12
13
|
onAuthRequired?: (loginUrl: string, context: ClientAuthRequiredContext) => void | Promise<void>;
|
|
14
|
+
onRecoverableAuthError?: (error: unknown) => void | Promise<void>;
|
|
13
15
|
};
|
package/dist/portal_flow.d.ts
CHANGED
|
@@ -2,12 +2,19 @@ import { type AuthConfig, type BrowserPortalFlowState as PortalFlowState } from
|
|
|
2
2
|
export type CreatePortalFlowConfig = AuthConfig & {
|
|
3
3
|
getUrl?: () => URL;
|
|
4
4
|
};
|
|
5
|
+
export type PortalFlowErrorClassification = {
|
|
6
|
+
kind: string;
|
|
7
|
+
recoverable: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
code?: string;
|
|
10
|
+
};
|
|
5
11
|
export declare class PortalFlowController {
|
|
6
12
|
#private;
|
|
7
13
|
flowId: string | null;
|
|
8
14
|
state: PortalFlowState | null;
|
|
9
15
|
loading: boolean;
|
|
10
16
|
error: string | null;
|
|
17
|
+
errorClassification: PortalFlowErrorClassification | null;
|
|
11
18
|
constructor(config: CreatePortalFlowConfig);
|
|
12
19
|
load(): Promise<PortalFlowState | null>;
|
|
13
20
|
providerUrl(providerId: string): string;
|
package/dist/portal_flow.js
CHANGED
|
@@ -8,6 +8,116 @@ import {
|
|
|
8
8
|
submitPortalApproval
|
|
9
9
|
} from "@qlever-llc/trellis/auth/browser";
|
|
10
10
|
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return value !== null && typeof value === "object";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalize(value) {
|
|
16
|
+
return value.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EXPIRED_FLOW_VALUES = new Set([
|
|
20
|
+
"flow_expired",
|
|
21
|
+
"flow_not_found",
|
|
22
|
+
"missing_flow",
|
|
23
|
+
"missing_flow_id",
|
|
24
|
+
"expired",
|
|
25
|
+
"trellis.auth.bind_expired",
|
|
26
|
+
"trellis.auth.flow_expired"
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const AUTH_REQUIRED_VALUES = new Set([
|
|
30
|
+
"auth_required",
|
|
31
|
+
"session_not_found",
|
|
32
|
+
"session_expired",
|
|
33
|
+
"trellis.bootstrap.auth_required",
|
|
34
|
+
"trellis.auth.session_not_found",
|
|
35
|
+
"trellis.auth.session_expired"
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function stringSignal(record, key) {
|
|
39
|
+
const value = record[key];
|
|
40
|
+
|
|
41
|
+
return typeof value === "string" ? value : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function collectErrorSignals(error) {
|
|
45
|
+
const values = [error];
|
|
46
|
+
|
|
47
|
+
if (isRecord(error) && isRecord(error.context)) values.push(error.context);
|
|
48
|
+
|
|
49
|
+
return values.flatMap((value) => {
|
|
50
|
+
if (typeof value === "string") return [{ message: value }];
|
|
51
|
+
if (!isRecord(value)) return [];
|
|
52
|
+
|
|
53
|
+
const code = stringSignal(value, "code") ?? stringSignal(value, "error");
|
|
54
|
+
const reason = stringSignal(value, "reason") ?? stringSignal(value, "status");
|
|
55
|
+
|
|
56
|
+
const message = value instanceof Error
|
|
57
|
+
? value.message
|
|
58
|
+
: stringSignal(value, "causeMessage") ?? stringSignal(value, "message");
|
|
59
|
+
|
|
60
|
+
return code || reason || message
|
|
61
|
+
? [
|
|
62
|
+
{
|
|
63
|
+
...code ? { code } : {},
|
|
64
|
+
...reason ? { reason } : {},
|
|
65
|
+
...message ? { message } : {}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
: [];
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function matchingSignal(signals, values, messages) {
|
|
73
|
+
return signals.find((signal) => {
|
|
74
|
+
const identifiers = [signal.code, signal.reason];
|
|
75
|
+
|
|
76
|
+
return identifiers.some((value) => value && values.has(normalize(value))) || messages.some((pattern) => pattern.test(signal.message?.toLowerCase() ?? ""));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function classification(kind, recoverable, signal) {
|
|
81
|
+
const reason = signal?.reason ?? signal?.code ?? signal?.message;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
kind,
|
|
85
|
+
recoverable,
|
|
86
|
+
...reason ? { reason } : {},
|
|
87
|
+
...signal?.code ? { code: signal.code } : {}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function classifyPortalFlowError(error) {
|
|
92
|
+
const signals = collectErrorSignals(error);
|
|
93
|
+
|
|
94
|
+
const expiredFlow = matchingSignal(signals, EXPIRED_FLOW_VALUES, [
|
|
95
|
+
/flow .*expired/,
|
|
96
|
+
/flow .*not found/,
|
|
97
|
+
/missing flow/,
|
|
98
|
+
/sign\-in .*expired/
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
if (expiredFlow) {
|
|
102
|
+
return classification("recoverable_expired_flow", true, expiredFlow);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const authRequired = matchingSignal(signals, AUTH_REQUIRED_VALUES, [
|
|
106
|
+
/auth required/,
|
|
107
|
+
/session .*expired/,
|
|
108
|
+
/session .*not found/,
|
|
109
|
+
/requires sign\-in/,
|
|
110
|
+
/requires signin/,
|
|
111
|
+
/requires authentication/
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
if (authRequired) {
|
|
115
|
+
return classification("recoverable_auth_required", true, authRequired);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return classification("unknown", false);
|
|
119
|
+
}
|
|
120
|
+
|
|
11
121
|
function errorMessage(error) {
|
|
12
122
|
return error instanceof Error ? error.message : String(error);
|
|
13
123
|
}
|
|
@@ -57,6 +167,16 @@ export class PortalFlowController {
|
|
|
57
167
|
$.set(this.#error, value, true);
|
|
58
168
|
}
|
|
59
169
|
|
|
170
|
+
#errorClassification = $.state(null);
|
|
171
|
+
|
|
172
|
+
get errorClassification() {
|
|
173
|
+
return $.get(this.#errorClassification);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
set errorClassification(value) {
|
|
177
|
+
$.set(this.#errorClassification, value, true);
|
|
178
|
+
}
|
|
179
|
+
|
|
60
180
|
#config;
|
|
61
181
|
#getUrl;
|
|
62
182
|
|
|
@@ -68,6 +188,7 @@ export class PortalFlowController {
|
|
|
68
188
|
async load() {
|
|
69
189
|
this.loading = true;
|
|
70
190
|
this.error = null;
|
|
191
|
+
this.errorClassification = null;
|
|
71
192
|
this.state = null;
|
|
72
193
|
|
|
73
194
|
try {
|
|
@@ -88,6 +209,7 @@ export class PortalFlowController {
|
|
|
88
209
|
return state;
|
|
89
210
|
} catch(error) {
|
|
90
211
|
this.error = errorMessage(error);
|
|
212
|
+
this.errorClassification = classifyPortalFlowError(error);
|
|
91
213
|
this.state = null;
|
|
92
214
|
|
|
93
215
|
return null;
|
|
@@ -115,12 +237,14 @@ export class PortalFlowController {
|
|
|
115
237
|
async #submit(decision) {
|
|
116
238
|
if (!this.flowId) {
|
|
117
239
|
this.error = "Missing flow id.";
|
|
240
|
+
this.errorClassification = null;
|
|
118
241
|
|
|
119
242
|
return null;
|
|
120
243
|
}
|
|
121
244
|
|
|
122
245
|
this.loading = true;
|
|
123
246
|
this.error = null;
|
|
247
|
+
this.errorClassification = null;
|
|
124
248
|
|
|
125
249
|
try {
|
|
126
250
|
const state = await submitPortalApproval(this.#config, this.flowId, decision);
|
|
@@ -130,6 +254,7 @@ export class PortalFlowController {
|
|
|
130
254
|
return state;
|
|
131
255
|
} catch(error) {
|
|
132
256
|
this.error = errorMessage(error);
|
|
257
|
+
this.errorClassification = classifyPortalFlowError(error);
|
|
133
258
|
|
|
134
259
|
return null;
|
|
135
260
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qlever-llc/trellis-svelte",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.19",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Svelte components and state helpers for Trellis browser applications.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@nats-io/nats-core": "^3.3.1",
|
|
33
|
-
"@qlever-llc/result": "0.10.
|
|
34
|
-
"@qlever-llc/trellis": "0.10.
|
|
33
|
+
"@qlever-llc/result": "^0.10.19",
|
|
34
|
+
"@qlever-llc/trellis": "^0.10.19",
|
|
35
35
|
"typebox": "^1.0.15",
|
|
36
36
|
"ulid": "^3.0.2"
|
|
37
37
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts" generics="TContract extends TrellisContractLike">
|
|
2
2
|
import {
|
|
3
|
+
classifyBrowserAuthError,
|
|
3
4
|
ClientAuthHandledError,
|
|
4
5
|
TrellisClient,
|
|
5
6
|
type ClientAuthOptions,
|
|
@@ -17,12 +18,17 @@
|
|
|
17
18
|
client,
|
|
18
19
|
children,
|
|
19
20
|
loading,
|
|
21
|
+
recoveringAuth,
|
|
20
22
|
error: errorSnippet,
|
|
21
23
|
onAuthRequired,
|
|
24
|
+
onRecoverableAuthError,
|
|
22
25
|
}: TrellisProviderProps<TContract> = $props();
|
|
23
26
|
|
|
27
|
+
type ProviderState = "connecting" | "connected" | "auth_handled" | "failed";
|
|
28
|
+
|
|
24
29
|
let trellis = $state<ConnectedTrellisClient<TContract> | null>(null);
|
|
25
30
|
let connectError = $state<unknown>(null);
|
|
31
|
+
let providerState = $state<ProviderState>("connecting");
|
|
26
32
|
|
|
27
33
|
type SerializableTrellisError = {
|
|
28
34
|
message?: unknown;
|
|
@@ -98,6 +104,7 @@
|
|
|
98
104
|
connectError = new TypeError(
|
|
99
105
|
"Expected trellisApp to resolve a Trellis URL",
|
|
100
106
|
);
|
|
107
|
+
providerState = "failed";
|
|
101
108
|
return;
|
|
102
109
|
}
|
|
103
110
|
|
|
@@ -118,14 +125,43 @@
|
|
|
118
125
|
|
|
119
126
|
if (active) {
|
|
120
127
|
trellis = connected;
|
|
128
|
+
providerState = "connected";
|
|
121
129
|
} else {
|
|
122
130
|
await connected.connection.close();
|
|
123
131
|
}
|
|
124
132
|
} catch (error) {
|
|
125
133
|
if (!active) return;
|
|
126
|
-
if (error instanceof ClientAuthHandledError)
|
|
134
|
+
if (error instanceof ClientAuthHandledError) {
|
|
135
|
+
providerState = "auth_handled";
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const authRecovery = classifyBrowserAuthError(error);
|
|
139
|
+
if (authRecovery.recoverable) {
|
|
140
|
+
if (onRecoverableAuthError) {
|
|
141
|
+
providerState = "auth_handled";
|
|
142
|
+
try {
|
|
143
|
+
await onRecoverableAuthError(error);
|
|
144
|
+
return;
|
|
145
|
+
} catch (recoveryError) {
|
|
146
|
+
if (!active) return;
|
|
147
|
+
console.error("TrellisProvider auth recovery callback failed", {
|
|
148
|
+
recoveryError,
|
|
149
|
+
error,
|
|
150
|
+
});
|
|
151
|
+
logConnectionError(recoveryError);
|
|
152
|
+
connectError = recoveryError;
|
|
153
|
+
providerState = "failed";
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (recoveringAuth) {
|
|
158
|
+
providerState = "auth_handled";
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
127
162
|
logConnectionError(error);
|
|
128
163
|
connectError = error;
|
|
164
|
+
providerState = "failed";
|
|
129
165
|
}
|
|
130
166
|
})();
|
|
131
167
|
|
|
@@ -133,6 +169,7 @@
|
|
|
133
169
|
active = false;
|
|
134
170
|
const connected = trellis;
|
|
135
171
|
trellis = null;
|
|
172
|
+
providerState = "connecting";
|
|
136
173
|
if (connected) {
|
|
137
174
|
void connected.connection.close();
|
|
138
175
|
}
|
|
@@ -142,10 +179,12 @@
|
|
|
142
179
|
|
|
143
180
|
{#if trellis}
|
|
144
181
|
<TrellisContextProvider {trellisApp} {trellis} {children} />
|
|
145
|
-
{:else if connectError}
|
|
182
|
+
{:else if providerState === "failed" && connectError}
|
|
146
183
|
{#if errorSnippet}
|
|
147
184
|
{@render errorSnippet(connectError)}
|
|
148
185
|
{/if}
|
|
186
|
+
{:else if providerState === "auth_handled" && recoveringAuth}
|
|
187
|
+
{@render recoveringAuth()}
|
|
149
188
|
{:else if loading}
|
|
150
189
|
{@render loading()}
|
|
151
190
|
{/if}
|
|
@@ -18,9 +18,11 @@ export type TrellisProviderProps<
|
|
|
18
18
|
client?: ClientOpts;
|
|
19
19
|
children: Snippet;
|
|
20
20
|
loading?: Snippet;
|
|
21
|
+
recoveringAuth?: Snippet;
|
|
21
22
|
error?: Snippet<[unknown]>;
|
|
22
23
|
onAuthRequired?: (
|
|
23
24
|
loginUrl: string,
|
|
24
25
|
context: ClientAuthRequiredContext,
|
|
25
26
|
) => void | Promise<void>;
|
|
27
|
+
onRecoverableAuthError?: (error: unknown) => void | Promise<void>;
|
|
26
28
|
};
|
|
@@ -11,6 +11,136 @@ export type CreatePortalFlowConfig = AuthConfig & {
|
|
|
11
11
|
getUrl?: () => URL;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
export type PortalFlowErrorClassification = {
|
|
15
|
+
kind: string;
|
|
16
|
+
recoverable: boolean;
|
|
17
|
+
reason?: string;
|
|
18
|
+
code?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ErrorSignal = {
|
|
22
|
+
code?: string;
|
|
23
|
+
reason?: string;
|
|
24
|
+
message?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
28
|
+
return value !== null && typeof value === "object";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalize(value: string): string {
|
|
32
|
+
return value.trim().toLowerCase().replaceAll("-", "_").replaceAll(" ", "_");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const EXPIRED_FLOW_VALUES = new Set([
|
|
36
|
+
"flow_expired",
|
|
37
|
+
"flow_not_found",
|
|
38
|
+
"missing_flow",
|
|
39
|
+
"missing_flow_id",
|
|
40
|
+
"expired",
|
|
41
|
+
"trellis.auth.bind_expired",
|
|
42
|
+
"trellis.auth.flow_expired",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
const AUTH_REQUIRED_VALUES = new Set([
|
|
46
|
+
"auth_required",
|
|
47
|
+
"session_not_found",
|
|
48
|
+
"session_expired",
|
|
49
|
+
"trellis.bootstrap.auth_required",
|
|
50
|
+
"trellis.auth.session_not_found",
|
|
51
|
+
"trellis.auth.session_expired",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
function stringSignal(
|
|
55
|
+
record: Record<string, unknown>,
|
|
56
|
+
key: string,
|
|
57
|
+
): string | undefined {
|
|
58
|
+
const value = record[key];
|
|
59
|
+
return typeof value === "string" ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function collectErrorSignals(error: unknown): ErrorSignal[] {
|
|
63
|
+
const values: unknown[] = [error];
|
|
64
|
+
if (isRecord(error) && isRecord(error.context)) values.push(error.context);
|
|
65
|
+
|
|
66
|
+
return values.flatMap((value) => {
|
|
67
|
+
if (typeof value === "string") return [{ message: value }];
|
|
68
|
+
if (!isRecord(value)) return [];
|
|
69
|
+
|
|
70
|
+
const code = stringSignal(value, "code") ?? stringSignal(value, "error");
|
|
71
|
+
const reason = stringSignal(value, "reason") ??
|
|
72
|
+
stringSignal(value, "status");
|
|
73
|
+
const message = value instanceof Error
|
|
74
|
+
? value.message
|
|
75
|
+
: stringSignal(value, "causeMessage") ?? stringSignal(value, "message");
|
|
76
|
+
|
|
77
|
+
return code || reason || message
|
|
78
|
+
? [{
|
|
79
|
+
...(code ? { code } : {}),
|
|
80
|
+
...(reason ? { reason } : {}),
|
|
81
|
+
...(message ? { message } : {}),
|
|
82
|
+
}]
|
|
83
|
+
: [];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function matchingSignal(
|
|
88
|
+
signals: ErrorSignal[],
|
|
89
|
+
values: ReadonlySet<string>,
|
|
90
|
+
messages: readonly RegExp[],
|
|
91
|
+
): ErrorSignal | undefined {
|
|
92
|
+
return signals.find((signal) => {
|
|
93
|
+
const identifiers = [signal.code, signal.reason];
|
|
94
|
+
return identifiers.some((value) => value && values.has(normalize(value))) ||
|
|
95
|
+
messages.some((pattern) =>
|
|
96
|
+
pattern.test(signal.message?.toLowerCase() ?? "")
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function classification(
|
|
102
|
+
kind: string,
|
|
103
|
+
recoverable: boolean,
|
|
104
|
+
signal?: ErrorSignal,
|
|
105
|
+
): PortalFlowErrorClassification {
|
|
106
|
+
const reason = signal?.reason ?? signal?.code ?? signal?.message;
|
|
107
|
+
return {
|
|
108
|
+
kind,
|
|
109
|
+
recoverable,
|
|
110
|
+
...(reason ? { reason } : {}),
|
|
111
|
+
...(signal?.code ? { code: signal.code } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function classifyPortalFlowError(
|
|
116
|
+
error: unknown,
|
|
117
|
+
): PortalFlowErrorClassification {
|
|
118
|
+
const signals = collectErrorSignals(error);
|
|
119
|
+
const expiredFlow = matchingSignal(signals, EXPIRED_FLOW_VALUES, [
|
|
120
|
+
/flow .*expired/,
|
|
121
|
+
/flow .*not found/,
|
|
122
|
+
/missing flow/,
|
|
123
|
+
/sign\-in .*expired/,
|
|
124
|
+
]);
|
|
125
|
+
if (expiredFlow) {
|
|
126
|
+
return classification("recoverable_expired_flow", true, expiredFlow);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const authRequired = matchingSignal(signals, AUTH_REQUIRED_VALUES, [
|
|
130
|
+
/auth required/,
|
|
131
|
+
/session .*expired/,
|
|
132
|
+
/session .*not found/,
|
|
133
|
+
/requires sign\-in/,
|
|
134
|
+
/requires signin/,
|
|
135
|
+
/requires authentication/,
|
|
136
|
+
]);
|
|
137
|
+
if (authRequired) {
|
|
138
|
+
return classification("recoverable_auth_required", true, authRequired);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return classification("unknown", false);
|
|
142
|
+
}
|
|
143
|
+
|
|
14
144
|
function errorMessage(error: unknown): string {
|
|
15
145
|
return error instanceof Error ? error.message : String(error);
|
|
16
146
|
}
|
|
@@ -24,6 +154,7 @@ export class PortalFlowController {
|
|
|
24
154
|
state: PortalFlowState | null = $state(null);
|
|
25
155
|
loading = $state(false);
|
|
26
156
|
error: string | null = $state(null);
|
|
157
|
+
errorClassification: PortalFlowErrorClassification | null = $state(null);
|
|
27
158
|
|
|
28
159
|
#config: AuthConfig;
|
|
29
160
|
#getUrl: () => URL;
|
|
@@ -36,6 +167,7 @@ export class PortalFlowController {
|
|
|
36
167
|
async load(): Promise<PortalFlowState | null> {
|
|
37
168
|
this.loading = true;
|
|
38
169
|
this.error = null;
|
|
170
|
+
this.errorClassification = null;
|
|
39
171
|
this.state = null;
|
|
40
172
|
|
|
41
173
|
try {
|
|
@@ -51,6 +183,7 @@ export class PortalFlowController {
|
|
|
51
183
|
return state;
|
|
52
184
|
} catch (error) {
|
|
53
185
|
this.error = errorMessage(error);
|
|
186
|
+
this.errorClassification = classifyPortalFlowError(error);
|
|
54
187
|
this.state = null;
|
|
55
188
|
return null;
|
|
56
189
|
} finally {
|
|
@@ -79,11 +212,13 @@ export class PortalFlowController {
|
|
|
79
212
|
): Promise<PortalFlowState | null> {
|
|
80
213
|
if (!this.flowId) {
|
|
81
214
|
this.error = "Missing flow id.";
|
|
215
|
+
this.errorClassification = null;
|
|
82
216
|
return null;
|
|
83
217
|
}
|
|
84
218
|
|
|
85
219
|
this.loading = true;
|
|
86
220
|
this.error = null;
|
|
221
|
+
this.errorClassification = null;
|
|
87
222
|
|
|
88
223
|
try {
|
|
89
224
|
const state = await submitPortalApproval(
|
|
@@ -95,6 +230,7 @@ export class PortalFlowController {
|
|
|
95
230
|
return state;
|
|
96
231
|
} catch (error) {
|
|
97
232
|
this.error = errorMessage(error);
|
|
233
|
+
this.errorClassification = classifyPortalFlowError(error);
|
|
98
234
|
return null;
|
|
99
235
|
} finally {
|
|
100
236
|
this.loading = false;
|