@apifuse/connector-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 +408 -0
- package/bin/apifuse-dev.ts +222 -0
- package/bin/apifuse-init.ts +390 -0
- package/bin/apifuse-perf.ts +1101 -0
- package/bin/apifuse-record.ts +446 -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__/connectors-yaml.test.ts +135 -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__/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/connectors-yaml.ts +373 -0
- package/src/config/loader.ts +122 -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/connector.ts +20 -0
- package/src/runtime/executor.ts +51 -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/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 +665 -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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import type { SessionStore } from "../types";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_NAMESPACE = "default";
|
|
9
|
+
const SQLITE_MEMORY_PATH = ":memory:";
|
|
10
|
+
const SQLITE_TABLE_SQL = `
|
|
11
|
+
CREATE TABLE IF NOT EXISTS connector_sessions (
|
|
12
|
+
key TEXT PRIMARY KEY,
|
|
13
|
+
value TEXT NOT NULL,
|
|
14
|
+
expires_at INTEGER
|
|
15
|
+
)
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const TTL_UNITS = {
|
|
19
|
+
ms: 1,
|
|
20
|
+
s: 1_000,
|
|
21
|
+
m: 60_000,
|
|
22
|
+
h: 3_600_000,
|
|
23
|
+
d: 86_400_000,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export type SessionStoreBackend = "sqlite" | "supabase";
|
|
27
|
+
|
|
28
|
+
export type SessionStoreOptions = {
|
|
29
|
+
backend?: SessionStoreBackend;
|
|
30
|
+
connectionId?: string;
|
|
31
|
+
databasePath?: string;
|
|
32
|
+
dbPath?: string;
|
|
33
|
+
encryptionKey?: string;
|
|
34
|
+
fetch?: SessionFetch;
|
|
35
|
+
namespace?: string;
|
|
36
|
+
supabaseKey?: string;
|
|
37
|
+
supabaseUrl?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type InternalSessionStoreOptions = SessionStoreOptions & {
|
|
41
|
+
databasePath?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type SessionEnvelope = {
|
|
45
|
+
v: 1;
|
|
46
|
+
data: Record<string, string>;
|
|
47
|
+
version: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type SupabaseSessionRecord = {
|
|
51
|
+
encrypted_blob: string;
|
|
52
|
+
encryption_key_version: number;
|
|
53
|
+
iv: string;
|
|
54
|
+
tag: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type SessionFetch = (
|
|
58
|
+
input: string | URL | Request,
|
|
59
|
+
init?: RequestInit,
|
|
60
|
+
) => Promise<Response>;
|
|
61
|
+
|
|
62
|
+
export type SupabaseSessionStoreConfig = {
|
|
63
|
+
connectionId: string;
|
|
64
|
+
encryptionKey: string;
|
|
65
|
+
fetch?: SessionFetch;
|
|
66
|
+
supabaseKey: string;
|
|
67
|
+
supabaseUrl: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type SessionRow = {
|
|
71
|
+
value: string;
|
|
72
|
+
expires_at: number | null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type SqliteSessionInspection = {
|
|
76
|
+
storedKey: string;
|
|
77
|
+
value: string;
|
|
78
|
+
expiresAt: number | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type SqliteSessionStore = SessionStore & {
|
|
82
|
+
__unsafeInspect(key: string): SqliteSessionInspection | null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const SUPABASE_SESSION_VERSION = 1;
|
|
86
|
+
const SUPABASE_IV_LENGTH = 12;
|
|
87
|
+
const SUPABASE_KEY_LENGTH = 32;
|
|
88
|
+
const SUPABASE_TABLE = "connection_secrets";
|
|
89
|
+
|
|
90
|
+
function createSupabaseHeaders(apiKey: string): Headers {
|
|
91
|
+
return new Headers({
|
|
92
|
+
Accept: "application/json",
|
|
93
|
+
Authorization: `Bearer ${apiKey}`,
|
|
94
|
+
apikey: apiKey,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function createSessionEnvelope(data: Record<string, string>): SessionEnvelope {
|
|
99
|
+
return {
|
|
100
|
+
v: SUPABASE_SESSION_VERSION,
|
|
101
|
+
data,
|
|
102
|
+
version: 0,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeEncryptionKey(encryptionKey: string): Buffer {
|
|
107
|
+
const trimmedKey = encryptionKey.trim();
|
|
108
|
+
const utf8Key = Buffer.from(trimmedKey, "utf8");
|
|
109
|
+
if (utf8Key.byteLength === SUPABASE_KEY_LENGTH) {
|
|
110
|
+
return utf8Key;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (/^[0-9a-f]{64}$/i.test(trimmedKey)) {
|
|
114
|
+
return Buffer.from(trimmedKey, "hex");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const base64Key = Buffer.from(trimmedKey, "base64");
|
|
118
|
+
if (base64Key.byteLength === SUPABASE_KEY_LENGTH) {
|
|
119
|
+
return base64Key;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
throw new TypeError(
|
|
123
|
+
"Supabase session encryption key must decode to exactly 32 bytes.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function encryptSessionEnvelope(
|
|
128
|
+
encryptionKey: Buffer,
|
|
129
|
+
envelope: SessionEnvelope,
|
|
130
|
+
): SupabaseSessionRecord {
|
|
131
|
+
const iv = randomBytes(SUPABASE_IV_LENGTH);
|
|
132
|
+
const cipher = createCipheriv("aes-256-gcm", encryptionKey, iv);
|
|
133
|
+
const encrypted = Buffer.concat([
|
|
134
|
+
cipher.update(JSON.stringify(envelope), "utf8"),
|
|
135
|
+
cipher.final(),
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
encrypted_blob: encrypted.toString("base64"),
|
|
140
|
+
encryption_key_version: SUPABASE_SESSION_VERSION,
|
|
141
|
+
iv: iv.toString("base64"),
|
|
142
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function decryptSessionEnvelope(
|
|
147
|
+
encryptionKey: Buffer,
|
|
148
|
+
record: SupabaseSessionRecord,
|
|
149
|
+
): SessionEnvelope {
|
|
150
|
+
if (record.encryption_key_version !== SUPABASE_SESSION_VERSION) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Unsupported session encryption key version: ${record.encryption_key_version}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const iv = Buffer.from(record.iv, "base64");
|
|
157
|
+
if (iv.byteLength !== SUPABASE_IV_LENGTH) {
|
|
158
|
+
throw new Error("Invalid AES-GCM IV length.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const tag = Buffer.from(record.tag, "base64");
|
|
162
|
+
if (tag.byteLength !== 16) {
|
|
163
|
+
throw new Error("Invalid AES-GCM authentication tag length.");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const decipher = createDecipheriv("aes-256-gcm", encryptionKey, iv);
|
|
167
|
+
decipher.setAuthTag(tag);
|
|
168
|
+
const decrypted = Buffer.concat([
|
|
169
|
+
decipher.update(Buffer.from(record.encrypted_blob, "base64")),
|
|
170
|
+
decipher.final(),
|
|
171
|
+
]);
|
|
172
|
+
const parsed = JSON.parse(
|
|
173
|
+
decrypted.toString("utf8"),
|
|
174
|
+
) as Partial<SessionEnvelope>;
|
|
175
|
+
|
|
176
|
+
if (parsed.v !== SUPABASE_SESSION_VERSION) {
|
|
177
|
+
throw new Error("Invalid session payload version.");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (
|
|
181
|
+
typeof parsed.data !== "object" ||
|
|
182
|
+
parsed.data === null ||
|
|
183
|
+
Array.isArray(parsed.data)
|
|
184
|
+
) {
|
|
185
|
+
throw new Error("Invalid session payload data.");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
v: SUPABASE_SESSION_VERSION,
|
|
190
|
+
data: parsed.data,
|
|
191
|
+
version:
|
|
192
|
+
typeof parsed.version === "number" && Number.isInteger(parsed.version)
|
|
193
|
+
? parsed.version
|
|
194
|
+
: 0,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function readSupabaseJson<T>(response: Response): Promise<T> {
|
|
199
|
+
const responseText = await response.text();
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Supabase session request failed (${response.status}): ${responseText || response.statusText}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!responseText) {
|
|
207
|
+
return [] as T;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return JSON.parse(responseText) as T;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function selectSecretRecord(
|
|
214
|
+
fetchImpl: SessionFetch,
|
|
215
|
+
supabaseUrl: string,
|
|
216
|
+
headers: Headers,
|
|
217
|
+
connectionId: string,
|
|
218
|
+
): Promise<SupabaseSessionRecord | null> {
|
|
219
|
+
const query = new URLSearchParams({
|
|
220
|
+
connection_id: `eq.${connectionId}`,
|
|
221
|
+
select: "encrypted_blob,encryption_key_version,iv,tag",
|
|
222
|
+
});
|
|
223
|
+
const response = await fetchImpl(
|
|
224
|
+
`${supabaseUrl}/rest/v1/${SUPABASE_TABLE}?${query.toString()}`,
|
|
225
|
+
{
|
|
226
|
+
headers,
|
|
227
|
+
method: "GET",
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
const rows = await readSupabaseJson<SupabaseSessionRecord[]>(response);
|
|
231
|
+
return rows[0] ?? null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function upsertSecretRecord(
|
|
235
|
+
fetchImpl: SessionFetch,
|
|
236
|
+
supabaseUrl: string,
|
|
237
|
+
headers: Headers,
|
|
238
|
+
connectionId: string,
|
|
239
|
+
record: SupabaseSessionRecord,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const response = await fetchImpl(
|
|
242
|
+
`${supabaseUrl}/rest/v1/${SUPABASE_TABLE}?on_conflict=connection_id`,
|
|
243
|
+
{
|
|
244
|
+
body: JSON.stringify([
|
|
245
|
+
{
|
|
246
|
+
connection_id: connectionId,
|
|
247
|
+
...record,
|
|
248
|
+
},
|
|
249
|
+
]),
|
|
250
|
+
headers,
|
|
251
|
+
method: "POST",
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
await readSupabaseJson<unknown>(response);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function deleteSecretRecord(
|
|
258
|
+
fetchImpl: SessionFetch,
|
|
259
|
+
supabaseUrl: string,
|
|
260
|
+
headers: Headers,
|
|
261
|
+
connectionId: string,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
const query = new URLSearchParams({
|
|
264
|
+
connection_id: `eq.${connectionId}`,
|
|
265
|
+
});
|
|
266
|
+
const response = await fetchImpl(
|
|
267
|
+
`${supabaseUrl}/rest/v1/${SUPABASE_TABLE}?${query.toString()}`,
|
|
268
|
+
{
|
|
269
|
+
headers,
|
|
270
|
+
method: "DELETE",
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
await readSupabaseJson<unknown>(response);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export class SupabaseSessionStore implements SessionStore {
|
|
277
|
+
private readonly encryptionKey: Buffer;
|
|
278
|
+
|
|
279
|
+
private cachedEnvelope: SessionEnvelope | null = null;
|
|
280
|
+
|
|
281
|
+
private readonly fetchImpl: SessionFetch;
|
|
282
|
+
|
|
283
|
+
private readonly readHeaders: Headers;
|
|
284
|
+
|
|
285
|
+
private readonly mutationHeaders: Headers;
|
|
286
|
+
|
|
287
|
+
private readonly deleteHeaders: Headers;
|
|
288
|
+
|
|
289
|
+
private readonly supabaseUrl: string;
|
|
290
|
+
|
|
291
|
+
constructor(private readonly config: SupabaseSessionStoreConfig) {
|
|
292
|
+
this.encryptionKey = decodeEncryptionKey(config.encryptionKey);
|
|
293
|
+
this.fetchImpl = config.fetch ?? fetch;
|
|
294
|
+
this.readHeaders = createSupabaseHeaders(config.supabaseKey);
|
|
295
|
+
this.mutationHeaders = createSupabaseHeaders(config.supabaseKey);
|
|
296
|
+
this.mutationHeaders.set("Content-Type", "application/json");
|
|
297
|
+
this.mutationHeaders.set(
|
|
298
|
+
"Prefer",
|
|
299
|
+
"resolution=merge-duplicates,return=minimal",
|
|
300
|
+
);
|
|
301
|
+
this.deleteHeaders = createSupabaseHeaders(config.supabaseKey);
|
|
302
|
+
this.deleteHeaders.set("Prefer", "return=minimal");
|
|
303
|
+
this.supabaseUrl = config.supabaseUrl.replace(/\/$/, "");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async get(key: string): Promise<string | null> {
|
|
307
|
+
const envelope = await this.loadEnvelopeCached();
|
|
308
|
+
return envelope.data[key] ?? null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async set(key: string, value: string): Promise<void> {
|
|
312
|
+
const envelope = await this.loadEnvelopeCached();
|
|
313
|
+
const nextEnvelope: SessionEnvelope = {
|
|
314
|
+
...envelope,
|
|
315
|
+
data: {
|
|
316
|
+
...envelope.data,
|
|
317
|
+
[key]: value,
|
|
318
|
+
},
|
|
319
|
+
version: envelope.version + 1,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
await this.persistEnvelope(nextEnvelope, envelope.version);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async delete(key: string): Promise<void> {
|
|
326
|
+
const envelope = await this.loadEnvelopeCached();
|
|
327
|
+
if (!(key in envelope.data)) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const nextData = { ...envelope.data };
|
|
332
|
+
delete nextData[key];
|
|
333
|
+
|
|
334
|
+
if (Object.keys(nextData).length === 0) {
|
|
335
|
+
await deleteSecretRecord(
|
|
336
|
+
this.fetchImpl,
|
|
337
|
+
this.supabaseUrl,
|
|
338
|
+
this.deleteHeaders,
|
|
339
|
+
this.config.connectionId,
|
|
340
|
+
);
|
|
341
|
+
this.cachedEnvelope = null;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
await this.persistEnvelope(
|
|
346
|
+
{
|
|
347
|
+
...envelope,
|
|
348
|
+
data: nextData,
|
|
349
|
+
version: envelope.version + 1,
|
|
350
|
+
},
|
|
351
|
+
envelope.version,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async loadAll(): Promise<Record<string, string>> {
|
|
356
|
+
const envelope = await this.loadEnvelopeCached();
|
|
357
|
+
return { ...envelope.data };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async loadEnvelope(): Promise<SessionEnvelope> {
|
|
361
|
+
const record = await selectSecretRecord(
|
|
362
|
+
this.fetchImpl,
|
|
363
|
+
this.supabaseUrl,
|
|
364
|
+
this.readHeaders,
|
|
365
|
+
this.config.connectionId,
|
|
366
|
+
);
|
|
367
|
+
if (!record) {
|
|
368
|
+
return createSessionEnvelope({});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return decryptSessionEnvelope(this.encryptionKey, record);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private async loadEnvelopeCached(): Promise<SessionEnvelope> {
|
|
375
|
+
if (this.cachedEnvelope) {
|
|
376
|
+
return this.cachedEnvelope;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
this.cachedEnvelope = await this.loadEnvelope();
|
|
380
|
+
return this.cachedEnvelope;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private async persistEnvelope(
|
|
384
|
+
envelope: SessionEnvelope,
|
|
385
|
+
expectedPreviousVersion: number,
|
|
386
|
+
): Promise<void> {
|
|
387
|
+
const latestRecord = await selectSecretRecord(
|
|
388
|
+
this.fetchImpl,
|
|
389
|
+
this.supabaseUrl,
|
|
390
|
+
this.readHeaders,
|
|
391
|
+
this.config.connectionId,
|
|
392
|
+
);
|
|
393
|
+
const latestVersion = latestRecord
|
|
394
|
+
? decryptSessionEnvelope(this.encryptionKey, latestRecord).version
|
|
395
|
+
: 0;
|
|
396
|
+
|
|
397
|
+
if (latestVersion !== expectedPreviousVersion) {
|
|
398
|
+
console.warn(
|
|
399
|
+
`SupabaseSessionStore version mismatch for connection ${this.config.connectionId}: expected ${expectedPreviousVersion}, got ${latestVersion}`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await upsertSecretRecord(
|
|
404
|
+
this.fetchImpl,
|
|
405
|
+
this.supabaseUrl,
|
|
406
|
+
this.mutationHeaders,
|
|
407
|
+
this.config.connectionId,
|
|
408
|
+
encryptSessionEnvelope(this.encryptionKey, envelope),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
this.cachedEnvelope = envelope;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function resolveDatabasePath(options?: InternalSessionStoreOptions): string {
|
|
416
|
+
if (options?.dbPath) {
|
|
417
|
+
return options.dbPath;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (options?.databasePath) {
|
|
421
|
+
return options.databasePath;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return join(process.cwd(), ".apifuse", "sessions.db");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function ensureDatabaseDirectory(databasePath: string): void {
|
|
428
|
+
if (databasePath === SQLITE_MEMORY_PATH) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
mkdirSync(dirname(databasePath), { recursive: true });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function toStorageKey(namespace: string | undefined, key: string): string {
|
|
436
|
+
return `${namespace ?? DEFAULT_NAMESPACE}:${key}`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function parseSessionTtl(ttl?: string): number | null {
|
|
440
|
+
if (!ttl) {
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const match = ttl.trim().match(/^(-?\d+)(ms|s|m|h|d)$/);
|
|
445
|
+
if (!match) {
|
|
446
|
+
throw new TypeError(`Invalid session ttl: ${ttl}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const [, rawValue, unit] = match;
|
|
450
|
+
return Number(rawValue) * TTL_UNITS[unit as keyof typeof TTL_UNITS];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function resolveExpiresAt(ttl?: string): number | null {
|
|
454
|
+
const ttlMs = parseSessionTtl(ttl);
|
|
455
|
+
if (ttlMs === null) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return Date.now() + ttlMs;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function resolveBackend(
|
|
463
|
+
options?: SessionStoreOptions,
|
|
464
|
+
): SessionStoreBackend {
|
|
465
|
+
if (options?.backend) {
|
|
466
|
+
return options.backend;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return process.env.SUPABASE_URL ? "supabase" : "sqlite";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export function createSqliteSessionStore(
|
|
473
|
+
options?: InternalSessionStoreOptions,
|
|
474
|
+
): SqliteSessionStore {
|
|
475
|
+
const databasePath = resolveDatabasePath(options);
|
|
476
|
+
ensureDatabaseDirectory(databasePath);
|
|
477
|
+
|
|
478
|
+
const database = new Database(databasePath, { create: true });
|
|
479
|
+
database.exec(SQLITE_TABLE_SQL);
|
|
480
|
+
|
|
481
|
+
const selectQuery = database.query(
|
|
482
|
+
"SELECT value, expires_at FROM connector_sessions WHERE key = ?",
|
|
483
|
+
);
|
|
484
|
+
const upsertQuery = database.query(
|
|
485
|
+
"INSERT OR REPLACE INTO connector_sessions (key, value, expires_at) VALUES (?, ?, ?)",
|
|
486
|
+
);
|
|
487
|
+
const deleteQuery = database.query(
|
|
488
|
+
"DELETE FROM connector_sessions WHERE key = ?",
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
function inspectRecord(key: string): SqliteSessionInspection | null {
|
|
492
|
+
const storedKey = toStorageKey(options?.namespace, key);
|
|
493
|
+
const row = selectQuery.get(storedKey) as SessionRow | null;
|
|
494
|
+
|
|
495
|
+
if (!row) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
storedKey,
|
|
501
|
+
value: row.value,
|
|
502
|
+
expiresAt: row.expires_at,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
async get(key) {
|
|
508
|
+
const storedKey = toStorageKey(options?.namespace, key);
|
|
509
|
+
const row = selectQuery.get(storedKey) as SessionRow | null;
|
|
510
|
+
|
|
511
|
+
if (!row) {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
516
|
+
deleteQuery.run(storedKey);
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return row.value;
|
|
521
|
+
},
|
|
522
|
+
async set(key, value, ttl) {
|
|
523
|
+
const storedKey = toStorageKey(options?.namespace, key);
|
|
524
|
+
const expiresAt = resolveExpiresAt(ttl);
|
|
525
|
+
|
|
526
|
+
upsertQuery.run(storedKey, value, expiresAt);
|
|
527
|
+
},
|
|
528
|
+
async delete(key) {
|
|
529
|
+
deleteQuery.run(toStorageKey(options?.namespace, key));
|
|
530
|
+
},
|
|
531
|
+
__unsafeInspect: inspectRecord,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function createSessionStore(
|
|
536
|
+
options?: SessionStoreOptions,
|
|
537
|
+
): SessionStore {
|
|
538
|
+
if (resolveBackend(options) === "supabase") {
|
|
539
|
+
if (!options?.supabaseUrl) {
|
|
540
|
+
throw new TypeError(
|
|
541
|
+
"Supabase session store requires supabaseUrl when backend is 'supabase'.",
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (!options.supabaseKey) {
|
|
546
|
+
throw new TypeError(
|
|
547
|
+
"Supabase session store requires supabaseKey when backend is 'supabase'.",
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (!options.encryptionKey) {
|
|
552
|
+
throw new TypeError(
|
|
553
|
+
"Supabase session store requires encryptionKey when backend is 'supabase'.",
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (!options.connectionId) {
|
|
558
|
+
throw new TypeError(
|
|
559
|
+
"Supabase session store requires connectionId when backend is 'supabase'.",
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return new SupabaseSessionStore({
|
|
564
|
+
connectionId: options.connectionId,
|
|
565
|
+
encryptionKey: options.encryptionKey,
|
|
566
|
+
fetch: options.fetch,
|
|
567
|
+
supabaseKey: options.supabaseKey,
|
|
568
|
+
supabaseUrl: options.supabaseUrl,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return createSqliteSessionStore(options);
|
|
573
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { StateContext } from "../types";
|
|
4
|
+
import { parseSessionTtl } from "./session";
|
|
5
|
+
|
|
6
|
+
type SealedPayload = {
|
|
7
|
+
data: unknown;
|
|
8
|
+
exp: number | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function toBase64Url(value: string): string {
|
|
12
|
+
return Buffer.from(value, "utf8").toString("base64url");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fromBase64Url(value: string): string | null {
|
|
16
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
return Buffer.from(value, "base64url").toString("utf8");
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function signPayload(payload: string, secret: string): string {
|
|
28
|
+
return createHmac("sha256", secret).update(payload).digest("base64url");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasValidSignature(
|
|
32
|
+
payload: string,
|
|
33
|
+
signature: string,
|
|
34
|
+
secret: string,
|
|
35
|
+
): boolean {
|
|
36
|
+
const expectedSignature = signPayload(payload, secret);
|
|
37
|
+
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
|
|
38
|
+
const receivedBuffer = Buffer.from(signature, "utf8");
|
|
39
|
+
|
|
40
|
+
if (expectedBuffer.length !== receivedBuffer.length) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return timingSafeEqual(expectedBuffer, receivedBuffer);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isSealedPayload(value: unknown): value is SealedPayload {
|
|
48
|
+
if (!value || typeof value !== "object") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const payload = value as Record<string, unknown>;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
"data" in payload &&
|
|
56
|
+
"exp" in payload &&
|
|
57
|
+
(payload.exp === null ||
|
|
58
|
+
(typeof payload.exp === "number" && Number.isFinite(payload.exp)))
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createStateContext(secret?: string): StateContext {
|
|
63
|
+
const resolvedSecret = process.env.APIFUSE_STATE_SECRET ?? secret;
|
|
64
|
+
if (!resolvedSecret) {
|
|
65
|
+
console.warn(
|
|
66
|
+
"[apifuse] Warning: APIFUSE_STATE_SECRET is not set. " +
|
|
67
|
+
"Using insecure dev secret. Set APIFUSE_STATE_SECRET in production.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const effectiveSecret = resolvedSecret ?? "dev-secret";
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
async seal(data, options) {
|
|
74
|
+
const ttlMs = parseSessionTtl(options?.ttl);
|
|
75
|
+
const payload = toBase64Url(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
data,
|
|
78
|
+
exp: ttlMs === null ? null : Date.now() + ttlMs,
|
|
79
|
+
} satisfies SealedPayload),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
return `${payload}.${signPayload(payload, effectiveSecret)}`;
|
|
83
|
+
},
|
|
84
|
+
async unseal<T = unknown>(token: string): Promise<T | null> {
|
|
85
|
+
try {
|
|
86
|
+
const parts = token.split(".");
|
|
87
|
+
|
|
88
|
+
if (parts.length !== 2) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const [payload, signature] = parts;
|
|
93
|
+
|
|
94
|
+
if (
|
|
95
|
+
!payload ||
|
|
96
|
+
!signature ||
|
|
97
|
+
!hasValidSignature(payload, signature, effectiveSecret)
|
|
98
|
+
) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const decodedPayload = fromBase64Url(payload);
|
|
103
|
+
|
|
104
|
+
if (decodedPayload === null) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parsedPayload = JSON.parse(decodedPayload) as unknown;
|
|
109
|
+
|
|
110
|
+
if (!isSealedPayload(parsedPayload)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (parsedPayload.exp !== null && parsedPayload.exp <= Date.now()) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return parsedPayload.data as T;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|