@apifuse/provider-sdk 2.0.0-beta.1
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/README.md +44 -0
- package/bin/apifuse-check.ts +406 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +387 -0
- package/bin/apifuse-perf.ts +1099 -0
- package/bin/apifuse-record.ts +444 -0
- package/bin/apifuse-test.ts +688 -0
- package/bin/apifuse.ts +51 -0
- package/package.json +64 -0
- package/src/__tests__/auth.test.ts +396 -0
- package/src/__tests__/browser-auth.test.ts +180 -0
- package/src/__tests__/browser.test.ts +632 -0
- package/src/__tests__/define.test.ts +225 -0
- package/src/__tests__/errors.test.ts +69 -0
- package/src/__tests__/executor.test.ts +214 -0
- package/src/__tests__/http.test.ts +238 -0
- package/src/__tests__/insights.test.ts +210 -0
- package/src/__tests__/instrumentation.test.ts +290 -0
- package/src/__tests__/otlp.test.ts +141 -0
- package/src/__tests__/perf.test.ts +60 -0
- package/src/__tests__/providers-yaml.test.ts +135 -0
- package/src/__tests__/proxy.test.ts +359 -0
- package/src/__tests__/recipes.test.ts +36 -0
- package/src/__tests__/serve.test.ts +233 -0
- package/src/__tests__/session.test.ts +231 -0
- package/src/__tests__/state.test.ts +100 -0
- package/src/__tests__/stealth.test.ts +57 -0
- package/src/__tests__/testing.test.ts +97 -0
- package/src/__tests__/tls.test.ts +345 -0
- package/src/__tests__/types.test.ts +142 -0
- package/src/__tests__/utils.test.ts +62 -0
- package/src/__tests__/waterfall.test.ts +270 -0
- package/src/config/loader.ts +122 -0
- package/src/config/providers-yaml.ts +370 -0
- package/src/define.ts +137 -0
- package/src/dev.ts +38 -0
- package/src/errors.ts +68 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +100 -0
- package/src/protocol.ts +183 -0
- package/src/recipes/gov-api.ts +97 -0
- package/src/recipes/rest-api.ts +152 -0
- package/src/runtime/auth.ts +245 -0
- package/src/runtime/browser.ts +724 -0
- package/src/runtime/executor.ts +54 -0
- package/src/runtime/http.ts +248 -0
- package/src/runtime/insights.ts +456 -0
- package/src/runtime/instrumentation.ts +424 -0
- package/src/runtime/otlp.ts +171 -0
- package/src/runtime/perf.ts +73 -0
- package/src/runtime/provider.ts +20 -0
- package/src/runtime/session.ts +573 -0
- package/src/runtime/state.ts +124 -0
- package/src/runtime/tls.ts +410 -0
- package/src/runtime/trace.ts +261 -0
- package/src/runtime/waterfall.ts +245 -0
- package/src/serve.ts +664 -0
- package/src/stealth/profiles.ts +391 -0
- package/src/testing/helpers.ts +144 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/run.ts +88 -0
- package/src/types/playwright-stealth.d.ts +9 -0
- package/src/types.ts +243 -0
- package/src/utils/date.ts +163 -0
- package/src/utils/parse.ts +66 -0
- package/src/utils/text.ts +20 -0
- package/src/utils/transform.ts +62 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container Protocol — Communication contract between Route Server and Provider Containers.
|
|
3
|
+
*
|
|
4
|
+
* All provider containers expose HTTP endpoints. Route Server sends requests
|
|
5
|
+
* and receives responses in these formats.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════
|
|
9
|
+
// Execute Operation
|
|
10
|
+
// POST /execute/:operationId
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════
|
|
12
|
+
|
|
13
|
+
export interface ExecuteRequest {
|
|
14
|
+
/** Validated input (already passed Zod validation by Route Server) */
|
|
15
|
+
input: unknown;
|
|
16
|
+
/** Decrypted session key-value pairs (loaded by Route Server from DB) */
|
|
17
|
+
session?: Record<string, string>;
|
|
18
|
+
/** Trace context for distributed tracing */
|
|
19
|
+
trace?: { traceId: string; spanId: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ExecuteResponse {
|
|
23
|
+
/** Operation result (will be Zod-validated by Route Server) */
|
|
24
|
+
data: unknown;
|
|
25
|
+
/** Session mutations to persist back to DB (key→value for set, key→null for delete) */
|
|
26
|
+
sessionPatch?: Record<string, string | null>;
|
|
27
|
+
/** Trace spans collected during execution */
|
|
28
|
+
trace?: { spans: unknown[] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════
|
|
32
|
+
// Connect (Auth Exchange)
|
|
33
|
+
// POST /connect
|
|
34
|
+
// ═══════════════════════════════════════════════════════════════
|
|
35
|
+
|
|
36
|
+
export interface ConnectRequest {
|
|
37
|
+
/** User-provided credentials */
|
|
38
|
+
credentials: Record<string, string>;
|
|
39
|
+
/** Existing session (for re-auth scenarios) */
|
|
40
|
+
session?: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Successful connection */
|
|
44
|
+
export interface ConnectSuccessResponse {
|
|
45
|
+
status: "success";
|
|
46
|
+
/** Display name for the connected upstream account */
|
|
47
|
+
accountRef?: string;
|
|
48
|
+
/** Session data to persist (full session state after exchange) */
|
|
49
|
+
sessionPatch: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Connection requires additional field (e.g., OTP) */
|
|
53
|
+
export interface ConnectPendingFieldResponse {
|
|
54
|
+
status: "pending_field";
|
|
55
|
+
/** Name of the field to request from user */
|
|
56
|
+
field: string;
|
|
57
|
+
/** Label to display to user */
|
|
58
|
+
fieldLabel?: string;
|
|
59
|
+
/** Opaque token to resume the connection flow */
|
|
60
|
+
resumeToken: string;
|
|
61
|
+
/** Partial session state to persist (intermediate state) */
|
|
62
|
+
sessionPatch?: Record<string, string>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Connection failed */
|
|
66
|
+
export interface ConnectFailedResponse {
|
|
67
|
+
status: "failed";
|
|
68
|
+
/** Error code for programmatic handling */
|
|
69
|
+
error: string;
|
|
70
|
+
/** Human-readable error message */
|
|
71
|
+
message?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ConnectResponse =
|
|
75
|
+
| ConnectSuccessResponse
|
|
76
|
+
| ConnectPendingFieldResponse
|
|
77
|
+
| ConnectFailedResponse;
|
|
78
|
+
|
|
79
|
+
// ═══════════════════════════════════════════════════════════════
|
|
80
|
+
// Resume (Continue deferred auth)
|
|
81
|
+
// POST /connect/resume
|
|
82
|
+
// ═══════════════════════════════════════════════════════════════
|
|
83
|
+
|
|
84
|
+
export interface ResumeRequest {
|
|
85
|
+
/** Resume token from ConnectPendingFieldResponse */
|
|
86
|
+
resumeToken: string;
|
|
87
|
+
/** User-provided value for the requested field */
|
|
88
|
+
fieldValue: string;
|
|
89
|
+
/** Current session state */
|
|
90
|
+
session?: Record<string, string>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type ResumeResponse = ConnectResponse; // same as ConnectResponse
|
|
94
|
+
|
|
95
|
+
// ═══════════════════════════════════════════════════════════════
|
|
96
|
+
// Refresh (Auth token refresh)
|
|
97
|
+
// POST /refresh
|
|
98
|
+
// ═══════════════════════════════════════════════════════════════
|
|
99
|
+
|
|
100
|
+
export interface RefreshRequest {
|
|
101
|
+
/** Current session to refresh */
|
|
102
|
+
session: Record<string, string>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RefreshSuccessResponse {
|
|
106
|
+
status: "success";
|
|
107
|
+
/** Updated session data */
|
|
108
|
+
sessionPatch: Record<string, string>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface RefreshFailedResponse {
|
|
112
|
+
status: "failed";
|
|
113
|
+
error: string;
|
|
114
|
+
message?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type RefreshResponse = RefreshSuccessResponse | RefreshFailedResponse;
|
|
118
|
+
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════
|
|
120
|
+
// Disconnect
|
|
121
|
+
// POST /disconnect
|
|
122
|
+
// ═══════════════════════════════════════════════════════════════
|
|
123
|
+
|
|
124
|
+
export interface DisconnectRequest {
|
|
125
|
+
/** Session to clean up */
|
|
126
|
+
session: Record<string, string>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface DisconnectResponse {
|
|
130
|
+
status: "success" | "failed";
|
|
131
|
+
error?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ═══════════════════════════════════════════════════════════════
|
|
135
|
+
// Health
|
|
136
|
+
// GET /health
|
|
137
|
+
// ═══════════════════════════════════════════════════════════════
|
|
138
|
+
|
|
139
|
+
export interface HealthResponse {
|
|
140
|
+
status: "ok" | "degraded" | "error";
|
|
141
|
+
provider: string;
|
|
142
|
+
version: string;
|
|
143
|
+
uptime?: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ═══════════════════════════════════════════════════════════════
|
|
147
|
+
// Schema
|
|
148
|
+
// GET /schema/:operationId
|
|
149
|
+
// ═══════════════════════════════════════════════════════════════
|
|
150
|
+
|
|
151
|
+
export interface SchemaResponse {
|
|
152
|
+
operationId: string;
|
|
153
|
+
description?: string;
|
|
154
|
+
input: Record<string, unknown>; // JSON Schema
|
|
155
|
+
output: Record<string, unknown>; // JSON Schema
|
|
156
|
+
hints?: Record<string, string>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ═══════════════════════════════════════════════════════════════
|
|
160
|
+
// Error envelope (used for any error response)
|
|
161
|
+
// ═══════════════════════════════════════════════════════════════
|
|
162
|
+
|
|
163
|
+
export interface ContainerErrorResponse {
|
|
164
|
+
error: {
|
|
165
|
+
code: string;
|
|
166
|
+
message: string;
|
|
167
|
+
details?: unknown;
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ═══════════════════════════════════════════════════════════════
|
|
172
|
+
// Standard endpoint paths
|
|
173
|
+
// ═══════════════════════════════════════════════════════════════
|
|
174
|
+
|
|
175
|
+
export const CONTAINER_ENDPOINTS = {
|
|
176
|
+
execute: "/execute/:operationId",
|
|
177
|
+
connect: "/connect",
|
|
178
|
+
resume: "/connect/resume",
|
|
179
|
+
refresh: "/refresh",
|
|
180
|
+
disconnect: "/disconnect",
|
|
181
|
+
health: "/health",
|
|
182
|
+
schema: "/schema/:operationId",
|
|
183
|
+
} as const;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { unwrapEnvelope } from "../utils/parse";
|
|
2
|
+
|
|
3
|
+
function getResultCode(raw: unknown): string | undefined {
|
|
4
|
+
if (!raw || typeof raw !== "object") {
|
|
5
|
+
return undefined;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const record = raw as Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
if (typeof record.resultCode === "string") {
|
|
11
|
+
return record.resultCode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const response = record.response as Record<string, unknown> | undefined;
|
|
15
|
+
const header = response?.header as Record<string, unknown> | undefined;
|
|
16
|
+
|
|
17
|
+
return typeof header?.resultCode === "string" ? header.resultCode : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check Korean government API result code
|
|
22
|
+
* Returns true if resultCode matches successCodes (default: ['00', '000', '0000'])
|
|
23
|
+
*/
|
|
24
|
+
export function checkResultCode(
|
|
25
|
+
raw: unknown,
|
|
26
|
+
successCodes: string[] = ["00", "000", "0000"],
|
|
27
|
+
): boolean {
|
|
28
|
+
const code = getResultCode(raw);
|
|
29
|
+
return code ? successCodes.includes(code) : false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replace placeholder values with null
|
|
34
|
+
* e.g., nullIfPlaceholder('해당없음', ['해당없음', '-', '']) → null
|
|
35
|
+
*/
|
|
36
|
+
export function nullIfPlaceholder(
|
|
37
|
+
v: unknown,
|
|
38
|
+
patterns: string[],
|
|
39
|
+
): unknown | null {
|
|
40
|
+
if (v === null || v === undefined) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof v !== "string") {
|
|
45
|
+
return v;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const normalized = v.trim();
|
|
49
|
+
return patterns.some((pattern) => normalized === pattern.trim()) ? null : v;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Unwrap Korean government API envelope
|
|
54
|
+
* Handles: {response: {body: {items: {item: ...}}}} nested structure
|
|
55
|
+
*/
|
|
56
|
+
export function unwrapGovEnvelope(raw: unknown): unknown {
|
|
57
|
+
const item = unwrapEnvelope(raw, "response.body.items.item");
|
|
58
|
+
|
|
59
|
+
if (Array.isArray(item)) {
|
|
60
|
+
return item;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (item !== undefined && item !== null) {
|
|
64
|
+
return [item];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const items = unwrapEnvelope(raw, "response.body.items");
|
|
68
|
+
if (Array.isArray(items)) {
|
|
69
|
+
return items;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (items !== undefined && items !== null) {
|
|
73
|
+
return items;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return raw;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if Korean government API returned empty result
|
|
81
|
+
*/
|
|
82
|
+
export function isEmptyResult(raw: unknown): boolean {
|
|
83
|
+
if (getResultCode(raw) === "03") {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const items = unwrapGovEnvelope(raw);
|
|
88
|
+
if (Array.isArray(items)) {
|
|
89
|
+
return items.length === 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (items && typeof items === "object") {
|
|
93
|
+
return Object.keys(items as Record<string, unknown>).length === 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export interface PaginationInfo {
|
|
2
|
+
page: number;
|
|
3
|
+
perPage: number;
|
|
4
|
+
total: number;
|
|
5
|
+
totalPages: number;
|
|
6
|
+
hasNext: boolean;
|
|
7
|
+
hasPrev: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function toPositiveInteger(value: unknown): number | null {
|
|
11
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
12
|
+
return Math.trunc(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
16
|
+
const parsed = Number.parseInt(value, 10);
|
|
17
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readPath(raw: unknown, path: string): unknown {
|
|
24
|
+
if (!raw || typeof raw !== "object") {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let current: unknown = raw;
|
|
29
|
+
for (const segment of path.split(".")) {
|
|
30
|
+
if (!current || typeof current !== "object") {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
current = (current as Record<string, unknown>)[segment];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Extract pagination info from various REST API response shapes.
|
|
42
|
+
* Handles: {page, per_page, total}, {meta: {pagination: {...}}}, {currentPage, totalCount, pageSize}
|
|
43
|
+
*/
|
|
44
|
+
export function extractPagination(raw: unknown): PaginationInfo | null {
|
|
45
|
+
const candidates = [
|
|
46
|
+
{
|
|
47
|
+
page: readPath(raw, "page"),
|
|
48
|
+
perPage: readPath(raw, "per_page"),
|
|
49
|
+
total: readPath(raw, "total"),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
page: readPath(raw, "meta.pagination.page"),
|
|
53
|
+
perPage: readPath(raw, "meta.pagination.per_page"),
|
|
54
|
+
total: readPath(raw, "meta.pagination.total"),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
page: readPath(raw, "meta.pagination.currentPage"),
|
|
58
|
+
perPage: readPath(raw, "meta.pagination.pageSize"),
|
|
59
|
+
total: readPath(raw, "meta.pagination.totalCount"),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
page: readPath(raw, "currentPage"),
|
|
63
|
+
perPage: readPath(raw, "pageSize"),
|
|
64
|
+
total: readPath(raw, "totalCount"),
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
for (const candidate of candidates) {
|
|
69
|
+
const page = toPositiveInteger(candidate.page);
|
|
70
|
+
const perPage = toPositiveInteger(candidate.perPage);
|
|
71
|
+
const total = toPositiveInteger(candidate.total);
|
|
72
|
+
|
|
73
|
+
if (page === null || perPage === null || total === null) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const totalPages = perPage > 0 ? Math.ceil(total / perPage) : 0;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
page,
|
|
81
|
+
perPage,
|
|
82
|
+
total,
|
|
83
|
+
totalPages,
|
|
84
|
+
hasNext: page < totalPages,
|
|
85
|
+
hasPrev: page > 1,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractMessage(value: unknown): string | null {
|
|
93
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!value || typeof value !== "object") {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const record = value as Record<string, unknown>;
|
|
102
|
+
|
|
103
|
+
if (typeof record.message === "string" && record.message.trim() !== "") {
|
|
104
|
+
return record.message;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof record.error === "string" && record.error.trim() !== "") {
|
|
108
|
+
return record.error;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Normalize error response to a standard format.
|
|
116
|
+
* Handles: {error: string}, {message: string}, {errors: []}, {error: {message: string}}
|
|
117
|
+
*/
|
|
118
|
+
export function normalizeErrorResponse(
|
|
119
|
+
raw: unknown,
|
|
120
|
+
): { message: string; code?: string } | null {
|
|
121
|
+
if (!raw || typeof raw !== "object") {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const record = raw as Record<string, unknown>;
|
|
126
|
+
|
|
127
|
+
const directMessage =
|
|
128
|
+
extractMessage(record.error) ?? extractMessage(record.message);
|
|
129
|
+
if (directMessage) {
|
|
130
|
+
const code =
|
|
131
|
+
typeof record.code === "string" && record.code.trim() !== ""
|
|
132
|
+
? record.code
|
|
133
|
+
: undefined;
|
|
134
|
+
return code ? { message: directMessage, code } : { message: directMessage };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const errors = record.errors;
|
|
138
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
139
|
+
for (const item of errors) {
|
|
140
|
+
const message = extractMessage(item);
|
|
141
|
+
if (message) {
|
|
142
|
+
const code =
|
|
143
|
+
typeof record.code === "string" && record.code.trim() !== ""
|
|
144
|
+
? record.code
|
|
145
|
+
: undefined;
|
|
146
|
+
return code ? { message, code } : { message };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { AuthError, TransportError } from "../errors";
|
|
2
|
+
import type {
|
|
3
|
+
AuthConfig,
|
|
4
|
+
AuthContext,
|
|
5
|
+
AuthField,
|
|
6
|
+
ProviderContext,
|
|
7
|
+
SessionStore,
|
|
8
|
+
} from "../types";
|
|
9
|
+
|
|
10
|
+
const AUTH_SESSION_KEY = "__auth__";
|
|
11
|
+
const REQUEST_FIELD_TIMEOUT_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
function createAuthSessionMarker(
|
|
14
|
+
marker: Record<string, boolean | number>,
|
|
15
|
+
): string {
|
|
16
|
+
return JSON.stringify(marker);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type PendingFieldRequest = {
|
|
20
|
+
promise: Promise<string>;
|
|
21
|
+
resolve: (value: string) => void;
|
|
22
|
+
reject: (error: AuthError) => void;
|
|
23
|
+
timeoutId: ReturnType<typeof setTimeout>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface AuthManager {
|
|
27
|
+
exchange(
|
|
28
|
+
ctx: ProviderContext,
|
|
29
|
+
credentials: Record<string, string>,
|
|
30
|
+
): Promise<void>;
|
|
31
|
+
refresh(ctx: ProviderContext): Promise<void>;
|
|
32
|
+
disconnect(ctx: ProviderContext): Promise<void>;
|
|
33
|
+
wrapWithAutoRefresh<T>(
|
|
34
|
+
ctx: ProviderContext,
|
|
35
|
+
operation: () => Promise<T>,
|
|
36
|
+
): Promise<T>;
|
|
37
|
+
getFields(): AuthField[];
|
|
38
|
+
createAuthContext(): AuthContext;
|
|
39
|
+
resolveField(name: string, value: string): void;
|
|
40
|
+
getPendingFields(): string[];
|
|
41
|
+
isAuthNone(): boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toAuthError(error: unknown): AuthError {
|
|
45
|
+
if (error instanceof AuthError) {
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (error instanceof Error) {
|
|
50
|
+
return new AuthError(error.message, { cause: error });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return new AuthError("Auth exchange failed");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function is401or403(error: unknown): error is TransportError {
|
|
57
|
+
return (
|
|
58
|
+
error instanceof TransportError &&
|
|
59
|
+
(error.status === 401 || error.status === 403)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createAuthManager(
|
|
64
|
+
config: AuthConfig | undefined,
|
|
65
|
+
session: SessionStore,
|
|
66
|
+
): AuthManager {
|
|
67
|
+
const pendingFieldRequests = new Map<string, PendingFieldRequest>();
|
|
68
|
+
const resolvedFieldValues = new Map<string, string>();
|
|
69
|
+
let refreshPromise: Promise<void> | null = null;
|
|
70
|
+
|
|
71
|
+
function createTimedFieldRequest(name: string): Promise<string> {
|
|
72
|
+
const existingRequest = pendingFieldRequests.get(name);
|
|
73
|
+
if (existingRequest) {
|
|
74
|
+
return existingRequest.promise;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const preResolvedValue = resolvedFieldValues.get(name);
|
|
78
|
+
if (preResolvedValue !== undefined) {
|
|
79
|
+
resolvedFieldValues.delete(name);
|
|
80
|
+
return Promise.resolve(preResolvedValue);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let resolvePromise!: (value: string) => void;
|
|
84
|
+
let rejectPromise!: (error: AuthError) => void;
|
|
85
|
+
|
|
86
|
+
const promise = new Promise<string>((resolve, reject) => {
|
|
87
|
+
resolvePromise = resolve;
|
|
88
|
+
rejectPromise = reject;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const timeoutId = setTimeout(() => {
|
|
92
|
+
pendingFieldRequests.delete(name);
|
|
93
|
+
rejectPromise(new AuthError(`Auth field request timed out: ${name}`));
|
|
94
|
+
}, REQUEST_FIELD_TIMEOUT_MS);
|
|
95
|
+
|
|
96
|
+
pendingFieldRequests.set(name, {
|
|
97
|
+
promise,
|
|
98
|
+
resolve: (value) => {
|
|
99
|
+
clearTimeout(timeoutId);
|
|
100
|
+
pendingFieldRequests.delete(name);
|
|
101
|
+
resolvePromise(value);
|
|
102
|
+
},
|
|
103
|
+
reject: (error) => {
|
|
104
|
+
clearTimeout(timeoutId);
|
|
105
|
+
pendingFieldRequests.delete(name);
|
|
106
|
+
rejectPromise(error);
|
|
107
|
+
},
|
|
108
|
+
timeoutId,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return promise;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const authContext: AuthContext = {
|
|
115
|
+
requestField(name) {
|
|
116
|
+
return createTimedFieldRequest(name);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const manager: AuthManager = {
|
|
121
|
+
async exchange(ctx, credentials) {
|
|
122
|
+
if (!config || config.mode === "none") {
|
|
123
|
+
throw new AuthError("No auth configured");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!config.exchange) {
|
|
127
|
+
throw new AuthError("Auth exchange is not configured");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
ctx.auth = authContext;
|
|
131
|
+
|
|
132
|
+
await ctx.trace.span("auth.exchange", async () => {
|
|
133
|
+
try {
|
|
134
|
+
await config.exchange?.(ctx, credentials);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
throw toAuthError(error);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await session.set(
|
|
140
|
+
AUTH_SESSION_KEY,
|
|
141
|
+
createAuthSessionMarker({
|
|
142
|
+
authenticated: true,
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
async refresh(ctx) {
|
|
149
|
+
if (refreshPromise) {
|
|
150
|
+
return refreshPromise;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!config?.refresh) {
|
|
154
|
+
throw new AuthError("No refresh configured");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const doRefresh = async () => {
|
|
158
|
+
await ctx.trace.span("auth.refresh", async () => {
|
|
159
|
+
try {
|
|
160
|
+
await config.refresh?.(ctx);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw toAuthError(error);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await session.set(
|
|
166
|
+
AUTH_SESSION_KEY,
|
|
167
|
+
createAuthSessionMarker({
|
|
168
|
+
authenticated: true,
|
|
169
|
+
refreshed: true,
|
|
170
|
+
timestamp: Date.now(),
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
refreshPromise = doRefresh().finally(() => {
|
|
177
|
+
refreshPromise = null;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return refreshPromise;
|
|
181
|
+
},
|
|
182
|
+
async disconnect(ctx) {
|
|
183
|
+
await ctx.trace.span("auth.disconnect", async () => {
|
|
184
|
+
let disconnectError: AuthError | null = null;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
if (config?.disconnect) {
|
|
188
|
+
await config.disconnect(ctx);
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
disconnectError = toAuthError(error);
|
|
192
|
+
} finally {
|
|
193
|
+
await session.delete(AUTH_SESSION_KEY);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (disconnectError) {
|
|
197
|
+
throw disconnectError;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
async wrapWithAutoRefresh(ctx, operation) {
|
|
202
|
+
try {
|
|
203
|
+
return await operation();
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (is401or403(error) && config?.refresh) {
|
|
206
|
+
const originalError = error;
|
|
207
|
+
await manager.refresh(ctx);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
return await operation();
|
|
211
|
+
} catch {
|
|
212
|
+
throw originalError;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
getFields() {
|
|
220
|
+
return config?.fields ?? [];
|
|
221
|
+
},
|
|
222
|
+
createAuthContext() {
|
|
223
|
+
return authContext;
|
|
224
|
+
},
|
|
225
|
+
resolveField(name, value) {
|
|
226
|
+
const pendingRequest = pendingFieldRequests.get(name);
|
|
227
|
+
|
|
228
|
+
if (!pendingRequest) {
|
|
229
|
+
resolvedFieldValues.set(name, value);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
clearTimeout(pendingRequest.timeoutId);
|
|
234
|
+
pendingRequest.resolve(value);
|
|
235
|
+
},
|
|
236
|
+
getPendingFields() {
|
|
237
|
+
return Array.from(pendingFieldRequests.keys());
|
|
238
|
+
},
|
|
239
|
+
isAuthNone() {
|
|
240
|
+
return !config || config.mode === "none";
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return manager;
|
|
245
|
+
}
|