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.
Files changed (180) hide show
  1. package/ARCHITECTURE.md +201 -0
  2. package/CHANGELOG.md +218 -0
  3. package/CONTROL_PLANE.md +86 -0
  4. package/LICENSE +34 -0
  5. package/README.md +249 -0
  6. package/THIRD_PARTY.md +22 -0
  7. package/THREAT_MODEL.md +107 -0
  8. package/dist/packages/vaultline-crypto/src/aead.d.ts +12 -0
  9. package/dist/packages/vaultline-crypto/src/aead.js +24 -0
  10. package/dist/packages/vaultline-crypto/src/chunk-access.d.ts +39 -0
  11. package/dist/packages/vaultline-crypto/src/chunk-access.js +93 -0
  12. package/dist/packages/vaultline-crypto/src/envelope.d.ts +71 -0
  13. package/dist/packages/vaultline-crypto/src/envelope.js +188 -0
  14. package/dist/packages/vaultline-crypto/src/format.d.ts +106 -0
  15. package/dist/packages/vaultline-crypto/src/format.js +43 -0
  16. package/dist/packages/vaultline-crypto/src/index.d.ts +5 -0
  17. package/dist/packages/vaultline-crypto/src/index.js +5 -0
  18. package/dist/packages/vaultline-crypto/src/recipients.d.ts +42 -0
  19. package/dist/packages/vaultline-crypto/src/recipients.js +129 -0
  20. package/dist/packages/vaultline-crypto/src/sha256-stream.d.ts +41 -0
  21. package/dist/packages/vaultline-crypto/src/sha256-stream.js +206 -0
  22. package/dist/packages/vaultline-crypto/src/stream.d.ts +139 -0
  23. package/dist/packages/vaultline-crypto/src/stream.js +477 -0
  24. package/dist/site/index.html +1542 -0
  25. package/dist/site.zip +0 -0
  26. package/dist/src/activity.d.ts +22 -0
  27. package/dist/src/activity.js +52 -0
  28. package/dist/src/adapters.d.ts +212 -0
  29. package/dist/src/adapters.js +533 -0
  30. package/dist/src/audit.d.ts +24 -0
  31. package/dist/src/audit.js +41 -0
  32. package/dist/src/autopilot.d.ts +77 -0
  33. package/dist/src/autopilot.js +148 -0
  34. package/dist/src/bip39-wordlist.d.ts +15 -0
  35. package/dist/src/bip39-wordlist.js +272 -0
  36. package/dist/src/branding.d.ts +31 -0
  37. package/dist/src/branding.js +31 -0
  38. package/dist/src/chunk-store.d.ts +142 -0
  39. package/dist/src/chunk-store.js +502 -0
  40. package/dist/src/cli.d.ts +2 -0
  41. package/dist/src/cli.js +2035 -0
  42. package/dist/src/cloud.d.ts +434 -0
  43. package/dist/src/cloud.js +851 -0
  44. package/dist/src/control-plane/auth.d.ts +62 -0
  45. package/dist/src/control-plane/auth.js +123 -0
  46. package/dist/src/control-plane/server.d.ts +31 -0
  47. package/dist/src/control-plane/server.js +263 -0
  48. package/dist/src/control-plane/store.d.ts +101 -0
  49. package/dist/src/control-plane/store.js +82 -0
  50. package/dist/src/control-plane-cli.d.ts +2 -0
  51. package/dist/src/control-plane-cli.js +37 -0
  52. package/dist/src/control-plane-server.d.ts +10 -0
  53. package/dist/src/control-plane-server.js +11 -0
  54. package/dist/src/control-plane.d.ts +78 -0
  55. package/dist/src/control-plane.js +61 -0
  56. package/dist/src/crypto.d.ts +56 -0
  57. package/dist/src/crypto.js +132 -0
  58. package/dist/src/daemon.d.ts +52 -0
  59. package/dist/src/daemon.js +142 -0
  60. package/dist/src/dashboard-cli.d.ts +2 -0
  61. package/dist/src/dashboard-cli.js +20 -0
  62. package/dist/src/disk.d.ts +110 -0
  63. package/dist/src/disk.js +169 -0
  64. package/dist/src/doctor.d.ts +11 -0
  65. package/dist/src/doctor.js +198 -0
  66. package/dist/src/enroll.d.ts +27 -0
  67. package/dist/src/enroll.js +136 -0
  68. package/dist/src/errors.d.ts +26 -0
  69. package/dist/src/errors.js +23 -0
  70. package/dist/src/heartbeat.d.ts +89 -0
  71. package/dist/src/heartbeat.js +120 -0
  72. package/dist/src/index-sync.d.ts +53 -0
  73. package/dist/src/index-sync.js +147 -0
  74. package/dist/src/leakscan.d.ts +48 -0
  75. package/dist/src/leakscan.js +222 -0
  76. package/dist/src/local-api.d.ts +132 -0
  77. package/dist/src/local-api.js +1757 -0
  78. package/dist/src/managed-chunks.d.ts +55 -0
  79. package/dist/src/managed-chunks.js +108 -0
  80. package/dist/src/mcp-install.d.ts +52 -0
  81. package/dist/src/mcp-install.js +140 -0
  82. package/dist/src/mcp.d.ts +1 -0
  83. package/dist/src/mcp.js +59 -0
  84. package/dist/src/migrate.d.ts +35 -0
  85. package/dist/src/migrate.js +88 -0
  86. package/dist/src/mnemonic.d.ts +60 -0
  87. package/dist/src/mnemonic.js +134 -0
  88. package/dist/src/net.d.ts +2 -0
  89. package/dist/src/net.js +16 -0
  90. package/dist/src/notify.d.ts +46 -0
  91. package/dist/src/notify.js +84 -0
  92. package/dist/src/offload.d.ts +117 -0
  93. package/dist/src/offload.js +331 -0
  94. package/dist/src/onboarding.d.ts +10 -0
  95. package/dist/src/onboarding.js +44 -0
  96. package/dist/src/packages.d.ts +126 -0
  97. package/dist/src/packages.js +114 -0
  98. package/dist/src/passkey.d.ts +26 -0
  99. package/dist/src/passkey.js +54 -0
  100. package/dist/src/password-lock.d.ts +19 -0
  101. package/dist/src/password-lock.js +156 -0
  102. package/dist/src/paths.d.ts +9 -0
  103. package/dist/src/paths.js +24 -0
  104. package/dist/src/providers/gcs.d.ts +133 -0
  105. package/dist/src/providers/gcs.js +235 -0
  106. package/dist/src/providers/gdrive.d.ts +156 -0
  107. package/dist/src/providers/gdrive.js +335 -0
  108. package/dist/src/providers/index.d.ts +45 -0
  109. package/dist/src/providers/index.js +74 -0
  110. package/dist/src/providers/s3.d.ts +174 -0
  111. package/dist/src/providers/s3.js +345 -0
  112. package/dist/src/providers/sigv4.d.ts +78 -0
  113. package/dist/src/providers/sigv4.js +112 -0
  114. package/dist/src/queue.d.ts +185 -0
  115. package/dist/src/queue.js +286 -0
  116. package/dist/src/recovery.d.ts +40 -0
  117. package/dist/src/recovery.js +132 -0
  118. package/dist/src/rehydrate.d.ts +43 -0
  119. package/dist/src/rehydrate.js +66 -0
  120. package/dist/src/restore.d.ts +34 -0
  121. package/dist/src/restore.js +80 -0
  122. package/dist/src/retention.d.ts +251 -0
  123. package/dist/src/retention.js +446 -0
  124. package/dist/src/rotate.d.ts +47 -0
  125. package/dist/src/rotate.js +95 -0
  126. package/dist/src/search.d.ts +147 -0
  127. package/dist/src/search.js +677 -0
  128. package/dist/src/secrets.d.ts +86 -0
  129. package/dist/src/secrets.js +220 -0
  130. package/dist/src/service.d.ts +73 -0
  131. package/dist/src/service.js +197 -0
  132. package/dist/src/share.d.ts +34 -0
  133. package/dist/src/share.js +68 -0
  134. package/dist/src/spool.d.ts +97 -0
  135. package/dist/src/spool.js +213 -0
  136. package/dist/src/start-tui.d.ts +17 -0
  137. package/dist/src/start-tui.js +113 -0
  138. package/dist/src/start.d.ts +75 -0
  139. package/dist/src/start.js +101 -0
  140. package/dist/src/storage-setup.d.ts +49 -0
  141. package/dist/src/storage-setup.js +222 -0
  142. package/dist/src/storage-targets.d.ts +40 -0
  143. package/dist/src/storage-targets.js +147 -0
  144. package/dist/src/stream-to-cloud.d.ts +76 -0
  145. package/dist/src/stream-to-cloud.js +820 -0
  146. package/dist/src/sync-rules.d.ts +85 -0
  147. package/dist/src/sync-rules.js +125 -0
  148. package/dist/src/trash.d.ts +15 -0
  149. package/dist/src/trash.js +63 -0
  150. package/dist/src/tui.d.ts +18 -0
  151. package/dist/src/tui.js +179 -0
  152. package/dist/src/types.d.ts +191 -0
  153. package/dist/src/types.js +3 -0
  154. package/dist/src/ui-server.d.ts +187 -0
  155. package/dist/src/ui-server.js +293 -0
  156. package/dist/src/ui.d.ts +41 -0
  157. package/dist/src/ui.js +102 -0
  158. package/dist/src/update.d.ts +30 -0
  159. package/dist/src/update.js +56 -0
  160. package/dist/src/upload.d.ts +46 -0
  161. package/dist/src/upload.js +80 -0
  162. package/dist/src/vault.d.ts +208 -0
  163. package/dist/src/vault.js +812 -0
  164. package/dist/src/watcher.d.ts +34 -0
  165. package/dist/src/watcher.js +121 -0
  166. package/dist/src/worker.d.ts +52 -0
  167. package/dist/src/worker.js +190 -0
  168. package/package.json +65 -0
  169. package/web/app.js +1372 -0
  170. package/web/index.html +476 -0
  171. package/web/rail.js +308 -0
  172. package/web/retention.html +17 -0
  173. package/web/rules-view.js +249 -0
  174. package/web/sessions-view.js +448 -0
  175. package/web/sessions.html +17 -0
  176. package/web/setup-api.js +181 -0
  177. package/web/setup-logic.js +394 -0
  178. package/web/setup.html +419 -0
  179. package/web/setup.js +697 -0
  180. package/web/style.css +990 -0
@@ -0,0 +1,174 @@
1
+ import { type Credentials } from "./sigv4.js";
2
+ import type { ProviderKind, ProviderUploadClient, UploadLease } from "../control-plane.js";
3
+ export type S3Endpoint = {
4
+ region: string;
5
+ bucket: string;
6
+ /** Which S3-compatible service this is. Decides the default host and addressing style. */
7
+ provider?: "s3" | "r2" | "b2";
8
+ /** Set for R2, MinIO, or any non-AWS S3-compatible endpoint. */
9
+ host?: string;
10
+ port?: number;
11
+ protocol?: "https" | "http";
12
+ pathStyle?: boolean;
13
+ };
14
+ export declare const DEFAULT_PART_BYTES: number;
15
+ export declare const MIN_PART_BYTES: number;
16
+ /**
17
+ * The checksum S3 stores for a multipart object, computed the way S3 computes it:
18
+ * SHA-256 over the concatenated raw part digests, base64, then `-<part count>`.
19
+ *
20
+ * Worth deriving rather than skipping. A multipart object's stored checksum is
21
+ * never the whole-object SHA-256, so comparing the two always fails — and the
22
+ * cheap way out is to skip the check whenever a `-N` suffix appears, which
23
+ * silently disables verification for exactly the largest archives. Computing it
24
+ * keeps the comparison real at every size.
25
+ */
26
+ export declare function compositeChecksum(partChecksumsB64: readonly string[]): string;
27
+ /** One uploaded part as the resume journal records it. `etag` and `checksum` are
28
+ * what CompleteMultipartUpload needs back; `bytes` is what makes a short part
29
+ * detectable at resume time, when the part bodies themselves are long gone. */
30
+ export type S3StreamPart = {
31
+ partNumber: number;
32
+ etag: string;
33
+ checksum: string;
34
+ bytes: number;
35
+ };
36
+ /** Everything a crashed stream needs to continue. Journal exactly this shape. */
37
+ export type S3StreamState = {
38
+ uploadId: string;
39
+ parts: S3StreamPart[];
40
+ };
41
+ export type S3StreamOptions = {
42
+ /** State from an earlier run's onState: skips initiation, reuses the parts
43
+ * already uploaded, and continues numbering after them. */
44
+ resume?: S3StreamState;
45
+ /** Awaited after every completed part upload with the full current state.
46
+ * This call is the durability point a resume journal writes at: once it
47
+ * returns, a crash can be resumed from that state. */
48
+ onState?: (state: S3StreamState) => void | Promise<void>;
49
+ };
50
+ /**
51
+ * The byte position a resumed stream must start feeding from: one full part per
52
+ * seeded entry, with exactly one exception.
53
+ *
54
+ * A short part anywhere but LAST means the journal describes a
55
+ * differently-shaped upload (or a different part size), and continuing would
56
+ * splice new bytes at the wrong offset — refused. A short LAST seed is the
57
+ * one legitimate shape: the object ended there, and the only thing left to do
58
+ * is complete. `uploadStream` therefore refuses any further data after a short
59
+ * last seed — bytes cannot follow a tail.
60
+ */
61
+ export declare function resumeOffsetBytes(parts: readonly S3StreamPart[], partBytes: number): number;
62
+ /** True when the seeds end in a short (object-ending) part: completion-only resume. */
63
+ export declare function seededTailIsFinal(parts: readonly S3StreamPart[], partBytes: number): boolean;
64
+ export declare function xmlValue(body: string, tag: string): string | null;
65
+ export declare function target(endpoint: S3Endpoint, key: string): {
66
+ host: string;
67
+ path: string;
68
+ };
69
+ /**
70
+ * An S3-compatible upload client for S3, R2, and MinIO.
71
+ *
72
+ * Requests are presigned locally with credentials this process was handed; no
73
+ * long-lived credential is ever written to a Sealkeep config file. Only the
74
+ * checksum header is signed, because `content-length` is a forbidden header for
75
+ * `fetch` and could not be sent verbatim — the SHA-256 already pins the bytes.
76
+ */
77
+ export declare class S3UploadClient implements ProviderUploadClient {
78
+ readonly kind: ProviderKind;
79
+ private readonly endpoint;
80
+ private readonly credentials;
81
+ private readonly partBytes;
82
+ constructor(kind: ProviderKind, endpoint: S3Endpoint, credentials: Credentials, partBytes?: number,
83
+ /** S3 requires every part except the last to be at least 5 MiB. Local emulators and
84
+ * MinIO do not, so this is the deliberate opt-out for those targets only. */
85
+ allowSmallParts?: boolean);
86
+ private sign;
87
+ private put;
88
+ /**
89
+ * The chunk-folder layout's whole provider API: put one object, ask about
90
+ * one object, remove one object. A 4 MB sealed chunk has a known length, so
91
+ * a plain checksummed PUT does everything multipart machinery did — which
92
+ * is exactly why the chunk layout needs none of it.
93
+ */
94
+ putObject(key: string, body: Buffer): Promise<{
95
+ checksum: string;
96
+ }>;
97
+ headObject(key: string): Promise<{
98
+ exists: boolean;
99
+ bytes: number;
100
+ checksum?: string;
101
+ }>;
102
+ getObject(key: string): Promise<Buffer>;
103
+ deleteObject(key: string): Promise<void>;
104
+ initiateMultipart(key: string): Promise<string>;
105
+ completeMultipart(key: string, uploadId: string, parts: {
106
+ partNumber: number;
107
+ etag: string;
108
+ checksum: string;
109
+ }[]): Promise<string>;
110
+ /**
111
+ * What the provider ACTUALLY holds for an in-flight multipart — the resume
112
+ * path's source of truth. A journal can lag the provider by exactly the
113
+ * parts that landed between a PUT and the journal write that would have
114
+ * recorded them; ListParts is how a resume sees those, so nothing the
115
+ * provider holds can be silently overwritten with re-sealed bytes.
116
+ */
117
+ listParts(key: string, uploadId: string): Promise<{
118
+ partNumber: number;
119
+ etag: string;
120
+ bytes: number;
121
+ }[]>;
122
+ abortMultipart(key: string, uploadId: string): Promise<void>;
123
+ /** Uploads ciphertext and returns the checksum the provider confirmed. */
124
+ upload(lease: UploadLease, ciphertext: Buffer): Promise<{
125
+ remoteChecksum: string;
126
+ bytes: number;
127
+ }>;
128
+ /**
129
+ * Streams an object up as it is produced: stored chunks accumulate into
130
+ * multipart parts of `partBytes`, each part uploads as soon as it fills, and
131
+ * the remainder goes up as the final part. A source that ends before one
132
+ * part fills becomes a plain single PUT — S3 refuses multiparts under 5 MB
133
+ * except as the last part, and a one-part multipart is just ceremony.
134
+ *
135
+ * Peak memory is one part buffer. On any failure the multipart is aborted so
136
+ * no half-object lingers as billable invisible parts.
137
+ *
138
+ * Resume: `options.resume` seeds the parts of an earlier run and skips
139
+ * initiation. The caller guarantees the incoming chunks begin at the byte
140
+ * position right after the seeded parts — it re-seals from a chunk boundary,
141
+ * so that guarantee only holds when `partBytes` is a multiple of the seal
142
+ * chunk size, an alignment invisible from here and therefore the caller's
143
+ * problem. What IS checkable is that the seeded parts imply a part-aligned
144
+ * offset, so a journal carrying a short non-final part is refused before any
145
+ * request goes out. `options.onState` is awaited after every completed part
146
+ * with the full state — the journal's durability point.
147
+ */
148
+ uploadStream(key: string, chunks: AsyncIterable<Buffer>, options?: S3StreamOptions): Promise<{
149
+ remoteChecksum: string;
150
+ bytes: number;
151
+ }>;
152
+ /**
153
+ * Fetches one object back.
154
+ *
155
+ * Managed downloads from an S3-compatible provider cannot be presigned by the
156
+ * control plane — Spala has no SigV4 signer — so the machine signs its own
157
+ * GET with the short-lived credential it was vended, exactly as it does for
158
+ * the upload.
159
+ */
160
+ download(lease: UploadLease): Promise<Buffer>;
161
+ /**
162
+ * Confirms the object exists remotely and reports what the provider stored.
163
+ *
164
+ * `x-amz-checksum-mode: ENABLED` is required before S3 will return a stored
165
+ * checksum on HEAD at all. Without it the header is simply absent, and a
166
+ * caller that skips the comparison when the header is missing ends up
167
+ * verifying nothing but the byte count.
168
+ */
169
+ head(lease: UploadLease): Promise<{
170
+ exists: boolean;
171
+ bytes: number;
172
+ checksum?: string;
173
+ }>;
174
+ }
@@ -0,0 +1,345 @@
1
+ import { createHash } from "node:crypto";
2
+ import { fail } from "../errors.js";
3
+ import { presign } from "./sigv4.js";
4
+ export const DEFAULT_PART_BYTES = 8 * 1024 * 1024;
5
+ export const MIN_PART_BYTES = 5 * 1024 * 1024;
6
+ const sha256b64 = (body) => createHash("sha256").update(body).digest("base64");
7
+ /**
8
+ * The checksum S3 stores for a multipart object, computed the way S3 computes it:
9
+ * SHA-256 over the concatenated raw part digests, base64, then `-<part count>`.
10
+ *
11
+ * Worth deriving rather than skipping. A multipart object's stored checksum is
12
+ * never the whole-object SHA-256, so comparing the two always fails — and the
13
+ * cheap way out is to skip the check whenever a `-N` suffix appears, which
14
+ * silently disables verification for exactly the largest archives. Computing it
15
+ * keeps the comparison real at every size.
16
+ */
17
+ export function compositeChecksum(partChecksumsB64) {
18
+ const digests = Buffer.concat(partChecksumsB64.map((part) => Buffer.from(part, "base64")));
19
+ return `${createHash("sha256").update(digests).digest("base64")}-${partChecksumsB64.length}`;
20
+ }
21
+ /**
22
+ * The byte position a resumed stream must start feeding from: one full part per
23
+ * seeded entry, with exactly one exception.
24
+ *
25
+ * A short part anywhere but LAST means the journal describes a
26
+ * differently-shaped upload (or a different part size), and continuing would
27
+ * splice new bytes at the wrong offset — refused. A short LAST seed is the
28
+ * one legitimate shape: the object ended there, and the only thing left to do
29
+ * is complete. `uploadStream` therefore refuses any further data after a short
30
+ * last seed — bytes cannot follow a tail.
31
+ */
32
+ export function resumeOffsetBytes(parts, partBytes) {
33
+ let offset = 0;
34
+ parts.forEach((part, index) => {
35
+ if (part.bytes !== partBytes && index !== parts.length - 1) {
36
+ fail("invalid_argument", `Cannot resume this upload: seeded part ${part.partNumber} holds ${part.bytes} bytes, but every part before the final one must hold exactly ${partBytes} bytes for the resume offset to land on a part boundary.`, { partNumber: part.partNumber, partBytes, bytes: part.bytes });
37
+ }
38
+ offset += part.bytes;
39
+ });
40
+ return offset;
41
+ }
42
+ /** True when the seeds end in a short (object-ending) part: completion-only resume. */
43
+ export function seededTailIsFinal(parts, partBytes) {
44
+ return parts.length > 0 && parts[parts.length - 1].bytes !== partBytes;
45
+ }
46
+ /** Minimal extraction for the three fields S3 returns in XML. No general XML parsing is required. */
47
+ /**
48
+ * Where each S3-compatible provider lives when no host is given explicitly.
49
+ *
50
+ * B2 and R2 speak S3, so they reuse this client wholesale; only the endpoint
51
+ * differs. B2 is path-style (`.../bucket/key`) rather than virtual-hosted,
52
+ * which is why the caller must not assume a bucket-prefixed host.
53
+ */
54
+ function defaultHost(endpoint) {
55
+ if (endpoint.provider === "b2")
56
+ return `s3.${endpoint.region}.backblazeb2.com`;
57
+ // R2 endpoints are per Cloudflare account, so there is nothing to derive from
58
+ // bucket and region. Falling through to the AWS host would send a presigned
59
+ // request — carrying the customer's R2 access key id in X-Amz-Credential — to
60
+ // Amazon, for a bucket name they do not own. Refuse instead.
61
+ if (endpoint.provider === "r2")
62
+ fail("storage_not_configured", "R2 needs its endpoint host: pass --endpoint https://<account-id>.r2.cloudflarestorage.com", { provider: "r2", bucket: endpoint.bucket });
63
+ return `${endpoint.bucket}.s3.${endpoint.region}.amazonaws.com`;
64
+ }
65
+ export function xmlValue(body, tag) {
66
+ const match = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`).exec(body);
67
+ return match ? match[1] : null;
68
+ }
69
+ export function target(endpoint, key) {
70
+ const host = endpoint.host ?? defaultHost(endpoint);
71
+ const pathStyle = endpoint.pathStyle ?? (endpoint.provider === "b2" || Boolean(endpoint.host));
72
+ return { host, path: pathStyle ? `/${endpoint.bucket}/${key}` : `/${key}` };
73
+ }
74
+ async function readError(response) {
75
+ const body = await response.text().catch(() => "");
76
+ return `${response.status} ${response.statusText}${body ? `: ${xmlValue(body, "Message") ?? body.slice(0, 200)}` : ""}`;
77
+ }
78
+ /**
79
+ * An S3-compatible upload client for S3, R2, and MinIO.
80
+ *
81
+ * Requests are presigned locally with credentials this process was handed; no
82
+ * long-lived credential is ever written to a Sealkeep config file. Only the
83
+ * checksum header is signed, because `content-length` is a forbidden header for
84
+ * `fetch` and could not be sent verbatim — the SHA-256 already pins the bytes.
85
+ */
86
+ export class S3UploadClient {
87
+ kind;
88
+ endpoint;
89
+ credentials;
90
+ partBytes;
91
+ constructor(kind, endpoint, credentials, partBytes = DEFAULT_PART_BYTES,
92
+ /** S3 requires every part except the last to be at least 5 MiB. Local emulators and
93
+ * MinIO do not, so this is the deliberate opt-out for those targets only. */
94
+ allowSmallParts = false) {
95
+ this.kind = kind;
96
+ this.endpoint = endpoint;
97
+ this.credentials = credentials;
98
+ this.partBytes = partBytes;
99
+ if (partBytes < MIN_PART_BYTES && !allowSmallParts)
100
+ fail("invalid_argument", `S3 multipart parts must be at least ${MIN_PART_BYTES} bytes`, { partBytes });
101
+ if (partBytes <= 0)
102
+ fail("invalid_argument", "partBytes must be positive");
103
+ }
104
+ sign(method, key, query, headers = {}, expiresInSeconds = 900) {
105
+ const { host, path } = target(this.endpoint, key);
106
+ return presign({ method, host, port: this.endpoint.port, protocol: this.endpoint.protocol, path, region: this.endpoint.region, credentials: this.credentials, query, headers, expiresInSeconds });
107
+ }
108
+ async put(key, body, query = {}) {
109
+ const checksum = sha256b64(body);
110
+ const { url } = this.sign("PUT", key, query, { "x-amz-checksum-sha256": checksum });
111
+ const response = await fetch(url, { method: "PUT", body: new Uint8Array(body), headers: { "x-amz-checksum-sha256": checksum } });
112
+ if (!response.ok)
113
+ fail("internal", `Upload failed for ${key}: ${await readError(response)}`, { key, status: response.status });
114
+ return { etag: (response.headers.get("etag") ?? "").replace(/"/g, ""), checksum };
115
+ }
116
+ /**
117
+ * The chunk-folder layout's whole provider API: put one object, ask about
118
+ * one object, remove one object. A 4 MB sealed chunk has a known length, so
119
+ * a plain checksummed PUT does everything multipart machinery did — which
120
+ * is exactly why the chunk layout needs none of it.
121
+ */
122
+ async putObject(key, body) {
123
+ const { checksum } = await this.put(key, body);
124
+ return { checksum };
125
+ }
126
+ async headObject(key) {
127
+ const { url } = this.sign("HEAD", key, {}, { "x-amz-checksum-mode": "ENABLED" });
128
+ const response = await fetch(url, { method: "HEAD", headers: { "x-amz-checksum-mode": "ENABLED" } });
129
+ if (response.status === 404)
130
+ return { exists: false, bytes: 0 };
131
+ if (!response.ok)
132
+ fail("internal", `Could not read ${key}: ${response.status} ${response.statusText}`, { key, status: response.status });
133
+ return { exists: true, bytes: Number(response.headers.get("content-length") ?? "0"), checksum: response.headers.get("x-amz-checksum-sha256") ?? undefined };
134
+ }
135
+ async getObject(key) {
136
+ const { url } = this.sign("GET", key, {});
137
+ const response = await fetch(url);
138
+ if (!response.ok)
139
+ fail("internal", `Could not download ${key}: ${await readError(response)}`, { key, status: response.status });
140
+ return Buffer.from(await response.arrayBuffer());
141
+ }
142
+ async deleteObject(key) {
143
+ const { url } = this.sign("DELETE", key, {});
144
+ const response = await fetch(url, { method: "DELETE" });
145
+ if (!response.ok && response.status !== 404)
146
+ fail("internal", `Could not delete ${key}: ${await readError(response)}`, { key, status: response.status });
147
+ }
148
+ async initiateMultipart(key) {
149
+ const { url } = this.sign("POST", key, { uploads: "" });
150
+ const response = await fetch(url, { method: "POST" });
151
+ if (!response.ok)
152
+ fail("internal", `Could not start a multipart upload for ${key}: ${await readError(response)}`, { key });
153
+ const uploadId = xmlValue(await response.text(), "UploadId");
154
+ return uploadId ?? fail("internal", `Multipart initiation for ${key} returned no UploadId`, { key });
155
+ }
156
+ async completeMultipart(key, uploadId, parts) {
157
+ const body = Buffer.from(`<CompleteMultipartUpload>${parts.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>"${part.etag}"</ETag><ChecksumSHA256>${part.checksum}</ChecksumSHA256></Part>`).join("")}</CompleteMultipartUpload>`);
158
+ const { url } = this.sign("POST", key, { uploadId });
159
+ const response = await fetch(url, { method: "POST", body: new Uint8Array(body) });
160
+ if (!response.ok)
161
+ fail("internal", `Could not complete the multipart upload for ${key}: ${await readError(response)}`, { key });
162
+ const text = await response.text();
163
+ if (xmlValue(text, "Code"))
164
+ fail("internal", `Multipart completion for ${key} reported ${xmlValue(text, "Code")}`, { key });
165
+ return (xmlValue(text, "ETag") ?? "").replace(/&quot;|"/g, "");
166
+ }
167
+ /**
168
+ * What the provider ACTUALLY holds for an in-flight multipart — the resume
169
+ * path's source of truth. A journal can lag the provider by exactly the
170
+ * parts that landed between a PUT and the journal write that would have
171
+ * recorded them; ListParts is how a resume sees those, so nothing the
172
+ * provider holds can be silently overwritten with re-sealed bytes.
173
+ */
174
+ async listParts(key, uploadId) {
175
+ const { url } = this.sign("GET", key, { uploadId });
176
+ const response = await fetch(url);
177
+ if (!response.ok)
178
+ fail("internal", `Cannot resume this upload: the provider would not list its parts (${await readError(response)})`, { key, uploadId });
179
+ const text = await response.text();
180
+ const parts = [];
181
+ for (const match of text.matchAll(/<Part>([\s\S]*?)<\/Part>/g)) {
182
+ const block = match[1];
183
+ const partNumber = Number(xmlValue(block, "PartNumber"));
184
+ const etag = (xmlValue(block, "ETag") ?? "").replace(/&quot;|"/g, "");
185
+ const bytes = Number(xmlValue(block, "Size"));
186
+ if (!Number.isInteger(partNumber) || !Number.isFinite(bytes))
187
+ continue;
188
+ parts.push({ partNumber, etag, bytes });
189
+ }
190
+ parts.sort((a, b) => a.partNumber - b.partNumber);
191
+ return parts;
192
+ }
193
+ async abortMultipart(key, uploadId) {
194
+ const { url } = this.sign("DELETE", key, { uploadId });
195
+ await fetch(url, { method: "DELETE" }).catch(() => undefined);
196
+ }
197
+ /** Uploads ciphertext and returns the checksum the provider confirmed. */
198
+ async upload(lease, ciphertext) {
199
+ const key = lease.objectKey;
200
+ if (ciphertext.length <= this.partBytes) {
201
+ const { checksum } = await this.put(key, ciphertext);
202
+ return { remoteChecksum: checksum, bytes: ciphertext.length };
203
+ }
204
+ const uploadId = await this.initiateMultipart(key);
205
+ try {
206
+ const parts = [];
207
+ for (let offset = 0, partNumber = 1; offset < ciphertext.length; offset += this.partBytes, partNumber += 1) {
208
+ const slice = ciphertext.subarray(offset, Math.min(offset + this.partBytes, ciphertext.length));
209
+ const { etag, checksum } = await this.put(key, slice, { partNumber: String(partNumber), uploadId });
210
+ parts.push({ partNumber, etag, checksum });
211
+ }
212
+ await this.completeMultipart(key, uploadId, parts);
213
+ // The composite, not sha256b64(ciphertext): a multipart object's stored
214
+ // checksum is derived from the part digests, so the whole-object hash
215
+ // would never match what `head` reads back.
216
+ return { remoteChecksum: compositeChecksum(parts.map((part) => part.checksum)), bytes: ciphertext.length };
217
+ }
218
+ catch (error) {
219
+ await this.abortMultipart(key, uploadId);
220
+ throw error;
221
+ }
222
+ }
223
+ /**
224
+ * Streams an object up as it is produced: stored chunks accumulate into
225
+ * multipart parts of `partBytes`, each part uploads as soon as it fills, and
226
+ * the remainder goes up as the final part. A source that ends before one
227
+ * part fills becomes a plain single PUT — S3 refuses multiparts under 5 MB
228
+ * except as the last part, and a one-part multipart is just ceremony.
229
+ *
230
+ * Peak memory is one part buffer. On any failure the multipart is aborted so
231
+ * no half-object lingers as billable invisible parts.
232
+ *
233
+ * Resume: `options.resume` seeds the parts of an earlier run and skips
234
+ * initiation. The caller guarantees the incoming chunks begin at the byte
235
+ * position right after the seeded parts — it re-seals from a chunk boundary,
236
+ * so that guarantee only holds when `partBytes` is a multiple of the seal
237
+ * chunk size, an alignment invisible from here and therefore the caller's
238
+ * problem. What IS checkable is that the seeded parts imply a part-aligned
239
+ * offset, so a journal carrying a short non-final part is refused before any
240
+ * request goes out. `options.onState` is awaited after every completed part
241
+ * with the full state — the journal's durability point.
242
+ */
243
+ async uploadStream(key, chunks, options = {}) {
244
+ const resume = options.resume;
245
+ if (resume) {
246
+ // Refusals happen before the try block on purpose: the catch below
247
+ // aborts the multipart, and a rejected resume must leave the seeded
248
+ // parts alive so the caller can correct its journal and try again.
249
+ resume.parts.forEach((part, index) => {
250
+ if (part.partNumber !== index + 1) {
251
+ fail("invalid_argument", `Cannot resume this upload: the seeded parts must be numbered 1 through ${resume.parts.length} without gaps, but entry ${index + 1} calls itself part ${part.partNumber}.`, { key, uploadId: resume.uploadId });
252
+ }
253
+ });
254
+ }
255
+ const seededBytes = resume ? resumeOffsetBytes(resume.parts, this.partBytes) : 0;
256
+ const completionOnly = resume ? seededTailIsFinal(resume.parts, this.partBytes) : false;
257
+ let uploadId = resume?.uploadId ?? null;
258
+ let held = [];
259
+ let heldBytes = 0;
260
+ // Whole-object accounting: head() reports the object's full length, so a
261
+ // resumed run counts the seeded bytes as well as what it sends itself.
262
+ let total = seededBytes;
263
+ const parts = resume ? resume.parts.map((part) => ({ ...part })) : [];
264
+ // Snapshots, not the live array: the journal may hold a state across an
265
+ // await while more parts land, and a shared reference would let a later
266
+ // push rewrite history the journal already recorded.
267
+ const journal = async (id) => {
268
+ if (options.onState)
269
+ await options.onState({ uploadId: id, parts: parts.map((part) => ({ ...part })) });
270
+ };
271
+ try {
272
+ for await (const chunk of chunks) {
273
+ if (completionOnly && chunk.length > 0) {
274
+ fail("invalid_argument", "Cannot resume this upload with more data: the seeded parts already end in the object's final short part, so the only thing left is completion.", { key });
275
+ }
276
+ held.push(chunk);
277
+ heldBytes += chunk.length;
278
+ total += chunk.length;
279
+ while (heldBytes >= this.partBytes) {
280
+ const buffered = Buffer.concat(held);
281
+ const part = buffered.subarray(0, this.partBytes);
282
+ held = part.length < buffered.length ? [buffered.subarray(this.partBytes)] : [];
283
+ heldBytes = buffered.length - part.length;
284
+ uploadId = uploadId ?? await this.initiateMultipart(key);
285
+ const { etag, checksum } = await this.put(key, part, { partNumber: String(parts.length + 1), uploadId });
286
+ parts.push({ partNumber: parts.length + 1, etag, checksum, bytes: part.length });
287
+ await journal(uploadId);
288
+ }
289
+ }
290
+ const tail = Buffer.concat(held);
291
+ if (!uploadId) {
292
+ // The whole object fit inside one part: a single PUT stores it with a
293
+ // whole-object checksum, exactly like the buffered small-object path.
294
+ // (Never reached on resume — a seeded uploadId always exists there.)
295
+ const { checksum } = await this.put(key, tail);
296
+ return { remoteChecksum: checksum, bytes: total };
297
+ }
298
+ if (tail.length > 0 || parts.length === 0) {
299
+ const { etag, checksum } = await this.put(key, tail, { partNumber: String(parts.length + 1), uploadId });
300
+ parts.push({ partNumber: parts.length + 1, etag, checksum, bytes: tail.length });
301
+ await journal(uploadId);
302
+ }
303
+ await this.completeMultipart(key, uploadId, parts);
304
+ return { remoteChecksum: compositeChecksum(parts.map((part) => part.checksum)), bytes: total };
305
+ }
306
+ catch (error) {
307
+ if (uploadId)
308
+ await this.abortMultipart(key, uploadId);
309
+ throw error;
310
+ }
311
+ }
312
+ /**
313
+ * Fetches one object back.
314
+ *
315
+ * Managed downloads from an S3-compatible provider cannot be presigned by the
316
+ * control plane — Spala has no SigV4 signer — so the machine signs its own
317
+ * GET with the short-lived credential it was vended, exactly as it does for
318
+ * the upload.
319
+ */
320
+ async download(lease) {
321
+ const { url } = this.sign("GET", lease.objectKey, {});
322
+ const response = await fetch(url, { method: "GET" });
323
+ if (!response.ok)
324
+ fail("internal", `Could not read ${lease.objectKey}: ${await readError(response)}`, { key: lease.objectKey, status: response.status });
325
+ return Buffer.from(await response.arrayBuffer());
326
+ }
327
+ /**
328
+ * Confirms the object exists remotely and reports what the provider stored.
329
+ *
330
+ * `x-amz-checksum-mode: ENABLED` is required before S3 will return a stored
331
+ * checksum on HEAD at all. Without it the header is simply absent, and a
332
+ * caller that skips the comparison when the header is missing ends up
333
+ * verifying nothing but the byte count.
334
+ */
335
+ async head(lease) {
336
+ const mode = { "x-amz-checksum-mode": "ENABLED" };
337
+ const { url } = this.sign("HEAD", lease.objectKey, {}, mode);
338
+ const response = await fetch(url, { method: "HEAD", headers: mode });
339
+ if (response.status === 404)
340
+ return { exists: false, bytes: 0 };
341
+ if (!response.ok)
342
+ fail("internal", `Could not read ${lease.objectKey}: ${response.status} ${response.statusText}`, { key: lease.objectKey, status: response.status });
343
+ return { exists: true, bytes: Number(response.headers.get("content-length") ?? "0"), checksum: response.headers.get("x-amz-checksum-sha256") ?? undefined };
344
+ }
345
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * AWS Signature Version 4 for S3-compatible endpoints (S3, R2, MinIO).
3
+ *
4
+ * Implemented from the published specification and checked against AWS's own
5
+ * documented example in `test/sigv4.test.ts`, including the intermediate canonical
6
+ * request and string-to-sign, so a regression is caught at the step that broke.
7
+ */
8
+ export declare const ALGORITHM = "AWS4-HMAC-SHA256";
9
+ export declare const UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD";
10
+ export type Credentials = {
11
+ accessKeyId: string;
12
+ secretAccessKey: string;
13
+ sessionToken?: string;
14
+ };
15
+ /** RFC 3986 unreserved characters stay literal; everything else is percent-encoded. */
16
+ export declare function uriEncode(value: string, encodeSlash?: boolean): string;
17
+ export declare function canonicalUri(path: string): string;
18
+ export declare function canonicalQuery(query: Iterable<[string, string]>): string;
19
+ export declare function canonicalHeaders(headers: Record<string, string>): {
20
+ canonical: string;
21
+ signed: string;
22
+ };
23
+ export declare function canonicalRequest(input: {
24
+ method: string;
25
+ path: string;
26
+ query: Iterable<[string, string]>;
27
+ headers: Record<string, string>;
28
+ payloadHash: string;
29
+ }): {
30
+ request: string;
31
+ signedHeaders: string;
32
+ };
33
+ export declare function credentialScope(date: string, region: string, service: string): string;
34
+ export declare function stringToSign(amzDate: string, scope: string, request: string): string;
35
+ export declare function signingKey(secretAccessKey: string, date: string, region: string, service: string): Buffer;
36
+ export declare function sign(secretAccessKey: string, date: string, region: string, service: string, toSign: string): string;
37
+ /** `20130524T000000Z` and its `20130524` date portion. */
38
+ export declare function amzDates(when: Date): {
39
+ amzDate: string;
40
+ date: string;
41
+ };
42
+ export type PresignInput = {
43
+ method: string;
44
+ host: string;
45
+ path: string;
46
+ region: string;
47
+ service?: string;
48
+ credentials: Credentials;
49
+ expiresInSeconds?: number;
50
+ query?: Record<string, string>;
51
+ headers?: Record<string, string>;
52
+ when?: Date;
53
+ payloadHash?: string;
54
+ protocol?: "https" | "http";
55
+ port?: number;
56
+ };
57
+ export type PresignResult = {
58
+ url: string;
59
+ signature: string;
60
+ canonicalRequest: string;
61
+ stringToSign: string;
62
+ signedHeaders: string;
63
+ expiresAt: string;
64
+ };
65
+ /**
66
+ * Produces a presigned URL. Host is always signed; any extra header supplied here
67
+ * is signed too and must be sent verbatim by the uploader or the request is rejected.
68
+ */
69
+ export declare function presign(input: PresignInput): PresignResult;
70
+ /** Recomputes a presigned signature so a server can verify it. Used by the test emulator. */
71
+ export declare function verifyPresigned(input: {
72
+ method: string;
73
+ url: URL;
74
+ credentials: Credentials;
75
+ region: string;
76
+ service?: string;
77
+ extraHeaders?: Record<string, string>;
78
+ }): boolean;