quickbase-js 0.1.0-beta.8 → 0.1.0-beta.9
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/CHANGELOG.md +29 -0
- package/README.md +46 -130
- package/dist/esm/quickbase.d.ts +822 -119
- package/dist/esm/quickbase.js +373 -236
- package/dist/esm/quickbase.js.map +1 -1
- package/dist/esm/quickbase.min.js +1 -1
- package/dist/esm/quickbase.min.js.map +1 -1
- package/dist/umd/quickbase.umd.js +373 -236
- package/dist/umd/quickbase.umd.js.map +1 -1
- package/dist/umd/quickbase.umd.min.js +1 -1
- package/dist/umd/quickbase.umd.min.js.map +1 -1
- package/open-api/generate-unified-interface.ts +97 -17
- package/open-api/utils/generateJsDoc.ts +96 -0
- package/package.json +3 -1
- package/src/ThrottleBucket.ts +17 -12
- package/src/authorizationStrategy.ts +246 -0
- package/src/generated-unified/QuickbaseClient.ts +809 -119
- package/src/invokeMethod.ts +150 -265
- package/src/quickbaseClient.ts +85 -58
- package/src/rateLimiter.ts +25 -0
- package/src/tokenCache.ts +10 -26
- package/tests/vitest/qb/auth/mockQuickbaseServer.ts +60 -0
- package/tests/vitest/qb/auth/retryUserToken401.test.ts +59 -42
- package/tests/vitest/qb/auth/ssoTokenIntegrationWithServer.test.ts +65 -0
- package/tests/vitest/qb/fields/getFieldUsage.test.ts +58 -150
- package/tests/vitest/qb/formulas/runFormula.test.ts +96 -0
- package/tests/vitest/qb/tables/createRelationship.test.ts +102 -0
- package/tests/vitest/qb/tables/deleteRelationship.test.ts +143 -0
- package/tests/vitest/qb/tables/updateRelationship.test.ts +118 -145
- package/tests/vitest/unit/ThrottleBucket.test.ts +95 -0
- package/tests/vitest/unit/apps/copyApp.test.ts +133 -40
- package/tests/vitest/unit/apps/updateApp.test.ts +42 -11
- package/tests/vitest/unit/auth/fetchTempToken401.test.ts +3 -2
- package/tests/vitest/unit/auth/getTempToken.test.ts +42 -18
- package/tests/vitest/unit/auth/retryTempToken401.test.ts +46 -64
- package/tests/vitest/unit/auth/retryTempTokenMaxRetries.test.ts +235 -0
- package/tests/vitest/unit/auth/retryUserToken401.test.ts +56 -43
- package/tests/vitest/unit/auth/ssoTokenRefresh.test.ts +325 -0
- package/tests/vitest/unit/fields/createField.test.ts +180 -116
- package/tests/vitest/unit/fields/getFieldUsage.test.ts +17 -3
- package/tests/vitest/unit/fields/updateField.test.ts +12 -7
- package/tests/vitest/unit/formulas/runFormula.test.ts +205 -0
- package/tests/vitest/unit/index.ts +2 -1
- package/tests/vitest/unit/inferHttpMethod.test.ts +131 -0
- package/tests/vitest/unit/rateLimit.test.ts +4 -2
- package/tests/vitest/unit/tables/createRelationship.test.ts +212 -0
- package/tests/vitest/unit/tables/deleteRelationship.test.ts +97 -0
- package/tests/vitest/unit/tables/updateRelationship.test.ts +12 -10
- package/tests/vitest/unit/tokenLifespan.test.ts +149 -83
- package/tools/log-gen.ts +14 -5
- package/tools/log-gen.yaml +710 -268
- package/tests/vitest/unit/auth/retryTempTokenTwo401.test.ts +0 -81
|
@@ -16,11 +16,15 @@ console.log("Path modules imported");
|
|
|
16
16
|
import { fileURLToPath } from "url";
|
|
17
17
|
console.log("URL module imported");
|
|
18
18
|
|
|
19
|
-
import { OpenAPIV2 } from "openapi-types";
|
|
19
|
+
import { OpenAPIV2 } from "openapi-types";
|
|
20
20
|
console.log("openapi-types imported");
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
import { Project } from "ts-morph";
|
|
23
|
+
console.log("ts-morph imported");
|
|
24
|
+
|
|
25
|
+
import { generateJsDoc } from "./utils/generateJsDoc.js";
|
|
26
|
+
|
|
27
|
+
const simplifyName = (str: string): string => str;
|
|
24
28
|
|
|
25
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
30
|
console.log("__dirname set:", __dirname);
|
|
@@ -110,7 +114,57 @@ function mapRefToType(
|
|
|
110
114
|
return "any";
|
|
111
115
|
}
|
|
112
116
|
|
|
113
|
-
|
|
117
|
+
interface PropertyDetail {
|
|
118
|
+
name: string;
|
|
119
|
+
type: string;
|
|
120
|
+
required: boolean;
|
|
121
|
+
jsdoc?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseInterfaceProperties(
|
|
125
|
+
modelName: string,
|
|
126
|
+
modelsDir: string
|
|
127
|
+
): PropertyDetail[] {
|
|
128
|
+
const project = new Project();
|
|
129
|
+
const filePath = join(modelsDir, `${modelName}.ts`);
|
|
130
|
+
if (!existsSync(filePath)) {
|
|
131
|
+
console.warn(`Model file ${filePath} not found for ${modelName}`);
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
136
|
+
const interfaceDec = sourceFile.getInterface(modelName);
|
|
137
|
+
if (!interfaceDec) {
|
|
138
|
+
console.warn(`Interface ${modelName} not found in ${filePath}`);
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return interfaceDec.getProperties().map((prop) => {
|
|
143
|
+
const jsDocs = prop.getJsDocs();
|
|
144
|
+
const jsdocText =
|
|
145
|
+
jsDocs.length > 0 ? jsDocs[0].getDescription().trim() : undefined;
|
|
146
|
+
return {
|
|
147
|
+
name: prop.getName(),
|
|
148
|
+
type: prop.getType().getText(prop),
|
|
149
|
+
required: !prop.hasQuestionToken(),
|
|
150
|
+
jsdoc: jsdocText,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface ParamDetail {
|
|
156
|
+
name: string;
|
|
157
|
+
type: string;
|
|
158
|
+
required: boolean;
|
|
159
|
+
properties: PropertyDetail[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function generateInterface(includeResponseProperties: boolean = false): void {
|
|
163
|
+
console.log(
|
|
164
|
+
"Generating interface with includeResponseProperties:",
|
|
165
|
+
includeResponseProperties
|
|
166
|
+
);
|
|
167
|
+
|
|
114
168
|
console.log("Checking spec file:", SPEC_FILE);
|
|
115
169
|
if (!existsSync(SPEC_FILE)) {
|
|
116
170
|
console.error(
|
|
@@ -121,7 +175,7 @@ function generateInterface() {
|
|
|
121
175
|
|
|
122
176
|
console.log("Reading spec file...");
|
|
123
177
|
const specContent = readFileSync(SPEC_FILE, "utf8");
|
|
124
|
-
const spec = JSON.parse(specContent)
|
|
178
|
+
const spec: OpenAPIV2.Document = JSON.parse(specContent);
|
|
125
179
|
console.log("Spec parsed, keys:", Object.keys(spec));
|
|
126
180
|
|
|
127
181
|
const { paths } = spec;
|
|
@@ -158,7 +212,9 @@ function generateInterface() {
|
|
|
158
212
|
|
|
159
213
|
const opId = simplifyName(op.operationId);
|
|
160
214
|
const summary = op.summary || "No description.";
|
|
161
|
-
const
|
|
215
|
+
const docLink = `https://developer.quickbase.com/operation/${op.operationId}`;
|
|
216
|
+
|
|
217
|
+
const paramDetails: ParamDetail[] = (op.parameters || [])
|
|
162
218
|
.filter((p: OpenAPIV2.ParameterObject | OpenAPIV2.ReferenceObject) => {
|
|
163
219
|
const param = p as OpenAPIV2.ParameterObject;
|
|
164
220
|
return !["QB-Realm-Hostname", "Authorization", "User-Agent"].includes(
|
|
@@ -167,8 +223,9 @@ function generateInterface() {
|
|
|
167
223
|
})
|
|
168
224
|
.map((param: OpenAPIV2.ParameterObject | OpenAPIV2.ReferenceObject) => {
|
|
169
225
|
const p = param as OpenAPIV2.ParameterObject;
|
|
170
|
-
if (!p.name) return
|
|
226
|
+
if (!p.name) return null;
|
|
171
227
|
let type = "any";
|
|
228
|
+
let properties: PropertyDetail[] = [];
|
|
172
229
|
if ("schema" in p && p.schema) {
|
|
173
230
|
type = mapRefToType(
|
|
174
231
|
p.schema,
|
|
@@ -178,13 +235,28 @@ function generateInterface() {
|
|
|
178
235
|
availableModels,
|
|
179
236
|
missingTypes
|
|
180
237
|
);
|
|
238
|
+
if ("$ref" in p.schema && p.schema.$ref) {
|
|
239
|
+
const refParts = p.schema.$ref.split("/");
|
|
240
|
+
const model = refParts[refParts.length - 1];
|
|
241
|
+
const pascalModel =
|
|
242
|
+
model.charAt(0).toUpperCase() + model.slice(1);
|
|
243
|
+
properties = parseInterfaceProperties(pascalModel, MODELS_DIR);
|
|
244
|
+
}
|
|
181
245
|
} else if ("type" in p && p.type) {
|
|
182
246
|
type = mapOpenApiTypeToTs(p.type);
|
|
183
247
|
}
|
|
184
248
|
const paramName = p.in === "body" ? "body" : p.name;
|
|
185
|
-
return
|
|
249
|
+
return {
|
|
250
|
+
name: paramName,
|
|
251
|
+
type,
|
|
252
|
+
required: p.required || false,
|
|
253
|
+
properties,
|
|
254
|
+
};
|
|
186
255
|
})
|
|
187
|
-
.filter((
|
|
256
|
+
.filter((p): p is ParamDetail => p !== null);
|
|
257
|
+
|
|
258
|
+
const params = paramDetails
|
|
259
|
+
.map((p) => `${p.name}${p.required ? "" : "?"}: ${p.type}`)
|
|
188
260
|
.join("; ");
|
|
189
261
|
|
|
190
262
|
const returnTypes = ["200", "207"]
|
|
@@ -207,13 +279,21 @@ function generateInterface() {
|
|
|
207
279
|
? returnTypes.join(" | ")
|
|
208
280
|
: returnTypes[0] || "void";
|
|
209
281
|
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
282
|
+
const returnTypeDetails: PropertyDetail[] =
|
|
283
|
+
includeResponseProperties &&
|
|
284
|
+
returnTypes.length === 1 &&
|
|
285
|
+
returnTypes[0] !== "void"
|
|
286
|
+
? parseInterfaceProperties(returnTypes[0], MODELS_DIR)
|
|
287
|
+
: [];
|
|
288
|
+
|
|
289
|
+
const jsDoc = generateJsDoc({
|
|
290
|
+
summary,
|
|
291
|
+
opId,
|
|
292
|
+
paramDetails,
|
|
293
|
+
returnType,
|
|
294
|
+
returnTypeDetails,
|
|
295
|
+
docLink,
|
|
296
|
+
});
|
|
217
297
|
|
|
218
298
|
methods.push(
|
|
219
299
|
`${jsDoc}\n ${opId}: (params: { ${params} }) => Promise<${returnType}>;`
|
|
@@ -247,7 +327,7 @@ function generateInterface() {
|
|
|
247
327
|
|
|
248
328
|
console.log("Entering try block");
|
|
249
329
|
try {
|
|
250
|
-
generateInterface();
|
|
330
|
+
generateInterface(true); // Toggle response properties here
|
|
251
331
|
} catch (error) {
|
|
252
332
|
console.error("Error in generateInterface:", error);
|
|
253
333
|
process.exit(1);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
interface ParamDetail {
|
|
2
|
+
name: string;
|
|
3
|
+
type: string;
|
|
4
|
+
required: boolean;
|
|
5
|
+
properties: {
|
|
6
|
+
name: string;
|
|
7
|
+
type: string;
|
|
8
|
+
required: boolean;
|
|
9
|
+
jsdoc?: string;
|
|
10
|
+
}[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface JsDocOptions {
|
|
14
|
+
summary: string;
|
|
15
|
+
opId: string;
|
|
16
|
+
paramDetails: ParamDetail[];
|
|
17
|
+
returnType: string;
|
|
18
|
+
returnTypeDetails: {
|
|
19
|
+
name: string;
|
|
20
|
+
type: string;
|
|
21
|
+
required: boolean;
|
|
22
|
+
jsdoc?: string;
|
|
23
|
+
}[];
|
|
24
|
+
docLink: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function generateJsDoc({
|
|
28
|
+
summary,
|
|
29
|
+
opId,
|
|
30
|
+
paramDetails,
|
|
31
|
+
returnType,
|
|
32
|
+
returnTypeDetails,
|
|
33
|
+
docLink,
|
|
34
|
+
}: JsDocOptions): string {
|
|
35
|
+
const jsDocLines: string[] = [
|
|
36
|
+
` /**`,
|
|
37
|
+
` * ${summary}`,
|
|
38
|
+
` *`,
|
|
39
|
+
` * @param {Object} params _Object containing the parameters for_ ${opId}`,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Parameters section
|
|
43
|
+
if (paramDetails.length > 0) {
|
|
44
|
+
paramDetails.forEach((p) => {
|
|
45
|
+
jsDocLines.push(
|
|
46
|
+
` * @param {${p.type}} params.${p.name} _${
|
|
47
|
+
p.required ? "Required" : "Optional"
|
|
48
|
+
} parameter with properties_`
|
|
49
|
+
);
|
|
50
|
+
if (p.properties.length > 0) {
|
|
51
|
+
p.properties.forEach((prop) => {
|
|
52
|
+
const propDesc = prop.jsdoc
|
|
53
|
+
? prop.jsdoc.replace(/@type\s*{[^}]+}\s*@memberof\s*\w+/, "").trim()
|
|
54
|
+
: `Type: ${prop.type}`;
|
|
55
|
+
jsDocLines.push(
|
|
56
|
+
` * - **${prop.name}** (\`${prop.type}\`${
|
|
57
|
+
prop.required ? ", required" : ", optional"
|
|
58
|
+
}) _${propDesc}_`
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
} else {
|
|
64
|
+
jsDocLines.push(` * No parameters`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Returns section
|
|
68
|
+
jsDocLines.push(` *`);
|
|
69
|
+
if (returnTypeDetails.length > 0) {
|
|
70
|
+
jsDocLines.push(
|
|
71
|
+
` * @returns {Promise<${returnType}>} _Promise resolving to the ${opId} response with properties_`
|
|
72
|
+
);
|
|
73
|
+
returnTypeDetails.forEach((prop) => {
|
|
74
|
+
const propDesc = prop.jsdoc
|
|
75
|
+
? prop.jsdoc.replace(/@type\s*{[^}]+}\s*@memberof\s*\w+/, "").trim()
|
|
76
|
+
: `Type: ${prop.type}`;
|
|
77
|
+
jsDocLines.push(
|
|
78
|
+
` * - **${prop.name}** (\`${prop.type}\`${
|
|
79
|
+
prop.required ? ", required" : ", optional"
|
|
80
|
+
}) _${propDesc}_`
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
jsDocLines.push(
|
|
85
|
+
` * @returns {Promise<${returnType}>} _Promise resolving to the ${opId} response_`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
jsDocLines.push(
|
|
90
|
+
` *`,
|
|
91
|
+
` * @see {@link ${docLink}} Official Quickbase API documentation`
|
|
92
|
+
);
|
|
93
|
+
jsDocLines.push(` */`);
|
|
94
|
+
|
|
95
|
+
return jsDocLines.join("\n");
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quickbase-js",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.9",
|
|
4
4
|
"description": "QuickBase JavaScript client for frameworks and vanilla JS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/esm/quickbase.js",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"test:vitest:unit:apps": "vitest run tests/vitest/unit/apps/*.test.ts",
|
|
53
53
|
"test:vitest:unit:auth": "vitest run tests/vitest/unit/auth/*.test.ts",
|
|
54
54
|
"test:vitest:unit:fields": "vitest run tests/vitest/unit/fields/*.test.ts",
|
|
55
|
+
"test:vitest:unit:formulas": "vitest run tests/vitest/unit/formulas/*.test.ts",
|
|
55
56
|
"test:vitest:unit:records": "vitest run tests/vitest/unit/records/*.test.ts",
|
|
56
57
|
"test:vitest:unit:tables": "vitest run tests/vitest/unit/tables/*.test.ts",
|
|
57
58
|
"test:pw:qb:auth": "playwright test tests/playwright/qb/auth"
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"@types/js-yaml": "^4.0.9",
|
|
68
69
|
"@types/node": "^22.13.10",
|
|
69
70
|
"dotenv": "^16.4.7",
|
|
71
|
+
"express": "^4.21.2",
|
|
70
72
|
"fast-json-patch": "^3.1.1",
|
|
71
73
|
"glob": "^10.4.5",
|
|
72
74
|
"js-yaml": "^4.1.0",
|
package/src/ThrottleBucket.ts
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
// src/
|
|
1
|
+
// src/ThrottleBucket.ts
|
|
2
2
|
export class ThrottleBucket {
|
|
3
3
|
private tokens: number;
|
|
4
4
|
private maxTokens: number;
|
|
5
5
|
private refillRate: number; // Tokens per second
|
|
6
6
|
private lastRefill: number;
|
|
7
|
+
private pending: Promise<void> = Promise.resolve(); // Queue for sequential execution
|
|
7
8
|
|
|
8
9
|
constructor(rate: number, burst: number) {
|
|
9
|
-
this.tokens = burst;
|
|
10
|
+
this.tokens = burst;
|
|
10
11
|
this.maxTokens = burst;
|
|
11
12
|
this.refillRate = rate;
|
|
12
13
|
this.lastRefill = Date.now();
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
// Refill tokens based on elapsed time
|
|
16
16
|
private refill(): void {
|
|
17
17
|
const now = Date.now();
|
|
18
18
|
const elapsed = (now - this.lastRefill) / 1000; // Seconds elapsed
|
|
@@ -21,16 +21,21 @@ export class ThrottleBucket {
|
|
|
21
21
|
this.lastRefill = now;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
// Acquire a token, waiting if necessary
|
|
25
24
|
async acquire(): Promise<void> {
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
// Chain the new acquisition onto the pending queue
|
|
26
|
+
const previous = this.pending;
|
|
27
|
+
this.pending = (async () => {
|
|
28
|
+
await previous; // Wait for prior calls to complete
|
|
29
|
+
this.refill();
|
|
30
|
+
if (this.tokens >= 1) {
|
|
31
|
+
this.tokens -= 1;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const waitTime = ((1 - this.tokens) / this.refillRate) * 1000; // ms until next token
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
36
|
+
this.refill();
|
|
28
37
|
this.tokens -= 1;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const waitTime = ((1 - this.tokens) / this.refillRate) * 1000; // ms until next token
|
|
32
|
-
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
33
|
-
this.refill();
|
|
34
|
-
this.tokens -= 1;
|
|
38
|
+
})();
|
|
39
|
+
await this.pending;
|
|
35
40
|
}
|
|
36
41
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// src/authorizationStrategy.ts
|
|
2
|
+
|
|
3
|
+
import { TokenCache } from "./tokenCache";
|
|
4
|
+
|
|
5
|
+
export interface AuthorizationStrategy {
|
|
6
|
+
getToken(dbid: string): Promise<string | undefined>;
|
|
7
|
+
applyHeaders(headers: Record<string, string>, token: string): void;
|
|
8
|
+
handleError(
|
|
9
|
+
status: number,
|
|
10
|
+
params: any,
|
|
11
|
+
fetchTempToken: (dbid: string) => Promise<string>,
|
|
12
|
+
attempt: number,
|
|
13
|
+
maxAttempts: number,
|
|
14
|
+
debug?: boolean,
|
|
15
|
+
methodName?: string
|
|
16
|
+
): Promise<string | null>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class TempTokenStrategy implements AuthorizationStrategy {
|
|
20
|
+
constructor(
|
|
21
|
+
private tokenCache: TokenCache,
|
|
22
|
+
private initialTempToken?: string
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
async getToken(dbid: string): Promise<string | undefined> {
|
|
26
|
+
return this.tokenCache.get(dbid) || this.initialTempToken;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
applyHeaders(headers: Record<string, string>, token: string): void {
|
|
30
|
+
headers["Authorization"] = `QB-TEMP-TOKEN ${token}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async handleError(
|
|
34
|
+
status: number,
|
|
35
|
+
params: any,
|
|
36
|
+
fetchTempToken: (dbid: string) => Promise<string>,
|
|
37
|
+
attempt: number,
|
|
38
|
+
maxAttempts: number,
|
|
39
|
+
debug?: boolean,
|
|
40
|
+
methodName?: string
|
|
41
|
+
): Promise<string | null> {
|
|
42
|
+
if (status !== 401 || attempt >= maxAttempts - 1) return null;
|
|
43
|
+
if (debug)
|
|
44
|
+
console.log(
|
|
45
|
+
`Authorization error for ${
|
|
46
|
+
methodName || "method"
|
|
47
|
+
} (temp token), refreshing token:`
|
|
48
|
+
);
|
|
49
|
+
const dbid = extractDbid(params);
|
|
50
|
+
if (!dbid) {
|
|
51
|
+
if (debug)
|
|
52
|
+
console.log(
|
|
53
|
+
`No dbid available for ${
|
|
54
|
+
methodName || "method"
|
|
55
|
+
}, skipping token refresh`
|
|
56
|
+
);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (debug) console.log(`Refreshing temp token for dbid: ${dbid}`);
|
|
60
|
+
const newToken = await fetchTempToken(dbid);
|
|
61
|
+
this.tokenCache.set(dbid, newToken);
|
|
62
|
+
return newToken;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class UserTokenStrategy implements AuthorizationStrategy {
|
|
67
|
+
constructor(private userToken: string) {}
|
|
68
|
+
|
|
69
|
+
async getToken(_dbid: string): Promise<string> {
|
|
70
|
+
return this.userToken;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
applyHeaders(headers: Record<string, string>, token: string): void {
|
|
74
|
+
headers["Authorization"] = `QB-USER-TOKEN ${token}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async handleError(
|
|
78
|
+
status: number,
|
|
79
|
+
_params: any,
|
|
80
|
+
_fetchTempToken: (dbid: string) => Promise<string>,
|
|
81
|
+
attempt: number,
|
|
82
|
+
maxAttempts: number,
|
|
83
|
+
debug?: boolean,
|
|
84
|
+
methodName?: string
|
|
85
|
+
): Promise<string | null> {
|
|
86
|
+
if (status !== 401 || attempt >= maxAttempts - 1) return null;
|
|
87
|
+
if (debug)
|
|
88
|
+
console.log(
|
|
89
|
+
`Retrying ${
|
|
90
|
+
methodName || "method"
|
|
91
|
+
} with existing user token: ${this.userToken.substring(0, 10)}...`
|
|
92
|
+
);
|
|
93
|
+
return this.userToken;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class SsoTokenStrategy implements AuthorizationStrategy {
|
|
98
|
+
private currentToken: string | undefined;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
private samlToken: string,
|
|
102
|
+
private realm: string,
|
|
103
|
+
private fetchApi: typeof fetch,
|
|
104
|
+
private debug: boolean = false
|
|
105
|
+
) {}
|
|
106
|
+
|
|
107
|
+
async getToken(_dbid: string): Promise<string | undefined> {
|
|
108
|
+
if (!this.currentToken) {
|
|
109
|
+
this.currentToken = await this.fetchSsoToken({
|
|
110
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
111
|
+
requested_token_type:
|
|
112
|
+
"urn:quickbase:params:oauth:token-type:temp_token",
|
|
113
|
+
subject_token: this.samlToken,
|
|
114
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return this.currentToken;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
applyHeaders(headers: Record<string, string>, token: string): void {
|
|
121
|
+
headers["Authorization"] = `QB-TEMP-TOKEN ${token}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async handleError(
|
|
125
|
+
status: number,
|
|
126
|
+
_params: any,
|
|
127
|
+
_fetchTempToken: (dbid: string) => Promise<string>,
|
|
128
|
+
attempt: number,
|
|
129
|
+
maxAttempts: number,
|
|
130
|
+
debug?: boolean,
|
|
131
|
+
methodName?: string
|
|
132
|
+
): Promise<string | null> {
|
|
133
|
+
if (status !== 401 || attempt >= maxAttempts - 1) return null;
|
|
134
|
+
|
|
135
|
+
if (debug || this.debug) {
|
|
136
|
+
console.log(
|
|
137
|
+
`Authorization error for ${
|
|
138
|
+
methodName || "method"
|
|
139
|
+
} (SSO), refreshing token`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const newToken = await this.refreshSsoToken(debug || this.debug);
|
|
144
|
+
if (newToken) {
|
|
145
|
+
this.currentToken = newToken;
|
|
146
|
+
return newToken;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async refreshSsoToken(debug: boolean = false): Promise<string> {
|
|
152
|
+
const payload = {
|
|
153
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
154
|
+
requested_token_type: "urn:quickbase:params:oauth:token-type:temp_token",
|
|
155
|
+
subject_token: this.samlToken,
|
|
156
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const response = await this.fetchApi(
|
|
160
|
+
`https://api.quickbase.com/v1/auth/oauth/token`,
|
|
161
|
+
{
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: {
|
|
164
|
+
"QB-Realm-Hostname": `${this.realm}.quickbase.com`,
|
|
165
|
+
"Content-Type": "application/json",
|
|
166
|
+
},
|
|
167
|
+
body: JSON.stringify(payload),
|
|
168
|
+
credentials: "omit",
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
174
|
+
if (debug) {
|
|
175
|
+
console.log(`[SSO Refresh] Failed: ${response.status}`, errorBody);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(
|
|
178
|
+
`SSO token refresh failed: ${errorBody.message || "Unknown error"}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = await response.json();
|
|
183
|
+
const newToken = result.access_token;
|
|
184
|
+
if (!newToken) {
|
|
185
|
+
throw new Error("No access token returned from SSO token exchange");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (debug) {
|
|
189
|
+
console.log(`[SSO Refresh] New token: ${newToken.substring(0, 10)}...`);
|
|
190
|
+
}
|
|
191
|
+
return newToken;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async fetchSsoToken(params: {
|
|
195
|
+
grant_type: string;
|
|
196
|
+
requested_token_type: string;
|
|
197
|
+
subject_token: string;
|
|
198
|
+
subject_token_type: string;
|
|
199
|
+
}): Promise<string> {
|
|
200
|
+
const response = await this.fetchApi(
|
|
201
|
+
`https://api.quickbase.com/v1/auth/oauth/token`,
|
|
202
|
+
{
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: {
|
|
205
|
+
"QB-Realm-Hostname": `${this.realm}.quickbase.com`,
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
},
|
|
208
|
+
body: JSON.stringify(params),
|
|
209
|
+
credentials: "omit",
|
|
210
|
+
}
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const errorBody = await response.json().catch(() => ({}));
|
|
215
|
+
if (this.debug) {
|
|
216
|
+
console.log(`[fetchSsoToken] Failed: ${response.status}`, errorBody);
|
|
217
|
+
}
|
|
218
|
+
throw new Error(
|
|
219
|
+
`SSO token fetch failed: ${errorBody.message || "Unknown error"}`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const result = await response.json();
|
|
224
|
+
const newToken = result.access_token;
|
|
225
|
+
if (!newToken) {
|
|
226
|
+
throw new Error("No access token returned from SSO token exchange");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (this.debug) {
|
|
230
|
+
console.log(
|
|
231
|
+
`[fetchSsoToken] Fetched token: ${newToken.substring(0, 10)}...`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return newToken;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function extractDbid(params: any): string | undefined {
|
|
239
|
+
return (
|
|
240
|
+
params.dbid ||
|
|
241
|
+
params.tableId ||
|
|
242
|
+
params.appId ||
|
|
243
|
+
params.body?.from ||
|
|
244
|
+
params.body?.to
|
|
245
|
+
);
|
|
246
|
+
}
|