@opengeni/api-router 0.12.12 → 0.14.3
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 +18 -38
- package/dist/app.js +3 -1
- package/dist/auth/managed-auth.d.ts +35 -0
- package/dist/{chunk-5TULDPWX.js → chunk-36PW33ND.js} +4111 -1277
- package/dist/chunk-36PW33ND.js.map +1 -0
- package/dist/codex-redemption-security.d.ts +15 -0
- package/dist/github-access.d.ts +12 -0
- package/dist/github-browser-flow.d.ts +14 -0
- package/dist/http/auth.d.ts +3 -0
- package/dist/http/common.d.ts +3 -0
- package/dist/http/sse.d.ts +70 -0
- package/dist/index.d.ts +11 -13
- package/dist/index.js +1 -1
- package/dist/integrations/google-drive.d.ts +76 -0
- package/dist/integrations/oauth-client.d.ts +42 -0
- package/dist/integrations/provider-domain.d.ts +7 -0
- package/dist/integrations/slack-bot.d.ts +343 -0
- package/dist/mcp/documents.d.ts +8 -0
- package/dist/mcp/files.d.ts +5 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/session-view.d.ts +191 -0
- package/dist/mcp/toolspace.d.ts +61 -0
- package/dist/model-catalog.d.ts +39 -0
- package/dist/observability.d.ts +3 -0
- package/dist/routes/api-keys.d.ts +3 -0
- package/dist/routes/billing.d.ts +16 -0
- package/dist/routes/capabilities.d.ts +3 -0
- package/dist/routes/catalog-assets.d.ts +5 -0
- package/dist/routes/codex.d.ts +10 -0
- package/dist/routes/connections.d.ts +3 -0
- package/dist/routes/documents.d.ts +3 -0
- package/dist/routes/enrollments.d.ts +3 -0
- package/dist/routes/environments.d.ts +5 -0
- package/dist/routes/files.d.ts +4 -0
- package/dist/routes/github.d.ts +3 -0
- package/dist/routes/insights.d.ts +3 -0
- package/dist/routes/install.d.ts +5 -0
- package/dist/routes/machines.d.ts +3 -0
- package/dist/routes/packs.d.ts +3 -0
- package/dist/routes/preference-registry.d.ts +5 -0
- package/dist/routes/rigs.d.ts +3 -0
- package/dist/routes/scheduled-tasks.d.ts +3 -0
- package/dist/routes/sessions.d.ts +15 -0
- package/dist/routes/social.d.ts +3 -0
- package/dist/routes/transcriptions.d.ts +3 -0
- package/dist/routes/workspace-capture.d.ts +59 -0
- package/dist/routes/workspace-instruction-policies.d.ts +3 -0
- package/dist/routes/workspace-state.d.ts +3 -0
- package/dist/routes/workspaces.d.ts +5 -0
- package/dist/sandbox/access.d.ts +21 -0
- package/dist/sandbox/auth-callout.d.ts +30 -0
- package/dist/sandbox/channel-a.d.ts +37 -0
- package/dist/sandbox/enrollment.d.ts +120 -0
- package/dist/sandbox/machines.d.ts +29 -0
- package/dist/sandbox/metrics-ingestion.d.ts +128 -0
- package/dist/sandbox/rematerialize.d.ts +24 -0
- package/dist/sandbox/viewer.d.ts +251 -0
- package/dist/transcription/providers/azure-openai.d.ts +9 -0
- package/dist/transcription/providers/codex-subscription.d.ts +9 -0
- package/dist/transcription/providers/openai.d.ts +9 -0
- package/dist/transcription/service.d.ts +10 -0
- package/dist/workspace-state-projection.d.ts +16 -0
- package/package.json +13 -13
- package/src/app.ts +67 -5
- package/src/integrations/google-drive.ts +869 -0
- package/src/integrations/oauth-client.ts +41 -6
- package/src/integrations/slack-bot.ts +756 -26
- package/src/mcp/server.ts +200 -3
- package/src/mcp/session-view.ts +1 -0
- package/src/mcp/toolspace.ts +17 -9
- package/src/routes/connections.ts +137 -7
- package/src/routes/documents.ts +13 -2
- package/src/routes/github.ts +7 -5
- package/src/routes/insights.ts +30 -0
- package/src/routes/preference-registry.ts +482 -0
- package/src/routes/sessions.ts +10 -0
- package/src/routes/transcriptions.ts +155 -0
- package/src/routes/workspace-state.ts +61 -0
- package/src/sandbox/channel-a.ts +2 -0
- package/src/sandbox/rematerialize.ts +111 -4
- package/src/sandbox/viewer.ts +29 -12
- package/src/transcription/providers/azure-openai.ts +45 -0
- package/src/transcription/providers/codex-subscription.ts +100 -0
- package/src/transcription/providers/openai.ts +93 -0
- package/src/transcription/service.ts +121 -0
- package/src/workspace-state-projection.ts +244 -0
- package/dist/chunk-5TULDPWX.js.map +0 -1
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import type { Settings } from "@opengeni/config";
|
|
3
|
+
import {
|
|
4
|
+
GOOGLE_DRIVE_CREDENTIAL_LABEL,
|
|
5
|
+
GOOGLE_DRIVE_CREDENTIAL_ROLE,
|
|
6
|
+
GOOGLE_DRIVE_METADATA_READONLY_SCOPE,
|
|
7
|
+
GOOGLE_DRIVE_PROVIDER_DOMAIN,
|
|
8
|
+
GOOGLE_DRIVE_READONLY_SCOPE,
|
|
9
|
+
GoogleDriveBrowseItem,
|
|
10
|
+
GoogleDriveBrowseResponse,
|
|
11
|
+
GoogleDriveConnectionMetadata,
|
|
12
|
+
GoogleDriveOAuthStartResponse,
|
|
13
|
+
SaveGoogleDriveSourceRequest,
|
|
14
|
+
type GoogleDriveOAuthStartRequest,
|
|
15
|
+
} from "@opengeni/contracts/google-drive";
|
|
16
|
+
import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
|
|
17
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
18
|
+
import {
|
|
19
|
+
buildConnectionTokenResolver,
|
|
20
|
+
consumeIntegrationOAuthStateNonce,
|
|
21
|
+
createConnection,
|
|
22
|
+
decryptEnvironmentValue,
|
|
23
|
+
encryptEnvironmentValue,
|
|
24
|
+
getConnectionMetadata,
|
|
25
|
+
getWorkspaceGrant,
|
|
26
|
+
loadConnectionCredentialForBroker,
|
|
27
|
+
updateConnection,
|
|
28
|
+
} from "@opengeni/db";
|
|
29
|
+
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
30
|
+
import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
|
|
31
|
+
import { HTTPException } from "hono/http-exception";
|
|
32
|
+
import {
|
|
33
|
+
integrationBaseUrl,
|
|
34
|
+
oauthStateTtlMs,
|
|
35
|
+
requireIntegrationsStateSecret,
|
|
36
|
+
} from "./oauth-client";
|
|
37
|
+
|
|
38
|
+
const GOOGLE_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
39
|
+
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
40
|
+
const GOOGLE_DRIVE_API_BASE = "https://www.googleapis.com/drive/v3";
|
|
41
|
+
const GOOGLE_DRIVE_FOLDER_MIME_TYPE = "application/vnd.google-apps.folder";
|
|
42
|
+
const GOOGLE_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
43
|
+
const GOOGLE_REQUEST_TIMEOUT_MS = 10_000;
|
|
44
|
+
const GOOGLE_DRIVE_PAGE_SIZE = 100;
|
|
45
|
+
const GOOGLE_DRIVE_RETURN_PATH = (workspaceId: string) => `/workspaces/${workspaceId}/capabilities`;
|
|
46
|
+
|
|
47
|
+
type GoogleDriveOAuthState = {
|
|
48
|
+
accountId: string;
|
|
49
|
+
workspaceId: string;
|
|
50
|
+
subjectId: string;
|
|
51
|
+
returnPath: string;
|
|
52
|
+
encryptedPkceVerifier: string;
|
|
53
|
+
connectionId?: string;
|
|
54
|
+
connectionVersion?: number;
|
|
55
|
+
nonce: string;
|
|
56
|
+
iat: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type GoogleTokenResponse = {
|
|
60
|
+
accessToken: string;
|
|
61
|
+
refreshToken?: string;
|
|
62
|
+
tokenType: string;
|
|
63
|
+
expiresAt: Date | null;
|
|
64
|
+
scopes: string[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export async function startGoogleDriveOAuth(
|
|
68
|
+
deps: ApiRouteDeps,
|
|
69
|
+
input: {
|
|
70
|
+
accountId: string;
|
|
71
|
+
workspaceId: string;
|
|
72
|
+
subjectId: string;
|
|
73
|
+
requestUrl: string;
|
|
74
|
+
payload: GoogleDriveOAuthStartRequest;
|
|
75
|
+
},
|
|
76
|
+
): Promise<GoogleDriveOAuthStartResponse> {
|
|
77
|
+
const google = requireGoogleDriveSettings(deps.settings);
|
|
78
|
+
const existing = input.payload.connectionId
|
|
79
|
+
? await getConnectionMetadata(
|
|
80
|
+
deps.db,
|
|
81
|
+
input.workspaceId,
|
|
82
|
+
input.payload.connectionId,
|
|
83
|
+
input.subjectId,
|
|
84
|
+
)
|
|
85
|
+
: null;
|
|
86
|
+
if (input.payload.connectionId && !existing) {
|
|
87
|
+
throw new HTTPException(404, { message: "Google Drive connection not found" });
|
|
88
|
+
}
|
|
89
|
+
if (existing) {
|
|
90
|
+
requireGoogleDriveConnection(existing, input.subjectId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const key = requireEnvironmentEncryption(deps.settings);
|
|
94
|
+
const verifier = randomBytes(48).toString("base64url");
|
|
95
|
+
const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
96
|
+
const redirectUri = `${baseUrl}/v1/integrations/google-drive/callback`;
|
|
97
|
+
const state = createSignedState(requireIntegrationsStateSecret(deps.settings), {
|
|
98
|
+
accountId: input.accountId,
|
|
99
|
+
workspaceId: input.workspaceId,
|
|
100
|
+
subjectId: input.subjectId,
|
|
101
|
+
returnPath: GOOGLE_DRIVE_RETURN_PATH(input.workspaceId),
|
|
102
|
+
encryptedPkceVerifier: encryptEnvironmentValue(key, verifier),
|
|
103
|
+
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
104
|
+
});
|
|
105
|
+
const authorizationUrl = new URL(GOOGLE_AUTHORIZATION_URL);
|
|
106
|
+
authorizationUrl.searchParams.set("client_id", google.clientId);
|
|
107
|
+
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
|
108
|
+
authorizationUrl.searchParams.set("response_type", "code");
|
|
109
|
+
authorizationUrl.searchParams.set("scope", GOOGLE_DRIVE_READONLY_SCOPE);
|
|
110
|
+
authorizationUrl.searchParams.set("access_type", "offline");
|
|
111
|
+
authorizationUrl.searchParams.set("include_granted_scopes", "true");
|
|
112
|
+
authorizationUrl.searchParams.set("prompt", "consent select_account");
|
|
113
|
+
authorizationUrl.searchParams.set("state", state);
|
|
114
|
+
authorizationUrl.searchParams.set("code_challenge_method", "S256");
|
|
115
|
+
authorizationUrl.searchParams.set(
|
|
116
|
+
"code_challenge",
|
|
117
|
+
createHash("sha256").update(verifier).digest("base64url"),
|
|
118
|
+
);
|
|
119
|
+
return GoogleDriveOAuthStartResponse.parse({
|
|
120
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
121
|
+
expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function completeGoogleDriveOAuthCallback(
|
|
126
|
+
deps: ApiRouteDeps,
|
|
127
|
+
input: {
|
|
128
|
+
code?: string | undefined;
|
|
129
|
+
state?: string | undefined;
|
|
130
|
+
error?: string | undefined;
|
|
131
|
+
requestUrl: string;
|
|
132
|
+
},
|
|
133
|
+
): Promise<{ redirectTo: string }> {
|
|
134
|
+
const baseUrl = integrationBaseUrl(deps.settings.publicBaseUrl, input.requestUrl);
|
|
135
|
+
const returnBaseUrl = deps.settings.webBaseUrl?.replace(/\/+$/, "") ?? baseUrl;
|
|
136
|
+
let state: GoogleDriveOAuthState | null = null;
|
|
137
|
+
try {
|
|
138
|
+
state = readGoogleDriveOAuthState(input.state, deps.settings);
|
|
139
|
+
await requireGoogleDriveCallbackGrant(deps, state);
|
|
140
|
+
const consumed = await consumeIntegrationOAuthStateNonce(deps.db, {
|
|
141
|
+
accountId: state.accountId,
|
|
142
|
+
workspaceId: state.workspaceId,
|
|
143
|
+
subjectId: state.subjectId,
|
|
144
|
+
nonce: state.nonce,
|
|
145
|
+
expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
|
|
146
|
+
now: new Date(),
|
|
147
|
+
});
|
|
148
|
+
if (!consumed) {
|
|
149
|
+
throw new HTTPException(400, { message: "Google Drive OAuth state has already been used" });
|
|
150
|
+
}
|
|
151
|
+
if (input.error) {
|
|
152
|
+
throw new GoogleDriveCallbackError("provider_denied");
|
|
153
|
+
}
|
|
154
|
+
if (!input.code) {
|
|
155
|
+
throw new GoogleDriveCallbackError("missing_code");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const google = requireGoogleDriveSettings(deps.settings);
|
|
159
|
+
const key = requireEnvironmentEncryption(deps.settings);
|
|
160
|
+
const verifier = decryptEnvironmentValue(key, state.encryptedPkceVerifier);
|
|
161
|
+
const redirectUri = `${baseUrl}/v1/integrations/google-drive/callback`;
|
|
162
|
+
const fetchImpl = deps.googleDriveFetch ?? fetch;
|
|
163
|
+
const token = await exchangeGoogleAuthorizationCode(
|
|
164
|
+
{
|
|
165
|
+
code: input.code,
|
|
166
|
+
verifier,
|
|
167
|
+
clientId: google.clientId,
|
|
168
|
+
clientSecret: google.clientSecret,
|
|
169
|
+
redirectUri,
|
|
170
|
+
},
|
|
171
|
+
fetchImpl,
|
|
172
|
+
);
|
|
173
|
+
if (!token.scopes.includes(GOOGLE_DRIVE_READONLY_SCOPE)) {
|
|
174
|
+
throw new GoogleDriveCallbackError("scope_not_granted");
|
|
175
|
+
}
|
|
176
|
+
const identity = await verifyGoogleDriveIdentity(token.accessToken, fetchImpl);
|
|
177
|
+
await requireGoogleDriveCallbackGrant(deps, state);
|
|
178
|
+
const existing = state.connectionId
|
|
179
|
+
? await getConnectionMetadata(deps.db, state.workspaceId, state.connectionId, state.subjectId)
|
|
180
|
+
: null;
|
|
181
|
+
if (state.connectionId && !existing) {
|
|
182
|
+
throw new GoogleDriveCallbackError("connection_conflict");
|
|
183
|
+
}
|
|
184
|
+
if (existing) {
|
|
185
|
+
requireGoogleDriveConnection(existing, state.subjectId);
|
|
186
|
+
if (existing.version !== state.connectionVersion) {
|
|
187
|
+
throw new GoogleDriveCallbackError("connection_conflict");
|
|
188
|
+
}
|
|
189
|
+
const previous = GoogleDriveConnectionMetadata.parse(existing.metadata);
|
|
190
|
+
if (previous.googlePermissionId !== identity.permissionId) {
|
|
191
|
+
throw new GoogleDriveCallbackError("account_mismatch");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const previousMetadata = existing
|
|
195
|
+
? GoogleDriveConnectionMetadata.parse(existing.metadata)
|
|
196
|
+
: null;
|
|
197
|
+
let refreshToken = token.refreshToken;
|
|
198
|
+
if (!refreshToken && existing) {
|
|
199
|
+
const previousCredential = await loadConnectionCredentialForBroker(deps.db, deps.settings, {
|
|
200
|
+
workspaceId: state.workspaceId,
|
|
201
|
+
connectionId: existing.id,
|
|
202
|
+
providerDomain: GOOGLE_DRIVE_PROVIDER_DOMAIN,
|
|
203
|
+
kind: "oauth2",
|
|
204
|
+
subjectId: state.subjectId,
|
|
205
|
+
allowSubjectOwned: true,
|
|
206
|
+
});
|
|
207
|
+
refreshToken = optionalString(previousCredential?.credential.refresh_token) ?? undefined;
|
|
208
|
+
}
|
|
209
|
+
if (!refreshToken) {
|
|
210
|
+
throw new GoogleDriveCallbackError("refresh_token_missing");
|
|
211
|
+
}
|
|
212
|
+
const credentialEncrypted = encryptEnvironmentValue(
|
|
213
|
+
key,
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
access_token: token.accessToken,
|
|
216
|
+
refresh_token: refreshToken,
|
|
217
|
+
token_type: token.tokenType,
|
|
218
|
+
...(token.expiresAt ? { expires_at: token.expiresAt.toISOString() } : {}),
|
|
219
|
+
scope: token.scopes.join(" "),
|
|
220
|
+
token_endpoint: GOOGLE_TOKEN_URL,
|
|
221
|
+
client_id: google.clientId,
|
|
222
|
+
client_secret: google.clientSecret,
|
|
223
|
+
token_endpoint_auth_method: "client_secret_post",
|
|
224
|
+
}),
|
|
225
|
+
);
|
|
226
|
+
const metadata = GoogleDriveConnectionMetadata.parse({
|
|
227
|
+
credentialRole: GOOGLE_DRIVE_CREDENTIAL_ROLE,
|
|
228
|
+
credentialLabel: GOOGLE_DRIVE_CREDENTIAL_LABEL,
|
|
229
|
+
googlePermissionId: identity.permissionId,
|
|
230
|
+
googleEmail: identity.emailAddress,
|
|
231
|
+
googleDisplayName: identity.displayName,
|
|
232
|
+
verifiedAt: new Date().toISOString(),
|
|
233
|
+
accessMode: "readonly",
|
|
234
|
+
...(previousMetadata?.selectedSources
|
|
235
|
+
? { selectedSources: previousMetadata.selectedSources }
|
|
236
|
+
: previousMetadata?.selectedSource
|
|
237
|
+
? { selectedSources: [previousMetadata.selectedSource] }
|
|
238
|
+
: {}),
|
|
239
|
+
});
|
|
240
|
+
const connection = existing
|
|
241
|
+
? await updateConnection(deps.db, {
|
|
242
|
+
workspaceId: state.workspaceId,
|
|
243
|
+
connectionId: existing.id,
|
|
244
|
+
visibleToSubjectId: state.subjectId,
|
|
245
|
+
expectedVersion: existing.version,
|
|
246
|
+
subjectId: state.subjectId,
|
|
247
|
+
providerDomain: GOOGLE_DRIVE_PROVIDER_DOMAIN,
|
|
248
|
+
kind: "oauth2",
|
|
249
|
+
status: "active",
|
|
250
|
+
credentialEncrypted,
|
|
251
|
+
grantedScopes: token.scopes,
|
|
252
|
+
expiresAt: token.expiresAt,
|
|
253
|
+
metadata,
|
|
254
|
+
updatedBySubjectId: state.subjectId,
|
|
255
|
+
})
|
|
256
|
+
: await createConnection(deps.db, {
|
|
257
|
+
accountId: state.accountId,
|
|
258
|
+
workspaceId: state.workspaceId,
|
|
259
|
+
subjectId: state.subjectId,
|
|
260
|
+
providerDomain: GOOGLE_DRIVE_PROVIDER_DOMAIN,
|
|
261
|
+
kind: "oauth2",
|
|
262
|
+
credentialEncrypted,
|
|
263
|
+
grantedScopes: token.scopes,
|
|
264
|
+
expiresAt: token.expiresAt,
|
|
265
|
+
metadata,
|
|
266
|
+
createdBySubjectId: state.subjectId,
|
|
267
|
+
});
|
|
268
|
+
if (!connection) {
|
|
269
|
+
throw new GoogleDriveCallbackError("connection_conflict");
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
redirectTo: googleDriveReturnUrl(returnBaseUrl, state.returnPath, "connected", connection.id),
|
|
273
|
+
};
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return {
|
|
276
|
+
redirectTo: googleDriveReturnUrl(
|
|
277
|
+
returnBaseUrl,
|
|
278
|
+
state?.returnPath ?? "/integrations",
|
|
279
|
+
"error",
|
|
280
|
+
googleDriveErrorReason(error),
|
|
281
|
+
),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export async function browseGoogleDrive(
|
|
287
|
+
deps: ApiRouteDeps,
|
|
288
|
+
input: {
|
|
289
|
+
workspaceId: string;
|
|
290
|
+
subjectId: string;
|
|
291
|
+
connectionId: string;
|
|
292
|
+
parentId: string;
|
|
293
|
+
pageToken?: string | undefined;
|
|
294
|
+
},
|
|
295
|
+
) {
|
|
296
|
+
const connection = await getConnectionMetadata(
|
|
297
|
+
deps.db,
|
|
298
|
+
input.workspaceId,
|
|
299
|
+
input.connectionId,
|
|
300
|
+
input.subjectId,
|
|
301
|
+
);
|
|
302
|
+
if (!connection) {
|
|
303
|
+
throw new HTTPException(404, { message: "Google Drive connection not found" });
|
|
304
|
+
}
|
|
305
|
+
requireGoogleDriveConnection(connection, input.subjectId);
|
|
306
|
+
const parentId = validDriveId(input.parentId, "parentId");
|
|
307
|
+
const currentItem = await resolveGoogleDriveBoundaryItem(deps, {
|
|
308
|
+
workspaceId: input.workspaceId,
|
|
309
|
+
subjectId: input.subjectId,
|
|
310
|
+
connectionId: input.connectionId,
|
|
311
|
+
sourceId: parentId,
|
|
312
|
+
});
|
|
313
|
+
const url = new URL(`${GOOGLE_DRIVE_API_BASE}/files`);
|
|
314
|
+
url.searchParams.set("q", `'${parentId}' in parents and trashed = false`);
|
|
315
|
+
url.searchParams.set("pageSize", String(GOOGLE_DRIVE_PAGE_SIZE));
|
|
316
|
+
url.searchParams.set("orderBy", "folder,name_natural");
|
|
317
|
+
url.searchParams.set("spaces", "drive");
|
|
318
|
+
url.searchParams.set("supportsAllDrives", "true");
|
|
319
|
+
url.searchParams.set("includeItemsFromAllDrives", "true");
|
|
320
|
+
url.searchParams.set(
|
|
321
|
+
"fields",
|
|
322
|
+
"nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,driveId,size,webViewLink)",
|
|
323
|
+
);
|
|
324
|
+
if (input.pageToken) {
|
|
325
|
+
url.searchParams.set("pageToken", validPageToken(input.pageToken));
|
|
326
|
+
}
|
|
327
|
+
const payload = await googleDriveApiRequest(deps, {
|
|
328
|
+
workspaceId: input.workspaceId,
|
|
329
|
+
subjectId: input.subjectId,
|
|
330
|
+
connectionId: input.connectionId,
|
|
331
|
+
url,
|
|
332
|
+
label: "Google Drive file list",
|
|
333
|
+
});
|
|
334
|
+
const record = objectRecord(payload);
|
|
335
|
+
const items = Array.isArray(record.files)
|
|
336
|
+
? record.files
|
|
337
|
+
.map(parseDriveItem)
|
|
338
|
+
.filter((item): item is GoogleDriveBrowseItem => item !== null)
|
|
339
|
+
: [];
|
|
340
|
+
const current =
|
|
341
|
+
(await getConnectionMetadata(
|
|
342
|
+
deps.db,
|
|
343
|
+
input.workspaceId,
|
|
344
|
+
input.connectionId,
|
|
345
|
+
input.subjectId,
|
|
346
|
+
)) ?? connection;
|
|
347
|
+
return GoogleDriveBrowseResponse.parse({
|
|
348
|
+
connection: current,
|
|
349
|
+
parentId,
|
|
350
|
+
current: currentItem,
|
|
351
|
+
items,
|
|
352
|
+
nextPageToken: optionalString(record.nextPageToken),
|
|
353
|
+
incompleteSearch: record.incompleteSearch === true,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function saveGoogleDriveSource(
|
|
358
|
+
deps: ApiRouteDeps,
|
|
359
|
+
input: {
|
|
360
|
+
workspaceId: string;
|
|
361
|
+
subjectId: string;
|
|
362
|
+
connectionId: string;
|
|
363
|
+
payload: unknown;
|
|
364
|
+
},
|
|
365
|
+
) {
|
|
366
|
+
const parsedPayload = SaveGoogleDriveSourceRequest.safeParse(input.payload);
|
|
367
|
+
if (!parsedPayload.success) {
|
|
368
|
+
throw new HTTPException(400, { message: "invalid Google Drive source selection" });
|
|
369
|
+
}
|
|
370
|
+
const payload = parsedPayload.data;
|
|
371
|
+
const existing = await getConnectionMetadata(
|
|
372
|
+
deps.db,
|
|
373
|
+
input.workspaceId,
|
|
374
|
+
input.connectionId,
|
|
375
|
+
input.subjectId,
|
|
376
|
+
);
|
|
377
|
+
if (!existing) {
|
|
378
|
+
throw new HTTPException(404, { message: "Google Drive connection not found" });
|
|
379
|
+
}
|
|
380
|
+
requireGoogleDriveConnection(existing, input.subjectId);
|
|
381
|
+
const verifiedSources = [];
|
|
382
|
+
for (const source of payload.sources) {
|
|
383
|
+
const sourceId = validDriveId(source.id, "source.id");
|
|
384
|
+
const verified = await resolveGoogleDriveBoundaryItem(deps, {
|
|
385
|
+
workspaceId: input.workspaceId,
|
|
386
|
+
subjectId: input.subjectId,
|
|
387
|
+
connectionId: input.connectionId,
|
|
388
|
+
sourceId,
|
|
389
|
+
});
|
|
390
|
+
if (
|
|
391
|
+
verified.name !== source.name ||
|
|
392
|
+
verified.mimeType !== source.mimeType ||
|
|
393
|
+
verified.driveId !== source.driveId
|
|
394
|
+
) {
|
|
395
|
+
throw new HTTPException(409, {
|
|
396
|
+
message: "Google Drive source changed while it was being selected; browse again",
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
verifiedSources.push(verified);
|
|
400
|
+
}
|
|
401
|
+
const latest =
|
|
402
|
+
(await getConnectionMetadata(
|
|
403
|
+
deps.db,
|
|
404
|
+
input.workspaceId,
|
|
405
|
+
input.connectionId,
|
|
406
|
+
input.subjectId,
|
|
407
|
+
)) ?? existing;
|
|
408
|
+
const latestMetadata = requireGoogleDriveConnection(latest, input.subjectId);
|
|
409
|
+
const updated = await updateConnection(deps.db, {
|
|
410
|
+
workspaceId: input.workspaceId,
|
|
411
|
+
connectionId: latest.id,
|
|
412
|
+
visibleToSubjectId: input.subjectId,
|
|
413
|
+
expectedVersion: latest.version,
|
|
414
|
+
metadata: GoogleDriveConnectionMetadata.parse({
|
|
415
|
+
...latestMetadata,
|
|
416
|
+
selectedSource: null,
|
|
417
|
+
selectedSources: verifiedSources.map((verified) => ({
|
|
418
|
+
id: verified.id,
|
|
419
|
+
name: verified.name,
|
|
420
|
+
mimeType: verified.mimeType,
|
|
421
|
+
driveId: verified.driveId,
|
|
422
|
+
targetScope: payload.targetScope,
|
|
423
|
+
syncCadence: payload.syncCadence,
|
|
424
|
+
readPolicy: payload.readPolicy,
|
|
425
|
+
selectedAt: new Date().toISOString(),
|
|
426
|
+
})),
|
|
427
|
+
}),
|
|
428
|
+
updatedBySubjectId: input.subjectId,
|
|
429
|
+
});
|
|
430
|
+
if (!updated) {
|
|
431
|
+
throw new HTTPException(409, { message: "Google Drive connection changed; try again" });
|
|
432
|
+
}
|
|
433
|
+
return updated;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function googleDriveFileMetadataUrl(fileId: string): URL {
|
|
437
|
+
const url = new URL(`${GOOGLE_DRIVE_API_BASE}/files/${encodeURIComponent(fileId)}`);
|
|
438
|
+
url.searchParams.set("supportsAllDrives", "true");
|
|
439
|
+
url.searchParams.set("fields", "id,name,mimeType,modifiedTime,driveId,size,webViewLink");
|
|
440
|
+
return url;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function resolveGoogleDriveBoundaryItem(
|
|
444
|
+
deps: ApiRouteDeps,
|
|
445
|
+
input: {
|
|
446
|
+
workspaceId: string;
|
|
447
|
+
subjectId: string;
|
|
448
|
+
connectionId: string;
|
|
449
|
+
sourceId: string;
|
|
450
|
+
},
|
|
451
|
+
): Promise<GoogleDriveBrowseItem> {
|
|
452
|
+
if (input.sourceId === "root") {
|
|
453
|
+
return GoogleDriveBrowseItem.parse({
|
|
454
|
+
id: "root",
|
|
455
|
+
name: "My Drive",
|
|
456
|
+
mimeType: "application/vnd.google-apps.folder",
|
|
457
|
+
kind: "folder",
|
|
458
|
+
driveId: null,
|
|
459
|
+
modifiedTime: null,
|
|
460
|
+
size: null,
|
|
461
|
+
webViewLink: "https://drive.google.com/drive/my-drive",
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const fileItem = parseDriveItem(
|
|
465
|
+
objectRecord(
|
|
466
|
+
await googleDriveApiRequest(deps, {
|
|
467
|
+
workspaceId: input.workspaceId,
|
|
468
|
+
subjectId: input.subjectId,
|
|
469
|
+
connectionId: input.connectionId,
|
|
470
|
+
url: googleDriveFileMetadataUrl(input.sourceId),
|
|
471
|
+
label: "Google Drive source metadata",
|
|
472
|
+
}),
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
if (!fileItem) {
|
|
476
|
+
throw new HTTPException(502, { message: "Google Drive returned invalid source metadata" });
|
|
477
|
+
}
|
|
478
|
+
if (fileItem.kind !== "folder") {
|
|
479
|
+
throw new HTTPException(400, { message: "Google Drive source must be a Drive or folder" });
|
|
480
|
+
}
|
|
481
|
+
if (fileItem.driveId !== fileItem.id) return fileItem;
|
|
482
|
+
|
|
483
|
+
const driveUrl = new URL(
|
|
484
|
+
`${GOOGLE_DRIVE_API_BASE}/drives/${encodeURIComponent(fileItem.driveId)}`,
|
|
485
|
+
);
|
|
486
|
+
driveUrl.searchParams.set("fields", "id,name");
|
|
487
|
+
let drive: Record<string, unknown>;
|
|
488
|
+
try {
|
|
489
|
+
drive = objectRecord(
|
|
490
|
+
await googleDriveApiRequest(deps, {
|
|
491
|
+
workspaceId: input.workspaceId,
|
|
492
|
+
subjectId: input.subjectId,
|
|
493
|
+
connectionId: input.connectionId,
|
|
494
|
+
url: driveUrl,
|
|
495
|
+
label: "Google Shared Drive metadata",
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (error instanceof HTTPException && error.status === 403) return fileItem;
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
const driveName = optionalString(drive.name);
|
|
503
|
+
if (optionalString(drive.id) !== fileItem.id || !driveName) {
|
|
504
|
+
throw new HTTPException(502, {
|
|
505
|
+
message: "Google Drive returned invalid Shared Drive metadata",
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return GoogleDriveBrowseItem.parse({
|
|
509
|
+
...fileItem,
|
|
510
|
+
name: driveName,
|
|
511
|
+
webViewLink: `https://drive.google.com/drive/folders/${encodeURIComponent(fileItem.id)}`,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function requireGoogleDriveSettings(settings: Settings): {
|
|
516
|
+
clientId: string;
|
|
517
|
+
clientSecret: string;
|
|
518
|
+
} {
|
|
519
|
+
const clientId = settings.googleDriveClientId?.trim();
|
|
520
|
+
const clientSecret = settings.googleDriveClientSecret?.trim();
|
|
521
|
+
if (!clientId || !clientSecret) {
|
|
522
|
+
throw new HTTPException(503, {
|
|
523
|
+
message:
|
|
524
|
+
"Google Drive requires OPENGENI_GOOGLE_DRIVE_CLIENT_ID and OPENGENI_GOOGLE_DRIVE_CLIENT_SECRET",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return { clientId, clientSecret };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function requireGoogleDriveConnection(
|
|
531
|
+
connection: {
|
|
532
|
+
subjectId: string | null;
|
|
533
|
+
providerDomain: string;
|
|
534
|
+
kind: string;
|
|
535
|
+
grantedScopes: string[];
|
|
536
|
+
metadata: Record<string, unknown>;
|
|
537
|
+
},
|
|
538
|
+
subjectId: string,
|
|
539
|
+
) {
|
|
540
|
+
const parsed = GoogleDriveConnectionMetadata.safeParse(connection.metadata);
|
|
541
|
+
if (
|
|
542
|
+
connection.subjectId !== subjectId ||
|
|
543
|
+
connection.providerDomain !== GOOGLE_DRIVE_PROVIDER_DOMAIN ||
|
|
544
|
+
connection.kind !== "oauth2" ||
|
|
545
|
+
!parsed.success
|
|
546
|
+
) {
|
|
547
|
+
throw new HTTPException(422, { message: "connection is not this user's Google Drive" });
|
|
548
|
+
}
|
|
549
|
+
if (
|
|
550
|
+
!connection.grantedScopes.includes(GOOGLE_DRIVE_READONLY_SCOPE) &&
|
|
551
|
+
!connection.grantedScopes.includes(GOOGLE_DRIVE_METADATA_READONLY_SCOPE)
|
|
552
|
+
) {
|
|
553
|
+
throw new HTTPException(401, {
|
|
554
|
+
message: "Google Drive needs to be reconnected with metadata access",
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return parsed.data;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function readGoogleDriveOAuthState(
|
|
561
|
+
raw: string | undefined,
|
|
562
|
+
settings: Settings,
|
|
563
|
+
): GoogleDriveOAuthState {
|
|
564
|
+
if (!raw) {
|
|
565
|
+
throw new HTTPException(400, { message: "missing Google Drive OAuth state" });
|
|
566
|
+
}
|
|
567
|
+
const payload = readSignedState(raw, requireIntegrationsStateSecret(settings)) as Record<
|
|
568
|
+
string,
|
|
569
|
+
unknown
|
|
570
|
+
> | null;
|
|
571
|
+
if (!payload) {
|
|
572
|
+
throw new HTTPException(400, { message: "invalid or expired Google Drive OAuth state" });
|
|
573
|
+
}
|
|
574
|
+
const iat = numberValue(payload.iat);
|
|
575
|
+
const now = Math.floor(Date.now() / 1000);
|
|
576
|
+
if (iat === undefined || now < iat || now - iat > oauthStateTtlMs / 1000) {
|
|
577
|
+
throw new HTTPException(400, { message: "invalid or expired Google Drive OAuth state" });
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
accountId: requiredString(payload.accountId, "state.accountId"),
|
|
581
|
+
workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
|
|
582
|
+
subjectId: requiredString(payload.subjectId, "state.subjectId"),
|
|
583
|
+
returnPath: requiredString(payload.returnPath, "state.returnPath"),
|
|
584
|
+
encryptedPkceVerifier: requiredString(
|
|
585
|
+
payload.encryptedPkceVerifier,
|
|
586
|
+
"state.encryptedPkceVerifier",
|
|
587
|
+
),
|
|
588
|
+
...(optionalString(payload.connectionId)
|
|
589
|
+
? { connectionId: optionalString(payload.connectionId)! }
|
|
590
|
+
: {}),
|
|
591
|
+
...(numberValue(payload.connectionVersion) !== undefined
|
|
592
|
+
? { connectionVersion: numberValue(payload.connectionVersion)! }
|
|
593
|
+
: {}),
|
|
594
|
+
nonce: requiredString(payload.nonce, "state.nonce"),
|
|
595
|
+
iat,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function requireGoogleDriveCallbackGrant(
|
|
600
|
+
deps: ApiRouteDeps,
|
|
601
|
+
state: GoogleDriveOAuthState,
|
|
602
|
+
): Promise<void> {
|
|
603
|
+
const grant = await getWorkspaceGrant(deps.db, state.subjectId, state.workspaceId);
|
|
604
|
+
if (
|
|
605
|
+
!grant ||
|
|
606
|
+
grant.accountId !== state.accountId ||
|
|
607
|
+
!hasPermission(grant.permissions, "connections:write")
|
|
608
|
+
) {
|
|
609
|
+
throw new HTTPException(403, {
|
|
610
|
+
message: "Google Drive OAuth subject no longer has permission for this workspace",
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function exchangeGoogleAuthorizationCode(
|
|
616
|
+
input: {
|
|
617
|
+
code: string;
|
|
618
|
+
verifier: string;
|
|
619
|
+
clientId: string;
|
|
620
|
+
clientSecret: string;
|
|
621
|
+
redirectUri: string;
|
|
622
|
+
},
|
|
623
|
+
fetchImpl: FetchLike,
|
|
624
|
+
): Promise<GoogleTokenResponse> {
|
|
625
|
+
const body = new URLSearchParams({
|
|
626
|
+
code: input.code,
|
|
627
|
+
code_verifier: input.verifier,
|
|
628
|
+
client_id: input.clientId,
|
|
629
|
+
client_secret: input.clientSecret,
|
|
630
|
+
redirect_uri: input.redirectUri,
|
|
631
|
+
grant_type: "authorization_code",
|
|
632
|
+
});
|
|
633
|
+
const response = await providerFetch(fetchImpl, GOOGLE_TOKEN_URL, {
|
|
634
|
+
method: "POST",
|
|
635
|
+
headers: {
|
|
636
|
+
accept: "application/json",
|
|
637
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
638
|
+
},
|
|
639
|
+
body,
|
|
640
|
+
});
|
|
641
|
+
if (!response.ok) {
|
|
642
|
+
await response.body?.cancel().catch(() => undefined);
|
|
643
|
+
throw new GoogleDriveCallbackError("token_exchange_failed");
|
|
644
|
+
}
|
|
645
|
+
const payload = objectRecord(
|
|
646
|
+
await readResponseJsonBounded<unknown>(
|
|
647
|
+
response,
|
|
648
|
+
GOOGLE_RESPONSE_MAX_BYTES,
|
|
649
|
+
"Google OAuth token response",
|
|
650
|
+
),
|
|
651
|
+
);
|
|
652
|
+
const accessToken = optionalString(payload.access_token);
|
|
653
|
+
if (!accessToken) {
|
|
654
|
+
throw new GoogleDriveCallbackError("token_exchange_failed");
|
|
655
|
+
}
|
|
656
|
+
const expiresIn = numberValue(payload.expires_in);
|
|
657
|
+
return {
|
|
658
|
+
accessToken,
|
|
659
|
+
...(optionalString(payload.refresh_token)
|
|
660
|
+
? { refreshToken: optionalString(payload.refresh_token)! }
|
|
661
|
+
: {}),
|
|
662
|
+
tokenType: optionalString(payload.token_type) ?? "Bearer",
|
|
663
|
+
expiresAt: expiresIn ? new Date(Date.now() + expiresIn * 1000) : null,
|
|
664
|
+
scopes: uniqueStrings((optionalString(payload.scope) ?? "").split(/\s+/)),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function verifyGoogleDriveIdentity(accessToken: string, fetchImpl: FetchLike) {
|
|
669
|
+
const url = new URL(`${GOOGLE_DRIVE_API_BASE}/about`);
|
|
670
|
+
url.searchParams.set("fields", "user(displayName,emailAddress,permissionId)");
|
|
671
|
+
const response = await providerFetch(fetchImpl, url, {
|
|
672
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" },
|
|
673
|
+
});
|
|
674
|
+
if (!response.ok) {
|
|
675
|
+
await response.body?.cancel().catch(() => undefined);
|
|
676
|
+
throw new GoogleDriveCallbackError("identity_verification_failed");
|
|
677
|
+
}
|
|
678
|
+
const payload = objectRecord(
|
|
679
|
+
await readResponseJsonBounded<unknown>(
|
|
680
|
+
response,
|
|
681
|
+
GOOGLE_RESPONSE_MAX_BYTES,
|
|
682
|
+
"Google Drive identity response",
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
const user = objectRecord(payload.user);
|
|
686
|
+
const permissionId = optionalString(user.permissionId);
|
|
687
|
+
const emailAddress = optionalString(user.emailAddress);
|
|
688
|
+
if (!permissionId || !emailAddress) {
|
|
689
|
+
throw new GoogleDriveCallbackError("identity_verification_failed");
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
permissionId,
|
|
693
|
+
emailAddress,
|
|
694
|
+
displayName: optionalString(user.displayName),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function googleDriveApiRequest(
|
|
699
|
+
deps: ApiRouteDeps,
|
|
700
|
+
input: {
|
|
701
|
+
workspaceId: string;
|
|
702
|
+
subjectId: string;
|
|
703
|
+
connectionId: string;
|
|
704
|
+
url: URL;
|
|
705
|
+
label: string;
|
|
706
|
+
},
|
|
707
|
+
): Promise<unknown> {
|
|
708
|
+
const resolver = buildConnectionTokenResolver(deps.db, deps.settings);
|
|
709
|
+
const resolve = async (forceRefresh: boolean) =>
|
|
710
|
+
await resolver({
|
|
711
|
+
workspaceId: input.workspaceId,
|
|
712
|
+
subjectId: input.subjectId,
|
|
713
|
+
serverId: "google-drive-source-browser",
|
|
714
|
+
toolName: input.label,
|
|
715
|
+
connectionRef: {
|
|
716
|
+
providerDomain: GOOGLE_DRIVE_PROVIDER_DOMAIN,
|
|
717
|
+
connectionId: input.connectionId,
|
|
718
|
+
kind: "oauth2",
|
|
719
|
+
subjectScope: "subject",
|
|
720
|
+
},
|
|
721
|
+
destinationUrl: input.url.toString(),
|
|
722
|
+
forceRefresh,
|
|
723
|
+
});
|
|
724
|
+
let credential = await resolve(false);
|
|
725
|
+
if (credential.status !== "ok") {
|
|
726
|
+
throw new HTTPException(401, { message: "Google Drive needs to be reconnected" });
|
|
727
|
+
}
|
|
728
|
+
const fetchImpl = deps.googleDriveFetch ?? fetch;
|
|
729
|
+
let response = await providerFetch(fetchImpl, input.url, {
|
|
730
|
+
headers: { ...credential.headers, accept: "application/json" },
|
|
731
|
+
});
|
|
732
|
+
if (response.status === 401) {
|
|
733
|
+
await response.body?.cancel().catch(() => undefined);
|
|
734
|
+
credential = await resolve(true);
|
|
735
|
+
if (credential.status !== "ok") {
|
|
736
|
+
throw new HTTPException(401, { message: "Google Drive needs to be reconnected" });
|
|
737
|
+
}
|
|
738
|
+
response = await providerFetch(fetchImpl, input.url, {
|
|
739
|
+
headers: { ...credential.headers, accept: "application/json" },
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
if (!response.ok) {
|
|
743
|
+
await response.body?.cancel().catch(() => undefined);
|
|
744
|
+
throw new HTTPException(response.status === 403 ? 403 : 502, {
|
|
745
|
+
message:
|
|
746
|
+
response.status === 403
|
|
747
|
+
? "Google Drive denied metadata access; reconnect and approve the requested scope"
|
|
748
|
+
: "Google Drive metadata request failed",
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return await readResponseJsonBounded<unknown>(response, GOOGLE_RESPONSE_MAX_BYTES, input.label);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function providerFetch(
|
|
755
|
+
fetchImpl: FetchLike,
|
|
756
|
+
url: string | URL,
|
|
757
|
+
init: RequestInit,
|
|
758
|
+
): Promise<Response> {
|
|
759
|
+
try {
|
|
760
|
+
return await fetchImpl(url, {
|
|
761
|
+
...init,
|
|
762
|
+
redirect: "error",
|
|
763
|
+
signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS),
|
|
764
|
+
});
|
|
765
|
+
} catch {
|
|
766
|
+
throw new HTTPException(502, { message: "Google Drive is temporarily unavailable" });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function parseDriveItem(value: unknown): GoogleDriveBrowseItem | null {
|
|
771
|
+
const item = objectRecord(value);
|
|
772
|
+
const id = optionalString(item.id);
|
|
773
|
+
const name = optionalString(item.name);
|
|
774
|
+
const mimeType = optionalString(item.mimeType);
|
|
775
|
+
if (!id || !name || !mimeType) {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
return {
|
|
779
|
+
id,
|
|
780
|
+
name,
|
|
781
|
+
mimeType,
|
|
782
|
+
kind: mimeType === GOOGLE_DRIVE_FOLDER_MIME_TYPE ? "folder" : "file",
|
|
783
|
+
driveId: optionalString(item.driveId),
|
|
784
|
+
modifiedTime: optionalString(item.modifiedTime),
|
|
785
|
+
size: optionalString(item.size),
|
|
786
|
+
webViewLink: optionalString(item.webViewLink),
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function validDriveId(value: string, label: string): string {
|
|
791
|
+
const candidate = value.trim();
|
|
792
|
+
if (candidate === "root" || /^[A-Za-z0-9_-]{1,256}$/.test(candidate)) {
|
|
793
|
+
return candidate;
|
|
794
|
+
}
|
|
795
|
+
try {
|
|
796
|
+
const url = new URL(candidate);
|
|
797
|
+
if (url.protocol === "https:" && url.hostname === "drive.google.com") {
|
|
798
|
+
const folderId = url.pathname.match(/(?:^|\/)folders\/([A-Za-z0-9_-]{1,256})(?:\/|$)/)?.[1];
|
|
799
|
+
if (folderId) {
|
|
800
|
+
return folderId;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
} catch {
|
|
804
|
+
// Fall through to the bounded public error below.
|
|
805
|
+
}
|
|
806
|
+
throw new HTTPException(400, { message: `${label} is invalid` });
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function validPageToken(value: string): string {
|
|
810
|
+
if (value.length > 0 && value.length <= 4096 && !/[\u0000-\u001f]/.test(value)) {
|
|
811
|
+
return value;
|
|
812
|
+
}
|
|
813
|
+
throw new HTTPException(400, { message: "pageToken is invalid" });
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function googleDriveReturnUrl(
|
|
817
|
+
returnBaseUrl: string,
|
|
818
|
+
returnPath: string,
|
|
819
|
+
status: "connected" | "error",
|
|
820
|
+
value: string,
|
|
821
|
+
): string {
|
|
822
|
+
const url = new URL(returnPath, returnBaseUrl);
|
|
823
|
+
url.searchParams.set("google_drive", status);
|
|
824
|
+
url.searchParams.set(status === "connected" ? "connectionId" : "reason", value);
|
|
825
|
+
return url.toString();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function googleDriveErrorReason(error: unknown): string {
|
|
829
|
+
if (error instanceof GoogleDriveCallbackError) {
|
|
830
|
+
return error.reason;
|
|
831
|
+
}
|
|
832
|
+
if (error instanceof HTTPException) {
|
|
833
|
+
return `http_${error.status}`;
|
|
834
|
+
}
|
|
835
|
+
return "connection_failed";
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
class GoogleDriveCallbackError extends Error {
|
|
839
|
+
constructor(readonly reason: string) {
|
|
840
|
+
super(reason);
|
|
841
|
+
this.name = "GoogleDriveCallbackError";
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function objectRecord(value: unknown): Record<string, unknown> {
|
|
846
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
847
|
+
? (value as Record<string, unknown>)
|
|
848
|
+
: {};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function optionalString(value: unknown): string | null {
|
|
852
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function requiredString(value: unknown, label: string): string {
|
|
856
|
+
const parsed = optionalString(value);
|
|
857
|
+
if (!parsed) {
|
|
858
|
+
throw new HTTPException(400, { message: `${label} is invalid` });
|
|
859
|
+
}
|
|
860
|
+
return parsed;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function numberValue(value: unknown): number | undefined {
|
|
864
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function uniqueStrings(values: string[]): string[] {
|
|
868
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
869
|
+
}
|