@ganju/utils 0.0.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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +23 -0
- package/dist/apiError.d.ts +3 -0
- package/dist/apiError.d.ts.map +1 -0
- package/dist/apiError.js +36 -0
- package/dist/attachment.d.ts +33 -0
- package/dist/attachment.d.ts.map +1 -0
- package/dist/attachment.js +59 -0
- package/dist/base64.d.ts +10 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +50 -0
- package/dist/channelDebounce.d.ts +26 -0
- package/dist/channelDebounce.d.ts.map +1 -0
- package/dist/channelDebounce.js +35 -0
- package/dist/channelNotifier.d.ts +9 -0
- package/dist/channelNotifier.d.ts.map +1 -0
- package/dist/channelNotifier.js +31 -0
- package/dist/chunking.d.ts +28 -0
- package/dist/chunking.d.ts.map +1 -0
- package/dist/chunking.js +97 -0
- package/dist/cliConstants.d.ts +9 -0
- package/dist/cliConstants.d.ts.map +1 -0
- package/dist/cliConstants.js +47 -0
- package/dist/coerce.d.ts +2 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +13 -0
- package/dist/constants.d.ts +624 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +2220 -0
- package/dist/crypto.d.ts +14 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +93 -0
- package/dist/customCodeProject.d.ts +91 -0
- package/dist/customCodeProject.d.ts.map +1 -0
- package/dist/customCodeProject.js +154 -0
- package/dist/customCodeToken.d.ts +41 -0
- package/dist/customCodeToken.d.ts.map +1 -0
- package/dist/customCodeToken.js +115 -0
- package/dist/deadline.d.ts +6 -0
- package/dist/deadline.d.ts.map +1 -0
- package/dist/deadline.js +45 -0
- package/dist/discordSend.d.ts +25 -0
- package/dist/discordSend.d.ts.map +1 -0
- package/dist/discordSend.js +8 -0
- package/dist/embeddable.d.ts +2 -0
- package/dist/embeddable.d.ts.map +1 -0
- package/dist/embeddable.js +6 -0
- package/dist/exposedResource.d.ts +6 -0
- package/dist/exposedResource.d.ts.map +1 -0
- package/dist/exposedResource.js +18 -0
- package/dist/extractedDocument.d.ts +70 -0
- package/dist/extractedDocument.d.ts.map +1 -0
- package/dist/extractedDocument.js +2 -0
- package/dist/fetcher.d.ts +8 -0
- package/dist/fetcher.d.ts.map +1 -0
- package/dist/fetcher.js +68 -0
- package/dist/formatFilename.d.ts +2 -0
- package/dist/formatFilename.d.ts.map +1 -0
- package/dist/formatFilename.js +19 -0
- package/dist/formatRelative.d.ts +12 -0
- package/dist/formatRelative.d.ts.map +1 -0
- package/dist/formatRelative.js +28 -0
- package/dist/formatRelativeTime.d.ts +2 -0
- package/dist/formatRelativeTime.d.ts.map +1 -0
- package/dist/formatRelativeTime.js +49 -0
- package/dist/getEnv.d.ts +5 -0
- package/dist/getEnv.d.ts.map +1 -0
- package/dist/getEnv.js +11 -0
- package/dist/gmailSend.d.ts +24 -0
- package/dist/gmailSend.d.ts.map +1 -0
- package/dist/gmailSend.js +9 -0
- package/dist/index.d.ts +2839 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +155 -0
- package/dist/jsonSchemaToZodShape.d.ts +38 -0
- package/dist/jsonSchemaToZodShape.d.ts.map +1 -0
- package/dist/jsonSchemaToZodShape.js +117 -0
- package/dist/l2Normalize.d.ts +2 -0
- package/dist/l2Normalize.d.ts.map +1 -0
- package/dist/l2Normalize.js +25 -0
- package/dist/languageCookieDomain.d.ts +21 -0
- package/dist/languageCookieDomain.d.ts.map +1 -0
- package/dist/languageCookieDomain.js +39 -0
- package/dist/localizeZodIssue.d.ts +43 -0
- package/dist/localizeZodIssue.d.ts.map +1 -0
- package/dist/localizeZodIssue.js +90 -0
- package/dist/mailHeaders.d.ts +4 -0
- package/dist/mailHeaders.d.ts.map +1 -0
- package/dist/mailHeaders.js +11 -0
- package/dist/mcpProxy.d.ts +2 -0
- package/dist/mcpProxy.d.ts.map +1 -0
- package/dist/mcpProxy.js +21 -0
- package/dist/mimeMessage.d.ts +21 -0
- package/dist/mimeMessage.d.ts.map +1 -0
- package/dist/mimeMessage.js +58 -0
- package/dist/oauth.d.ts +21 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +75 -0
- package/dist/oauthProviders.d.ts +12 -0
- package/dist/oauthProviders.d.ts.map +1 -0
- package/dist/oauthProviders.js +99 -0
- package/dist/outlookSend.d.ts +19 -0
- package/dist/outlookSend.d.ts.map +1 -0
- package/dist/outlookSend.js +9 -0
- package/dist/parseHttpError.d.ts +2 -0
- package/dist/parseHttpError.d.ts.map +1 -0
- package/dist/parseHttpError.js +23 -0
- package/dist/planLimitError.d.ts +18 -0
- package/dist/planLimitError.d.ts.map +1 -0
- package/dist/planLimitError.js +35 -0
- package/dist/processQueueBatch.d.ts +17 -0
- package/dist/processQueueBatch.d.ts.map +1 -0
- package/dist/processQueueBatch.js +32 -0
- package/dist/reservedToolName.d.ts +16 -0
- package/dist/reservedToolName.d.ts.map +1 -0
- package/dist/reservedToolName.js +25 -0
- package/dist/resourceUri.d.ts +2 -0
- package/dist/resourceUri.d.ts.map +1 -0
- package/dist/resourceUri.js +19 -0
- package/dist/retry.d.ts +10 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +37 -0
- package/dist/sanitize.d.ts +7 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +69 -0
- package/dist/schema.d.ts +2175 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +1382 -0
- package/dist/sdkConstants.d.ts +15 -0
- package/dist/sdkConstants.d.ts.map +1 -0
- package/dist/sdkConstants.js +43 -0
- package/dist/slackSend.d.ts +38 -0
- package/dist/slackSend.d.ts.map +1 -0
- package/dist/slackSend.js +14 -0
- package/dist/slug.d.ts +5 -0
- package/dist/slug.d.ts.map +1 -0
- package/dist/slug.js +34 -0
- package/dist/slugifyTitle.d.ts +2 -0
- package/dist/slugifyTitle.d.ts.map +1 -0
- package/dist/slugifyTitle.js +11 -0
- package/dist/sources.d.ts +43 -0
- package/dist/sources.d.ts.map +1 -0
- package/dist/sources.js +73 -0
- package/dist/ssrf.d.ts +3 -0
- package/dist/ssrf.d.ts.map +1 -0
- package/dist/ssrf.js +46 -0
- package/dist/tallyUsageKinds.d.ts +9 -0
- package/dist/tallyUsageKinds.d.ts.map +1 -0
- package/dist/tallyUsageKinds.js +20 -0
- package/dist/telegramSend.d.ts +28 -0
- package/dist/telegramSend.d.ts.map +1 -0
- package/dist/telegramSend.js +9 -0
- package/dist/toolCatalog.d.ts +452 -0
- package/dist/toolCatalog.d.ts.map +1 -0
- package/dist/toolCatalog.js +552 -0
- package/dist/validateMessageVariables.d.ts +7 -0
- package/dist/validateMessageVariables.d.ts.map +1 -0
- package/dist/validateMessageVariables.js +16 -0
- package/dist/vendorError.d.ts +2 -0
- package/dist/vendorError.d.ts.map +1 -0
- package/dist/vendorError.js +25 -0
- package/dist/whatsappSend.d.ts +31 -0
- package/dist/whatsappSend.d.ts.map +1 -0
- package/dist/whatsappSend.js +11 -0
- package/package.json +76 -0
- package/src/apiError.ts +40 -0
- package/src/attachment.ts +97 -0
- package/src/base64.ts +60 -0
- package/src/channelDebounce.ts +76 -0
- package/src/channelNotifier.ts +39 -0
- package/src/chunking.ts +131 -0
- package/src/cliConstants.ts +50 -0
- package/src/coerce.ts +9 -0
- package/src/constants.ts +2542 -0
- package/src/crypto.ts +108 -0
- package/src/customCodeProject.ts +182 -0
- package/src/customCodeToken.ts +198 -0
- package/src/deadline.ts +43 -0
- package/src/discordSend.ts +41 -0
- package/src/embeddable.ts +4 -0
- package/src/exposedResource.ts +40 -0
- package/src/extractedDocument.ts +62 -0
- package/src/fetcher.ts +82 -0
- package/src/formatFilename.ts +19 -0
- package/src/formatRelative.ts +36 -0
- package/src/formatRelativeTime.ts +45 -0
- package/src/getEnv.ts +13 -0
- package/src/gmailSend.ts +34 -0
- package/src/index.ts +397 -0
- package/src/jsonSchemaToZodShape.ts +111 -0
- package/src/l2Normalize.ts +19 -0
- package/src/languageCookieDomain.ts +37 -0
- package/src/localizeZodIssue.ts +124 -0
- package/src/mailHeaders.ts +12 -0
- package/src/mcpProxy.ts +20 -0
- package/src/mimeMessage.ts +82 -0
- package/src/oauth.ts +103 -0
- package/src/oauthProviders.ts +118 -0
- package/src/outlookSend.ts +44 -0
- package/src/parseHttpError.ts +16 -0
- package/src/planLimitError.ts +50 -0
- package/src/processQueueBatch.ts +46 -0
- package/src/reservedToolName.ts +27 -0
- package/src/resourceUri.ts +17 -0
- package/src/retry.ts +43 -0
- package/src/sanitize.ts +67 -0
- package/src/schema.ts +1624 -0
- package/src/sdkConstants.ts +44 -0
- package/src/slackSend.ts +65 -0
- package/src/slug.ts +37 -0
- package/src/slugifyTitle.ts +8 -0
- package/src/sources.ts +117 -0
- package/src/ssrf.ts +34 -0
- package/src/tallyUsageKinds.ts +20 -0
- package/src/telegramSend.ts +39 -0
- package/src/toolCatalog.ts +635 -0
- package/src/validateMessageVariables.ts +18 -0
- package/src/vendorError.ts +19 -0
- package/src/whatsappSend.ts +47 -0
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Context } from 'hono';
|
|
2
|
+
import { xchacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
3
|
+
import { getEnv } from './getEnv';
|
|
4
|
+
import { base64ToBytes, bytesToBase64 } from './base64';
|
|
5
|
+
|
|
6
|
+
const PREFIX = 'enc:v1:';
|
|
7
|
+
const NONCE_BYTES = 24;
|
|
8
|
+
const KEY_BYTES = 32;
|
|
9
|
+
const KEY_ENV = 'CRYPTO_SECRET';
|
|
10
|
+
|
|
11
|
+
export const getCredentialEncryptionKey = (c: Context) => {
|
|
12
|
+
const rawKey = getEnv(c, KEY_ENV);
|
|
13
|
+
if (!rawKey) {
|
|
14
|
+
throw new Error(`Missing env: ${KEY_ENV}`);
|
|
15
|
+
}
|
|
16
|
+
return rawKey;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const loadKey = (rawKey: string) => {
|
|
20
|
+
const bytes = base64ToBytes(rawKey);
|
|
21
|
+
if (bytes.length !== KEY_BYTES) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`CRYPTO_SECRET must decode to ${KEY_BYTES} bytes (got ${bytes.length})`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return bytes;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const encryptString = (plaintext: string, rawKey: string) => {
|
|
30
|
+
const key = loadKey(rawKey);
|
|
31
|
+
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
|
32
|
+
const cipher = xchacha20poly1305(key, nonce);
|
|
33
|
+
const ciphertext = cipher.encrypt(new TextEncoder().encode(plaintext));
|
|
34
|
+
const bundle = new Uint8Array(nonce.length + ciphertext.length);
|
|
35
|
+
bundle.set(nonce, 0);
|
|
36
|
+
bundle.set(ciphertext, nonce.length);
|
|
37
|
+
return `${PREFIX}${bytesToBase64(bundle)}`;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const decryptString = (value: string, rawKey: string) => {
|
|
41
|
+
if (!value.startsWith(PREFIX)) return value;
|
|
42
|
+
const bundle = base64ToBytes(value.slice(PREFIX.length));
|
|
43
|
+
const nonce = bundle.slice(0, NONCE_BYTES);
|
|
44
|
+
const ciphertext = bundle.slice(NONCE_BYTES);
|
|
45
|
+
const key = loadKey(rawKey);
|
|
46
|
+
const cipher = xchacha20poly1305(key, nonce);
|
|
47
|
+
const plaintext = cipher.decrypt(ciphertext);
|
|
48
|
+
return new TextDecoder().decode(plaintext);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const sha256Hex = async (input: string) => {
|
|
52
|
+
const digest = await crypto.subtle.digest(
|
|
53
|
+
'SHA-256',
|
|
54
|
+
new TextEncoder().encode(input)
|
|
55
|
+
);
|
|
56
|
+
const bytes = new Uint8Array(digest);
|
|
57
|
+
let hex = '';
|
|
58
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
59
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
60
|
+
}
|
|
61
|
+
return hex;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* SHA-256 as unpadded base64url — the format `@better-auth/oauth-provider`
|
|
66
|
+
* stores hashed client secrets in. Anything comparing against a stored
|
|
67
|
+
* `oauth_client.client_secret` has to hash the candidate exactly this way.
|
|
68
|
+
*/
|
|
69
|
+
export const sha256Base64Url = async (input: string) => {
|
|
70
|
+
const digest = await crypto.subtle.digest(
|
|
71
|
+
'SHA-256',
|
|
72
|
+
new TextEncoder().encode(input)
|
|
73
|
+
);
|
|
74
|
+
const bytes = new Uint8Array(digest);
|
|
75
|
+
let binary = '';
|
|
76
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
77
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const hmacSha256Hex = async (key: string, message: string) => {
|
|
81
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
82
|
+
'raw',
|
|
83
|
+
new TextEncoder().encode(key),
|
|
84
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
85
|
+
false,
|
|
86
|
+
['sign']
|
|
87
|
+
);
|
|
88
|
+
const signature = await crypto.subtle.sign(
|
|
89
|
+
'HMAC',
|
|
90
|
+
cryptoKey,
|
|
91
|
+
new TextEncoder().encode(message)
|
|
92
|
+
);
|
|
93
|
+
const bytes = new Uint8Array(signature);
|
|
94
|
+
let hex = '';
|
|
95
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
96
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
97
|
+
}
|
|
98
|
+
return hex;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const timingSafeEqual = (a: string, b: string) => {
|
|
102
|
+
if (a.length !== b.length) return false;
|
|
103
|
+
let diff = 0;
|
|
104
|
+
for (let i = 0; i < a.length; i++) {
|
|
105
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
106
|
+
}
|
|
107
|
+
return diff === 0;
|
|
108
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { constants } from './constants';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A dashboard-authored script is a set of files, not one file.
|
|
5
|
+
*
|
|
6
|
+
* The deploy path already uploads more than one module — the SDK travels beside
|
|
7
|
+
* every script as `ganju-sdk.js` — so the runtime has never been the thing
|
|
8
|
+
* holding this to a single file. What was single was the storage: one R2 object
|
|
9
|
+
* holding one module's text.
|
|
10
|
+
*
|
|
11
|
+
* So the object becomes an envelope. `sourceKind: 'editor'` bytes are JSON of
|
|
12
|
+
* this shape; `'bundle'` bytes stay exactly what the CLI compiled, because a
|
|
13
|
+
* bundle IS one module and wrapping it would mean the stored bytes are no longer
|
|
14
|
+
* the thing that runs. `decodeProject` tells the two apart by reading the marker,
|
|
15
|
+
* which is why there is a marker rather than a bare `{ files }` — arbitrary
|
|
16
|
+
* minified JS never parses as JSON, but a bundle that happens to be a JSON
|
|
17
|
+
* module would, and guessing is not a thing to do with someone's deploy.
|
|
18
|
+
*/
|
|
19
|
+
export const CUSTOM_CODE_PROJECT_MARKER = 'ganjuProject';
|
|
20
|
+
|
|
21
|
+
export interface CustomCodeProject {
|
|
22
|
+
[CUSTOM_CODE_PROJECT_MARKER]: 1;
|
|
23
|
+
files: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const encodeProject = (files: Record<string, string>): string =>
|
|
27
|
+
JSON.stringify({ [CUSTOM_CODE_PROJECT_MARKER]: 1, files });
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read stored bytes back as a set of files, or `null` when they are a single
|
|
31
|
+
* module — the CLI's bundle, and every editor version written before folders
|
|
32
|
+
* existed.
|
|
33
|
+
*/
|
|
34
|
+
export const decodeProject = (text: string): Record<string, string> | null => {
|
|
35
|
+
if (!text.trimStart().startsWith('{')) return null;
|
|
36
|
+
let parsed: unknown;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(text);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
43
|
+
const envelope = parsed as Partial<CustomCodeProject>;
|
|
44
|
+
if (envelope[CUSTOM_CODE_PROJECT_MARKER] !== 1) return null;
|
|
45
|
+
if (!envelope.files || typeof envelope.files !== 'object') return null;
|
|
46
|
+
const files: Record<string, string> = {};
|
|
47
|
+
for (const [path, content] of Object.entries(envelope.files)) {
|
|
48
|
+
if (typeof content === 'string') files[path] = content;
|
|
49
|
+
}
|
|
50
|
+
return files;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Why one path is not a legal file in a script — the rule that refused it, and
|
|
55
|
+
* what it needs to say so.
|
|
56
|
+
*
|
|
57
|
+
* A code rather than a sentence, because there are two readers. The upload path
|
|
58
|
+
* throws it as English, which is what `error_log` has always held; the
|
|
59
|
+
* dashboard's file explorer renders it in whatever language the person is
|
|
60
|
+
* reading, at the keystroke. Returning prose would have forced the explorer to
|
|
61
|
+
* either show English or keep a second copy of these rules — and a second copy
|
|
62
|
+
* is how the two surfaces come to disagree about what a legal name is.
|
|
63
|
+
*/
|
|
64
|
+
export type ProjectPathIssue =
|
|
65
|
+
| { code: 'required' }
|
|
66
|
+
| { code: 'tooLong'; path: string; max: number }
|
|
67
|
+
| { code: 'charset'; path: string }
|
|
68
|
+
| { code: 'dots'; path: string }
|
|
69
|
+
| { code: 'extension'; path: string }
|
|
70
|
+
| { code: 'reserved'; path: string }
|
|
71
|
+
| { code: 'taken'; path: string };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check ONE path against every rule a stored file has to satisfy.
|
|
75
|
+
*
|
|
76
|
+
* Split out of the set-level check below so the dashboard's file explorer can
|
|
77
|
+
* apply the identical rules as someone types a name, rather than letting them
|
|
78
|
+
* finish, click deploy, and read the same sentence back as a 400. There is one
|
|
79
|
+
* definition of what a legal path is, and both surfaces call it.
|
|
80
|
+
*
|
|
81
|
+
* `taken` is the paths already in the project, compared case-insensitively —
|
|
82
|
+
* pass the file being renamed's own path in it and the rename reports a
|
|
83
|
+
* collision with itself, so callers exclude it.
|
|
84
|
+
*/
|
|
85
|
+
export const projectPathIssue = (
|
|
86
|
+
path: string,
|
|
87
|
+
taken: Iterable<string> = []
|
|
88
|
+
): ProjectPathIssue | null => {
|
|
89
|
+
if (!path) return { code: 'required' };
|
|
90
|
+
if (path.length > constants.CUSTOM_CODE_MAX_FILE_PATH) {
|
|
91
|
+
return {
|
|
92
|
+
code: 'tooLong',
|
|
93
|
+
path,
|
|
94
|
+
max: constants.CUSTOM_CODE_MAX_FILE_PATH
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!/^[a-zA-Z0-9._-]+(\/[a-zA-Z0-9._-]+)*$/.test(path)) {
|
|
98
|
+
return { code: 'charset', path };
|
|
99
|
+
}
|
|
100
|
+
if (path.split('/').some(segment => segment === '.' || segment === '..')) {
|
|
101
|
+
return { code: 'dots', path };
|
|
102
|
+
}
|
|
103
|
+
if (!path.endsWith('.js')) {
|
|
104
|
+
return { code: 'extension', path };
|
|
105
|
+
}
|
|
106
|
+
if (path === constants.CUSTOM_CODE_SDK_MODULE) {
|
|
107
|
+
return { code: 'reserved', path };
|
|
108
|
+
}
|
|
109
|
+
// Case-insensitively, because two files differing only in case are one file
|
|
110
|
+
// on the machine of whoever downloads them next.
|
|
111
|
+
const lower = path.toLowerCase();
|
|
112
|
+
for (const other of taken) {
|
|
113
|
+
if (other.toLowerCase() === lower) {
|
|
114
|
+
return { code: 'taken', path };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The same check, rendered in English — what the upload path throws and what
|
|
122
|
+
* lands in `error_log`. Messages carry a word `matchStatus` recognises, so they
|
|
123
|
+
* land as 400s rather than opaque 500s.
|
|
124
|
+
*/
|
|
125
|
+
export const validateProjectPath = (
|
|
126
|
+
path: string,
|
|
127
|
+
taken: Iterable<string> = []
|
|
128
|
+
): string | null => {
|
|
129
|
+
const issue = projectPathIssue(path, taken);
|
|
130
|
+
if (!issue) return null;
|
|
131
|
+
|
|
132
|
+
switch (issue.code) {
|
|
133
|
+
case 'required':
|
|
134
|
+
return 'A name is required';
|
|
135
|
+
case 'tooLong':
|
|
136
|
+
return `The path "${issue.path}" exceeds ${issue.max} characters`;
|
|
137
|
+
case 'charset':
|
|
138
|
+
return `Invalid file path "${issue.path}" — letters, digits, dot, dash, underscore and / only, with no leading or trailing slash`;
|
|
139
|
+
case 'dots':
|
|
140
|
+
return `Invalid file path "${issue.path}" — . and .. are not allowed`;
|
|
141
|
+
case 'extension':
|
|
142
|
+
return `Invalid file path "${issue.path}" — every file must end in .js, since it is deployed as a module exactly as written`;
|
|
143
|
+
case 'reserved':
|
|
144
|
+
return `Invalid file path "${issue.path}" — that name belongs to the SDK, which is attached to every deploy`;
|
|
145
|
+
case 'taken':
|
|
146
|
+
return `Invalid file path "${issue.path}" — it is already in use`;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Check a set of files before anything is stored or deployed.
|
|
152
|
+
*
|
|
153
|
+
* Paths are the module names the Workers upload API receives, and a script's
|
|
154
|
+
* imports resolve against them — so a path that escapes the root, collides with
|
|
155
|
+
* the SDK's own module, or arrives twice under different spellings is a deploy
|
|
156
|
+
* that fails in the runtime rather than here. Messages carry a word `matchStatus`
|
|
157
|
+
* recognises, so these land as 400s rather than opaque 500s.
|
|
158
|
+
*/
|
|
159
|
+
export const validateProjectFiles = (files: Record<string, string>): void => {
|
|
160
|
+
const paths = Object.keys(files);
|
|
161
|
+
|
|
162
|
+
if (paths.length === 0) {
|
|
163
|
+
throw new Error('At least one file is required');
|
|
164
|
+
}
|
|
165
|
+
if (paths.length > constants.CUSTOM_CODE_MAX_FILES) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`The project exceeds the ${constants.CUSTOM_CODE_MAX_FILES}-file limit`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (!files[constants.CUSTOM_CODE_MAIN_MODULE]) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`A file named ${constants.CUSTOM_CODE_MAIN_MODULE} is required — it is the module the dispatcher calls`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const seen = new Set<string>();
|
|
177
|
+
for (const path of paths) {
|
|
178
|
+
const problem = validateProjectPath(path, seen);
|
|
179
|
+
if (problem) throw new Error(problem);
|
|
180
|
+
seen.add(path.toLowerCase());
|
|
181
|
+
}
|
|
182
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
bytesToBase64,
|
|
3
|
+
toBase64Url,
|
|
4
|
+
fromBase64Url,
|
|
5
|
+
base64ToBytes
|
|
6
|
+
} from './base64';
|
|
7
|
+
import { constants } from './constants';
|
|
8
|
+
|
|
9
|
+
// The credential a deployed user script uses to call the broker.
|
|
10
|
+
//
|
|
11
|
+
// A service binding carries no caller identity — the broker sees a request from
|
|
12
|
+
// "some script in the namespace" and nothing more — so the script has to present
|
|
13
|
+
// something that names it. That something is minted here at publish time,
|
|
14
|
+
// injected as the script's `GANJU_TOOL_TOKEN` secret binding, and verified by the
|
|
15
|
+
// broker on every call.
|
|
16
|
+
//
|
|
17
|
+
// It is a signed value rather than a random string checked against a table for
|
|
18
|
+
// one reason: revocation is already free. The token names the version it was
|
|
19
|
+
// minted for, and the broker refuses any token whose version is no longer the
|
|
20
|
+
// artifact's active one — a check it performs against a row it has to read
|
|
21
|
+
// anyway (the tool config, for the connection allow-list). Publishing a new
|
|
22
|
+
// version therefore invalidates every older token without a second write, and
|
|
23
|
+
// there is no token table to keep in step with the version table.
|
|
24
|
+
export interface CustomCodeTokenPayload {
|
|
25
|
+
// Token format version, so a future change to the payload can be rejected
|
|
26
|
+
// rather than misread.
|
|
27
|
+
v: string;
|
|
28
|
+
artifactId: string;
|
|
29
|
+
// The artifact_tool_version this token was minted for. Checked against
|
|
30
|
+
// config.activeVersionId by the broker, which is what makes rotation real.
|
|
31
|
+
versionId: string;
|
|
32
|
+
// Issued-at, seconds. Not used as an expiry — the version check is the
|
|
33
|
+
// lifetime — but it makes two tokens for the same version distinguishable in
|
|
34
|
+
// logs, and gives an operator something to reason about.
|
|
35
|
+
iat: number;
|
|
36
|
+
// Minted for a test run rather than for a published version.
|
|
37
|
+
//
|
|
38
|
+
// The distinction matters to the broker and nowhere else: a preview token
|
|
39
|
+
// names a version that is deliberately NOT the active one, so the check that
|
|
40
|
+
// gives a live token its lifetime cannot apply to it. In exchange it carries
|
|
41
|
+
// an expiry, which a live token does not need.
|
|
42
|
+
preview?: boolean;
|
|
43
|
+
// Expiry, seconds. Present on preview tokens, absent on live ones. Checked
|
|
44
|
+
// whenever present, so adding it to live tokens later needs no broker change.
|
|
45
|
+
exp?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const encoder = new TextEncoder();
|
|
49
|
+
|
|
50
|
+
const importKey = (secret: string): Promise<CryptoKey> =>
|
|
51
|
+
crypto.subtle.importKey(
|
|
52
|
+
'raw',
|
|
53
|
+
encoder.encode(secret),
|
|
54
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
55
|
+
false,
|
|
56
|
+
['sign', 'verify']
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const signPayload = async (
|
|
60
|
+
encodedPayload: string,
|
|
61
|
+
secret: string
|
|
62
|
+
): Promise<string> => {
|
|
63
|
+
const key = await importKey(secret);
|
|
64
|
+
const signature = await crypto.subtle.sign(
|
|
65
|
+
'HMAC',
|
|
66
|
+
key,
|
|
67
|
+
encoder.encode(encodedPayload)
|
|
68
|
+
);
|
|
69
|
+
return toBase64Url(bytesToBase64(new Uint8Array(signature)));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Mint a tool token for one artifact + version. Called by the publish pipeline
|
|
74
|
+
* immediately before upload; the result is written as a `secret_text` binding so
|
|
75
|
+
* it never appears in the bundle the user can read back.
|
|
76
|
+
*/
|
|
77
|
+
export const mintCustomCodeToken = async (
|
|
78
|
+
payload: Omit<CustomCodeTokenPayload, 'v' | 'iat' | 'exp'> & {
|
|
79
|
+
ttlMs?: number;
|
|
80
|
+
},
|
|
81
|
+
secret: string,
|
|
82
|
+
issuedAt: number = Date.now()
|
|
83
|
+
): Promise<string> => {
|
|
84
|
+
const body: CustomCodeTokenPayload = {
|
|
85
|
+
v: constants.CUSTOM_CODE_TOKEN_VERSION,
|
|
86
|
+
artifactId: payload.artifactId,
|
|
87
|
+
versionId: payload.versionId,
|
|
88
|
+
iat: Math.floor(issuedAt / 1000),
|
|
89
|
+
...(payload.preview ? { preview: true } : {}),
|
|
90
|
+
// Rounded up, not down: `exp` is in seconds, and truncating would make a
|
|
91
|
+
// stated ten-minute lifetime end up to a second early. A ceiling means the
|
|
92
|
+
// TTL is a floor, which is what the caller asked for.
|
|
93
|
+
...(payload.ttlMs
|
|
94
|
+
? { exp: Math.ceil((issuedAt + payload.ttlMs) / 1000) }
|
|
95
|
+
: {})
|
|
96
|
+
};
|
|
97
|
+
const encodedPayload = toBase64Url(
|
|
98
|
+
bytesToBase64(encoder.encode(JSON.stringify(body)))
|
|
99
|
+
);
|
|
100
|
+
const signature = await signPayload(encodedPayload, secret);
|
|
101
|
+
return `${encodedPayload}.${signature}`;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verify a tool token and return its payload, or null when it is malformed, of
|
|
106
|
+
* an unknown format version, or not signed by this deployment's secret.
|
|
107
|
+
*
|
|
108
|
+
* Returning null rather than throwing keeps the broker's failure path uniform:
|
|
109
|
+
* every rejection is the same 401, so a caller learns nothing about which check
|
|
110
|
+
* failed.
|
|
111
|
+
*/
|
|
112
|
+
export const verifyCustomCodeToken = async (
|
|
113
|
+
token: string,
|
|
114
|
+
secret: string,
|
|
115
|
+
now?: number
|
|
116
|
+
): Promise<CustomCodeTokenPayload | null> => {
|
|
117
|
+
const separator = token.lastIndexOf('.');
|
|
118
|
+
if (separator <= 0) return null;
|
|
119
|
+
|
|
120
|
+
const encodedPayload = token.slice(0, separator);
|
|
121
|
+
const signature = token.slice(separator + 1);
|
|
122
|
+
|
|
123
|
+
const key = await importKey(secret);
|
|
124
|
+
let valid: boolean;
|
|
125
|
+
try {
|
|
126
|
+
// Copied into a fresh view so its backing store is a plain ArrayBuffer —
|
|
127
|
+
// base64ToBytes is typed against ArrayBufferLike, which subtle.verify's
|
|
128
|
+
// BufferSource parameter does not accept.
|
|
129
|
+
const signatureBytes = new Uint8Array(
|
|
130
|
+
base64ToBytes(fromBase64Url(signature))
|
|
131
|
+
);
|
|
132
|
+
valid = await crypto.subtle.verify(
|
|
133
|
+
'HMAC',
|
|
134
|
+
key,
|
|
135
|
+
signatureBytes,
|
|
136
|
+
encoder.encode(encodedPayload)
|
|
137
|
+
);
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
if (!valid) return null;
|
|
142
|
+
|
|
143
|
+
let parsed: unknown;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(
|
|
146
|
+
new TextDecoder().decode(base64ToBytes(fromBase64Url(encodedPayload)))
|
|
147
|
+
);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
153
|
+
const candidate = parsed as Partial<CustomCodeTokenPayload>;
|
|
154
|
+
if (candidate.v !== constants.CUSTOM_CODE_TOKEN_VERSION) return null;
|
|
155
|
+
if (typeof candidate.artifactId !== 'string' || !candidate.artifactId) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
if (typeof candidate.versionId !== 'string' || !candidate.versionId) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Checked here rather than in the broker so every caller gets it: a token past
|
|
163
|
+
// its expiry is as good as unsigned.
|
|
164
|
+
if (
|
|
165
|
+
typeof candidate.exp === 'number' &&
|
|
166
|
+
candidate.exp * 1000 <= (now ?? Date.now())
|
|
167
|
+
) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
v: candidate.v,
|
|
173
|
+
artifactId: candidate.artifactId,
|
|
174
|
+
versionId: candidate.versionId,
|
|
175
|
+
iat: typeof candidate.iat === 'number' ? candidate.iat : 0,
|
|
176
|
+
...(candidate.preview === true ? { preview: true } : {}),
|
|
177
|
+
...(typeof candidate.exp === 'number' ? { exp: candidate.exp } : {})
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The dispatch-namespace script name for an artifact: `artifact_<id>`.
|
|
183
|
+
*
|
|
184
|
+
* The id, never the slug — slugs are user-editable and a rename would orphan the
|
|
185
|
+
* deployed script while the database still pointed at a live version.
|
|
186
|
+
*/
|
|
187
|
+
export const customCodeScriptName = (artifactId: string): string =>
|
|
188
|
+
`${constants.CUSTOM_CODE_SCRIPT_NAME_PREFIX}${artifactId}`;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The script name a test run deploys into: `artifact_<id>_preview`.
|
|
192
|
+
*
|
|
193
|
+
* A second script rather than a second version of the live one, because a test
|
|
194
|
+
* must not be able to disturb what MCP clients are being served — and the only
|
|
195
|
+
* way to be certain of that is for it to run under a name nothing dispatches to.
|
|
196
|
+
*/
|
|
197
|
+
export const customCodePreviewScriptName = (artifactId: string): string =>
|
|
198
|
+
`${customCodeScriptName(artifactId)}${constants.CUSTOM_CODE_PREVIEW_SCRIPT_SUFFIX}`;
|
package/src/deadline.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Bound how long we wait for a dispatched user script, without handing the
|
|
2
|
+
// binding an AbortSignal.
|
|
3
|
+
//
|
|
4
|
+
// The obvious version — `fetch(url, { signal })` on a Fetcher from a dispatch
|
|
5
|
+
// namespace — works when the binding is in the same process and fails when it is
|
|
6
|
+
// not: `AbortSignal serialization is not enabled`. A local `wrangler dev` proxies
|
|
7
|
+
// the dispatch namespace to the remote account, so every test run and every tool
|
|
8
|
+
// call raised that instead of running, while the deployed Worker was fine. A
|
|
9
|
+
// timeout that only works in production is worse than no timeout, because it
|
|
10
|
+
// breaks the environment where people are actually writing the code.
|
|
11
|
+
//
|
|
12
|
+
// What is lost by racing instead of aborting: the isolate keeps running after we
|
|
13
|
+
// stop waiting for it. That is what the per-script `limits.cpu_ms` ceiling is
|
|
14
|
+
// for, and this deadline was never a cost control — it bounds how long a person
|
|
15
|
+
// watches a spinner.
|
|
16
|
+
export class DeadlineError extends Error {
|
|
17
|
+
constructor(ms: number) {
|
|
18
|
+
super(`Timed out after ${ms}ms`);
|
|
19
|
+
// Named the way an aborted fetch is, so a call site that already branches on
|
|
20
|
+
// the name keeps reading the same way.
|
|
21
|
+
this.name = 'TimeoutError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const withDeadline = async <T>(
|
|
26
|
+
work: Promise<T>,
|
|
27
|
+
ms: number
|
|
28
|
+
): Promise<T> => {
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([
|
|
32
|
+
work,
|
|
33
|
+
new Promise<never>((_, reject) => {
|
|
34
|
+
timer = setTimeout(() => reject(new DeadlineError(ms)), ms);
|
|
35
|
+
})
|
|
36
|
+
]);
|
|
37
|
+
} finally {
|
|
38
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const isDeadlineError = (error: unknown): boolean =>
|
|
43
|
+
error instanceof Error && error.name === 'TimeoutError';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Wire protocol shared between the worker (which constructs the request) and the
|
|
2
|
+
// resource-handler container (which parses it and calls the Discord API). The
|
|
3
|
+
// worker sends a multipart/form-data POST with one `metadata` field (JSON
|
|
4
|
+
// matching DiscordSendRequest) and exactly one `file` field (binary). Discord
|
|
5
|
+
// uploads a file by posting a `payload_json` + `files[0]` multipart to
|
|
6
|
+
// POST /channels/{channelId}/messages.
|
|
7
|
+
|
|
8
|
+
export interface DiscordSendRequest {
|
|
9
|
+
botToken: string;
|
|
10
|
+
// Discord channel id (a DM channel or a guild text channel) — Discord replies
|
|
11
|
+
// are posted into the same channel the message arrived in.
|
|
12
|
+
channelId: string;
|
|
13
|
+
// Optional message id to reply to (Discord message_reference).
|
|
14
|
+
replyToMessageId?: string;
|
|
15
|
+
// Text posted alongside the file (Discord has no separate "caption" — it's the
|
|
16
|
+
// message content, capped at 2000 chars by the caller).
|
|
17
|
+
content?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DiscordSendResponse {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
id?: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Wire protocol for sending a PROXIED (remote MCP) resource as a Discord file.
|
|
27
|
+
// Mirrors TelegramSendRemoteResourceRequest / SlackSendRemoteResourceRequest:
|
|
28
|
+
// the worker sends only the remote connection details + resolved auth header as
|
|
29
|
+
// JSON, and the resource-handler container does the remote read + decode + upload
|
|
30
|
+
// itself — so a large file's bytes never transit the 128 MiB worker.
|
|
31
|
+
export interface DiscordSendRemoteResourceRequest {
|
|
32
|
+
discord: DiscordSendRequest;
|
|
33
|
+
remote: {
|
|
34
|
+
url: string;
|
|
35
|
+
transport: string;
|
|
36
|
+
// Single header injected on the remote MCP connection (e.g. Authorization).
|
|
37
|
+
authHeader?: { name: string; value: string } | null;
|
|
38
|
+
uri: string;
|
|
39
|
+
timeoutMs: number;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { constants } from './constants';
|
|
2
|
+
|
|
3
|
+
// Which of an artifact's resources are actually addressable.
|
|
4
|
+
//
|
|
5
|
+
// Two source types produce rows that exist to hold structure rather than
|
|
6
|
+
// content, and neither should ever be handed to a caller:
|
|
7
|
+
//
|
|
8
|
+
// - a WEBSITE seed (the row with no parent) is the crawl's starting point. The
|
|
9
|
+
// page at that URL is indexed separately as its child, so the seed and the
|
|
10
|
+
// page carry the SAME uri while only the child has content.
|
|
11
|
+
// - a Drive/OneDrive folder is a pure reference whose content lives in its
|
|
12
|
+
// children.
|
|
13
|
+
//
|
|
14
|
+
// The seed case is why this has to be shared rather than reimplemented. Applied
|
|
15
|
+
// in one place and not another, a lookup by uri can return the seed in one code
|
|
16
|
+
// path and the page in another — the same uri on the same artifact answering
|
|
17
|
+
// "146 characters of text" and "no inline content and no file in storage"
|
|
18
|
+
// depending on which surface asked.
|
|
19
|
+
export interface ExposableResource {
|
|
20
|
+
sourceType?: string | null;
|
|
21
|
+
parentResourceId?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const isExposedResource = (resource: ExposableResource): boolean => {
|
|
25
|
+
if (
|
|
26
|
+
resource.sourceType === constants.RESOURCE_SOURCE_TYPE_WEBSITE &&
|
|
27
|
+
!resource.parentResourceId
|
|
28
|
+
) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (
|
|
32
|
+
resource.sourceType === constants.RESOURCE_SOURCE_TYPE_GOOGLE_DRIVE_FOLDER
|
|
33
|
+
) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (resource.sourceType === constants.RESOURCE_SOURCE_TYPE_ONE_DRIVE_FOLDER) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface ExtractedDocumentSource {
|
|
2
|
+
fileName?: string;
|
|
3
|
+
mimeType: string;
|
|
4
|
+
sizeBytes: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ExtractedDocumentMetadata {
|
|
8
|
+
loc: {
|
|
9
|
+
pageNumber: number;
|
|
10
|
+
totalPages: number;
|
|
11
|
+
sheetName?: string;
|
|
12
|
+
slideTitle?: string;
|
|
13
|
+
};
|
|
14
|
+
source: ExtractedDocumentSource;
|
|
15
|
+
pdf?: {
|
|
16
|
+
info?: Record<string, unknown>;
|
|
17
|
+
pdfMetadata?: Record<string, unknown> | null;
|
|
18
|
+
};
|
|
19
|
+
docx?: {
|
|
20
|
+
coreProps?: Record<string, string>;
|
|
21
|
+
appProps?: Record<string, string>;
|
|
22
|
+
};
|
|
23
|
+
xlsx?: {
|
|
24
|
+
sheetIndex: number;
|
|
25
|
+
range?: string;
|
|
26
|
+
rowCount?: number;
|
|
27
|
+
columnCount?: number;
|
|
28
|
+
workbookProps?: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
pptx?: {
|
|
31
|
+
coreProps?: Record<string, string>;
|
|
32
|
+
appProps?: Record<string, string>;
|
|
33
|
+
notes?: string;
|
|
34
|
+
};
|
|
35
|
+
web?: {
|
|
36
|
+
url: string;
|
|
37
|
+
canonicalUrl?: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
siteName?: string;
|
|
41
|
+
author?: string;
|
|
42
|
+
keywords?: string[];
|
|
43
|
+
language?: string;
|
|
44
|
+
publishedAt?: string;
|
|
45
|
+
modifiedAt?: string;
|
|
46
|
+
openGraph?: Record<string, string>;
|
|
47
|
+
twitter?: Record<string, string>;
|
|
48
|
+
jsonLd?: unknown[];
|
|
49
|
+
headings?: { tag: string; text: string }[];
|
|
50
|
+
images?: { src: string; alt?: string }[];
|
|
51
|
+
links?: { url: string; text?: string }[];
|
|
52
|
+
favicon?: string;
|
|
53
|
+
httpStatus?: number;
|
|
54
|
+
contentType?: string;
|
|
55
|
+
renderer: 'cheerio' | 'playwright';
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ExtractedDocument {
|
|
60
|
+
pageContent: string;
|
|
61
|
+
metadata: ExtractedDocumentMetadata;
|
|
62
|
+
}
|