@opengeni/api-router 0.26.1 → 0.30.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/dist/app.d.ts +1 -0
- package/dist/app.js +5 -3
- package/dist/auth/canonical-human-session-admission.d.ts +27 -0
- package/dist/browser-auth-broker.d.ts +20 -0
- package/dist/browser-controller-authority.d.ts +19 -1
- package/dist/browser-network-route.d.ts +8 -0
- package/dist/{chunk-JIKNR5YL.js → chunk-2PQMSRCB.js} +15518 -6863
- package/dist/chunk-2PQMSRCB.js.map +1 -0
- package/dist/controller-data-plane.d.ts +12 -0
- package/dist/http/auth.d.ts +1 -1
- package/dist/http/sse.d.ts +18 -1
- package/dist/index.js +1 -1
- package/dist/integrations/api-integrations.d.ts +2 -2
- package/dist/integrations/fiken.d.ts +233 -0
- package/dist/integrations/google-drive.d.ts +6 -6
- package/dist/integrations/provider-oauth.d.ts +0 -2
- package/dist/integrations/slack-bot.d.ts +171 -1
- package/dist/integrations/slack-interactions.d.ts +17 -5
- package/dist/interaction-holder-heartbeat.d.ts +17 -0
- package/dist/interaction-metrics.d.ts +11 -0
- package/dist/mcp/server.d.ts +3 -2
- package/dist/model-catalog.d.ts +1 -0
- package/dist/routes/canonical-human-identities.d.ts +3 -0
- package/dist/routes/channels.d.ts +3 -0
- package/dist/routes/integration-facets.d.ts +3 -0
- package/dist/routes/interaction-resources.d.ts +5 -0
- package/dist/routes/organization-memberships.d.ts +3 -0
- package/dist/routes/sessions.d.ts +2 -1
- package/dist/routes/slack-task-policy.d.ts +3 -0
- package/dist/routes/supergrok.d.ts +3 -0
- package/dist/sandbox/channel-a.d.ts +8 -1
- package/dist/sandbox/machines.d.ts +2 -2
- package/dist/sandbox/viewer.d.ts +3 -1
- package/dist/slack-reaction-files.d.ts +27 -0
- package/dist/transcription/providers/xai-subscription.d.ts +8 -0
- package/dist/xai-realtime.d.ts +26 -0
- package/dist/xai-subscription-auth.d.ts +23 -0
- package/package.json +17 -16
- package/src/app.ts +352 -49
- package/src/auth/canonical-human-session-admission.ts +116 -0
- package/src/auth/managed-auth.ts +101 -0
- package/src/browser-auth-broker.ts +155 -0
- package/src/browser-controller-authority.ts +50 -1
- package/src/browser-network-route.ts +34 -0
- package/src/controller-data-plane.ts +35 -0
- package/src/http/auth.ts +27 -9
- package/src/http/sse.ts +229 -5
- package/src/integrations/api-integrations.ts +57 -42
- package/src/integrations/fiken.ts +1230 -0
- package/src/integrations/google-drive.ts +31 -26
- package/src/integrations/provider-oauth.ts +70 -91
- package/src/integrations/slack-bot.ts +638 -9
- package/src/integrations/slack-interactions.ts +1583 -73
- package/src/interaction-holder-heartbeat.ts +95 -0
- package/src/interaction-metrics.ts +159 -0
- package/src/mcp/documents.ts +115 -6
- package/src/mcp/server.ts +865 -242
- package/src/model-catalog.ts +19 -6
- package/src/routes/api-integrations.ts +32 -29
- package/src/routes/browser-identities.ts +24 -0
- package/src/routes/browser-sessions.ts +2122 -183
- package/src/routes/canonical-human-identities.ts +156 -0
- package/src/routes/channels.ts +90 -0
- package/src/routes/computer-sessions.ts +441 -86
- package/src/routes/connections.ts +141 -4
- package/src/routes/{integration-features.ts → integration-facets.ts} +63 -63
- package/src/routes/interaction-resources.ts +595 -0
- package/src/routes/organization-memberships.ts +53 -0
- package/src/routes/plugins.ts +2 -1
- package/src/routes/sessions.ts +419 -43
- package/src/routes/skills.ts +92 -10
- package/src/routes/slack-task-policy.ts +115 -0
- package/src/routes/supergrok.ts +717 -0
- package/src/routes/transcription-recordings.ts +9 -2
- package/src/routes/transcriptions.ts +2 -1
- package/src/routes/video-generation.ts +34 -4
- package/src/routes/workspaces.ts +22 -4
- package/src/sandbox/channel-a.ts +29 -2
- package/src/sandbox/machines.ts +5 -5
- package/src/sandbox/viewer.ts +10 -3
- package/src/slack-reaction-files.ts +181 -0
- package/src/transcription/providers/xai-subscription.ts +110 -0
- package/src/transcription/service.ts +17 -2
- package/src/xai-realtime.ts +216 -0
- package/src/xai-subscription-auth.ts +132 -0
- package/dist/chunk-JIKNR5YL.js.map +0 -1
- package/dist/routes/integration-features.d.ts +0 -3
|
@@ -0,0 +1,1230 @@
|
|
|
1
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
2
|
+
import type { Settings } from "@opengeni/config";
|
|
3
|
+
import {
|
|
4
|
+
FIKEN_CREDENTIAL_LABEL,
|
|
5
|
+
FIKEN_CREDENTIAL_ROLE,
|
|
6
|
+
FIKEN_PROVIDER_DOMAIN,
|
|
7
|
+
FikenOAuthStartResponse,
|
|
8
|
+
type AccessGrant,
|
|
9
|
+
type ConnectionMetadata,
|
|
10
|
+
type FikenCompanySummary,
|
|
11
|
+
type FikenConnectionMetadata,
|
|
12
|
+
type FikenOAuthStartRequest,
|
|
13
|
+
} from "@opengeni/contracts";
|
|
14
|
+
import {
|
|
15
|
+
fikenConnectionMetadata,
|
|
16
|
+
hasPermission,
|
|
17
|
+
isFikenConnection,
|
|
18
|
+
preferredFikenConnection,
|
|
19
|
+
requireEnvironmentEncryption,
|
|
20
|
+
resolveFikenDefaultCompanySlug,
|
|
21
|
+
} from "@opengeni/core";
|
|
22
|
+
import {
|
|
23
|
+
buildConnectionTokenResolver,
|
|
24
|
+
consumeIntegrationOAuthStateNonce,
|
|
25
|
+
createConnection,
|
|
26
|
+
encryptEnvironmentValue,
|
|
27
|
+
getConnectionMetadata,
|
|
28
|
+
getWorkspaceGrant,
|
|
29
|
+
listConnectionsMetadata,
|
|
30
|
+
recordAuditEvent,
|
|
31
|
+
setConnectionStatus,
|
|
32
|
+
updateConnection,
|
|
33
|
+
type Database,
|
|
34
|
+
} from "@opengeni/db";
|
|
35
|
+
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
36
|
+
import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
|
|
37
|
+
import { HTTPException } from "hono/http-exception";
|
|
38
|
+
import {
|
|
39
|
+
integrationBaseUrl,
|
|
40
|
+
oauthStateTtlMs,
|
|
41
|
+
requireIntegrationsStateSecret,
|
|
42
|
+
} from "./oauth-client";
|
|
43
|
+
|
|
44
|
+
const FIKEN_API_BASE = "https://api.fiken.no/api/v2";
|
|
45
|
+
const FIKEN_TIMEOUT_MS = 15_000;
|
|
46
|
+
const FIKEN_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
47
|
+
const FIKEN_ERROR_BODY_MAX_BYTES = 64 * 1024;
|
|
48
|
+
const FIKEN_PROVIDER_MESSAGE_MAX_CHARS = 500;
|
|
49
|
+
const MAX_PAGE_SIZE = 100;
|
|
50
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
51
|
+
const MAX_VERIFIED_COMPANIES = 100;
|
|
52
|
+
const MAX_DRAFT_LINES = 100;
|
|
53
|
+
|
|
54
|
+
// Fiken company slugs as issued by Fiken: lowercase alphanumerics and dashes.
|
|
55
|
+
const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
56
|
+
|
|
57
|
+
export type FikenOperation =
|
|
58
|
+
| "companies.list"
|
|
59
|
+
| "contacts.list"
|
|
60
|
+
| "contact.create"
|
|
61
|
+
| "products.list"
|
|
62
|
+
| "invoices.list"
|
|
63
|
+
| "invoice.get"
|
|
64
|
+
| "invoice_draft.create"
|
|
65
|
+
| "bank_accounts.list"
|
|
66
|
+
| "purchases.list"
|
|
67
|
+
| "sales.list";
|
|
68
|
+
|
|
69
|
+
export class FikenProviderError extends Error {
|
|
70
|
+
constructor(
|
|
71
|
+
readonly code: string,
|
|
72
|
+
readonly status: number | null = null,
|
|
73
|
+
providerMessage: string | null = null,
|
|
74
|
+
) {
|
|
75
|
+
super(
|
|
76
|
+
providerMessage
|
|
77
|
+
? `Fiken request failed: ${code}: ${providerMessage}`
|
|
78
|
+
: `Fiken request failed: ${code}`,
|
|
79
|
+
);
|
|
80
|
+
this.name = "FikenProviderError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class FikenCredentialVerificationError extends HTTPException {
|
|
85
|
+
constructor(message: string) {
|
|
86
|
+
super(422, { message });
|
|
87
|
+
this.name = "FikenCredentialVerificationError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type FikenPage = {
|
|
92
|
+
page: number;
|
|
93
|
+
pageSize: number;
|
|
94
|
+
pageCount: number | null;
|
|
95
|
+
resultCount: number | null;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type FikenContext = {
|
|
99
|
+
accountId: string;
|
|
100
|
+
workspaceId: string;
|
|
101
|
+
subjectId: string | null;
|
|
102
|
+
sessionId: string | null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fiken allows only a single concurrent API request per credential; concurrent
|
|
107
|
+
* requests can 429 and repeated violations can get the token banned. Serialize
|
|
108
|
+
* every provider call through a per-connection promise chain in this process.
|
|
109
|
+
*/
|
|
110
|
+
const connectionRequestChains = new Map<string, Promise<unknown>>();
|
|
111
|
+
|
|
112
|
+
async function serializedPerConnection<T>(connectionId: string, run: () => Promise<T>): Promise<T> {
|
|
113
|
+
const previous = connectionRequestChains.get(connectionId) ?? Promise.resolve();
|
|
114
|
+
const next = previous.catch(() => undefined).then(run);
|
|
115
|
+
connectionRequestChains.set(connectionId, next);
|
|
116
|
+
void next
|
|
117
|
+
.catch(() => undefined)
|
|
118
|
+
.finally(() => {
|
|
119
|
+
if (connectionRequestChains.get(connectionId) === next) {
|
|
120
|
+
connectionRequestChains.delete(connectionId);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return await next;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validates a pasted Fiken personal API token before it enters encrypted
|
|
128
|
+
* storage, and discovers the companies it can act on. The companies list is
|
|
129
|
+
* the connection's bounded metadata; company slugs are re-validated by Fiken
|
|
130
|
+
* itself on every later call.
|
|
131
|
+
*/
|
|
132
|
+
export async function verifyFikenApiToken(
|
|
133
|
+
token: string,
|
|
134
|
+
fetchImpl: FetchLike = fetch,
|
|
135
|
+
): Promise<{ companies: FikenCompanySummary[] }> {
|
|
136
|
+
const result = await fetchFikenCompanies(token, fetchImpl);
|
|
137
|
+
switch (result.outcome) {
|
|
138
|
+
case "ok":
|
|
139
|
+
return { companies: result.companies };
|
|
140
|
+
case "unreachable":
|
|
141
|
+
throw new FikenCredentialVerificationError("Fiken could not be reached to verify the token");
|
|
142
|
+
case "rejected":
|
|
143
|
+
throw new FikenCredentialVerificationError(
|
|
144
|
+
"Fiken rejected the API token. Create a personal API token under Rediger konto -> API in Fiken and make sure API module access is active.",
|
|
145
|
+
);
|
|
146
|
+
case "http_error":
|
|
147
|
+
throw new FikenCredentialVerificationError(
|
|
148
|
+
`Fiken token verification failed with HTTP ${result.status}`,
|
|
149
|
+
);
|
|
150
|
+
case "invalid_response":
|
|
151
|
+
throw new FikenCredentialVerificationError("Fiken returned an unexpected companies response");
|
|
152
|
+
case "no_companies":
|
|
153
|
+
throw new FikenCredentialVerificationError(
|
|
154
|
+
"The Fiken token is valid but has API access to no company. Order API module access in Fiken first.",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type FikenCompaniesResult =
|
|
160
|
+
| { outcome: "ok"; companies: FikenCompanySummary[] }
|
|
161
|
+
| { outcome: "unreachable" }
|
|
162
|
+
| { outcome: "rejected" }
|
|
163
|
+
| { outcome: "http_error"; status: number }
|
|
164
|
+
| { outcome: "invalid_response" }
|
|
165
|
+
| { outcome: "no_companies" };
|
|
166
|
+
|
|
167
|
+
/** Bounded discovery of the companies a bearer credential can act on. */
|
|
168
|
+
async function fetchFikenCompanies(
|
|
169
|
+
bearerToken: string,
|
|
170
|
+
fetchImpl: FetchLike,
|
|
171
|
+
): Promise<FikenCompaniesResult> {
|
|
172
|
+
let response: Response;
|
|
173
|
+
try {
|
|
174
|
+
response = await fetchImpl(`${FIKEN_API_BASE}/companies?pageSize=${MAX_PAGE_SIZE}`, {
|
|
175
|
+
method: "GET",
|
|
176
|
+
headers: { authorization: `Bearer ${bearerToken}`, accept: "application/json" },
|
|
177
|
+
redirect: "error",
|
|
178
|
+
signal: AbortSignal.timeout(FIKEN_TIMEOUT_MS),
|
|
179
|
+
});
|
|
180
|
+
} catch {
|
|
181
|
+
return { outcome: "unreachable" };
|
|
182
|
+
}
|
|
183
|
+
if (response.status === 401 || response.status === 403) {
|
|
184
|
+
await response.body?.cancel().catch(() => undefined);
|
|
185
|
+
return { outcome: "rejected" };
|
|
186
|
+
}
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
await response.body?.cancel().catch(() => undefined);
|
|
189
|
+
return { outcome: "http_error", status: response.status };
|
|
190
|
+
}
|
|
191
|
+
let payload: unknown;
|
|
192
|
+
try {
|
|
193
|
+
payload = await readResponseJsonBounded<unknown>(
|
|
194
|
+
response,
|
|
195
|
+
FIKEN_RESPONSE_MAX_BYTES,
|
|
196
|
+
"Fiken companies response",
|
|
197
|
+
);
|
|
198
|
+
} catch {
|
|
199
|
+
return { outcome: "invalid_response" };
|
|
200
|
+
}
|
|
201
|
+
if (!Array.isArray(payload)) {
|
|
202
|
+
return { outcome: "invalid_response" };
|
|
203
|
+
}
|
|
204
|
+
const companies: FikenCompanySummary[] = [];
|
|
205
|
+
for (const entry of payload.slice(0, MAX_VERIFIED_COMPANIES)) {
|
|
206
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
207
|
+
const record = entry as Record<string, unknown>;
|
|
208
|
+
const slug = typeof record.slug === "string" ? record.slug : null;
|
|
209
|
+
if (!slug || !COMPANY_SLUG_PATTERN.test(slug)) continue;
|
|
210
|
+
companies.push({
|
|
211
|
+
slug,
|
|
212
|
+
name: typeof record.name === "string" && record.name.length > 0 ? record.name : slug,
|
|
213
|
+
organizationNumber:
|
|
214
|
+
typeof record.organizationNumber === "string" ? record.organizationNumber : null,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (companies.length === 0) {
|
|
218
|
+
return { outcome: "no_companies" };
|
|
219
|
+
}
|
|
220
|
+
return { outcome: "ok", companies };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function fikenCredentialBundle(token: string): Record<string, unknown> {
|
|
224
|
+
// The api_key broker shape: headers injected verbatim on provider requests.
|
|
225
|
+
return { headers: { authorization: `Bearer ${token}` } };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function resolveFikenConnectionForTool(input: {
|
|
229
|
+
db: Database;
|
|
230
|
+
grant: AccessGrant;
|
|
231
|
+
sessionId: string | null;
|
|
232
|
+
requestedConnectionId?: string;
|
|
233
|
+
}): Promise<{
|
|
234
|
+
connection: ConnectionMetadata;
|
|
235
|
+
metadata: FikenConnectionMetadata;
|
|
236
|
+
context: FikenContext;
|
|
237
|
+
}> {
|
|
238
|
+
const connection = input.requestedConnectionId
|
|
239
|
+
? await getConnectionMetadata(
|
|
240
|
+
input.db,
|
|
241
|
+
input.grant.workspaceId,
|
|
242
|
+
input.requestedConnectionId,
|
|
243
|
+
null,
|
|
244
|
+
)
|
|
245
|
+
: preferredFikenConnection(
|
|
246
|
+
(await listConnectionsMetadata(input.db, input.grant.workspaceId, null)).filter(
|
|
247
|
+
isFikenConnection,
|
|
248
|
+
),
|
|
249
|
+
);
|
|
250
|
+
if (!connection || !isFikenConnection(connection)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
"no Fiken connection is available in this workspace; connect Fiken from the Capabilities page first",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (connection.status !== "active") {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`the Fiken connection is ${connection.status}; reconnect it with a fresh API token from the Capabilities page`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const metadata = fikenConnectionMetadata(connection.metadata);
|
|
261
|
+
if (!metadata) {
|
|
262
|
+
throw new Error("Fiken connection metadata is invalid");
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
connection,
|
|
266
|
+
metadata,
|
|
267
|
+
context: {
|
|
268
|
+
accountId: input.grant.accountId,
|
|
269
|
+
workspaceId: input.grant.workspaceId,
|
|
270
|
+
subjectId: input.grant.subjectId,
|
|
271
|
+
sessionId: input.sessionId,
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export type FikenListInput = {
|
|
277
|
+
companySlug?: string | undefined;
|
|
278
|
+
page?: number | undefined;
|
|
279
|
+
pageSize?: number | undefined;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export class FikenClient {
|
|
283
|
+
private readonly resolveCredential: ReturnType<typeof buildConnectionTokenResolver>;
|
|
284
|
+
|
|
285
|
+
constructor(
|
|
286
|
+
private readonly db: Database,
|
|
287
|
+
private readonly settings: Settings,
|
|
288
|
+
private readonly connection: ConnectionMetadata,
|
|
289
|
+
private readonly metadata: FikenConnectionMetadata,
|
|
290
|
+
private readonly context: FikenContext,
|
|
291
|
+
private readonly fetchImpl: FetchLike = fetch,
|
|
292
|
+
) {
|
|
293
|
+
// OAuth-kind connections refresh through the generic broker; route that
|
|
294
|
+
// token-endpoint transport through the same injectable Fiken fetch.
|
|
295
|
+
this.resolveCredential = buildConnectionTokenResolver(db, settings, undefined, {
|
|
296
|
+
refreshTransport: { fetchImpl },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async listCompanies(input: { page?: number; pageSize?: number } = {}) {
|
|
301
|
+
const { payload, page } = await this.request("companies.list", "GET", "/companies", {
|
|
302
|
+
query: pageQuery(input),
|
|
303
|
+
});
|
|
304
|
+
return { companies: projectArray(payload), page };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async listContacts(
|
|
308
|
+
input: FikenListInput & {
|
|
309
|
+
name?: string | undefined;
|
|
310
|
+
email?: string | undefined;
|
|
311
|
+
organizationNumber?: string | undefined;
|
|
312
|
+
customer?: boolean | undefined;
|
|
313
|
+
supplier?: boolean | undefined;
|
|
314
|
+
inactive?: boolean | undefined;
|
|
315
|
+
},
|
|
316
|
+
) {
|
|
317
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
318
|
+
const { payload, page } = await this.request(
|
|
319
|
+
"contacts.list",
|
|
320
|
+
"GET",
|
|
321
|
+
`/companies/${slug}/contacts`,
|
|
322
|
+
{
|
|
323
|
+
query: {
|
|
324
|
+
...pageQuery(input),
|
|
325
|
+
...(input.name ? { name: input.name } : {}),
|
|
326
|
+
...(input.email ? { email: input.email } : {}),
|
|
327
|
+
...(input.organizationNumber ? { organizationNumber: input.organizationNumber } : {}),
|
|
328
|
+
...(input.customer !== undefined ? { customer: String(input.customer) } : {}),
|
|
329
|
+
...(input.supplier !== undefined ? { supplier: String(input.supplier) } : {}),
|
|
330
|
+
...(input.inactive !== undefined ? { inactive: String(input.inactive) } : {}),
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
);
|
|
334
|
+
return { companySlug: slug, contacts: projectArray(payload), page };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async createContact(input: {
|
|
338
|
+
companySlug?: string | undefined;
|
|
339
|
+
name: string;
|
|
340
|
+
email?: string | undefined;
|
|
341
|
+
organizationNumber?: string | undefined;
|
|
342
|
+
phoneNumber?: string | undefined;
|
|
343
|
+
customer?: boolean | undefined;
|
|
344
|
+
supplier?: boolean | undefined;
|
|
345
|
+
}) {
|
|
346
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
347
|
+
const { locationId } = await this.request(
|
|
348
|
+
"contact.create",
|
|
349
|
+
"POST",
|
|
350
|
+
`/companies/${slug}/contacts`,
|
|
351
|
+
{
|
|
352
|
+
body: {
|
|
353
|
+
name: input.name,
|
|
354
|
+
...(input.email ? { email: input.email } : {}),
|
|
355
|
+
...(input.organizationNumber ? { organizationNumber: input.organizationNumber } : {}),
|
|
356
|
+
...(input.phoneNumber ? { phoneNumber: input.phoneNumber } : {}),
|
|
357
|
+
...(input.customer !== undefined ? { customer: input.customer } : {}),
|
|
358
|
+
...(input.supplier !== undefined ? { supplier: input.supplier } : {}),
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
);
|
|
362
|
+
if (locationId === null) {
|
|
363
|
+
// The contact may have been created; without an id the caller cannot
|
|
364
|
+
// reference it, and silently returning success would invite a duplicate
|
|
365
|
+
// retry. Surface the off-contract response instead.
|
|
366
|
+
throw new FikenProviderError("invalid_response", null, "created contact returned no id");
|
|
367
|
+
}
|
|
368
|
+
return { companySlug: slug, contactId: locationId, receipt: this.receipt("contact.create") };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async listProducts(
|
|
372
|
+
input: FikenListInput & { name?: string | undefined; active?: boolean | undefined },
|
|
373
|
+
) {
|
|
374
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
375
|
+
const { payload, page } = await this.request(
|
|
376
|
+
"products.list",
|
|
377
|
+
"GET",
|
|
378
|
+
`/companies/${slug}/products`,
|
|
379
|
+
{
|
|
380
|
+
query: {
|
|
381
|
+
...pageQuery(input),
|
|
382
|
+
...(input.name ? { name: input.name } : {}),
|
|
383
|
+
...(input.active !== undefined ? { active: String(input.active) } : {}),
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
);
|
|
387
|
+
return { companySlug: slug, products: projectArray(payload), page };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async listInvoices(
|
|
391
|
+
input: FikenListInput & {
|
|
392
|
+
issueDateGe?: string | undefined;
|
|
393
|
+
issueDateLe?: string | undefined;
|
|
394
|
+
customerId?: number | undefined;
|
|
395
|
+
settled?: boolean | undefined;
|
|
396
|
+
invoiceNumber?: string | undefined;
|
|
397
|
+
},
|
|
398
|
+
) {
|
|
399
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
400
|
+
const { payload, page } = await this.request(
|
|
401
|
+
"invoices.list",
|
|
402
|
+
"GET",
|
|
403
|
+
`/companies/${slug}/invoices`,
|
|
404
|
+
{
|
|
405
|
+
query: {
|
|
406
|
+
...pageQuery(input),
|
|
407
|
+
...(input.issueDateGe ? { issueDateGe: input.issueDateGe } : {}),
|
|
408
|
+
...(input.issueDateLe ? { issueDateLe: input.issueDateLe } : {}),
|
|
409
|
+
...(input.customerId !== undefined ? { customerId: String(input.customerId) } : {}),
|
|
410
|
+
...(input.settled !== undefined ? { settled: String(input.settled) } : {}),
|
|
411
|
+
...(input.invoiceNumber ? { invoiceNumber: input.invoiceNumber } : {}),
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
);
|
|
415
|
+
return { companySlug: slug, invoices: projectArray(payload), page };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async getInvoice(input: { companySlug?: string | undefined; invoiceId: number }) {
|
|
419
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
420
|
+
const { payload } = await this.request(
|
|
421
|
+
"invoice.get",
|
|
422
|
+
"GET",
|
|
423
|
+
`/companies/${slug}/invoices/${encodeURIComponent(String(input.invoiceId))}`,
|
|
424
|
+
{},
|
|
425
|
+
);
|
|
426
|
+
return { companySlug: slug, invoice: projectRecord(payload) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async createInvoiceDraft(input: {
|
|
430
|
+
companySlug?: string | undefined;
|
|
431
|
+
operationId: string;
|
|
432
|
+
customerId: number;
|
|
433
|
+
daysUntilDueDate: number;
|
|
434
|
+
invoiceText?: string | undefined;
|
|
435
|
+
yourReference?: string | undefined;
|
|
436
|
+
ourReference?: string | undefined;
|
|
437
|
+
currency?: string | undefined;
|
|
438
|
+
bankAccountNumber?: string | undefined;
|
|
439
|
+
lines: Array<{
|
|
440
|
+
description?: string | undefined;
|
|
441
|
+
productId?: number | undefined;
|
|
442
|
+
unitPriceCents?: number | undefined;
|
|
443
|
+
vatType?: string | undefined;
|
|
444
|
+
quantity: number;
|
|
445
|
+
discountPercent?: number | undefined;
|
|
446
|
+
incomeAccount?: string | undefined;
|
|
447
|
+
comment?: string | undefined;
|
|
448
|
+
}>;
|
|
449
|
+
}) {
|
|
450
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
451
|
+
if (input.lines.length === 0 || input.lines.length > MAX_DRAFT_LINES) {
|
|
452
|
+
throw new FikenProviderError("invalid_lines");
|
|
453
|
+
}
|
|
454
|
+
for (const line of input.lines) {
|
|
455
|
+
// Fiken requires an income account (or a product carrying one) per line;
|
|
456
|
+
// reject locally with a clear code instead of a post-write provider 400.
|
|
457
|
+
if (line.productId === undefined && !(line.incomeAccount && line.vatType)) {
|
|
458
|
+
throw new FikenProviderError(
|
|
459
|
+
"invalid_lines",
|
|
460
|
+
null,
|
|
461
|
+
"each line needs productId or incomeAccount + vatType",
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Fiken drafts carry a caller-chosen uuid: reusing the operationId as that
|
|
466
|
+
// uuid makes retries observable. A retry first looks the draft up and
|
|
467
|
+
// returns the existing row instead of creating a duplicate. The client
|
|
468
|
+
// re-filters on uuid so a provider that ignored the filter cannot make an
|
|
469
|
+
// unrelated draft pass as ours.
|
|
470
|
+
const existing = await this.request(
|
|
471
|
+
"invoice_draft.create",
|
|
472
|
+
"GET",
|
|
473
|
+
`/companies/${slug}/invoices/drafts`,
|
|
474
|
+
{ query: { uuid: input.operationId } },
|
|
475
|
+
);
|
|
476
|
+
const existingDraft = projectArray(existing.payload).find(
|
|
477
|
+
(draft) => draft.uuid === input.operationId,
|
|
478
|
+
);
|
|
479
|
+
if (existingDraft) {
|
|
480
|
+
return {
|
|
481
|
+
companySlug: slug,
|
|
482
|
+
draftId: typeof existingDraft.draftId === "number" ? existingDraft.draftId : null,
|
|
483
|
+
draft: existingDraft,
|
|
484
|
+
alreadyExisted: true,
|
|
485
|
+
receipt: this.receipt("invoice_draft.create", input.operationId),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const { locationId } = await this.request(
|
|
489
|
+
"invoice_draft.create",
|
|
490
|
+
"POST",
|
|
491
|
+
`/companies/${slug}/invoices/drafts`,
|
|
492
|
+
{
|
|
493
|
+
body: {
|
|
494
|
+
type: "invoice",
|
|
495
|
+
uuid: input.operationId,
|
|
496
|
+
customerId: input.customerId,
|
|
497
|
+
daysUntilDueDate: input.daysUntilDueDate,
|
|
498
|
+
...(input.invoiceText ? { invoiceText: input.invoiceText } : {}),
|
|
499
|
+
...(input.yourReference ? { yourReference: input.yourReference } : {}),
|
|
500
|
+
...(input.ourReference ? { ourReference: input.ourReference } : {}),
|
|
501
|
+
...(input.currency ? { currency: input.currency } : {}),
|
|
502
|
+
...(input.bankAccountNumber ? { bankAccountNumber: input.bankAccountNumber } : {}),
|
|
503
|
+
lines: input.lines.map((line) => ({
|
|
504
|
+
...(line.description ? { description: line.description } : {}),
|
|
505
|
+
...(line.productId !== undefined ? { productId: line.productId } : {}),
|
|
506
|
+
...(line.unitPriceCents !== undefined ? { unitPrice: line.unitPriceCents } : {}),
|
|
507
|
+
...(line.vatType ? { vatType: line.vatType } : {}),
|
|
508
|
+
quantity: line.quantity,
|
|
509
|
+
...(line.discountPercent !== undefined ? { discount: line.discountPercent } : {}),
|
|
510
|
+
...(line.incomeAccount ? { incomeAccount: line.incomeAccount } : {}),
|
|
511
|
+
...(line.comment ? { comment: line.comment } : {}),
|
|
512
|
+
})),
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
);
|
|
516
|
+
if (locationId === null) {
|
|
517
|
+
// A 201 without a Location id is off-contract; the caller can retry with
|
|
518
|
+
// the same operationId and recover through the uuid lookup.
|
|
519
|
+
throw new FikenProviderError("invalid_response", null, "created draft returned no id");
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
companySlug: slug,
|
|
523
|
+
draftId: locationId,
|
|
524
|
+
alreadyExisted: false,
|
|
525
|
+
receipt: this.receipt("invoice_draft.create", input.operationId),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async listBankAccounts(input: FikenListInput & { inactive?: boolean | undefined }) {
|
|
530
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
531
|
+
const { payload, page } = await this.request(
|
|
532
|
+
"bank_accounts.list",
|
|
533
|
+
"GET",
|
|
534
|
+
`/companies/${slug}/bankAccounts`,
|
|
535
|
+
{
|
|
536
|
+
query: {
|
|
537
|
+
...pageQuery(input),
|
|
538
|
+
...(input.inactive !== undefined ? { inactive: String(input.inactive) } : {}),
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
return { companySlug: slug, bankAccounts: projectArray(payload), page };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async listPurchases(
|
|
546
|
+
input: FikenListInput & {
|
|
547
|
+
dateGe?: string | undefined;
|
|
548
|
+
dateLe?: string | undefined;
|
|
549
|
+
paid?: boolean | undefined;
|
|
550
|
+
},
|
|
551
|
+
) {
|
|
552
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
553
|
+
const { payload, page } = await this.request(
|
|
554
|
+
"purchases.list",
|
|
555
|
+
"GET",
|
|
556
|
+
`/companies/${slug}/purchases`,
|
|
557
|
+
{
|
|
558
|
+
query: {
|
|
559
|
+
...pageQuery(input),
|
|
560
|
+
...(input.dateGe ? { dateGe: input.dateGe } : {}),
|
|
561
|
+
...(input.dateLe ? { dateLe: input.dateLe } : {}),
|
|
562
|
+
...(input.paid !== undefined ? { paid: String(input.paid) } : {}),
|
|
563
|
+
},
|
|
564
|
+
},
|
|
565
|
+
);
|
|
566
|
+
return { companySlug: slug, purchases: projectArray(payload), page };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async listSales(
|
|
570
|
+
input: FikenListInput & {
|
|
571
|
+
dateGe?: string | undefined;
|
|
572
|
+
dateLe?: string | undefined;
|
|
573
|
+
settled?: boolean | undefined;
|
|
574
|
+
},
|
|
575
|
+
) {
|
|
576
|
+
const slug = this.companySlugFor(input.companySlug);
|
|
577
|
+
const { payload, page } = await this.request("sales.list", "GET", `/companies/${slug}/sales`, {
|
|
578
|
+
query: {
|
|
579
|
+
...pageQuery(input),
|
|
580
|
+
...(input.dateGe ? { dateGe: input.dateGe } : {}),
|
|
581
|
+
...(input.dateLe ? { dateLe: input.dateLe } : {}),
|
|
582
|
+
...(input.settled !== undefined ? { settled: String(input.settled) } : {}),
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
return { companySlug: slug, sales: projectArray(payload), page };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* The company scope for a call: an explicit slug wins, then the connection's
|
|
590
|
+
* configured default, then the only verified company. Ambiguity is an error
|
|
591
|
+
* that lists the available slugs instead of guessing.
|
|
592
|
+
*/
|
|
593
|
+
private companySlugFor(requested: string | undefined): string {
|
|
594
|
+
const slug =
|
|
595
|
+
requested ??
|
|
596
|
+
this.metadata.defaultCompanySlug ??
|
|
597
|
+
(this.metadata.companies.length === 1 ? this.metadata.companies[0]!.slug : null);
|
|
598
|
+
if (!slug) {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`companySlug is required because this Fiken connection can access several companies: ${this.metadata.companies
|
|
601
|
+
.map((company) => company.slug)
|
|
602
|
+
.join(", ")}`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
if (!COMPANY_SLUG_PATTERN.test(slug)) {
|
|
606
|
+
throw new Error("companySlug must be a lowercase Fiken company slug");
|
|
607
|
+
}
|
|
608
|
+
return slug;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private async request(
|
|
612
|
+
operation: FikenOperation,
|
|
613
|
+
method: "GET" | "POST",
|
|
614
|
+
path: string,
|
|
615
|
+
input: { query?: Record<string, string>; body?: unknown },
|
|
616
|
+
): Promise<{ payload: unknown; page: FikenPage | null; locationId: number | null }> {
|
|
617
|
+
try {
|
|
618
|
+
const url = new URL(`${FIKEN_API_BASE}${path}`);
|
|
619
|
+
for (const [key, value] of Object.entries(input.query ?? {})) {
|
|
620
|
+
url.searchParams.set(key, value);
|
|
621
|
+
}
|
|
622
|
+
// Credential resolution (DB read + possible broker refresh) happens
|
|
623
|
+
// outside the serialization chain: only the provider HTTP call itself
|
|
624
|
+
// must obey Fiken's single-concurrent-request rule.
|
|
625
|
+
const headers = await this.headersFor(operation, url.toString());
|
|
626
|
+
const result = await serializedPerConnection(this.connection.id, async () => {
|
|
627
|
+
let response: Response;
|
|
628
|
+
try {
|
|
629
|
+
response = await this.fetchImpl(url, {
|
|
630
|
+
method,
|
|
631
|
+
headers: {
|
|
632
|
+
...headers,
|
|
633
|
+
accept: "application/json",
|
|
634
|
+
...(input.body !== undefined ? { "content-type": "application/json" } : {}),
|
|
635
|
+
},
|
|
636
|
+
...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
|
|
637
|
+
// A redirect would replay the bearer to the redirect target; the
|
|
638
|
+
// Fiken API never legitimately redirects.
|
|
639
|
+
redirect: "error",
|
|
640
|
+
signal: AbortSignal.timeout(FIKEN_TIMEOUT_MS),
|
|
641
|
+
});
|
|
642
|
+
} catch {
|
|
643
|
+
throw new FikenProviderError("transport_error");
|
|
644
|
+
}
|
|
645
|
+
return await this.readResponse(operation, response);
|
|
646
|
+
});
|
|
647
|
+
await this.recordAudit(operation, "succeeded");
|
|
648
|
+
return result;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
await this.recordAudit(operation, "failed", safeFailureCode(error));
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private async readResponse(
|
|
656
|
+
operation: FikenOperation,
|
|
657
|
+
response: Response,
|
|
658
|
+
): Promise<{ payload: unknown; page: FikenPage | null; locationId: number | null }> {
|
|
659
|
+
if (response.status === 401) {
|
|
660
|
+
await response.body?.cancel().catch(() => undefined);
|
|
661
|
+
// A 401 after resolution means the credential is genuinely rejected
|
|
662
|
+
// (revoked token or revoked OAuth grant). setConnectionStatus is a
|
|
663
|
+
// strict version CAS and a broker refresh may have bumped the version
|
|
664
|
+
// since this client resolved the row, so re-read the current version
|
|
665
|
+
// instead of using the resolution-time snapshot.
|
|
666
|
+
await this.markNeedsReauth().catch(() => undefined);
|
|
667
|
+
throw new FikenProviderError("credential_rejected", 401);
|
|
668
|
+
}
|
|
669
|
+
if (response.status === 429) {
|
|
670
|
+
await response.body?.cancel().catch(() => undefined);
|
|
671
|
+
throw new FikenProviderError("rate_limited", 429);
|
|
672
|
+
}
|
|
673
|
+
if (!response.ok) {
|
|
674
|
+
throw new FikenProviderError(
|
|
675
|
+
`http_${response.status}`,
|
|
676
|
+
response.status,
|
|
677
|
+
await providerErrorMessage(response),
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const locationId = locationResourceId(response.headers.get("location"));
|
|
681
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
682
|
+
await response.body?.cancel().catch(() => undefined);
|
|
683
|
+
return { payload: null, page: null, locationId };
|
|
684
|
+
}
|
|
685
|
+
let payload: unknown = null;
|
|
686
|
+
try {
|
|
687
|
+
payload = await readResponseJsonBounded<unknown>(
|
|
688
|
+
response,
|
|
689
|
+
FIKEN_RESPONSE_MAX_BYTES,
|
|
690
|
+
`Fiken ${operation} response`,
|
|
691
|
+
);
|
|
692
|
+
} catch (error) {
|
|
693
|
+
// Creation endpoints answer 201 with a Location header and an empty
|
|
694
|
+
// body; only a parse failure with no Location is a provider error.
|
|
695
|
+
if (locationId === null) {
|
|
696
|
+
throw new FikenProviderError("invalid_response", response.status, safeFailureCode(error));
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return { payload, page: pageFromHeaders(response.headers), locationId };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private async markNeedsReauth(): Promise<void> {
|
|
703
|
+
const current = await getConnectionMetadata(
|
|
704
|
+
this.db,
|
|
705
|
+
this.context.workspaceId,
|
|
706
|
+
this.connection.id,
|
|
707
|
+
null,
|
|
708
|
+
);
|
|
709
|
+
if (!current || current.status === "needs_reauth") return;
|
|
710
|
+
await setConnectionStatus(
|
|
711
|
+
this.db,
|
|
712
|
+
this.context.workspaceId,
|
|
713
|
+
"needs_reauth",
|
|
714
|
+
"Fiken rejected the credential",
|
|
715
|
+
{ id: current.id, version: current.version, subjectId: null },
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private async headersFor(
|
|
720
|
+
operation: FikenOperation,
|
|
721
|
+
destinationUrl: string,
|
|
722
|
+
): Promise<Record<string, string>> {
|
|
723
|
+
const result = await this.resolveCredential({
|
|
724
|
+
workspaceId: this.context.workspaceId,
|
|
725
|
+
serverId: "opengeni-fiken",
|
|
726
|
+
toolName: `fiken_${operation.replaceAll(".", "_")}`,
|
|
727
|
+
connectionRef: {
|
|
728
|
+
connectionId: this.connection.id,
|
|
729
|
+
providerDomain: FIKEN_PROVIDER_DOMAIN,
|
|
730
|
+
kind: this.connection.kind === "oauth2" ? "oauth2" : "api_key",
|
|
731
|
+
subjectScope: "workspace",
|
|
732
|
+
},
|
|
733
|
+
destinationUrl,
|
|
734
|
+
});
|
|
735
|
+
if (result.status !== "ok" || result.connectionId !== this.connection.id) {
|
|
736
|
+
throw new Error("the Fiken connection needs to be reconnected");
|
|
737
|
+
}
|
|
738
|
+
return result.headers;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private receipt(operation: FikenOperation, operationId?: string) {
|
|
742
|
+
return {
|
|
743
|
+
credentialRole: FIKEN_CREDENTIAL_ROLE,
|
|
744
|
+
credentialLabel: FIKEN_CREDENTIAL_LABEL,
|
|
745
|
+
connectionId: this.connection.id,
|
|
746
|
+
operation,
|
|
747
|
+
...(operationId ? { operationId } : {}),
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private async recordAudit(
|
|
752
|
+
operation: FikenOperation,
|
|
753
|
+
outcome: "succeeded" | "failed",
|
|
754
|
+
failureCode?: string,
|
|
755
|
+
): Promise<void> {
|
|
756
|
+
await recordAuditEvent(this.db, {
|
|
757
|
+
accountId: this.context.accountId,
|
|
758
|
+
workspaceId: this.context.workspaceId,
|
|
759
|
+
subjectId: this.context.subjectId,
|
|
760
|
+
action: `fiken.${operation}`,
|
|
761
|
+
targetType: "connection",
|
|
762
|
+
targetId: this.connection.id,
|
|
763
|
+
metadata: {
|
|
764
|
+
...this.receipt(operation),
|
|
765
|
+
outcome,
|
|
766
|
+
...(failureCode ? { failureCode } : {}),
|
|
767
|
+
...(this.context.sessionId ? { sessionId: this.context.sessionId } : {}),
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export function createFikenClient(
|
|
774
|
+
deps: { db: Database; settings: Settings; fikenFetch?: typeof fetch },
|
|
775
|
+
resolved: Awaited<ReturnType<typeof resolveFikenConnectionForTool>>,
|
|
776
|
+
): FikenClient {
|
|
777
|
+
return new FikenClient(
|
|
778
|
+
deps.db,
|
|
779
|
+
deps.settings,
|
|
780
|
+
resolved.connection,
|
|
781
|
+
resolved.metadata,
|
|
782
|
+
resolved.context,
|
|
783
|
+
deps.fikenFetch,
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function pageQuery(input: {
|
|
788
|
+
page?: number | undefined;
|
|
789
|
+
pageSize?: number | undefined;
|
|
790
|
+
}): Record<string, string> {
|
|
791
|
+
return {
|
|
792
|
+
page: String(boundedInt(input.page, Number.MAX_SAFE_INTEGER, 0)),
|
|
793
|
+
pageSize: String(boundedInt(input.pageSize, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE)),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function boundedInt(value: number | undefined, max: number, fallback: number): number {
|
|
798
|
+
if (value === undefined || !Number.isInteger(value) || value < 0) return fallback;
|
|
799
|
+
return Math.min(value, max);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function pageFromHeaders(headers: Headers): FikenPage | null {
|
|
803
|
+
const page = headerInt(headers, "fiken-api-page");
|
|
804
|
+
const pageSize = headerInt(headers, "fiken-api-page-size");
|
|
805
|
+
if (page === null || pageSize === null) return null;
|
|
806
|
+
return {
|
|
807
|
+
page,
|
|
808
|
+
pageSize,
|
|
809
|
+
pageCount: headerInt(headers, "fiken-api-page-count"),
|
|
810
|
+
resultCount: headerInt(headers, "fiken-api-result-count"),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function headerInt(headers: Headers, name: string): number | null {
|
|
815
|
+
const raw = headers.get(name);
|
|
816
|
+
if (raw === null) return null;
|
|
817
|
+
const value = Number.parseInt(raw, 10);
|
|
818
|
+
return Number.isFinite(value) ? value : null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The numeric resource id at the end of a Fiken `Location: .../{id}` header. */
|
|
822
|
+
function locationResourceId(location: string | null): number | null {
|
|
823
|
+
if (!location) return null;
|
|
824
|
+
const match = /\/(\d+)\/?$/.exec(location);
|
|
825
|
+
if (!match) return null;
|
|
826
|
+
const value = Number.parseInt(match[1]!, 10);
|
|
827
|
+
return Number.isSafeInteger(value) ? value : null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Attachment and document blobs are large and useless to the agent surface;
|
|
831
|
+
// strip them from every projected provider record.
|
|
832
|
+
const STRIPPED_RECORD_FIELDS = new Set(["attachments", "documents"]);
|
|
833
|
+
|
|
834
|
+
function projectRecord(payload: unknown): Record<string, unknown> | null {
|
|
835
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return null;
|
|
836
|
+
const projected: Record<string, unknown> = {};
|
|
837
|
+
for (const [key, value] of Object.entries(payload as Record<string, unknown>)) {
|
|
838
|
+
if (STRIPPED_RECORD_FIELDS.has(key)) continue;
|
|
839
|
+
projected[key] = value;
|
|
840
|
+
}
|
|
841
|
+
return projected;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function projectArray(payload: unknown): Array<Record<string, unknown>> {
|
|
845
|
+
if (!Array.isArray(payload)) return [];
|
|
846
|
+
return payload
|
|
847
|
+
.map(projectRecord)
|
|
848
|
+
.filter((record): record is Record<string, unknown> => record !== null);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function providerErrorMessage(response: Response): Promise<string | null> {
|
|
852
|
+
try {
|
|
853
|
+
const payload = await readResponseJsonBounded<unknown>(
|
|
854
|
+
response,
|
|
855
|
+
FIKEN_ERROR_BODY_MAX_BYTES,
|
|
856
|
+
"Fiken error response",
|
|
857
|
+
);
|
|
858
|
+
if (typeof payload !== "object" || payload === null) return null;
|
|
859
|
+
const record = payload as Record<string, unknown>;
|
|
860
|
+
const parts: string[] = [];
|
|
861
|
+
for (const key of ["message", "error", "error_description"]) {
|
|
862
|
+
if (typeof record[key] === "string") parts.push(record[key] as string);
|
|
863
|
+
}
|
|
864
|
+
if (Array.isArray(record.validationMessages)) {
|
|
865
|
+
for (const entry of record.validationMessages) {
|
|
866
|
+
if (typeof entry === "string") parts.push(entry);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const joined = parts.join("; ");
|
|
870
|
+
return joined ? joined.slice(0, FIKEN_PROVIDER_MESSAGE_MAX_CHARS) : null;
|
|
871
|
+
} catch {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function safeFailureCode(error: unknown): string {
|
|
877
|
+
if (error instanceof FikenProviderError) return error.code;
|
|
878
|
+
return "unexpected_error";
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// --- OAuth (registered Fiken app) -----------------------------------------------------------
|
|
882
|
+
//
|
|
883
|
+
// Fiken's authorization-code flow: no PKCE and no scopes, `state` required, a
|
|
884
|
+
// Basic-authenticated token endpoint, ~24h access tokens, and a refresh token
|
|
885
|
+
// that may rotate on every refresh. The stored bundle carries the token
|
|
886
|
+
// endpoint plus client credentials in `client_secret_basic` shape so the
|
|
887
|
+
// generic connection broker owns all later refreshes.
|
|
888
|
+
|
|
889
|
+
const FIKEN_AUTHORIZE_URL = "https://fiken.no/oauth/authorize";
|
|
890
|
+
const FIKEN_TOKEN_URL = "https://fiken.no/oauth/token";
|
|
891
|
+
const FIKEN_OAUTH_MAX_RESPONSE_BYTES = 256 * 1024;
|
|
892
|
+
|
|
893
|
+
export class FikenOAuthCallbackError extends Error {
|
|
894
|
+
constructor(readonly reason: string) {
|
|
895
|
+
super(`Fiken OAuth callback failed: ${reason}`);
|
|
896
|
+
this.name = "FikenOAuthCallbackError";
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
type FikenOAuthState = {
|
|
901
|
+
accountId: string;
|
|
902
|
+
workspaceId: string;
|
|
903
|
+
subjectId: string;
|
|
904
|
+
returnPath: string;
|
|
905
|
+
connectionId?: string;
|
|
906
|
+
connectionVersion?: number;
|
|
907
|
+
nonce: string;
|
|
908
|
+
iat: number;
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
function requireFikenOAuthSettings(settings: Settings): {
|
|
912
|
+
clientId: string;
|
|
913
|
+
clientSecret: string;
|
|
914
|
+
} {
|
|
915
|
+
const clientId = settings.fikenClientId?.trim();
|
|
916
|
+
const clientSecret = settings.fikenClientSecret?.trim();
|
|
917
|
+
if (!clientId || !clientSecret) {
|
|
918
|
+
throw new HTTPException(503, {
|
|
919
|
+
message:
|
|
920
|
+
"Fiken OAuth requires OPENGENI_FIKEN_OAUTH_CLIENT_ID and OPENGENI_FIKEN_OAUTH_CLIENT_SECRET",
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
return { clientId, clientSecret };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export async function startFikenOAuth(
|
|
927
|
+
deps: ApiRouteDeps,
|
|
928
|
+
input: {
|
|
929
|
+
accountId: string;
|
|
930
|
+
workspaceId: string;
|
|
931
|
+
subjectId: string;
|
|
932
|
+
requestUrl: string;
|
|
933
|
+
payload: FikenOAuthStartRequest;
|
|
934
|
+
},
|
|
935
|
+
): Promise<FikenOAuthStartResponse> {
|
|
936
|
+
const fiken = requireFikenOAuthSettings(deps.settings);
|
|
937
|
+
const existing = input.payload.connectionId
|
|
938
|
+
? await getConnectionMetadata(deps.db, input.workspaceId, input.payload.connectionId, null)
|
|
939
|
+
: null;
|
|
940
|
+
if (input.payload.connectionId && !existing) {
|
|
941
|
+
throw new HTTPException(404, { message: "connection not found" });
|
|
942
|
+
}
|
|
943
|
+
if (existing && !isFikenConnection(existing)) {
|
|
944
|
+
throw new HTTPException(422, { message: "connectionId is not a Fiken connection" });
|
|
945
|
+
}
|
|
946
|
+
const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
947
|
+
const state = createSignedState(requireIntegrationsStateSecret(deps.settings), {
|
|
948
|
+
accountId: input.accountId,
|
|
949
|
+
workspaceId: input.workspaceId,
|
|
950
|
+
subjectId: input.subjectId,
|
|
951
|
+
returnPath: `/workspaces/${input.workspaceId}/capabilities`,
|
|
952
|
+
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
953
|
+
});
|
|
954
|
+
const authorizationUrl = new URL(FIKEN_AUTHORIZE_URL);
|
|
955
|
+
authorizationUrl.searchParams.set("response_type", "code");
|
|
956
|
+
authorizationUrl.searchParams.set("client_id", fiken.clientId);
|
|
957
|
+
authorizationUrl.searchParams.set("redirect_uri", `${baseUrl}/v1/integrations/fiken/callback`);
|
|
958
|
+
authorizationUrl.searchParams.set("state", state);
|
|
959
|
+
return FikenOAuthStartResponse.parse({
|
|
960
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
961
|
+
expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export async function completeFikenOAuthCallback(
|
|
966
|
+
deps: ApiRouteDeps,
|
|
967
|
+
input: {
|
|
968
|
+
code?: string | undefined;
|
|
969
|
+
state?: string | undefined;
|
|
970
|
+
error?: string | undefined;
|
|
971
|
+
requestUrl: string;
|
|
972
|
+
},
|
|
973
|
+
): Promise<{ redirectTo: string }> {
|
|
974
|
+
const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
975
|
+
const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/, "") ?? baseUrl;
|
|
976
|
+
let state: FikenOAuthState | null = null;
|
|
977
|
+
try {
|
|
978
|
+
state = readFikenOAuthState(input.state, deps.settings);
|
|
979
|
+
await requireFikenCallbackGrant(deps.db, state);
|
|
980
|
+
const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
|
|
981
|
+
accountId: state.accountId,
|
|
982
|
+
workspaceId: state.workspaceId,
|
|
983
|
+
subjectId: state.subjectId,
|
|
984
|
+
nonce: state.nonce,
|
|
985
|
+
expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
|
|
986
|
+
now: new Date(),
|
|
987
|
+
});
|
|
988
|
+
if (!consumed) {
|
|
989
|
+
throw new FikenOAuthCallbackError("state_replayed");
|
|
990
|
+
}
|
|
991
|
+
if (input.error) {
|
|
992
|
+
throw new FikenOAuthCallbackError(
|
|
993
|
+
input.error === "access_denied" ? "provider_denied" : "provider_error",
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
if (!input.code) {
|
|
997
|
+
throw new FikenOAuthCallbackError("missing_code");
|
|
998
|
+
}
|
|
999
|
+
const fiken = requireFikenOAuthSettings(deps.settings);
|
|
1000
|
+
const key = requireEnvironmentEncryption(deps.settings);
|
|
1001
|
+
const fetchImpl = deps.fikenFetch ?? fetch;
|
|
1002
|
+
const token = await exchangeFikenAuthorizationCode(
|
|
1003
|
+
{
|
|
1004
|
+
code: input.code,
|
|
1005
|
+
clientId: fiken.clientId,
|
|
1006
|
+
clientSecret: fiken.clientSecret,
|
|
1007
|
+
redirectUri: `${baseUrl}/v1/integrations/fiken/callback`,
|
|
1008
|
+
state: input.state!,
|
|
1009
|
+
},
|
|
1010
|
+
fetchImpl,
|
|
1011
|
+
);
|
|
1012
|
+
const companies = await fetchFikenCompanies(token.accessToken, fetchImpl);
|
|
1013
|
+
if (companies.outcome !== "ok") {
|
|
1014
|
+
throw new FikenOAuthCallbackError(
|
|
1015
|
+
companies.outcome === "no_companies" ? "no_api_company" : "company_discovery_failed",
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
// Re-check authorization after the slow provider round-trips.
|
|
1019
|
+
await requireFikenCallbackGrant(deps.db, state);
|
|
1020
|
+
const existing = state.connectionId
|
|
1021
|
+
? await getConnectionMetadata(deps.db, state.workspaceId, state.connectionId, null)
|
|
1022
|
+
: null;
|
|
1023
|
+
if (state.connectionId && (!existing || !isFikenConnection(existing))) {
|
|
1024
|
+
throw new FikenOAuthCallbackError("connection_conflict");
|
|
1025
|
+
}
|
|
1026
|
+
if (existing && existing.version !== state.connectionVersion) {
|
|
1027
|
+
throw new FikenOAuthCallbackError("connection_conflict");
|
|
1028
|
+
}
|
|
1029
|
+
const previousMetadata = existing ? fikenConnectionMetadata(existing.metadata) : null;
|
|
1030
|
+
const defaultCompanySlug = resolveFikenDefaultCompanySlug({
|
|
1031
|
+
requested: null,
|
|
1032
|
+
previous: previousMetadata?.defaultCompanySlug ?? null,
|
|
1033
|
+
companies: companies.companies,
|
|
1034
|
+
});
|
|
1035
|
+
const credentialEncrypted = encryptEnvironmentValue(
|
|
1036
|
+
key,
|
|
1037
|
+
JSON.stringify({
|
|
1038
|
+
access_token: token.accessToken,
|
|
1039
|
+
refresh_token: token.refreshToken,
|
|
1040
|
+
token_type: token.tokenType,
|
|
1041
|
+
...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
|
|
1042
|
+
token_endpoint: FIKEN_TOKEN_URL,
|
|
1043
|
+
client_id: fiken.clientId,
|
|
1044
|
+
client_secret: fiken.clientSecret,
|
|
1045
|
+
token_endpoint_auth_method: "client_secret_basic",
|
|
1046
|
+
}),
|
|
1047
|
+
);
|
|
1048
|
+
const metadata = {
|
|
1049
|
+
credentialRole: FIKEN_CREDENTIAL_ROLE,
|
|
1050
|
+
credentialLabel: FIKEN_CREDENTIAL_LABEL,
|
|
1051
|
+
companies: companies.companies,
|
|
1052
|
+
defaultCompanySlug,
|
|
1053
|
+
verifiedAt: new Date().toISOString(),
|
|
1054
|
+
};
|
|
1055
|
+
const connection = existing
|
|
1056
|
+
? await updateConnection(deps.db, {
|
|
1057
|
+
workspaceId: state.workspaceId,
|
|
1058
|
+
connectionId: existing.id,
|
|
1059
|
+
visibleToSubjectId: null,
|
|
1060
|
+
expectedVersion: existing.version,
|
|
1061
|
+
kind: "oauth2",
|
|
1062
|
+
status: "active",
|
|
1063
|
+
credentialEncrypted,
|
|
1064
|
+
expiresAt: token.expiresAt,
|
|
1065
|
+
metadata,
|
|
1066
|
+
updatedBySubjectId: state.subjectId,
|
|
1067
|
+
})
|
|
1068
|
+
: await createConnection(deps.db, {
|
|
1069
|
+
accountId: state.accountId,
|
|
1070
|
+
workspaceId: state.workspaceId,
|
|
1071
|
+
// Workspace-owned by design, like the pasted-token install: the
|
|
1072
|
+
// first-party fiken tools resolve only workspace connections until
|
|
1073
|
+
// the delegation-snapshot lane exists for personal ownership.
|
|
1074
|
+
subjectId: null,
|
|
1075
|
+
providerDomain: FIKEN_PROVIDER_DOMAIN,
|
|
1076
|
+
kind: "oauth2",
|
|
1077
|
+
credentialEncrypted,
|
|
1078
|
+
grantedScopes: [],
|
|
1079
|
+
expiresAt: token.expiresAt,
|
|
1080
|
+
metadata,
|
|
1081
|
+
createdBySubjectId: state.subjectId,
|
|
1082
|
+
});
|
|
1083
|
+
if (!connection) {
|
|
1084
|
+
throw new FikenOAuthCallbackError("connection_conflict");
|
|
1085
|
+
}
|
|
1086
|
+
return {
|
|
1087
|
+
redirectTo: fikenReturnUrl(returnBaseUrl, state.returnPath, "connected", connection.id),
|
|
1088
|
+
};
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
return {
|
|
1091
|
+
redirectTo: fikenReturnUrl(
|
|
1092
|
+
returnBaseUrl,
|
|
1093
|
+
state?.returnPath ?? "/integrations",
|
|
1094
|
+
"error",
|
|
1095
|
+
error instanceof FikenOAuthCallbackError ? error.reason : "callback_failed",
|
|
1096
|
+
),
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function exchangeFikenAuthorizationCode(
|
|
1102
|
+
input: {
|
|
1103
|
+
code: string;
|
|
1104
|
+
clientId: string;
|
|
1105
|
+
clientSecret: string;
|
|
1106
|
+
redirectUri: string;
|
|
1107
|
+
state: string;
|
|
1108
|
+
},
|
|
1109
|
+
fetchImpl: FetchLike,
|
|
1110
|
+
): Promise<{
|
|
1111
|
+
accessToken: string;
|
|
1112
|
+
refreshToken: string;
|
|
1113
|
+
tokenType: string;
|
|
1114
|
+
expiresAt: Date | null;
|
|
1115
|
+
}> {
|
|
1116
|
+
let response: Response;
|
|
1117
|
+
try {
|
|
1118
|
+
response = await fetchImpl(FIKEN_TOKEN_URL, {
|
|
1119
|
+
method: "POST",
|
|
1120
|
+
headers: {
|
|
1121
|
+
accept: "application/json",
|
|
1122
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1123
|
+
authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString("base64")}`,
|
|
1124
|
+
},
|
|
1125
|
+
body: new URLSearchParams({
|
|
1126
|
+
grant_type: "authorization_code",
|
|
1127
|
+
code: input.code,
|
|
1128
|
+
redirect_uri: input.redirectUri,
|
|
1129
|
+
state: input.state,
|
|
1130
|
+
}),
|
|
1131
|
+
redirect: "error",
|
|
1132
|
+
signal: AbortSignal.timeout(FIKEN_TIMEOUT_MS),
|
|
1133
|
+
});
|
|
1134
|
+
} catch {
|
|
1135
|
+
throw new FikenOAuthCallbackError("token_exchange_unreachable");
|
|
1136
|
+
}
|
|
1137
|
+
if (!response.ok) {
|
|
1138
|
+
await response.body?.cancel().catch(() => undefined);
|
|
1139
|
+
throw new FikenOAuthCallbackError("token_exchange_failed");
|
|
1140
|
+
}
|
|
1141
|
+
let payload: Record<string, unknown>;
|
|
1142
|
+
try {
|
|
1143
|
+
payload = await readResponseJsonBounded<Record<string, unknown>>(
|
|
1144
|
+
response,
|
|
1145
|
+
FIKEN_OAUTH_MAX_RESPONSE_BYTES,
|
|
1146
|
+
"Fiken OAuth token response",
|
|
1147
|
+
);
|
|
1148
|
+
} catch {
|
|
1149
|
+
throw new FikenOAuthCallbackError("token_exchange_failed");
|
|
1150
|
+
}
|
|
1151
|
+
const accessToken = typeof payload.access_token === "string" ? payload.access_token : null;
|
|
1152
|
+
const refreshToken = typeof payload.refresh_token === "string" ? payload.refresh_token : null;
|
|
1153
|
+
if (!accessToken || !refreshToken) {
|
|
1154
|
+
throw new FikenOAuthCallbackError("token_exchange_failed");
|
|
1155
|
+
}
|
|
1156
|
+
const expiresIn = typeof payload.expires_in === "number" ? payload.expires_in : null;
|
|
1157
|
+
return {
|
|
1158
|
+
accessToken,
|
|
1159
|
+
refreshToken,
|
|
1160
|
+
tokenType: typeof payload.token_type === "string" ? payload.token_type : "Bearer",
|
|
1161
|
+
expiresAt:
|
|
1162
|
+
expiresIn && Number.isFinite(expiresIn) ? new Date(Date.now() + expiresIn * 1000) : null,
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function readFikenOAuthState(raw: string | undefined, settings: Settings): FikenOAuthState {
|
|
1167
|
+
if (!raw) {
|
|
1168
|
+
throw new FikenOAuthCallbackError("missing_state");
|
|
1169
|
+
}
|
|
1170
|
+
const payload = readSignedState(raw, requireIntegrationsStateSecret(settings)) as Record<
|
|
1171
|
+
string,
|
|
1172
|
+
unknown
|
|
1173
|
+
> | null;
|
|
1174
|
+
if (!payload) {
|
|
1175
|
+
throw new FikenOAuthCallbackError("invalid_state");
|
|
1176
|
+
}
|
|
1177
|
+
const iat = typeof payload.iat === "number" ? payload.iat : undefined;
|
|
1178
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1179
|
+
if (iat === undefined || now < iat || now - iat > oauthStateTtlMs / 1000) {
|
|
1180
|
+
throw new FikenOAuthCallbackError("invalid_state");
|
|
1181
|
+
}
|
|
1182
|
+
const required = (value: unknown): string => {
|
|
1183
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1184
|
+
throw new FikenOAuthCallbackError("invalid_state");
|
|
1185
|
+
}
|
|
1186
|
+
return value;
|
|
1187
|
+
};
|
|
1188
|
+
return {
|
|
1189
|
+
accountId: required(payload.accountId),
|
|
1190
|
+
workspaceId: required(payload.workspaceId),
|
|
1191
|
+
subjectId: required(payload.subjectId),
|
|
1192
|
+
returnPath: required(payload.returnPath),
|
|
1193
|
+
...(typeof payload.connectionId === "string" ? { connectionId: payload.connectionId } : {}),
|
|
1194
|
+
...(typeof payload.connectionVersion === "number"
|
|
1195
|
+
? { connectionVersion: payload.connectionVersion }
|
|
1196
|
+
: {}),
|
|
1197
|
+
nonce: required(payload.nonce),
|
|
1198
|
+
iat,
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function requireFikenCallbackGrant(db: Database, state: FikenOAuthState): Promise<void> {
|
|
1203
|
+
const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
|
|
1204
|
+
if (
|
|
1205
|
+
!grant ||
|
|
1206
|
+
grant.accountId !== state.accountId ||
|
|
1207
|
+
!hasPermission(grant.permissions, "connections:write")
|
|
1208
|
+
) {
|
|
1209
|
+
throw new FikenOAuthCallbackError("permission_lost");
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
function fikenReturnUrl(
|
|
1214
|
+
returnBaseUrl: string,
|
|
1215
|
+
returnPath: string,
|
|
1216
|
+
status: "connected" | "error",
|
|
1217
|
+
value: string,
|
|
1218
|
+
): string {
|
|
1219
|
+
// returnPath is server-minted inside the signed state, but keep the same
|
|
1220
|
+
// open-redirect defense the shared OAuth helpers use: a path that resolves
|
|
1221
|
+
// off the return origin (e.g. a protocol-relative "//host") is discarded.
|
|
1222
|
+
const base = new URL(returnBaseUrl);
|
|
1223
|
+
let url = new URL(returnPath, base);
|
|
1224
|
+
if (url.origin !== base.origin) {
|
|
1225
|
+
url = new URL("/", base);
|
|
1226
|
+
}
|
|
1227
|
+
url.searchParams.set("fiken", status);
|
|
1228
|
+
url.searchParams.set(status === "connected" ? "connectionId" : "reason", value);
|
|
1229
|
+
return url.toString();
|
|
1230
|
+
}
|