@qitqode/qitqore 0.1.0 → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/dist/device-authorization.d.ts +67 -0
- package/dist/device-authorization.d.ts.map +1 -0
- package/dist/device-authorization.js +122 -0
- package/dist/device-authorization.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +108 -17
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/recovery-capabilities.d.ts +26 -0
- package/dist/recovery-capabilities.d.ts.map +1 -0
- package/dist/recovery-capabilities.js +113 -0
- package/dist/recovery-capabilities.js.map +1 -0
- package/dist/recovery-directive.d.ts +20 -0
- package/dist/recovery-directive.d.ts.map +1 -0
- package/dist/recovery-directive.js +116 -0
- package/dist/recovery-directive.js.map +1 -0
- package/dist/recovery-lease.d.ts +80 -0
- package/dist/recovery-lease.d.ts.map +1 -0
- package/dist/recovery-lease.js +257 -0
- package/dist/recovery-lease.js.map +1 -0
- package/dist/recovery-status.d.ts +22 -0
- package/dist/recovery-status.d.ts.map +1 -0
- package/dist/recovery-status.js +113 -0
- package/dist/recovery-status.js.map +1 -0
- package/dist/recovery.d.ts +13 -0
- package/dist/recovery.d.ts.map +1 -1
- package/dist/recovery.js +94 -55
- package/dist/recovery.js.map +1 -1
- package/dist/redaction.d.ts +64 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +154 -0
- package/dist/redaction.js.map +1 -0
- package/dist/route-contract.d.ts +36 -0
- package/dist/route-contract.d.ts.map +1 -0
- package/dist/route-contract.js +89 -0
- package/dist/route-contract.js.map +1 -0
- package/dist/stream-payload.d.ts +39 -0
- package/dist/stream-payload.d.ts.map +1 -0
- package/dist/stream-payload.js +86 -0
- package/dist/stream-payload.js.map +1 -0
- package/dist/transport.d.ts +10 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +28 -0
- package/dist/transport.js.map +1 -1
- package/package.json +12 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 QitQode contributors, Spannings LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -8,6 +8,28 @@ It is not a CLI, agent runtime, authentication store, policy engine, scheduler,
|
|
|
8
8
|
or billing/settlement system. Importing the package creates no directories,
|
|
9
9
|
reads credentials, changes process settings, or makes network requests.
|
|
10
10
|
|
|
11
|
+
## Repository and workspace
|
|
12
|
+
|
|
13
|
+
The dedicated repository is
|
|
14
|
+
[`forgewisetech/qitqore`](https://github.com/forgewisetech/qitqore).
|
|
15
|
+
It is a private, independently buildable mirror of the canonical
|
|
16
|
+
`products/qitqore` directory in the Forgewise monorepo. Make source changes in
|
|
17
|
+
that canonical directory, then use the workspace's per-product export to update
|
|
18
|
+
the repository; do not maintain a second implementation in either consumer.
|
|
19
|
+
The repository does not contain QitQode, Wud, their credentials, or their state.
|
|
20
|
+
|
|
21
|
+
The library is tracked as its own product in Umbrella. Its own `products/qitqore`
|
|
22
|
+
folder, dedicated repository, and Umbrella tracking are the intended setup;
|
|
23
|
+
a separate Replit preview artifact is not required.
|
|
24
|
+
|
|
25
|
+
Repository metadata here applies to future package releases. The already
|
|
26
|
+
published `@qitqode/qitqore@0.1.0` remains immutable and has not been republished.
|
|
27
|
+
Future releases are licensed under the [MIT License](./LICENSE), the same license
|
|
28
|
+
as QitQode. The copyright holder is QitQode contributors, Spannings LLC.
|
|
29
|
+
|
|
30
|
+
The local source candidate is now `0.2.0`; it is not published. The immutable
|
|
31
|
+
`0.1.0` artifact and all consumer pins remain unchanged.
|
|
32
|
+
|
|
11
33
|
## Development
|
|
12
34
|
|
|
13
35
|
- Requires Node.js 24 or newer.
|
|
@@ -21,6 +43,57 @@ reads credentials, changes process settings, or makes network requests.
|
|
|
21
43
|
The package has no runtime dependencies. Its sole public entry point is the
|
|
22
44
|
compiled ESM export at `.`.
|
|
23
45
|
|
|
46
|
+
## Released consumer adoption
|
|
47
|
+
|
|
48
|
+
The public package is **`@qitqode/qitqore@0.1.0`**. Install it with
|
|
49
|
+
`npm install --save-exact @qitqode/qitqore@0.1.0`. The independently managed
|
|
50
|
+
QitQode CLI, its backend SDK, and Wud pin that exact release in their own
|
|
51
|
+
manifests and native Bun/npm lockfiles; they do not import sibling source.
|
|
52
|
+
|
|
53
|
+
QitQode retains AI SDK/native-tool adaptation, credential recovery, advisory
|
|
54
|
+
budgets and session policy. Session retries carry an explicit operation ID;
|
|
55
|
+
prompt equality does not identify an operation. Qortex session retries require
|
|
56
|
+
an explicit non-acceptance verdict and no observed output or started tool.
|
|
57
|
+
Unknown or interrupted execution stops without replay, preserving partial text,
|
|
58
|
+
reasoning and interrupted tool records. Wud retains credentials,
|
|
59
|
+
durable operations, authorization, reservations, leases and settlement.
|
|
60
|
+
Both use the shared request lifecycle, SSE decoder and bounded recovery.
|
|
61
|
+
Compatibility fallback requires explicit pre-execution validation evidence.
|
|
62
|
+
An unterminated stream preserves available text/accounting evidence but is not
|
|
63
|
+
reported as successful completion.
|
|
64
|
+
|
|
65
|
+
Hosts may pass `deadlineAt` to `runWithRecovery` to apply one absolute deadline
|
|
66
|
+
to every attempt, retry delay and backoff; a request timeout alone is not an
|
|
67
|
+
operation-wide limit. `getRecoveryStatus` and `getRecoveryCapabilities`
|
|
68
|
+
validate separate Qortex execution and route-capability contracts; neither is
|
|
69
|
+
billing evidence. `parseRecoveryDirective` accepts only bounded decision fields.
|
|
70
|
+
QitQore does not provide exactly-once effects: callers still need stable
|
|
71
|
+
operation IDs, receipts and reconciliation for ambiguous external operations.
|
|
72
|
+
Recovery status reports only a retained Qortex route result. It never proves that
|
|
73
|
+
a caller applied a local change, an external effect happened exactly once, or
|
|
74
|
+
acceptance verification succeeded.
|
|
75
|
+
|
|
76
|
+
From the monorepo, run
|
|
77
|
+
`node products/qitqore/scripts/verify-consumers.mjs --production-install --bun /path/to/bun`
|
|
78
|
+
under Node 24, with Bun 1.3.11. This checks exact manifest/lock integrity,
|
|
79
|
+
compiled public imports in all consumers, and a clean Wud production dependency
|
|
80
|
+
installation without either application's source. Temporary fixtures are removed.
|
|
81
|
+
Package-local tests remain deterministic and independent of this network install.
|
|
82
|
+
Wud's injected-transport and accounting regressions run without Mongo initialization:
|
|
83
|
+
`cd products/wud && npx vitest run --config vitest.qitqore.config.ts`.
|
|
84
|
+
|
|
85
|
+
Publication, packed-candidate verification, released-consumer adoption and
|
|
86
|
+
deployment are separate claims. The package was published and its public tarball
|
|
87
|
+
verified; adopting it locally does **not** push either consumer or deploy it.
|
|
88
|
+
Future core changes require a new immutable package version, package tests and
|
|
89
|
+
packing checks, separate publication approval, then exact consumer pins and
|
|
90
|
+
regenerated native lockfiles.
|
|
91
|
+
|
|
92
|
+
Release 0.1.0's registry manifest does not declare a license. QitQode's generated
|
|
93
|
+
notice inventory identifies that exact release as first-party code without
|
|
94
|
+
inventing a third-party license grant. That release is not rewritten. The 0.2.0
|
|
95
|
+
candidate declares `"license": "MIT"` and ships its `LICENSE` file in the package.
|
|
96
|
+
|
|
24
97
|
## Host responsibilities
|
|
25
98
|
|
|
26
99
|
Create a client with an explicit base URL and optional `fetch` implementation.
|
|
@@ -40,6 +113,62 @@ produced partial output, or started a side effect. Acceptance, partial output,
|
|
|
40
113
|
and side effects block replay. Unknown outcomes require affirmative host
|
|
41
114
|
evidence and authorization; an idempotency header alone is not evidence.
|
|
42
115
|
|
|
116
|
+
Hosts that maintain a durable attempt ledger can use `onAttemptStart` and
|
|
117
|
+
`onAttemptOutcome` to record dispatch and settlement evidence. The outcome hook
|
|
118
|
+
is informational and does not authorize replay; hosts must still use
|
|
119
|
+
`authorizeReplay` for every additional attempt. Hook payloads contain operation
|
|
120
|
+
and attempt identities plus bounded execution facts, never request bodies,
|
|
121
|
+
credentials, or response content.
|
|
122
|
+
|
|
43
123
|
Errors expose bounded, redacted diagnostic fields and request IDs, not raw
|
|
44
124
|
response bodies or request credentials. Hosts may retain their existing
|
|
45
125
|
product-specific error mappings around these shared primitives.
|
|
126
|
+
|
|
127
|
+
## Shared wire contracts (0.2 candidate)
|
|
128
|
+
|
|
129
|
+
The 0.2 candidate adds one implementation of the public Qortex contracts that
|
|
130
|
+
QitQode and Wud previously duplicated. Each area states what QitQore does and
|
|
131
|
+
what stays with the host.
|
|
132
|
+
|
|
133
|
+
- **Recovery lease and control.** `parseRecoveryStatus` accepts Qortex's
|
|
134
|
+
optional `recoveryLeaseUsage` only when it names the status's request, and the
|
|
135
|
+
status's route whenever the status includes one.
|
|
136
|
+
`parseRecoveryControlEnvelope`, `encodeRecoveryControlEnvelope`, and
|
|
137
|
+
`decodeRecoveryControlHeader` validate the header value Qortex reads. The
|
|
138
|
+
decoder accepts only canonical base64url. The host allocates token budgets,
|
|
139
|
+
persists leases, decides which work may run, and settles outcomes.
|
|
140
|
+
- **Routes and headers.** `recoveryRouteID`, `qortexModuleEndpoint`,
|
|
141
|
+
`qortexRequestHeaders`, and `withRecoveryControlHeader` take every module name,
|
|
142
|
+
identity, and key from the caller. Scoped historical paths such as
|
|
143
|
+
`@bijikyu/omniquery` are preserved exactly. QitQore never chooses between the
|
|
144
|
+
canonical and historical module, never generates a request or idempotency
|
|
145
|
+
identity, and never adds credentials.
|
|
146
|
+
- **SSE payloads.** `SseDecoder` and `iterateSse` frame the stream.
|
|
147
|
+
`classifyQortexStreamPayload` then returns a tagged result: `text`, `json`,
|
|
148
|
+
`usage`, `cost`, `control` (the documented free-fallback routing signal),
|
|
149
|
+
`done`, `error`, or `empty`. Plain tokens stay `text`, malformed or non-object
|
|
150
|
+
JSON stays `text`, and unknown objects stay `json`. The host converts OpenAI
|
|
151
|
+
chunks, tool calls, finish reasons, accounting, and terminal success. A stream
|
|
152
|
+
that ends without a terminator remains incomplete.
|
|
153
|
+
- **Error evidence.** `qortexErrorEvidence` reads the top-level, `error`, and
|
|
154
|
+
`details` envelopes once and keeps only allowlisted fields: acceptance,
|
|
155
|
+
partial output, side-effect markers, a validated recovery directive, and
|
|
156
|
+
whether replay evidence was available. Any non-`false` acceptance signal
|
|
157
|
+
blocks replay, including values QitQore does not recognize. A terminal
|
|
158
|
+
`retryable: false` wins over `true`. Use `hasReplayBlockingEvidence` before
|
|
159
|
+
replaying; absence of evidence is never permission to replay.
|
|
160
|
+
- **Secret redaction.** `createSecretRedactor` is pure and opt-in. The host
|
|
161
|
+
passes exact values and key names. It returns redaction, chunk-safe stream
|
|
162
|
+
redaction, and bounded structural redaction. It does not read the environment,
|
|
163
|
+
keep a global registry, or discover credentials. Pass `patterns: false` for
|
|
164
|
+
model output, where credential-shape heuristics could alter ordinary prose.
|
|
165
|
+
- **Forgewise device authorization.** `parseDeviceAuthorizationStart` validates
|
|
166
|
+
the start response and resolves expiry from an explicit `now`.
|
|
167
|
+
`classifyDeviceTokenPoll` classifies one poll as success, pending, slow-down
|
|
168
|
+
(through `pending`), denied, expired, failed, or invalid. It does not poll,
|
|
169
|
+
sleep, open a browser, persist a code, install a credential, refresh a session,
|
|
170
|
+
or read a clock. The host owns all of those.
|
|
171
|
+
|
|
172
|
+
Acceptance, partial-output, side-effect, and directive fields are evidence for
|
|
173
|
+
the host's replay decision. They are not authorization, billing settlement, or
|
|
174
|
+
proof of an exactly-once effect.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless Forgewise device-authorization protocol (RFC 8628 response shapes).
|
|
3
|
+
* These functions validate protocol data and classify poll outcomes. They never
|
|
4
|
+
* poll, sleep, open a browser, persist a code, install or refresh a credential,
|
|
5
|
+
* or read a clock: the caller passes the current time explicitly.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEVICE_AUTHORIZATION_DEFAULT_EXPIRES_IN_SECONDS = 900;
|
|
8
|
+
export declare const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL_SECONDS = 5;
|
|
9
|
+
export declare const DEVICE_AUTHORIZATION_SLOW_DOWN_STEP_SECONDS = 5;
|
|
10
|
+
/** Upper bound for any server-supplied polling interval, so a hostile value cannot stall a host. */
|
|
11
|
+
export declare const DEVICE_AUTHORIZATION_MAX_INTERVAL_SECONDS = 3600;
|
|
12
|
+
/** Upper bound for any server-supplied code lifetime. */
|
|
13
|
+
export declare const DEVICE_AUTHORIZATION_MAX_EXPIRES_IN_SECONDS = 86400;
|
|
14
|
+
/** Validated device authorization start, with the expiry resolved to epoch milliseconds. */
|
|
15
|
+
export interface DeviceAuthorizationStart {
|
|
16
|
+
deviceCode: string;
|
|
17
|
+
userCode: string;
|
|
18
|
+
verificationUri: string;
|
|
19
|
+
verificationUriComplete?: string;
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
intervalSeconds: number;
|
|
22
|
+
}
|
|
23
|
+
/** Outcome of one token poll. Only bounded, safe fields are carried. */
|
|
24
|
+
export type DeviceTokenPollOutcome = {
|
|
25
|
+
kind: "success";
|
|
26
|
+
accessToken: string;
|
|
27
|
+
refreshToken?: string;
|
|
28
|
+
expiresInSeconds?: number;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "pending";
|
|
31
|
+
intervalSeconds: number;
|
|
32
|
+
} | {
|
|
33
|
+
kind: "denied";
|
|
34
|
+
} | {
|
|
35
|
+
kind: "expired";
|
|
36
|
+
} | {
|
|
37
|
+
kind: "failed";
|
|
38
|
+
code: string;
|
|
39
|
+
} | {
|
|
40
|
+
kind: "invalid";
|
|
41
|
+
};
|
|
42
|
+
/** Thrown when the authorization-start response is missing a required protocol field. */
|
|
43
|
+
export declare class DeviceAuthorizationProtocolError extends Error {
|
|
44
|
+
constructor(message?: string);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Validate the device authorization response. Required fields must be present
|
|
48
|
+
* and well-formed. Optional timing is clamped, not trusted. `now` is the caller's
|
|
49
|
+
* current epoch time in milliseconds.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseDeviceAuthorizationStart(value: unknown, now: number): DeviceAuthorizationStart;
|
|
52
|
+
/** True once the code's lifetime has elapsed. The boundary instant itself counts as expired. */
|
|
53
|
+
export declare function deviceAuthorizationExpired(input: {
|
|
54
|
+
expiresAt: number;
|
|
55
|
+
now: number;
|
|
56
|
+
}): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Classify one device-token poll. `intervalSeconds` is the host's current
|
|
59
|
+
* interval, which `slow_down` increases by a bounded step. Errors other than the
|
|
60
|
+
* RFC 8628 set are reported only by their safe code, never by description text.
|
|
61
|
+
*/
|
|
62
|
+
export declare function classifyDeviceTokenPoll(input: {
|
|
63
|
+
status: number;
|
|
64
|
+
body: unknown;
|
|
65
|
+
intervalSeconds: number;
|
|
66
|
+
}): DeviceTokenPollOutcome;
|
|
67
|
+
//# sourceMappingURL=device-authorization.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-authorization.d.ts","sourceRoot":"","sources":["../src/device-authorization.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,eAAO,MAAM,+CAA+C,MAAM,CAAC;AACnE,eAAO,MAAM,6CAA6C,IAAI,CAAC;AAC/D,eAAO,MAAM,2CAA2C,IAAI,CAAC;AAC7D,oGAAoG;AACpG,eAAO,MAAM,yCAAyC,OAAQ,CAAC;AAC/D,yDAAyD;AACzD,eAAO,MAAM,2CAA2C,QAAS,CAAC;AAQlE,4FAA4F;AAC5F,MAAM,WAAW,wBAAwB;IACvC,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;CACzB;AAED,wEAAwE;AACxE,MAAM,MAAM,sBAAsB,GAC9B;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAAE,GAC1F;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,eAAe,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,GAClB;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GACnB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAExB,yFAAyF;AACzF,qBAAa,gCAAiC,SAAQ,KAAK;gBAC7C,OAAO,SAAiE;CAIrF;AA6BD;;;;GAIG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,wBAAwB,CA8BnG;AAED,gGAAgG;AAChG,wBAAgB,0BAA0B,CAAC,KAAK,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAE7F;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,OAAO,CAAC;IACd,eAAe,EAAE,MAAM,CAAC;CACzB,GAAG,sBAAsB,CAiCzB"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless Forgewise device-authorization protocol (RFC 8628 response shapes).
|
|
3
|
+
* These functions validate protocol data and classify poll outcomes. They never
|
|
4
|
+
* poll, sleep, open a browser, persist a code, install or refresh a credential,
|
|
5
|
+
* or read a clock: the caller passes the current time explicitly.
|
|
6
|
+
*/
|
|
7
|
+
export const DEVICE_AUTHORIZATION_DEFAULT_EXPIRES_IN_SECONDS = 900;
|
|
8
|
+
export const DEVICE_AUTHORIZATION_DEFAULT_INTERVAL_SECONDS = 5;
|
|
9
|
+
export const DEVICE_AUTHORIZATION_SLOW_DOWN_STEP_SECONDS = 5;
|
|
10
|
+
/** Upper bound for any server-supplied polling interval, so a hostile value cannot stall a host. */
|
|
11
|
+
export const DEVICE_AUTHORIZATION_MAX_INTERVAL_SECONDS = 3_600;
|
|
12
|
+
/** Upper bound for any server-supplied code lifetime. */
|
|
13
|
+
export const DEVICE_AUTHORIZATION_MAX_EXPIRES_IN_SECONDS = 86_400;
|
|
14
|
+
const MAX_CODE_LENGTH = 2_048;
|
|
15
|
+
const MAX_USER_CODE_LENGTH = 64;
|
|
16
|
+
const MAX_URI_LENGTH = 2_048;
|
|
17
|
+
const MAX_TOKEN_LENGTH = 16_384;
|
|
18
|
+
const SAFE_ERROR_CODE = /^[a-z_]{1,64}$/;
|
|
19
|
+
/** Thrown when the authorization-start response is missing a required protocol field. */
|
|
20
|
+
export class DeviceAuthorizationProtocolError extends Error {
|
|
21
|
+
constructor(message = "Forgewise returned an invalid device authorization response.") {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "DeviceAuthorizationProtocolError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function isRecord(value) {
|
|
27
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
function boundedString(value, maxLength) {
|
|
30
|
+
return typeof value === "string" && value.length > 0 && value.length <= maxLength ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** A verification URI must be absolute HTTPS, or plain HTTP only for a loopback host. */
|
|
33
|
+
function verificationUri(value) {
|
|
34
|
+
const text = boundedString(value, MAX_URI_LENGTH);
|
|
35
|
+
if (text === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
const url = new URL(text);
|
|
39
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
40
|
+
return url.protocol === "https:" || (url.protocol === "http:" && loopback) ? text : undefined;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Clamp a server-supplied number to [1, max] and fall back to the default when it is absent or invalid. */
|
|
47
|
+
function boundedSeconds(value, fallback, max) {
|
|
48
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
49
|
+
return fallback;
|
|
50
|
+
return Math.min(max, Math.max(1, Math.floor(value)));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Validate the device authorization response. Required fields must be present
|
|
54
|
+
* and well-formed. Optional timing is clamped, not trusted. `now` is the caller's
|
|
55
|
+
* current epoch time in milliseconds.
|
|
56
|
+
*/
|
|
57
|
+
export function parseDeviceAuthorizationStart(value, now) {
|
|
58
|
+
if (!isRecord(value))
|
|
59
|
+
throw new DeviceAuthorizationProtocolError();
|
|
60
|
+
const deviceCode = boundedString(value.device_code, MAX_CODE_LENGTH);
|
|
61
|
+
const userCode = boundedString(value.user_code, MAX_USER_CODE_LENGTH);
|
|
62
|
+
const verification = verificationUri(value.verification_uri);
|
|
63
|
+
if (deviceCode === undefined || userCode === undefined || verification === undefined) {
|
|
64
|
+
throw new DeviceAuthorizationProtocolError();
|
|
65
|
+
}
|
|
66
|
+
const complete = value.verification_uri_complete === undefined ? undefined : verificationUri(value.verification_uri_complete);
|
|
67
|
+
if (value.verification_uri_complete !== undefined && complete === undefined) {
|
|
68
|
+
throw new DeviceAuthorizationProtocolError();
|
|
69
|
+
}
|
|
70
|
+
const expiresIn = boundedSeconds(value.expires_in, DEVICE_AUTHORIZATION_DEFAULT_EXPIRES_IN_SECONDS, DEVICE_AUTHORIZATION_MAX_EXPIRES_IN_SECONDS);
|
|
71
|
+
return {
|
|
72
|
+
deviceCode,
|
|
73
|
+
userCode,
|
|
74
|
+
verificationUri: verification,
|
|
75
|
+
...(complete === undefined ? {} : { verificationUriComplete: complete }),
|
|
76
|
+
expiresAt: now + expiresIn * 1_000,
|
|
77
|
+
intervalSeconds: boundedSeconds(value.interval, DEVICE_AUTHORIZATION_DEFAULT_INTERVAL_SECONDS, DEVICE_AUTHORIZATION_MAX_INTERVAL_SECONDS),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** True once the code's lifetime has elapsed. The boundary instant itself counts as expired. */
|
|
81
|
+
export function deviceAuthorizationExpired(input) {
|
|
82
|
+
return input.now >= input.expiresAt;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Classify one device-token poll. `intervalSeconds` is the host's current
|
|
86
|
+
* interval, which `slow_down` increases by a bounded step. Errors other than the
|
|
87
|
+
* RFC 8628 set are reported only by their safe code, never by description text.
|
|
88
|
+
*/
|
|
89
|
+
export function classifyDeviceTokenPoll(input) {
|
|
90
|
+
const body = isRecord(input.body) ? input.body : undefined;
|
|
91
|
+
if (input.status >= 200 && input.status < 300) {
|
|
92
|
+
const accessToken = boundedString(body?.access_token, MAX_TOKEN_LENGTH);
|
|
93
|
+
if (accessToken === undefined)
|
|
94
|
+
return { kind: "invalid" };
|
|
95
|
+
const refreshToken = boundedString(body?.refresh_token, MAX_TOKEN_LENGTH);
|
|
96
|
+
const expiresIn = typeof body?.expires_in === "number" && Number.isFinite(body.expires_in) && body.expires_in > 0
|
|
97
|
+
? body.expires_in
|
|
98
|
+
: undefined;
|
|
99
|
+
return {
|
|
100
|
+
kind: "success",
|
|
101
|
+
accessToken,
|
|
102
|
+
...(refreshToken === undefined ? {} : { refreshToken }),
|
|
103
|
+
...(expiresIn === undefined ? {} : { expiresInSeconds: expiresIn }),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const error = typeof body?.error === "string" && SAFE_ERROR_CODE.test(body.error) ? body.error : undefined;
|
|
107
|
+
if (error === "authorization_pending") {
|
|
108
|
+
return { kind: "pending", intervalSeconds: input.intervalSeconds };
|
|
109
|
+
}
|
|
110
|
+
if (error === "slow_down") {
|
|
111
|
+
return {
|
|
112
|
+
kind: "pending",
|
|
113
|
+
intervalSeconds: Math.min(DEVICE_AUTHORIZATION_MAX_INTERVAL_SECONDS, input.intervalSeconds + DEVICE_AUTHORIZATION_SLOW_DOWN_STEP_SECONDS),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (error === "access_denied")
|
|
117
|
+
return { kind: "denied" };
|
|
118
|
+
if (error === "expired_token")
|
|
119
|
+
return { kind: "expired" };
|
|
120
|
+
return { kind: "failed", code: error ?? `http_${input.status}` };
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=device-authorization.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-authorization.js","sourceRoot":"","sources":["../src/device-authorization.ts"],"names":[],"mappings":"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"}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,29 @@
|
|
|
1
|
+
import { type QortexRecoveryDirective } from "./recovery-directive.js";
|
|
1
2
|
export type QortexErrorCategory = "credentials" | "authorization" | "validation" | "rate-limit" | "gateway" | "server" | "http" | "protocol" | "transport" | "timeout";
|
|
3
|
+
/** Server-reported acceptance, restricted to the public states. */
|
|
4
|
+
export type QortexAcceptanceState = "unknown" | "not-accepted" | "accepted";
|
|
5
|
+
/**
|
|
6
|
+
* Bounded replay evidence from an error envelope. Only the fields below survive.
|
|
7
|
+
* `accepted` is set whenever the server reported any acceptance other than
|
|
8
|
+
* `not-accepted`, including an unrecognized value, so ambiguity fails closed.
|
|
9
|
+
* Use `hasReplayBlockingEvidence` rather than reading one field by itself.
|
|
10
|
+
*/
|
|
11
|
+
export interface QortexErrorEvidence {
|
|
12
|
+
contractVersion: 1;
|
|
13
|
+
/** True when the error body was a JSON object whose fields could be examined. */
|
|
14
|
+
replayEvidenceAvailable: boolean;
|
|
15
|
+
accepted?: true;
|
|
16
|
+
partialOutput?: true;
|
|
17
|
+
sideEffectStarted?: true;
|
|
18
|
+
acceptance?: QortexAcceptanceState;
|
|
19
|
+
recoveryDirective?: QortexRecoveryDirective;
|
|
20
|
+
/**
|
|
21
|
+
* The server's explicit retryability verdict, from its directive or envelope.
|
|
22
|
+
* Absent when the server stated none, in which case `QortexError.retryable`
|
|
23
|
+
* falls back to the HTTP status.
|
|
24
|
+
*/
|
|
25
|
+
retryable?: boolean;
|
|
26
|
+
}
|
|
2
27
|
export interface QortexErrorDetails {
|
|
3
28
|
category: QortexErrorCategory;
|
|
4
29
|
message: string;
|
|
@@ -6,13 +31,24 @@ export interface QortexErrorDetails {
|
|
|
6
31
|
status?: number;
|
|
7
32
|
requestId?: string;
|
|
8
33
|
retryable: boolean;
|
|
34
|
+
evidence?: QortexErrorEvidence;
|
|
9
35
|
}
|
|
36
|
+
/** The first safe request ID named in an error body, read from the documented envelope locations. */
|
|
37
|
+
export declare function qortexErrorRequestId(payload: unknown): string | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Read only the bounded evidence from an error payload. Raw strings, unknown
|
|
40
|
+
* fields, and credentials are never copied into the result.
|
|
41
|
+
*/
|
|
42
|
+
export declare function qortexErrorEvidence(payload: unknown): QortexErrorEvidence;
|
|
43
|
+
/** True when any server evidence forbids replaying the failed attempt. */
|
|
44
|
+
export declare function hasReplayBlockingEvidence(evidence: QortexErrorEvidence | undefined): boolean;
|
|
10
45
|
export declare class QortexError extends Error {
|
|
11
46
|
readonly category: QortexErrorCategory;
|
|
12
47
|
readonly code?: string;
|
|
13
48
|
readonly status?: number;
|
|
14
49
|
readonly requestId?: string;
|
|
15
50
|
readonly retryable: boolean;
|
|
51
|
+
readonly evidence?: QortexErrorEvidence;
|
|
16
52
|
constructor(details: QortexErrorDetails, options?: {
|
|
17
53
|
cause?: unknown;
|
|
18
54
|
});
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,mBAAmB,GAC3B,aAAa,GACb,eAAe,GACf,YAAY,GACZ,YAAY,GACZ,SAAS,GACT,QAAQ,GACR,MAAM,GACN,UAAU,GACV,WAAW,GACX,SAAS,CAAC;AAEd,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAA0B,KAAK,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAG/F,MAAM,MAAM,mBAAmB,GAC3B,aAAa,GACb,eAAe,GACf,YAAY,GACZ,YAAY,GACZ,SAAS,GACT,QAAQ,GACR,MAAM,GACN,UAAU,GACV,WAAW,GACX,SAAS,CAAC;AAEd,mEAAmE;AACnE,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,cAAc,GAAG,UAAU,CAAC;AAE5E;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,eAAe,EAAE,CAAC,CAAC;IACnB,iFAAiF;IACjF,uBAAuB,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,EAAE,IAAI,CAAC;IAChB,aAAa,CAAC,EAAE,IAAI,CAAC;IACrB,iBAAiB,CAAC,EAAE,IAAI,CAAC;IACzB,UAAU,CAAC,EAAE,qBAAqB,CAAC;IACnC,iBAAiB,CAAC,EAAE,uBAAuB,CAAC;IAC5C;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,mBAAmB,CAAC;CAChC;AAsCD,qGAAqG;AACrG,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAIzE;AA8BD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,mBAAmB,CAczE;AA4BD,0EAA0E;AAC1E,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,mBAAmB,GAAG,SAAS,GAAG,OAAO,CAQ5F;AAQD,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,CAAC;IACvC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,mBAAmB,CAAC;gBAGtC,OAAO,EAAE,kBAAkB,EAC3B,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;IAYnC,MAAM,IAAI,kBAAkB;CAW7B;AAED,qBAAa,eAAgB,SAAQ,WAAW;gBAClC,OAAO,EAAE,kBAAkB,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAI3E;AAED,qBAAa,kBAAmB,SAAQ,WAAW;gBACrC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAYjE;AAED,qBAAa,oBAAqB,SAAQ,WAAW;gBACvC,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAY9C;AAED,qBAAa,mBAAoB,SAAQ,WAAW;gBACtC,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM;CAWjE;AAED,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,WAAW,GACpB,eAAe,CA+CjB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAKpD"}
|
package/dist/errors.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
+
import { parseRecoveryDirective } from "./recovery-directive.js";
|
|
2
|
+
import { redactSensitiveText } from "./redaction.js";
|
|
1
3
|
const SAFE_CODE = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
2
4
|
const SAFE_REQUEST_ID = /^[A-Za-z0-9_.:-]{1,160}$/;
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
const BEARER_TOKEN = /\b(bearer|basic)\s+[A-Za-z0-9._~+/-]+=*/gi;
|
|
6
|
-
const URL_SECRET = /([?&](?:api[-_]?key|access[-_]?token|refresh[-_]?token|token|secret)=)[^&\s]+/gi;
|
|
7
|
-
const JWT = /\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g;
|
|
5
|
+
const ACCEPTANCE_STATES = new Set(["unknown", "not-accepted", "accepted"]);
|
|
6
|
+
const REPLAY_FLAGS = ["accepted", "partialOutput", "sideEffectStarted"];
|
|
8
7
|
function record(value) {
|
|
9
8
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
10
9
|
? value
|
|
@@ -13,16 +12,7 @@ function record(value) {
|
|
|
13
12
|
function boundedText(value, maxLength) {
|
|
14
13
|
if (typeof value !== "string")
|
|
15
14
|
return undefined;
|
|
16
|
-
|
|
17
|
-
.replace(AUTH_HEADER, "$1: [redacted]")
|
|
18
|
-
.replace(SECRET_VALUE, "$1=[redacted]")
|
|
19
|
-
.replace(BEARER_TOKEN, "$1 [redacted]")
|
|
20
|
-
.replace(URL_SECRET, "$1[redacted]")
|
|
21
|
-
.replace(JWT, "[redacted-token]")
|
|
22
|
-
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ")
|
|
23
|
-
.trim()
|
|
24
|
-
.slice(0, maxLength);
|
|
25
|
-
return safe || undefined;
|
|
15
|
+
return redactSensitiveText(value, maxLength) || undefined;
|
|
26
16
|
}
|
|
27
17
|
function safeCode(value) {
|
|
28
18
|
if (typeof value !== "string")
|
|
@@ -42,12 +32,108 @@ function safeRequestId(value) {
|
|
|
42
32
|
const candidate = value?.trim();
|
|
43
33
|
return candidate && SAFE_REQUEST_ID.test(candidate) ? candidate : undefined;
|
|
44
34
|
}
|
|
35
|
+
/** The first safe request ID named in an error body, read from the documented envelope locations. */
|
|
36
|
+
export function qortexErrorRequestId(payload) {
|
|
37
|
+
return envelopeRecords(payload)
|
|
38
|
+
.map((item) => safeRequestId(typeof item.requestId === "string" ? item.requestId : undefined))
|
|
39
|
+
.find((value) => value !== undefined);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The error envelope may appear at the top level, under `error`, or under
|
|
43
|
+
* `details`. Those are the documented locations, and each is read once here,
|
|
44
|
+
* before the raw payload is discarded.
|
|
45
|
+
*/
|
|
46
|
+
function envelopeRecords(payload) {
|
|
47
|
+
const outer = record(payload);
|
|
48
|
+
if (!outer)
|
|
49
|
+
return [];
|
|
50
|
+
return [outer, record(outer.error), record(outer.details)].filter((item) => item !== undefined);
|
|
51
|
+
}
|
|
52
|
+
function replayEvidence(records) {
|
|
53
|
+
const evidence = {};
|
|
54
|
+
for (const flag of REPLAY_FLAGS) {
|
|
55
|
+
// Any value other than an explicit `false` is ambiguous, and ambiguity blocks replay.
|
|
56
|
+
if (records.some((item) => item[flag] != null && item[flag] !== false))
|
|
57
|
+
evidence[flag] = true;
|
|
58
|
+
}
|
|
59
|
+
const reported = records.map((item) => item.acceptance).filter((value) => value != null);
|
|
60
|
+
if (reported.some((value) => value !== "not-accepted"))
|
|
61
|
+
evidence.accepted = true;
|
|
62
|
+
if (reported.length === 0)
|
|
63
|
+
return evidence;
|
|
64
|
+
if (reported.includes("accepted"))
|
|
65
|
+
evidence.acceptance = "accepted";
|
|
66
|
+
else if (reported.includes("unknown"))
|
|
67
|
+
evidence.acceptance = "unknown";
|
|
68
|
+
else if (reported.every((value) => value === "not-accepted"))
|
|
69
|
+
evidence.acceptance = "not-accepted";
|
|
70
|
+
return evidence;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read only the bounded evidence from an error payload. Raw strings, unknown
|
|
74
|
+
* fields, and credentials are never copied into the result.
|
|
75
|
+
*/
|
|
76
|
+
export function qortexErrorEvidence(payload) {
|
|
77
|
+
const records = envelopeRecords(payload);
|
|
78
|
+
if (records.length === 0)
|
|
79
|
+
return { contractVersion: 1, replayEvidenceAvailable: false };
|
|
80
|
+
const directiveSource = records.map((item) => item.recoveryDirective).find((value) => value !== undefined);
|
|
81
|
+
const directive = directiveSource === undefined ? undefined : parseDirectiveSafely(directiveSource);
|
|
82
|
+
const retryable = explicitRetryable(records, directive);
|
|
83
|
+
return {
|
|
84
|
+
contractVersion: 1,
|
|
85
|
+
replayEvidenceAvailable: true,
|
|
86
|
+
...replayEvidence(records),
|
|
87
|
+
...(directive === undefined ? {} : { recoveryDirective: directive }),
|
|
88
|
+
...(retryable === undefined ? {} : { retryable }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The server's own verdict. An explicit `false` is terminal. Otherwise a valid
|
|
93
|
+
* directive decides, and then an explicit `true` from the envelope.
|
|
94
|
+
*/
|
|
95
|
+
function explicitRetryable(records, directive) {
|
|
96
|
+
const verdicts = records
|
|
97
|
+
.map((item) => item.retryable)
|
|
98
|
+
.filter((value) => typeof value === "boolean");
|
|
99
|
+
if (verdicts.includes(false))
|
|
100
|
+
return false;
|
|
101
|
+
if (directive !== undefined)
|
|
102
|
+
return directive.retryable;
|
|
103
|
+
if (verdicts.includes(true))
|
|
104
|
+
return true;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
function parseDirectiveSafely(value) {
|
|
108
|
+
// A malformed or future directive is omitted rather than reflected to the caller.
|
|
109
|
+
try {
|
|
110
|
+
return parseRecoveryDirective(value);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** True when any server evidence forbids replaying the failed attempt. */
|
|
117
|
+
export function hasReplayBlockingEvidence(evidence) {
|
|
118
|
+
return (evidence !== undefined &&
|
|
119
|
+
(evidence.accepted === true ||
|
|
120
|
+
evidence.partialOutput === true ||
|
|
121
|
+
evidence.sideEffectStarted === true ||
|
|
122
|
+
(evidence.acceptance !== undefined && evidence.acceptance !== "not-accepted")));
|
|
123
|
+
}
|
|
124
|
+
/** The server's explicit verdict wins. Otherwise the HTTP status decides. */
|
|
125
|
+
function resolveRetryable(status, evidence) {
|
|
126
|
+
if (evidence.retryable !== undefined)
|
|
127
|
+
return evidence.retryable;
|
|
128
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
129
|
+
}
|
|
45
130
|
export class QortexError extends Error {
|
|
46
131
|
category;
|
|
47
132
|
code;
|
|
48
133
|
status;
|
|
49
134
|
requestId;
|
|
50
135
|
retryable;
|
|
136
|
+
evidence;
|
|
51
137
|
constructor(details, options = {}) {
|
|
52
138
|
super(details.message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
53
139
|
this.name = "QortexError";
|
|
@@ -56,6 +142,7 @@ export class QortexError extends Error {
|
|
|
56
142
|
this.status = details.status;
|
|
57
143
|
this.requestId = details.requestId;
|
|
58
144
|
this.retryable = details.retryable;
|
|
145
|
+
this.evidence = details.evidence;
|
|
59
146
|
}
|
|
60
147
|
toJSON() {
|
|
61
148
|
return {
|
|
@@ -65,6 +152,7 @@ export class QortexError extends Error {
|
|
|
65
152
|
...(this.status === undefined ? {} : { status: this.status }),
|
|
66
153
|
...(this.requestId ? { requestId: this.requestId } : {}),
|
|
67
154
|
retryable: this.retryable,
|
|
155
|
+
...(this.evidence ? { evidence: this.evidence } : {}),
|
|
68
156
|
};
|
|
69
157
|
}
|
|
70
158
|
}
|
|
@@ -112,6 +200,7 @@ export class QortexProtocolError extends QortexError {
|
|
|
112
200
|
export function qortexHttpError(status, payload, headers) {
|
|
113
201
|
const outer = record(payload);
|
|
114
202
|
const nested = record(outer?.error);
|
|
203
|
+
const evidence = qortexErrorEvidence(payload);
|
|
115
204
|
const code = safeCode(outer?.code) ??
|
|
116
205
|
safeCode(nested?.code) ??
|
|
117
206
|
(typeof outer?.error === "string" ? safeCode(outer.error) : undefined) ??
|
|
@@ -124,7 +213,8 @@ export function qortexHttpError(status, payload, headers) {
|
|
|
124
213
|
const requestHeaders = new Headers(headers);
|
|
125
214
|
const requestId = safeRequestId(requestHeaders.get("x-request-id")) ??
|
|
126
215
|
safeRequestId(requestHeaders.get("request-id")) ??
|
|
127
|
-
safeRequestId(requestHeaders.get("x-qlex-request-id"))
|
|
216
|
+
safeRequestId(requestHeaders.get("x-qlex-request-id")) ??
|
|
217
|
+
qortexErrorRequestId(payload);
|
|
128
218
|
const envelopeFailure = outer?.success === false;
|
|
129
219
|
let category;
|
|
130
220
|
if (envelopeFailure && status < 400)
|
|
@@ -153,7 +243,8 @@ export function qortexHttpError(status, payload, headers) {
|
|
|
153
243
|
code,
|
|
154
244
|
status,
|
|
155
245
|
...(requestId ? { requestId } : {}),
|
|
156
|
-
retryable: status
|
|
246
|
+
retryable: resolveRetryable(status, evidence),
|
|
247
|
+
evidence,
|
|
157
248
|
});
|
|
158
249
|
}
|
|
159
250
|
export function isAbortError(error) {
|