sealkeep 0.5.0
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/ARCHITECTURE.md +201 -0
- package/CHANGELOG.md +218 -0
- package/CONTROL_PLANE.md +86 -0
- package/LICENSE +34 -0
- package/README.md +249 -0
- package/THIRD_PARTY.md +22 -0
- package/THREAT_MODEL.md +107 -0
- package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
- package/dist/packages/vaultline-crypto/src/aead.js +24 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
- package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
- package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
- package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
- package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
- package/dist/packages/vaultline-crypto/src/format.js +43 -0
- package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
- package/dist/packages/vaultline-crypto/src/index.js +5 -0
- package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
- package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
- package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
- package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
- package/dist/packages/vaultline-crypto/src/stream.js +477 -0
- package/dist/site/index.html +1542 -0
- package/dist/site.zip +0 -0
- package/dist/src/activity.d.ts +22 -0
- package/dist/src/activity.js +52 -0
- package/dist/src/adapters.d.ts +212 -0
- package/dist/src/adapters.js +533 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +41 -0
- package/dist/src/autopilot.d.ts +77 -0
- package/dist/src/autopilot.js +148 -0
- package/dist/src/bip39-wordlist.d.ts +15 -0
- package/dist/src/bip39-wordlist.js +272 -0
- package/dist/src/branding.d.ts +31 -0
- package/dist/src/branding.js +31 -0
- package/dist/src/chunk-store.d.ts +142 -0
- package/dist/src/chunk-store.js +502 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +2035 -0
- package/dist/src/cloud.d.ts +434 -0
- package/dist/src/cloud.js +851 -0
- package/dist/src/control-plane/auth.d.ts +62 -0
- package/dist/src/control-plane/auth.js +123 -0
- package/dist/src/control-plane/server.d.ts +31 -0
- package/dist/src/control-plane/server.js +263 -0
- package/dist/src/control-plane/store.d.ts +101 -0
- package/dist/src/control-plane/store.js +82 -0
- package/dist/src/control-plane-cli.d.ts +2 -0
- package/dist/src/control-plane-cli.js +37 -0
- package/dist/src/control-plane-server.d.ts +10 -0
- package/dist/src/control-plane-server.js +11 -0
- package/dist/src/control-plane.d.ts +78 -0
- package/dist/src/control-plane.js +61 -0
- package/dist/src/crypto.d.ts +56 -0
- package/dist/src/crypto.js +132 -0
- package/dist/src/daemon.d.ts +52 -0
- package/dist/src/daemon.js +142 -0
- package/dist/src/dashboard-cli.d.ts +2 -0
- package/dist/src/dashboard-cli.js +20 -0
- package/dist/src/disk.d.ts +110 -0
- package/dist/src/disk.js +169 -0
- package/dist/src/doctor.d.ts +11 -0
- package/dist/src/doctor.js +198 -0
- package/dist/src/enroll.d.ts +27 -0
- package/dist/src/enroll.js +136 -0
- package/dist/src/errors.d.ts +26 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/heartbeat.d.ts +89 -0
- package/dist/src/heartbeat.js +120 -0
- package/dist/src/index-sync.d.ts +53 -0
- package/dist/src/index-sync.js +147 -0
- package/dist/src/leakscan.d.ts +48 -0
- package/dist/src/leakscan.js +222 -0
- package/dist/src/local-api.d.ts +132 -0
- package/dist/src/local-api.js +1757 -0
- package/dist/src/managed-chunks.d.ts +55 -0
- package/dist/src/managed-chunks.js +108 -0
- package/dist/src/mcp-install.d.ts +52 -0
- package/dist/src/mcp-install.js +140 -0
- package/dist/src/mcp.d.ts +1 -0
- package/dist/src/mcp.js +59 -0
- package/dist/src/migrate.d.ts +35 -0
- package/dist/src/migrate.js +88 -0
- package/dist/src/mnemonic.d.ts +60 -0
- package/dist/src/mnemonic.js +134 -0
- package/dist/src/net.d.ts +2 -0
- package/dist/src/net.js +16 -0
- package/dist/src/notify.d.ts +46 -0
- package/dist/src/notify.js +84 -0
- package/dist/src/offload.d.ts +117 -0
- package/dist/src/offload.js +331 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +44 -0
- package/dist/src/packages.d.ts +126 -0
- package/dist/src/packages.js +114 -0
- package/dist/src/passkey.d.ts +26 -0
- package/dist/src/passkey.js +54 -0
- package/dist/src/password-lock.d.ts +19 -0
- package/dist/src/password-lock.js +156 -0
- package/dist/src/paths.d.ts +9 -0
- package/dist/src/paths.js +24 -0
- package/dist/src/providers/gcs.d.ts +133 -0
- package/dist/src/providers/gcs.js +235 -0
- package/dist/src/providers/gdrive.d.ts +156 -0
- package/dist/src/providers/gdrive.js +335 -0
- package/dist/src/providers/index.d.ts +45 -0
- package/dist/src/providers/index.js +74 -0
- package/dist/src/providers/s3.d.ts +174 -0
- package/dist/src/providers/s3.js +345 -0
- package/dist/src/providers/sigv4.d.ts +78 -0
- package/dist/src/providers/sigv4.js +112 -0
- package/dist/src/queue.d.ts +185 -0
- package/dist/src/queue.js +286 -0
- package/dist/src/recovery.d.ts +40 -0
- package/dist/src/recovery.js +132 -0
- package/dist/src/rehydrate.d.ts +43 -0
- package/dist/src/rehydrate.js +66 -0
- package/dist/src/restore.d.ts +34 -0
- package/dist/src/restore.js +80 -0
- package/dist/src/retention.d.ts +251 -0
- package/dist/src/retention.js +446 -0
- package/dist/src/rotate.d.ts +47 -0
- package/dist/src/rotate.js +95 -0
- package/dist/src/search.d.ts +147 -0
- package/dist/src/search.js +677 -0
- package/dist/src/secrets.d.ts +86 -0
- package/dist/src/secrets.js +220 -0
- package/dist/src/service.d.ts +73 -0
- package/dist/src/service.js +197 -0
- package/dist/src/share.d.ts +34 -0
- package/dist/src/share.js +68 -0
- package/dist/src/spool.d.ts +97 -0
- package/dist/src/spool.js +213 -0
- package/dist/src/start-tui.d.ts +17 -0
- package/dist/src/start-tui.js +113 -0
- package/dist/src/start.d.ts +75 -0
- package/dist/src/start.js +101 -0
- package/dist/src/storage-setup.d.ts +49 -0
- package/dist/src/storage-setup.js +222 -0
- package/dist/src/storage-targets.d.ts +40 -0
- package/dist/src/storage-targets.js +147 -0
- package/dist/src/stream-to-cloud.d.ts +76 -0
- package/dist/src/stream-to-cloud.js +820 -0
- package/dist/src/sync-rules.d.ts +85 -0
- package/dist/src/sync-rules.js +125 -0
- package/dist/src/trash.d.ts +15 -0
- package/dist/src/trash.js +63 -0
- package/dist/src/tui.d.ts +18 -0
- package/dist/src/tui.js +179 -0
- package/dist/src/types.d.ts +191 -0
- package/dist/src/types.js +3 -0
- package/dist/src/ui-server.d.ts +187 -0
- package/dist/src/ui-server.js +293 -0
- package/dist/src/ui.d.ts +41 -0
- package/dist/src/ui.js +102 -0
- package/dist/src/update.d.ts +30 -0
- package/dist/src/update.js +56 -0
- package/dist/src/upload.d.ts +46 -0
- package/dist/src/upload.js +80 -0
- package/dist/src/vault.d.ts +208 -0
- package/dist/src/vault.js +812 -0
- package/dist/src/watcher.d.ts +34 -0
- package/dist/src/watcher.js +121 -0
- package/dist/src/worker.d.ts +52 -0
- package/dist/src/worker.js +190 -0
- package/package.json +65 -0
- package/web/app.js +1372 -0
- package/web/index.html +476 -0
- package/web/rail.js +308 -0
- package/web/retention.html +17 -0
- package/web/rules-view.js +249 -0
- package/web/sessions-view.js +448 -0
- package/web/sessions.html +17 -0
- package/web/setup-api.js +181 -0
- package/web/setup-logic.js +394 -0
- package/web/setup.html +419 -0
- package/web/setup.js +697 -0
- package/web/style.css +990 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { ProviderKind, ProviderUploadClient, UploadLease } from "../control-plane.js";
|
|
2
|
+
/**
|
|
3
|
+
* Google Drive as a bring-your-own storage provider (Drive API v3).
|
|
4
|
+
*
|
|
5
|
+
* The privacy story lives in the scope. Sealkeep asks Google for
|
|
6
|
+
* `drive.file` ONLY: in Google's permission model that grant lets an app see
|
|
7
|
+
* and touch just the files it created itself — never the rest of the Drive,
|
|
8
|
+
* not even file names. So connecting Sealkeep exposes nothing a person
|
|
9
|
+
* already keeps in Drive, and what Sealkeep does create there is ciphertext
|
|
10
|
+
* sealed on this machine before any byte leaves it.
|
|
11
|
+
*
|
|
12
|
+
* Authentication is the OAuth desktop flow with PKCE and a loopback redirect
|
|
13
|
+
* (RFC 8252): a local one-shot server on 127.0.0.1 receives the code, and the
|
|
14
|
+
* PKCE verifier — not a client secret, desktop apps cannot keep one — proves
|
|
15
|
+
* the exchange. The only long-lived secret is the refresh token, which goes
|
|
16
|
+
* into the OS keystore via secrets.ts and never into a config file.
|
|
17
|
+
*/
|
|
18
|
+
export declare const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
19
|
+
/** Placeholder until a first-party client id ships. Point VAULTLINE_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
|
|
20
|
+
export declare const GDRIVE_CLIENT_ID_PLACEHOLDER = "000000000000-vaultline-placeholder.apps.googleusercontent.com";
|
|
21
|
+
export declare function gdriveClientId(env?: NodeJS.ProcessEnv): string;
|
|
22
|
+
export declare const GDRIVE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
23
|
+
export declare const GDRIVE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
24
|
+
export declare const GDRIVE_API_BASE = "https://www.googleapis.com";
|
|
25
|
+
/** What the keystore holds for a connected Drive. Restated nowhere: secrets.ts imports this into its union. */
|
|
26
|
+
export type GdriveCredentials = {
|
|
27
|
+
kind: "gdrive";
|
|
28
|
+
refreshToken: string;
|
|
29
|
+
clientId: string;
|
|
30
|
+
/** "cloud" = the refresh needs the account's server (web-client secret lives there). */
|
|
31
|
+
exchange?: "cloud";
|
|
32
|
+
/** Runtime-only: attached at load time, never persisted. Refreshes through the plane. */
|
|
33
|
+
refresher?: (refreshToken: string) => Promise<{
|
|
34
|
+
accessToken: string;
|
|
35
|
+
expiresIn: number;
|
|
36
|
+
}>;
|
|
37
|
+
};
|
|
38
|
+
export type GdriveEndpoint = {
|
|
39
|
+
/** Drive has no buckets; archives live in one folder with this name at the Drive root. */
|
|
40
|
+
folderName: string;
|
|
41
|
+
/** Store in the hidden per-app `appDataFolder` space instead of a visible folder. */
|
|
42
|
+
appData?: boolean;
|
|
43
|
+
/** Test overrides. When host is set, the token endpoint is `<base>/token` on the same stub. */
|
|
44
|
+
host?: string;
|
|
45
|
+
port?: number;
|
|
46
|
+
protocol?: "https" | "http";
|
|
47
|
+
};
|
|
48
|
+
/** RFC 7636 verifier/challenge pair: 32 random bytes → 43-char verifier, S256 challenge. */
|
|
49
|
+
export declare function createPkcePair(random?: () => Buffer): {
|
|
50
|
+
verifier: string;
|
|
51
|
+
challenge: string;
|
|
52
|
+
method: "S256";
|
|
53
|
+
};
|
|
54
|
+
export declare function gdriveConsentUrl(input: {
|
|
55
|
+
clientId: string;
|
|
56
|
+
redirectUri: string;
|
|
57
|
+
challenge: string;
|
|
58
|
+
state: string;
|
|
59
|
+
authUrl?: string;
|
|
60
|
+
}): string;
|
|
61
|
+
export declare function exchangeCodeForTokens(input: {
|
|
62
|
+
code: string;
|
|
63
|
+
verifier: string;
|
|
64
|
+
clientId: string;
|
|
65
|
+
redirectUri: string;
|
|
66
|
+
tokenUrl?: string;
|
|
67
|
+
fetchImpl?: typeof fetch;
|
|
68
|
+
}): Promise<{
|
|
69
|
+
refreshToken: string;
|
|
70
|
+
accessToken: string;
|
|
71
|
+
expiresIn: number;
|
|
72
|
+
}>;
|
|
73
|
+
/**
|
|
74
|
+
* Fire-and-forget platform opener. Failing to open a browser is never failing
|
|
75
|
+
* to connect — the consent URL is reported to the caller either way — so this
|
|
76
|
+
* mirrors ui-server's opener rules (BROWSER wins, `none` means don't) without
|
|
77
|
+
* importing that module: ui-server reaches local-api, and a provider that
|
|
78
|
+
* pulled the whole API surface into every upload would be an import cycle.
|
|
79
|
+
*/
|
|
80
|
+
export declare function launchBrowser(url: string, env?: NodeJS.ProcessEnv, platformName?: NodeJS.Platform): void;
|
|
81
|
+
export type ConnectOptions = {
|
|
82
|
+
clientId?: string;
|
|
83
|
+
env?: NodeJS.ProcessEnv;
|
|
84
|
+
/** Called with the consent URL before anything waits on the browser; the CLI prints it here. */
|
|
85
|
+
onConsentUrl?: (url: string) => void;
|
|
86
|
+
/** Replaces the platform browser launch; tests drive the loopback redirect from here. */
|
|
87
|
+
opener?: (url: string) => void | Promise<void>;
|
|
88
|
+
fetchImpl?: typeof fetch;
|
|
89
|
+
authUrl?: string;
|
|
90
|
+
tokenUrl?: string;
|
|
91
|
+
/** Loopback port; 0 (the default) asks the OS for a random one, which is what Google's loopback flow expects. */
|
|
92
|
+
port?: number;
|
|
93
|
+
timeoutMs?: number;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* The whole consent dance: loopback server, consent URL, code, PKCE exchange.
|
|
97
|
+
* Returns the credential shape the keystore stores. Everything slow or
|
|
98
|
+
* environment-shaped (fetch, browser, endpoints) is injectable so the entire
|
|
99
|
+
* flow runs offline under test.
|
|
100
|
+
*/
|
|
101
|
+
export declare function connectGdrive(options?: ConnectOptions): Promise<GdriveCredentials>;
|
|
102
|
+
/**
|
|
103
|
+
* Drive upload client.
|
|
104
|
+
*
|
|
105
|
+
* A lease's objectKey (e.g. `vaultline/<id>.vlarchive`) has no path meaning in
|
|
106
|
+
* Drive, so the mapping is: files live flat in one folder (or the hidden
|
|
107
|
+
* appDataFolder space), named by the key's basename, and each carries
|
|
108
|
+
* `appProperties.vaultlineKey = <full objectKey>`. head/download locate by that
|
|
109
|
+
* property — never by name, which Drive does not keep unique.
|
|
110
|
+
*/
|
|
111
|
+
export declare class GdriveUploadClient implements ProviderUploadClient {
|
|
112
|
+
private readonly endpoint;
|
|
113
|
+
private readonly credentials;
|
|
114
|
+
readonly kind: ProviderKind;
|
|
115
|
+
private folderId?;
|
|
116
|
+
private accessToken?;
|
|
117
|
+
private readonly chunkBytes;
|
|
118
|
+
private readonly fetchImpl;
|
|
119
|
+
private readonly now;
|
|
120
|
+
constructor(endpoint: GdriveEndpoint, credentials: GdriveCredentials, options?: {
|
|
121
|
+
chunkBytes?: number;
|
|
122
|
+
fetchImpl?: typeof fetch;
|
|
123
|
+
now?: () => number;
|
|
124
|
+
});
|
|
125
|
+
private base;
|
|
126
|
+
private tokenUrl;
|
|
127
|
+
private space;
|
|
128
|
+
/** Access token minted from the refresh token, cached until shortly before expiry so it never dies mid-chunk. */
|
|
129
|
+
private token;
|
|
130
|
+
private api;
|
|
131
|
+
private list;
|
|
132
|
+
/** Exact lookup by the appProperties key. Two files claiming one key is a state we refuse to guess about. */
|
|
133
|
+
private findByKey;
|
|
134
|
+
/** Finds or creates the archive folder; the id is cached per instance. */
|
|
135
|
+
private parentId;
|
|
136
|
+
/**
|
|
137
|
+
* Starts a resumable session (create, or in-place update when the key already
|
|
138
|
+
* has a file — re-uploading must never mint a same-named duplicate, or the
|
|
139
|
+
* appProperties lookup stops being exact). The session URI is a bearer
|
|
140
|
+
* capability: used immediately, never persisted, never logged.
|
|
141
|
+
*/
|
|
142
|
+
private initiateResumable;
|
|
143
|
+
upload(lease: UploadLease, ciphertext: Buffer): Promise<{
|
|
144
|
+
remoteChecksum: string;
|
|
145
|
+
bytes: number;
|
|
146
|
+
}>;
|
|
147
|
+
head(lease: UploadLease): Promise<{
|
|
148
|
+
exists: boolean;
|
|
149
|
+
bytes: number;
|
|
150
|
+
checksum?: string;
|
|
151
|
+
}>;
|
|
152
|
+
/** Reads an object back for restore or offload-proof: locate by key, then `alt=media`. */
|
|
153
|
+
/** Removes the file holding this object key. Absent already means done. */
|
|
154
|
+
deleteByKey(objectKey: string): Promise<void>;
|
|
155
|
+
download(lease: UploadLease): Promise<Buffer>;
|
|
156
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { fail, VaultlineError } from "../errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Google Drive as a bring-your-own storage provider (Drive API v3).
|
|
7
|
+
*
|
|
8
|
+
* The privacy story lives in the scope. Sealkeep asks Google for
|
|
9
|
+
* `drive.file` ONLY: in Google's permission model that grant lets an app see
|
|
10
|
+
* and touch just the files it created itself — never the rest of the Drive,
|
|
11
|
+
* not even file names. So connecting Sealkeep exposes nothing a person
|
|
12
|
+
* already keeps in Drive, and what Sealkeep does create there is ciphertext
|
|
13
|
+
* sealed on this machine before any byte leaves it.
|
|
14
|
+
*
|
|
15
|
+
* Authentication is the OAuth desktop flow with PKCE and a loopback redirect
|
|
16
|
+
* (RFC 8252): a local one-shot server on 127.0.0.1 receives the code, and the
|
|
17
|
+
* PKCE verifier — not a client secret, desktop apps cannot keep one — proves
|
|
18
|
+
* the exchange. The only long-lived secret is the refresh token, which goes
|
|
19
|
+
* into the OS keystore via secrets.ts and never into a config file.
|
|
20
|
+
*/
|
|
21
|
+
export const GDRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
|
|
22
|
+
/** Placeholder until a first-party client id ships. Point VAULTLINE_GDRIVE_CLIENT_ID at your own Desktop-app OAuth client to connect today. */
|
|
23
|
+
export const GDRIVE_CLIENT_ID_PLACEHOLDER = "000000000000-vaultline-placeholder.apps.googleusercontent.com";
|
|
24
|
+
export function gdriveClientId(env = process.env) {
|
|
25
|
+
return env.VAULTLINE_GDRIVE_CLIENT_ID?.trim() || GDRIVE_CLIENT_ID_PLACEHOLDER;
|
|
26
|
+
}
|
|
27
|
+
export const GDRIVE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
28
|
+
export const GDRIVE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
29
|
+
export const GDRIVE_API_BASE = "https://www.googleapis.com";
|
|
30
|
+
const base64url = (buffer) => buffer.toString("base64url");
|
|
31
|
+
/** RFC 7636 verifier/challenge pair: 32 random bytes → 43-char verifier, S256 challenge. */
|
|
32
|
+
export function createPkcePair(random = () => randomBytes(32)) {
|
|
33
|
+
const verifier = base64url(random());
|
|
34
|
+
return { verifier, challenge: base64url(createHash("sha256").update(verifier).digest()), method: "S256" };
|
|
35
|
+
}
|
|
36
|
+
export function gdriveConsentUrl(input) {
|
|
37
|
+
const url = new URL(input.authUrl ?? GDRIVE_AUTH_URL);
|
|
38
|
+
url.searchParams.set("client_id", input.clientId);
|
|
39
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
40
|
+
url.searchParams.set("response_type", "code");
|
|
41
|
+
url.searchParams.set("scope", GDRIVE_SCOPE);
|
|
42
|
+
url.searchParams.set("code_challenge", input.challenge);
|
|
43
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
44
|
+
url.searchParams.set("state", input.state);
|
|
45
|
+
// A refresh token is only issued for offline access, and only reliably when
|
|
46
|
+
// consent is re-prompted; without both, a reconnect can come back token-less.
|
|
47
|
+
url.searchParams.set("access_type", "offline");
|
|
48
|
+
url.searchParams.set("prompt", "consent");
|
|
49
|
+
return url.toString();
|
|
50
|
+
}
|
|
51
|
+
export async function exchangeCodeForTokens(input) {
|
|
52
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
53
|
+
const response = await fetchImpl(input.tokenUrl ?? GDRIVE_TOKEN_URL, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
56
|
+
body: new URLSearchParams({
|
|
57
|
+
grant_type: "authorization_code",
|
|
58
|
+
code: input.code,
|
|
59
|
+
client_id: input.clientId,
|
|
60
|
+
redirect_uri: input.redirectUri,
|
|
61
|
+
code_verifier: input.verifier
|
|
62
|
+
}).toString()
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok)
|
|
65
|
+
fail("unauthorized", `Google rejected the authorization code: ${response.status} ${response.statusText}`, { status: response.status });
|
|
66
|
+
const data = await response.json();
|
|
67
|
+
const refreshToken = data.refresh_token
|
|
68
|
+
?? fail("unauthorized", "Google returned no refresh token. Remove Sealkeep at https://myaccount.google.com/permissions and connect again.");
|
|
69
|
+
return { refreshToken, accessToken: data.access_token ?? "", expiresIn: data.expires_in ?? 0 };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Fire-and-forget platform opener. Failing to open a browser is never failing
|
|
73
|
+
* to connect — the consent URL is reported to the caller either way — so this
|
|
74
|
+
* mirrors ui-server's opener rules (BROWSER wins, `none` means don't) without
|
|
75
|
+
* importing that module: ui-server reaches local-api, and a provider that
|
|
76
|
+
* pulled the whole API surface into every upload would be an import cycle.
|
|
77
|
+
*/
|
|
78
|
+
export function launchBrowser(url, env = process.env, platformName = process.platform) {
|
|
79
|
+
const preference = env.BROWSER?.trim();
|
|
80
|
+
if (preference?.toLowerCase() === "none")
|
|
81
|
+
return;
|
|
82
|
+
const opener = preference ? { command: preference, args: [url] }
|
|
83
|
+
: platformName === "darwin" ? { command: "open", args: [url] }
|
|
84
|
+
: platformName === "win32" ? { command: "cmd", args: ["/c", "start", "", url] }
|
|
85
|
+
: { command: "xdg-open", args: [url] };
|
|
86
|
+
try {
|
|
87
|
+
const child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
88
|
+
child.once("error", () => { });
|
|
89
|
+
child.unref();
|
|
90
|
+
}
|
|
91
|
+
catch { /* same: the printed URL is the fallback */ }
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The whole consent dance: loopback server, consent URL, code, PKCE exchange.
|
|
95
|
+
* Returns the credential shape the keystore stores. Everything slow or
|
|
96
|
+
* environment-shaped (fetch, browser, endpoints) is injectable so the entire
|
|
97
|
+
* flow runs offline under test.
|
|
98
|
+
*/
|
|
99
|
+
export async function connectGdrive(options = {}) {
|
|
100
|
+
const clientId = options.clientId ?? gdriveClientId(options.env);
|
|
101
|
+
const { verifier, challenge } = createPkcePair();
|
|
102
|
+
const state = base64url(randomBytes(16));
|
|
103
|
+
const server = createServer();
|
|
104
|
+
await new Promise((ready, refuse) => { server.once("error", refuse); server.listen(options.port ?? 0, "127.0.0.1", ready); });
|
|
105
|
+
const port = server.address().port;
|
|
106
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
107
|
+
const consentUrl = gdriveConsentUrl({ clientId, redirectUri, challenge, state, authUrl: options.authUrl });
|
|
108
|
+
let timer;
|
|
109
|
+
const code = new Promise((resolve, reject) => {
|
|
110
|
+
timer = setTimeout(() => reject(new VaultlineError("unauthorized", `No response from the browser within ${Math.round((options.timeoutMs ?? 300_000) / 1000)}s. Run the connect command again.`)), options.timeoutMs ?? 300_000);
|
|
111
|
+
server.on("request", (request, response) => {
|
|
112
|
+
const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`);
|
|
113
|
+
if (url.pathname !== "/callback") {
|
|
114
|
+
response.writeHead(404);
|
|
115
|
+
response.end();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const deny = (message) => {
|
|
119
|
+
response.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
120
|
+
response.end("<p>Sealkeep could not finish connecting. Return to the terminal.</p>");
|
|
121
|
+
reject(new VaultlineError("unauthorized", message));
|
|
122
|
+
};
|
|
123
|
+
if (url.searchParams.get("error"))
|
|
124
|
+
return deny(`Google reported: ${url.searchParams.get("error")}`);
|
|
125
|
+
// A code arriving under the wrong state was not minted for this attempt;
|
|
126
|
+
// accepting it would let anything that can reach 127.0.0.1 splice its own
|
|
127
|
+
// account into this vault's uploads.
|
|
128
|
+
if (url.searchParams.get("state") !== state)
|
|
129
|
+
return deny("The browser reply did not match this connection attempt (state mismatch)");
|
|
130
|
+
const received = url.searchParams.get("code");
|
|
131
|
+
if (!received)
|
|
132
|
+
return deny("The browser reply carried no authorization code");
|
|
133
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
134
|
+
response.end("<p>Sealkeep is connected to Google Drive. You can close this tab.</p>");
|
|
135
|
+
resolve(received);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
// A denial can land while the opener is still being awaited, before anything
|
|
139
|
+
// awaits `code`; without a handler already attached that early rejection is
|
|
140
|
+
// "unhandled" and can bring the process down instead of failing the command.
|
|
141
|
+
code.catch(() => { });
|
|
142
|
+
try {
|
|
143
|
+
options.onConsentUrl?.(consentUrl);
|
|
144
|
+
await Promise.resolve((options.opener ?? launchBrowser)(consentUrl)).catch(() => { });
|
|
145
|
+
const authorizationCode = await code;
|
|
146
|
+
const tokens = await exchangeCodeForTokens({ code: authorizationCode, verifier, clientId, redirectUri, tokenUrl: options.tokenUrl, fetchImpl: options.fetchImpl });
|
|
147
|
+
return { kind: "gdrive", refreshToken: tokens.refreshToken, clientId };
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
if (timer)
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
server.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** Drive query strings quote values with single quotes; backslash-escape the two characters that break out. */
|
|
156
|
+
const escapeQuery = (value) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
157
|
+
const FOLDER_MIME = "application/vnd.google-apps.folder";
|
|
158
|
+
/**
|
|
159
|
+
* Drive upload client.
|
|
160
|
+
*
|
|
161
|
+
* A lease's objectKey (e.g. `vaultline/<id>.vlarchive`) has no path meaning in
|
|
162
|
+
* Drive, so the mapping is: files live flat in one folder (or the hidden
|
|
163
|
+
* appDataFolder space), named by the key's basename, and each carries
|
|
164
|
+
* `appProperties.vaultlineKey = <full objectKey>`. head/download locate by that
|
|
165
|
+
* property — never by name, which Drive does not keep unique.
|
|
166
|
+
*/
|
|
167
|
+
export class GdriveUploadClient {
|
|
168
|
+
endpoint;
|
|
169
|
+
credentials;
|
|
170
|
+
// "gdrive" is not yet in control-plane's ProviderKind union — that file (and its
|
|
171
|
+
// restatements in types.ts / local-api.ts) is frozen in this change, so the new
|
|
172
|
+
// kind rides through under a cast contained to this one line. Remove the cast
|
|
173
|
+
// when the union learns "gdrive".
|
|
174
|
+
kind = "gdrive";
|
|
175
|
+
folderId;
|
|
176
|
+
accessToken;
|
|
177
|
+
chunkBytes;
|
|
178
|
+
fetchImpl;
|
|
179
|
+
now;
|
|
180
|
+
constructor(endpoint, credentials, options = {}) {
|
|
181
|
+
this.endpoint = endpoint;
|
|
182
|
+
this.credentials = credentials;
|
|
183
|
+
this.chunkBytes = options.chunkBytes ?? 8 * 1024 * 1024;
|
|
184
|
+
// Drive rejects resumable chunks that are not 256 KiB multiples (except the last).
|
|
185
|
+
if (this.chunkBytes % (256 * 1024) !== 0 || this.chunkBytes <= 0) {
|
|
186
|
+
fail("invalid_argument", `Drive resumable chunks must be positive multiples of 256 KiB, not ${this.chunkBytes} bytes`);
|
|
187
|
+
}
|
|
188
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
189
|
+
this.now = options.now ?? Date.now;
|
|
190
|
+
}
|
|
191
|
+
base() {
|
|
192
|
+
const { host, port, protocol } = this.endpoint;
|
|
193
|
+
if (!host)
|
|
194
|
+
return GDRIVE_API_BASE;
|
|
195
|
+
return `${protocol ?? "https"}://${host}${port ? `:${port}` : ""}`;
|
|
196
|
+
}
|
|
197
|
+
tokenUrl() { return this.endpoint.host ? `${this.base()}/token` : GDRIVE_TOKEN_URL; }
|
|
198
|
+
space() { return this.endpoint.appData ? "appDataFolder" : "drive"; }
|
|
199
|
+
/** Access token minted from the refresh token, cached until shortly before expiry so it never dies mid-chunk. */
|
|
200
|
+
async token() {
|
|
201
|
+
if (this.accessToken && this.now() < this.accessToken.expiresAt)
|
|
202
|
+
return this.accessToken.token;
|
|
203
|
+
// A cloud-exchanged credential refreshes through the account's server —
|
|
204
|
+
// the web client's secret lives there, never in this process.
|
|
205
|
+
if (this.credentials.refresher) {
|
|
206
|
+
const fresh = await this.credentials.refresher(this.credentials.refreshToken);
|
|
207
|
+
this.accessToken = { token: fresh.accessToken, expiresAt: this.now() + Math.max(fresh.expiresIn - 60, 60) * 1000 };
|
|
208
|
+
return fresh.accessToken;
|
|
209
|
+
}
|
|
210
|
+
const response = await this.fetchImpl(this.tokenUrl(), {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
213
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: this.credentials.refreshToken, client_id: this.credentials.clientId }).toString()
|
|
214
|
+
});
|
|
215
|
+
if (!response.ok)
|
|
216
|
+
fail("unauthorized", `Google Drive refused to mint an access token (${response.status}). Reconnect with: sealkeep storage connect gdrive`, { status: response.status });
|
|
217
|
+
const data = await response.json();
|
|
218
|
+
const token = data.access_token ?? fail("unauthorized", "Google Drive's token response carried no access token");
|
|
219
|
+
this.accessToken = { token, expiresAt: this.now() + Math.max((data.expires_in ?? 0) - 60, 60) * 1000 };
|
|
220
|
+
return token;
|
|
221
|
+
}
|
|
222
|
+
async api(path, init = {}) {
|
|
223
|
+
const token = await this.token();
|
|
224
|
+
return this.fetchImpl(`${this.base()}${path}`, { ...init, headers: { authorization: `Bearer ${token}`, ...init.headers } });
|
|
225
|
+
}
|
|
226
|
+
async list(query) {
|
|
227
|
+
const params = new URLSearchParams({ q: query, spaces: this.space(), fields: "files(id,name,size,md5Checksum)", pageSize: "10" });
|
|
228
|
+
const response = await this.api(`/drive/v3/files?${params.toString()}`);
|
|
229
|
+
if (!response.ok)
|
|
230
|
+
fail("internal", `Google Drive file lookup failed: ${response.status} ${response.statusText}`, { status: response.status });
|
|
231
|
+
const data = await response.json();
|
|
232
|
+
return data.files ?? [];
|
|
233
|
+
}
|
|
234
|
+
/** Exact lookup by the appProperties key. Two files claiming one key is a state we refuse to guess about. */
|
|
235
|
+
async findByKey(objectKey) {
|
|
236
|
+
const files = await this.list(`appProperties has { key = 'vaultlineKey' and value = '${escapeQuery(objectKey)}' } and trashed = false`);
|
|
237
|
+
if (files.length > 1)
|
|
238
|
+
fail("internal", `Google Drive holds ${files.length} files claiming ${objectKey}; refusing to pick one`, { objectKey, count: files.length });
|
|
239
|
+
return files[0] ?? null;
|
|
240
|
+
}
|
|
241
|
+
/** Finds or creates the archive folder; the id is cached per instance. */
|
|
242
|
+
async parentId() {
|
|
243
|
+
if (this.endpoint.appData)
|
|
244
|
+
return "appDataFolder";
|
|
245
|
+
if (this.folderId)
|
|
246
|
+
return this.folderId;
|
|
247
|
+
const name = this.endpoint.folderName;
|
|
248
|
+
const existing = (await this.list(`name = '${escapeQuery(name)}' and mimeType = '${FOLDER_MIME}' and trashed = false`))[0]?.id;
|
|
249
|
+
if (existing) {
|
|
250
|
+
this.folderId = existing;
|
|
251
|
+
return existing;
|
|
252
|
+
}
|
|
253
|
+
const response = await this.api("/drive/v3/files?fields=id", {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: { "content-type": "application/json; charset=UTF-8" },
|
|
256
|
+
body: JSON.stringify({ name, mimeType: FOLDER_MIME })
|
|
257
|
+
});
|
|
258
|
+
if (!response.ok)
|
|
259
|
+
fail("internal", `Could not create the "${name}" folder in Google Drive: ${response.status} ${response.statusText}`, { status: response.status });
|
|
260
|
+
const created = await response.json();
|
|
261
|
+
this.folderId = created.id ?? fail("internal", "Drive created a folder but returned no id");
|
|
262
|
+
return this.folderId;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Starts a resumable session (create, or in-place update when the key already
|
|
266
|
+
* has a file — re-uploading must never mint a same-named duplicate, or the
|
|
267
|
+
* appProperties lookup stops being exact). The session URI is a bearer
|
|
268
|
+
* capability: used immediately, never persisted, never logged.
|
|
269
|
+
*/
|
|
270
|
+
async initiateResumable(objectKey, existingFileId, totalBytes) {
|
|
271
|
+
const metadata = existingFileId
|
|
272
|
+
? { appProperties: { vaultlineKey: objectKey } }
|
|
273
|
+
: { name: objectKey.split("/").pop() ?? objectKey, parents: [await this.parentId()], appProperties: { vaultlineKey: objectKey } };
|
|
274
|
+
const response = await this.api(existingFileId ? `/upload/drive/v3/files/${existingFileId}?uploadType=resumable` : "/upload/drive/v3/files?uploadType=resumable", {
|
|
275
|
+
method: existingFileId ? "PATCH" : "POST",
|
|
276
|
+
headers: { "content-type": "application/json; charset=UTF-8", "x-upload-content-length": String(totalBytes) },
|
|
277
|
+
body: JSON.stringify(metadata)
|
|
278
|
+
});
|
|
279
|
+
if (!response.ok)
|
|
280
|
+
fail("internal", `Could not start a Drive resumable upload for ${objectKey}: ${response.status} ${response.statusText}`, { objectKey, status: response.status });
|
|
281
|
+
return response.headers.get("location") ?? fail("internal", `Drive's resumable initiation for ${objectKey} returned no session URI`, { objectKey });
|
|
282
|
+
}
|
|
283
|
+
async upload(lease, ciphertext) {
|
|
284
|
+
const objectKey = lease.objectKey;
|
|
285
|
+
const existing = await this.findByKey(objectKey);
|
|
286
|
+
const session = await this.initiateResumable(objectKey, existing?.id ?? null, ciphertext.length);
|
|
287
|
+
for (let offset = 0; offset < Math.max(ciphertext.length, 1); offset += this.chunkBytes) {
|
|
288
|
+
const slice = ciphertext.subarray(offset, Math.min(offset + this.chunkBytes, ciphertext.length));
|
|
289
|
+
const last = offset + slice.length >= ciphertext.length;
|
|
290
|
+
const range = ciphertext.length === 0 ? "bytes */0" : `bytes ${offset}-${offset + slice.length - 1}/${ciphertext.length}`;
|
|
291
|
+
const response = await this.fetchImpl(session, { method: "PUT", body: new Uint8Array(slice), headers: { "content-range": range } });
|
|
292
|
+
// 308 is "resume incomplete", the expected status between chunks.
|
|
293
|
+
if (!response.ok && !(response.status === 308 && !last)) {
|
|
294
|
+
fail("internal", `Drive resumable upload for ${objectKey} failed at ${range}: ${response.status} ${response.statusText}`, { objectKey, status: response.status });
|
|
295
|
+
}
|
|
296
|
+
if (last)
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
// The whole-object proof Drive reports back is `md5Checksum` in file
|
|
300
|
+
// metadata, as lowercase hex — it computes no SHA-256, and unlike GCS's
|
|
301
|
+
// base64 header this one is hex. The verifier in upload.ts compares this
|
|
302
|
+
// value against head()'s, so both sides must speak that same hex dialect;
|
|
303
|
+
// MD5 here is transport proof, not trust — the ciphertext is
|
|
304
|
+
// AEAD-authenticated and restore re-checks its SHA-256 independently.
|
|
305
|
+
return { remoteChecksum: createHash("md5").update(ciphertext).digest("hex"), bytes: ciphertext.length };
|
|
306
|
+
}
|
|
307
|
+
async head(lease) {
|
|
308
|
+
const file = await this.findByKey(lease.objectKey);
|
|
309
|
+
if (!file)
|
|
310
|
+
return { exists: false, bytes: 0 };
|
|
311
|
+
// `md5Checksum` (hex) is absent for Google-native docs but always present
|
|
312
|
+
// for binary content we wrote. If it is ever missing the object cannot be
|
|
313
|
+
// proven from here, and undefined makes the verifier refuse rather than pretend.
|
|
314
|
+
return { exists: true, bytes: Number(file.size ?? "0"), checksum: file.md5Checksum };
|
|
315
|
+
}
|
|
316
|
+
/** Reads an object back for restore or offload-proof: locate by key, then `alt=media`. */
|
|
317
|
+
/** Removes the file holding this object key. Absent already means done. */
|
|
318
|
+
async deleteByKey(objectKey) {
|
|
319
|
+
const existing = await this.findByKey(objectKey);
|
|
320
|
+
if (!existing)
|
|
321
|
+
return;
|
|
322
|
+
const response = await this.api(`/drive/v3/files/${existing.id}`, { method: "DELETE" });
|
|
323
|
+
if (!response.ok && response.status !== 404) {
|
|
324
|
+
fail("internal", `Drive refused the delete: ${response.status} ${response.statusText}. The stored copy may still be there.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async download(lease) {
|
|
328
|
+
const file = await this.findByKey(lease.objectKey)
|
|
329
|
+
?? fail("archive_not_found", `Google Drive holds no file for ${lease.objectKey}`, { key: lease.objectKey });
|
|
330
|
+
const response = await this.api(`/drive/v3/files/${file.id}?alt=media`);
|
|
331
|
+
if (!response.ok)
|
|
332
|
+
fail("internal", `Could not download ${lease.objectKey}: ${response.status} ${response.statusText}`, { key: lease.objectKey, status: response.status });
|
|
333
|
+
return Buffer.from(await response.arrayBuffer());
|
|
334
|
+
}
|
|
335
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type BackendName, type ProviderCredentials } from "../secrets.js";
|
|
2
|
+
import { type ProviderConfig, type ProviderKind, type ProviderUploadClient, type UploadLease } from "../control-plane.js";
|
|
3
|
+
import { S3UploadClient } from "./s3.js";
|
|
4
|
+
import { GcsUploadClient } from "./gcs.js";
|
|
5
|
+
import { GdriveUploadClient } from "./gdrive.js";
|
|
6
|
+
export type EndpointOverrides = {
|
|
7
|
+
host?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
protocol?: "https" | "http";
|
|
10
|
+
pathStyle?: boolean;
|
|
11
|
+
partBytes?: number;
|
|
12
|
+
allowSmallParts?: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* "gdrive" rides alongside control-plane's ProviderKind rather than inside it:
|
|
16
|
+
* that union (and its restatements in types.ts and local-api.ts) is frozen in
|
|
17
|
+
* this change, so the widening lives here at the edge that accepts the new
|
|
18
|
+
* provider. Fold it into ProviderKind when the control-plane learns "gdrive".
|
|
19
|
+
*/
|
|
20
|
+
export type UploadProviderKind = ProviderKind | "gdrive";
|
|
21
|
+
export type UploadProviderConfig = Omit<ProviderConfig, "provider"> & {
|
|
22
|
+
provider: UploadProviderKind;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Builds the client for a configured provider. This is the only place a credential
|
|
26
|
+
* is handed to a network client, and the feature flag is checked here rather than
|
|
27
|
+
* at import time so an operator can turn uploads off without a restart.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createUploadClient(config: UploadProviderConfig, credentials: ProviderCredentials, overrides?: EndpointOverrides, env?: NodeJS.ProcessEnv): ProviderUploadClient;
|
|
30
|
+
export declare function uploadClientFromStore(dataDir: string, storageConfigId: string, config: UploadProviderConfig, overrides?: EndpointOverrides, backend?: BackendName): Promise<ProviderUploadClient>;
|
|
31
|
+
/**
|
|
32
|
+
* Mints a lease that a configured client may actually use.
|
|
33
|
+
*
|
|
34
|
+
* The object key is derived from the configured prefix and the archive id; a caller
|
|
35
|
+
* cannot choose it, which keeps one account's objects inside its own prefix.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createActiveLease(config: UploadProviderConfig, input: {
|
|
38
|
+
archiveId?: string;
|
|
39
|
+
ciphertextSha256: string;
|
|
40
|
+
bytes: number;
|
|
41
|
+
now?: number;
|
|
42
|
+
ttlMs?: number;
|
|
43
|
+
}, env?: NodeJS.ProcessEnv): UploadLease;
|
|
44
|
+
export { S3UploadClient, GcsUploadClient, GdriveUploadClient };
|
|
45
|
+
export type { ProviderKind };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { fail } from "../errors.js";
|
|
3
|
+
import { loadProviderCredentials } from "../secrets.js";
|
|
4
|
+
import { DEFAULT_LEASE_TTL_MS, signerEnabled } from "../control-plane.js";
|
|
5
|
+
import { S3UploadClient } from "./s3.js";
|
|
6
|
+
import { GcsUploadClient } from "./gcs.js";
|
|
7
|
+
import { GdriveUploadClient } from "./gdrive.js";
|
|
8
|
+
function isAwsStyle(credentials) {
|
|
9
|
+
return "accessKeyId" in credentials;
|
|
10
|
+
}
|
|
11
|
+
function isGdriveCredentials(credentials) {
|
|
12
|
+
return "kind" in credentials && credentials.kind === "gdrive";
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Builds the client for a configured provider. This is the only place a credential
|
|
16
|
+
* is handed to a network client, and the feature flag is checked here rather than
|
|
17
|
+
* at import time so an operator can turn uploads off without a restart.
|
|
18
|
+
*/
|
|
19
|
+
export function createUploadClient(config, credentials, overrides = {}, env = process.env) {
|
|
20
|
+
if (!signerEnabled(env))
|
|
21
|
+
fail("signer_not_configured", "Uploads are disabled. Set VAULTLINE_ENABLE_SIGNER=1 to enable a configured provider client.", { provider: config.provider });
|
|
22
|
+
if (config.provider === "gdrive") {
|
|
23
|
+
if (!isGdriveCredentials(credentials))
|
|
24
|
+
fail("invalid_argument", "Google Drive needs an OAuth credential. Run: sealkeep storage connect gdrive");
|
|
25
|
+
// Drive has no buckets: the configured bucket names the archive folder, and
|
|
26
|
+
// a prefix of "appdata" selects the hidden per-app appDataFolder space.
|
|
27
|
+
return new GdriveUploadClient({ folderName: config.bucket, appData: config.prefix.replace(/^\/+|\/+$/g, "") === "appdata", host: overrides.host, port: overrides.port, protocol: overrides.protocol }, credentials);
|
|
28
|
+
}
|
|
29
|
+
if (config.provider === "gcs") {
|
|
30
|
+
if (isAwsStyle(credentials) || isGdriveCredentials(credentials))
|
|
31
|
+
fail("invalid_argument", "GCS needs a service-account credential, not an access key or OAuth token");
|
|
32
|
+
return new GcsUploadClient({ bucket: config.bucket, host: overrides.host, port: overrides.port, protocol: overrides.protocol }, credentials);
|
|
33
|
+
}
|
|
34
|
+
if (!isAwsStyle(credentials))
|
|
35
|
+
fail("invalid_argument", `${config.provider} needs an access key credential, not a service account or OAuth token`);
|
|
36
|
+
const endpoint = { provider: config.provider === "vaultline" ? "s3" : config.provider, region: config.region ?? "auto", bucket: config.bucket, host: overrides.host, port: overrides.port, protocol: overrides.protocol, pathStyle: overrides.pathStyle };
|
|
37
|
+
return new S3UploadClient(config.provider, endpoint, credentials, overrides.partBytes, overrides.allowSmallParts);
|
|
38
|
+
}
|
|
39
|
+
export async function uploadClientFromStore(dataDir, storageConfigId, config, overrides = {}, backend) {
|
|
40
|
+
const credentials = await loadProviderCredentials(dataDir, storageConfigId, backend);
|
|
41
|
+
// A Drive credential minted through the account's server refreshes there
|
|
42
|
+
// too — attach the (runtime-only) refresher with the dataDir it needs.
|
|
43
|
+
if (isGdriveCredentials(credentials) && credentials.exchange === "cloud") {
|
|
44
|
+
const { gdriveRefresh } = await import("../cloud.js");
|
|
45
|
+
credentials.refresher = (refreshToken) => gdriveRefresh(dataDir, refreshToken);
|
|
46
|
+
}
|
|
47
|
+
return createUploadClient(config, credentials, overrides);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Mints a lease that a configured client may actually use.
|
|
51
|
+
*
|
|
52
|
+
* The object key is derived from the configured prefix and the archive id; a caller
|
|
53
|
+
* cannot choose it, which keeps one account's objects inside its own prefix.
|
|
54
|
+
*/
|
|
55
|
+
export function createActiveLease(config, input, env = process.env) {
|
|
56
|
+
if (!signerEnabled(env))
|
|
57
|
+
fail("signer_not_configured", "Active leases require an enabled signer", { provider: config.provider });
|
|
58
|
+
if (!config.bucket || !config.prefix)
|
|
59
|
+
fail("invalid_argument", "bucket and prefix are required");
|
|
60
|
+
const archiveId = input.archiveId ?? randomUUID();
|
|
61
|
+
const issuedAt = input.now ?? Date.now();
|
|
62
|
+
return {
|
|
63
|
+
// UploadLease.provider is still control-plane's narrower union; the cast
|
|
64
|
+
// goes away with the UploadProviderKind widening above.
|
|
65
|
+
archiveId, provider: config.provider,
|
|
66
|
+
objectKey: `${config.prefix.replace(/^\/+|\/+$/g, "")}/${archiveId}.vlarchive`,
|
|
67
|
+
expiresAt: new Date(issuedAt + (input.ttlMs ?? DEFAULT_LEASE_TTL_MS)).toISOString(),
|
|
68
|
+
method: config.provider === "gcs" || config.provider === "gdrive" ? "RESUMABLE" : "MULTIPART",
|
|
69
|
+
uploadUrl: `${config.provider}://${config.bucket}/${config.prefix.replace(/^\/+|\/+$/g, "")}/${archiveId}.vlarchive`,
|
|
70
|
+
requiredHeaders: { "content-type": "application/vnd.vaultline.ciphertext", "x-vaultline-ciphertext-sha256": input.ciphertextSha256, "content-length": String(input.bytes) },
|
|
71
|
+
status: "active", uploadable: true
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export { S3UploadClient, GcsUploadClient, GdriveUploadClient };
|