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,101 @@
1
+ import { generateKeyPairSync } from "node:crypto";
2
+ import { addRecipient, initialize } from "./vault.js";
3
+ import { recoveryKit } from "./recovery.js";
4
+ import { register, sealEscrow } from "./cloud.js";
5
+ import { setupPlan } from "./storage-setup.js";
6
+ import { fail, isVaultlineError } from "./errors.js";
7
+ /** An X25519 keypair for this machine. The private half never leaves it unsealed. */
8
+ export function generateDeviceKeys() {
9
+ const { publicKey, privateKey } = generateKeyPairSync("x25519");
10
+ return {
11
+ publicKeyBase64: publicKey.export({ type: "spki", format: "der" }).subarray(-32).toString("base64"),
12
+ privateKeyBase64: privateKey.export({ type: "pkcs8", format: "der" }).toString("base64")
13
+ };
14
+ }
15
+ /**
16
+ * Free tier: the vault is local and the bucket is theirs.
17
+ *
18
+ * We do not ask for cloud credentials and we cannot see their data. The reward
19
+ * for that is that they do the bucket setup — so we generate the exact console
20
+ * links, commands, and least-privilege policy rather than leaving them to guess.
21
+ */
22
+ export async function startFree(dataDir, choice) {
23
+ const { config, phrase } = await initialize(dataDir);
24
+ const storageSetup = setupPlan({ provider: choice.provider, bucket: choice.bucket, prefix: choice.prefix, region: choice.region, accountId: choice.accountId, project: choice.project });
25
+ return {
26
+ mode: "free", vaultId: config.vaultId, phrase, recoveryKit: recoveryKit(config), storageSetup,
27
+ nextSteps: [
28
+ "Write down the recovery phrase. It is the only way to open your archives.",
29
+ "Follow the storage steps to create your bucket and a scoped credential.",
30
+ "Then run: sealkeep autopilot"
31
+ ]
32
+ };
33
+ }
34
+ /**
35
+ * Paid tier: they configure nothing.
36
+ *
37
+ * We hold the bucket and the bill; they hold the only key that opens the data.
38
+ * The escrow blob makes a lost machine survivable without making us able to
39
+ * read anything — it is sealed with the phrase before it leaves here.
40
+ */
41
+ export async function startPaid(dataDir, choice) {
42
+ if (!choice.email.includes("@"))
43
+ fail("invalid_argument", "A real email address is required to recover a paid account");
44
+ if (choice.password.length < 12)
45
+ fail("invalid_argument", "Use a password of at least 12 characters");
46
+ const { config, phrase } = await initialize(dataDir);
47
+ const keys = generateDeviceKeys();
48
+ await addRecipient(dataDir, choice.label || "this machine", keys.publicKeyBase64);
49
+ const account = await register(dataDir, { email: choice.email, password: choice.password, label: choice.label });
50
+ // This is the only moment the device private key exists: it is generated
51
+ // above, its public half is registered as a recipient, and the private half
52
+ // is never written to disk. Sealing it and dropping the result — which is
53
+ // what used to happen here — left the escrow empty, made every archive's
54
+ // device wrap unopenable by anyone, and made the promise below false.
55
+ const material = { vaultId: config.vaultId, devicePrivateKey: keys.privateKeyBase64, createdAt: new Date().toISOString() };
56
+ const escrow = sealEscrow(material, phrase);
57
+ let sealed = true;
58
+ let escrowNote;
59
+ try {
60
+ const { pushEscrow } = await import("./cloud.js");
61
+ await pushEscrow(dataDir, material, phrase);
62
+ }
63
+ catch (error) {
64
+ // Do not fail the whole signup over this. The phrase alone still opens
65
+ // every archive, so the vault is usable — but say so plainly rather than
66
+ // printing a promise we did not keep.
67
+ sealed = false;
68
+ escrowNote = `The sealed copy of your key could not be stored (${isVaultlineError(error) ? error.code : "unexpected error"}). Run \`sealkeep recovery seal\` once you are online.`;
69
+ }
70
+ return {
71
+ mode: "paid", vaultId: config.vaultId, phrase, recoveryKit: recoveryKit(config), account, escrow,
72
+ nextSteps: [
73
+ sealed
74
+ ? "Write down the recovery phrase. We store a sealed copy of your key, but only this phrase opens it."
75
+ : "Write down the recovery phrase. It is the only thing that opens your archives.",
76
+ "If you lose the phrase, nobody — including us — can recover your archives.",
77
+ ...(escrowNote ? [escrowNote] : []),
78
+ "Then run: sealkeep autopilot"
79
+ ]
80
+ };
81
+ }
82
+ export async function start(dataDir, choice) {
83
+ return choice.mode === "paid" ? startPaid(dataDir, choice) : startFree(dataDir, choice);
84
+ }
85
+ /** What the first-run UI offers, before anything is created. */
86
+ export function modeOptions() {
87
+ return [
88
+ {
89
+ mode: "free", title: "Free — your own storage",
90
+ summary: "No account, no bill. You set up the bucket; the setup time is yours.",
91
+ youProvide: ["A bucket you create yourself", "Its credential, pasted once", "The time it takes to get IAM right"],
92
+ weProvide: ["Encryption, archiving, search, retention", "Commands and a least-privilege policy if you want them"]
93
+ },
94
+ {
95
+ mode: "paid", title: "Paid — we run the storage",
96
+ summary: "You configure nothing. We create and run the bucket; you pick a GB package.",
97
+ youProvide: ["An email and password", "A recovery phrase you keep safe"],
98
+ weProvide: ["The bucket, the quota, the durability", "No IAM, no keys, no console — nothing to set up", "A sealed backup of your key that only your phrase opens"]
99
+ }
100
+ ];
101
+ }
@@ -0,0 +1,49 @@
1
+ import type { ProviderKind } from "./control-plane.js";
2
+ /**
3
+ * Getting a bucket and a scoped credential is the step where people give up.
4
+ *
5
+ * So the product does the tedious part: it writes the exact console link, the exact
6
+ * commands with your own bucket and prefix already filled in, and a policy that
7
+ * grants the least it can — write and read inside your prefix, and nothing else.
8
+ *
9
+ * Nothing here runs anything or touches an account. It produces text you read,
10
+ * check, and paste yourself, which is the only honest way to hand someone an IAM
11
+ * policy.
12
+ */
13
+ /** Providers the guided setup covers. "gdrive" is wider than control-plane's ProviderKind, which is frozen in this change. */
14
+ export type SetupProviderKind = ProviderKind | "gdrive";
15
+ export type SetupInput = {
16
+ provider: SetupProviderKind;
17
+ /** Bucket name — or, for Google Drive, the folder archives live in. */
18
+ bucket: string;
19
+ prefix: string;
20
+ region?: string;
21
+ /** Cloudflare account id, needed for the R2 endpoint. */
22
+ accountId?: string;
23
+ /** Google Cloud project id. */
24
+ project?: string;
25
+ };
26
+ export type SetupStep = {
27
+ title: string;
28
+ /** Why this step exists, in one line. */
29
+ why: string;
30
+ /** A link that opens the right page already scoped where possible. */
31
+ link?: string;
32
+ /** Commands to paste, with the user's own values filled in. */
33
+ commands?: string[];
34
+ /** A policy or config document to paste into a form. */
35
+ document?: {
36
+ name: string;
37
+ body: string;
38
+ };
39
+ };
40
+ export type SetupPlan = {
41
+ provider: SetupProviderKind;
42
+ bucket: string;
43
+ prefix: string;
44
+ steps: SetupStep[];
45
+ /** What to run once the credential exists. */
46
+ finish: string[];
47
+ notes: string[];
48
+ };
49
+ export declare function setupPlan(input: SetupInput): SetupPlan;
@@ -0,0 +1,222 @@
1
+ import { fail } from "./errors.js";
2
+ const clean = (value) => value.replace(/^\/+|\/+$/g, "");
3
+ function requireValue(value, flag, why) {
4
+ if (!value?.trim())
5
+ fail("invalid_argument", `${flag} is required for this provider — ${why}`);
6
+ return value.trim();
7
+ }
8
+ /** Write, read, and finish multipart uploads inside one prefix. Nothing else. */
9
+ function s3Policy(bucket, prefix) {
10
+ return JSON.stringify({
11
+ Version: "2012-10-17",
12
+ Statement: [
13
+ {
14
+ Sid: "SealkeepWriteAndReadOwnPrefix",
15
+ Effect: "Allow",
16
+ Action: ["s3:PutObject", "s3:GetObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"],
17
+ Resource: `arn:aws:s3:::${bucket}/${prefix}/*`
18
+ },
19
+ {
20
+ Sid: "SealkeepListOwnPrefixOnly",
21
+ Effect: "Allow",
22
+ Action: ["s3:ListBucket", "s3:ListBucketMultipartUploads"],
23
+ Resource: `arn:aws:s3:::${bucket}`,
24
+ Condition: { StringLike: { "s3:prefix": [`${prefix}/*`] } }
25
+ }
26
+ ]
27
+ }, null, 2);
28
+ }
29
+ function s3Plan(input) {
30
+ const bucket = input.bucket;
31
+ const prefix = clean(input.prefix);
32
+ const region = input.region ?? "us-east-1";
33
+ const policyName = "SealkeepArchiveWriter";
34
+ return {
35
+ provider: "s3", bucket, prefix,
36
+ steps: [
37
+ {
38
+ title: "Create the bucket",
39
+ why: "Sealkeep only ever writes inside one prefix, but it needs a bucket to write into.",
40
+ link: `https://s3.console.aws.amazon.com/s3/bucket/create?region=${region}`,
41
+ commands: [
42
+ `aws s3api create-bucket --bucket ${bucket} --region ${region}` + (region === "us-east-1" ? "" : ` \\\n --create-bucket-configuration LocationConstraint=${region}`),
43
+ `aws s3api put-public-access-block --bucket ${bucket} \\\n --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true`
44
+ ]
45
+ },
46
+ {
47
+ title: "Create a least-privilege policy",
48
+ why: `This grants write and read under ${prefix}/ and nothing else — not the rest of the bucket, not other buckets.`,
49
+ link: "https://console.aws.amazon.com/iam/home#/policies$new?step=edit",
50
+ document: { name: "vaultline-policy.json", body: s3Policy(bucket, prefix) },
51
+ commands: [`aws iam create-policy --policy-name ${policyName} \\\n --policy-document file://vaultline-policy.json`]
52
+ },
53
+ {
54
+ title: "Create a user and an access key",
55
+ why: "Sealkeep signs its own requests, so it needs a key rather than a role it cannot assume.",
56
+ link: "https://console.aws.amazon.com/iam/home#/users/create",
57
+ commands: [
58
+ `aws iam create-user --user-name vaultline`,
59
+ `aws iam attach-user-policy --user-name vaultline \\\n --policy-arn arn:aws:iam::$(aws sts get-caller-identity --query Account --output text):policy/${policyName}`,
60
+ `aws iam create-access-key --user-name vaultline`
61
+ ]
62
+ }
63
+ ],
64
+ finish: [
65
+ `sealkeep storage configure --provider s3 --bucket ${bucket} --prefix ${prefix} --region ${region}`,
66
+ `echo '{"accessKeyId":"AKIA…","secretAccessKey":"…"}' | sealkeep storage credentials set`,
67
+ `VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all`
68
+ ],
69
+ notes: [
70
+ "Enable versioning on the bucket if you want protection against an accidental overwrite.",
71
+ "The access key is stored in your OS keychain, never in a Sealkeep config file."
72
+ ]
73
+ };
74
+ }
75
+ function r2Plan(input) {
76
+ const bucket = input.bucket;
77
+ const prefix = clean(input.prefix);
78
+ const accountId = requireValue(input.accountId, "--account-id", "R2 endpoints are per account");
79
+ return {
80
+ provider: "r2", bucket, prefix,
81
+ steps: [
82
+ {
83
+ title: "Create the bucket",
84
+ why: "R2 has no egress fees, which suits archives you rarely read back.",
85
+ link: "https://dash.cloudflare.com/?to=/:account/r2/new",
86
+ commands: [`npx wrangler r2 bucket create ${bucket}`]
87
+ },
88
+ {
89
+ title: "Create an R2 API token",
90
+ why: "Choose Object Read & Write, and scope it to this one bucket.",
91
+ link: `https://dash.cloudflare.com/${accountId}/r2/api-tokens`,
92
+ commands: []
93
+ },
94
+ {
95
+ title: "Note the S3-compatible endpoint",
96
+ why: "R2 speaks the S3 protocol, so Sealkeep signs for it the same way.",
97
+ commands: [`# endpoint: https://${accountId}.r2.cloudflarestorage.com`]
98
+ }
99
+ ],
100
+ finish: [
101
+ `sealkeep storage configure --provider r2 --bucket ${bucket} --prefix ${prefix} --region auto`,
102
+ `echo '{"accessKeyId":"<R2 access key id>","secretAccessKey":"<R2 secret>"}' | sealkeep storage credentials set`,
103
+ `VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all --endpoint https://${accountId}.r2.cloudflarestorage.com`
104
+ ],
105
+ notes: [
106
+ "R2 tokens are scoped per bucket in the dashboard, so the prefix restriction is Sealkeep's own object-key discipline rather than an IAM condition.",
107
+ "Keep the token as Object Read & Write. Admin tokens are never needed."
108
+ ]
109
+ };
110
+ }
111
+ function gcsPlan(input) {
112
+ const bucket = input.bucket;
113
+ const prefix = clean(input.prefix);
114
+ const project = requireValue(input.project, "--project", "every Google Cloud resource belongs to a project");
115
+ const location = input.region ?? "US";
116
+ const account = `vaultline@${project}.iam.gserviceaccount.com`;
117
+ return {
118
+ provider: "gcs", bucket, prefix,
119
+ steps: [
120
+ {
121
+ title: "Create the bucket",
122
+ why: "Uniform bucket-level access keeps permissions in IAM rather than per-object ACLs.",
123
+ link: `https://console.cloud.google.com/storage/create-bucket?project=${project}`,
124
+ commands: [`gcloud storage buckets create gs://${bucket} --project=${project} --location=${location} --uniform-bucket-level-access`]
125
+ },
126
+ {
127
+ title: "Create a service account",
128
+ why: "Sealkeep signs upload URLs with this account's key; it never uses your own credentials.",
129
+ link: `https://console.cloud.google.com/iam-admin/serviceaccounts/create?project=${project}`,
130
+ commands: [`gcloud iam service-accounts create sealkeep --project=${project} \\\n --display-name="Sealkeep archive writer"`]
131
+ },
132
+ {
133
+ title: "Grant access to the prefix only",
134
+ why: `The IAM condition limits this account to objects under ${prefix}/, so it cannot read the rest of the bucket.`,
135
+ document: {
136
+ name: "vaultline-binding.txt",
137
+ body: `role: roles/storage.objectAdmin\nmember: serviceAccount:${account}\ncondition:\n title: vaultline-prefix-only\n expression: |\n resource.name.startsWith("projects/_/buckets/${bucket}/objects/${prefix}/")`
138
+ },
139
+ commands: [
140
+ `gcloud storage buckets add-iam-policy-binding gs://${bucket} \\\n --member="serviceAccount:${account}" \\\n --role="roles/storage.objectAdmin" \\\n --condition='expression=resource.name.startsWith("projects/_/buckets/${bucket}/objects/${prefix}/"),title=vaultline-prefix-only'`
141
+ ]
142
+ },
143
+ {
144
+ title: "Create a key for the service account",
145
+ why: "Sealkeep needs the private key to sign upload URLs. It is stored in your OS keychain.",
146
+ link: `https://console.cloud.google.com/iam-admin/serviceaccounts?project=${project}`,
147
+ commands: [`gcloud iam service-accounts keys create vaultline-key.json \\\n --iam-account=${account} --project=${project}`]
148
+ }
149
+ ],
150
+ finish: [
151
+ `sealkeep storage configure --provider gcs --bucket ${bucket} --prefix ${prefix}`,
152
+ `node -e 'const k=require("./vaultline-key.json");process.stdout.write(JSON.stringify({clientEmail:k.client_email,privateKey:k.private_key}))' \\\n | sealkeep storage credentials set`,
153
+ `rm vaultline-key.json # the key now lives in your keychain`,
154
+ `VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all`
155
+ ],
156
+ notes: [
157
+ "Delete the downloaded key file once it is in your keychain. A key on disk is the usual way these leak.",
158
+ "IAM conditions need uniform bucket-level access, which the create command above sets."
159
+ ]
160
+ };
161
+ }
162
+ /**
163
+ * Google Drive is the no-bucket path: no console policies, no keys to paste.
164
+ * The whole grant is one OAuth consent for `drive.file`, which in Google's
165
+ * permission model shows Sealkeep only the files Sealkeep itself created.
166
+ * The tedious part a person still has to do — until a first-party OAuth
167
+ * client id ships — is minting a Desktop-app client id of their own.
168
+ */
169
+ function gdrivePlan(input) {
170
+ const folder = input.bucket;
171
+ const prefix = clean(input.prefix);
172
+ return {
173
+ provider: "gdrive", bucket: folder, prefix,
174
+ steps: [
175
+ {
176
+ title: "Create a Desktop-app OAuth client",
177
+ why: "Sealkeep signs in as an app you control. A Desktop client has no secret to leak — the PKCE exchange proves each connection instead.",
178
+ link: "https://console.cloud.google.com/apis/credentials/oauthclient",
179
+ commands: ["# Application type: Desktop app. Copy the client id; there is no secret to copy."]
180
+ },
181
+ {
182
+ title: "Enable the Drive API on that project",
183
+ why: "The consent screen belongs to your project, and Google refuses tokens for an API the project has not enabled.",
184
+ link: "https://console.cloud.google.com/apis/library/drive.googleapis.com",
185
+ commands: [`gcloud services enable drive.googleapis.com`]
186
+ },
187
+ {
188
+ title: "Connect your Google account",
189
+ why: `Sealkeep asks for drive.file only: it can see files it created — a "${folder}" folder of ciphertext — and nothing else in the Drive.`,
190
+ commands: [
191
+ `export VAULTLINE_GDRIVE_CLIENT_ID="<your-client-id>.apps.googleusercontent.com"`,
192
+ `sealkeep storage connect gdrive`
193
+ ]
194
+ }
195
+ ],
196
+ finish: [
197
+ `# once the provider registry accepts gdrive targets:`,
198
+ `sealkeep storage configure --provider gdrive --bucket ${folder} --prefix ${prefix}`,
199
+ `VAULTLINE_ENABLE_SIGNER=1 sealkeep upload --all`
200
+ ],
201
+ notes: [
202
+ `Archives land in a "${folder}" folder as sealed ciphertext; a prefix of "appdata" uses Drive's hidden per-app area instead.`,
203
+ "The refresh token is stored in your OS keychain, never in a Sealkeep config file.",
204
+ "Selecting gdrive as the upload target (`storage configure`, and the panel) lands with the provider-registry update; the connection above is stored now and reused then."
205
+ ]
206
+ };
207
+ }
208
+ export function setupPlan(input) {
209
+ if (!input.bucket?.trim())
210
+ fail("invalid_argument", "--bucket is required");
211
+ if (!clean(input.prefix ?? ""))
212
+ fail("invalid_argument", "--prefix is required, so your objects stay in their own namespace");
213
+ if (input.provider === "s3")
214
+ return s3Plan(input);
215
+ if (input.provider === "r2")
216
+ return r2Plan(input);
217
+ if (input.provider === "gcs")
218
+ return gcsPlan(input);
219
+ if (input.provider === "gdrive")
220
+ return gdrivePlan(input);
221
+ return fail("provider_unsupported", `No guided setup for ${input.provider}. Use --provider s3, r2, gcs, or gdrive.`);
222
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Multiple storage targets, one vault. Paul's shape: a pro account keeps the
3
+ * managed cloud AND a personal Drive (or a bucket) connected at once, with
4
+ * rules deciding where a session lands — a byte limit per target ("Drive
5
+ * holds at most 50 GB"), project pinning ("this project archives to Drive"),
6
+ * and an order of preference in which the managed cloud wins by default.
7
+ *
8
+ * Reads never consult this router: every record names its own provider and
9
+ * bucket, so restore, search, and delete follow the record wherever it went.
10
+ * Routing decides only where NEW seals go.
11
+ */
12
+ import type { StorageTargetConfig } from "./types.js";
13
+ export type StorageTarget = StorageTargetConfig;
14
+ /**
15
+ * The effective target list. Explicit `storageTargets` in config wins;
16
+ * otherwise the single `remoteStorage` entry is the one target; otherwise a
17
+ * vault signed into the cloud account is managed-only — the same ladder the
18
+ * rest of the product climbs.
19
+ */
20
+ export declare function resolveTargets(dataDir: string): Promise<StorageTarget[]>;
21
+ /** Pushes the rules to the account so every other machine syncs them. Advisory: offline edits still apply locally. */
22
+ export declare function pushTargetsToCloud(dataDir: string, targets: StorageTarget[]): Promise<boolean>;
23
+ /** Bytes each target currently holds, from the records: targetId when stamped, provider+bucket for older records. */
24
+ export declare function targetUsage(dataDir: string, targets: StorageTarget[]): Promise<Map<string, number>>;
25
+ /**
26
+ * Where a new seal goes. Eligibility first (limit not blown, chunk-capable
27
+ * when the seal streams — Drive stores single objects), then order: targets
28
+ * pinning this session's project come first, then priority, managed ahead by
29
+ * default. Deterministic and explainable; no silent multi-target retries.
30
+ */
31
+ export declare function chooseTarget(dataDir: string, input: {
32
+ project?: string | null;
33
+ bytes: number;
34
+ requireChunkCapable?: boolean;
35
+ targetId?: string;
36
+ }): Promise<StorageTarget>;
37
+ /** Persist an explicit target list. Empty array removes the setting (back to single remoteStorage / managed). */
38
+ export declare function setStorageTargets(dataDir: string, targets: StorageTarget[], options?: {
39
+ push?: boolean;
40
+ }): Promise<void>;
@@ -0,0 +1,147 @@
1
+ import { fail } from "./errors.js";
2
+ import { readConfig, listArchives } from "./vault.js";
3
+ import { isV2 } from "./types.js";
4
+ const defaultPriority = (target) => target.priority ?? (target.provider === "vaultline" ? 0 : 10);
5
+ /**
6
+ * The effective target list. Explicit `storageTargets` in config wins;
7
+ * otherwise the single `remoteStorage` entry is the one target; otherwise a
8
+ * vault signed into the cloud account is managed-only — the same ladder the
9
+ * rest of the product climbs.
10
+ */
11
+ export async function resolveTargets(dataDir) {
12
+ const config = await readConfig(dataDir);
13
+ // The account is the source of truth: every machine of the vault syncs the
14
+ // SAME rules down, so a cap set on the laptop holds on the desktop too.
15
+ // The local config is the offline cache; an explicit local list still wins
16
+ // when the account cannot be reached.
17
+ const fromCloud = await pullTargetsFromCloud(dataDir).catch(() => null);
18
+ if (fromCloud && fromCloud.length > 0) {
19
+ if (JSON.stringify(fromCloud) !== JSON.stringify(config.storageTargets ?? null)) {
20
+ await setStorageTargets(dataDir, fromCloud, { push: false }).catch(() => undefined);
21
+ }
22
+ return fromCloud;
23
+ }
24
+ const declared = config.storageTargets;
25
+ if (declared && declared.length > 0)
26
+ return declared;
27
+ if (config.remoteStorage) {
28
+ return [{ id: "primary", provider: config.remoteStorage.provider, bucket: config.remoteStorage.bucket, prefix: config.remoteStorage.prefix, region: config.remoteStorage.region }];
29
+ }
30
+ try {
31
+ const { cloudToken } = await import("./cloud.js");
32
+ await cloudToken(dataDir);
33
+ return [{ id: "managed", provider: "vaultline" }];
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ }
39
+ /** The account's rules, or null when not signed in / none saved. */
40
+ async function pullTargetsFromCloud(dataDir) {
41
+ const { cloudToken } = await import("./cloud.js");
42
+ const token = await cloudToken(dataDir).catch(() => null);
43
+ if (!token)
44
+ return null;
45
+ const { DEFAULT_CLOUD_URL } = await import("./cloud.js");
46
+ const base = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
47
+ const response = await fetch(`${base}/v1/cloud/storage-targets`, { headers: { authorization: `Bearer ${token}` } });
48
+ if (!response.ok)
49
+ return null;
50
+ const body = await response.json();
51
+ if (!body.targets_json)
52
+ return null;
53
+ try {
54
+ const parsed = JSON.parse(body.targets_json);
55
+ return Array.isArray(parsed) ? parsed : null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ /** Pushes the rules to the account so every other machine syncs them. Advisory: offline edits still apply locally. */
62
+ export async function pushTargetsToCloud(dataDir, targets) {
63
+ try {
64
+ const { cloudToken, DEFAULT_CLOUD_URL } = await import("./cloud.js");
65
+ const token = await cloudToken(dataDir);
66
+ const base = (process.env.VAULTLINE_CLOUD_URL ?? DEFAULT_CLOUD_URL).replace(/\/+$/, "");
67
+ const response = await fetch(`${base}/v1/cloud/storage-targets`, {
68
+ method: "PUT",
69
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
70
+ body: JSON.stringify({ targets_json: JSON.stringify(targets) })
71
+ });
72
+ return response.ok;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }
78
+ /** Bytes each target currently holds, from the records: targetId when stamped, provider+bucket for older records. */
79
+ export async function targetUsage(dataDir, targets) {
80
+ const usage = new Map(targets.map((target) => [target.id, 0]));
81
+ const records = await listArchives(dataDir).catch(() => []);
82
+ for (const record of records) {
83
+ if (!isV2(record) || !record.remote)
84
+ continue;
85
+ const stamped = record.remote.targetId;
86
+ const match = (stamped && targets.find((target) => target.id === stamped))
87
+ ?? targets.find((target) => target.provider === record.remote.provider && (target.provider === "vaultline" || target.bucket === record.remote.bucket));
88
+ if (match)
89
+ usage.set(match.id, (usage.get(match.id) ?? 0) + (record.remote.bytes ?? 0));
90
+ }
91
+ return usage;
92
+ }
93
+ /**
94
+ * Where a new seal goes. Eligibility first (limit not blown, chunk-capable
95
+ * when the seal streams — Drive stores single objects), then order: targets
96
+ * pinning this session's project come first, then priority, managed ahead by
97
+ * default. Deterministic and explainable; no silent multi-target retries.
98
+ */
99
+ export async function chooseTarget(dataDir, input) {
100
+ const targets = await resolveTargets(dataDir);
101
+ if (targets.length === 0)
102
+ fail("storage_not_configured", "No storage target configured. Run: sealkeep storage configure ... (or sign in to Sealkeep Cloud)");
103
+ if (input.targetId) {
104
+ const named = targets.find((target) => target.id === input.targetId);
105
+ if (!named)
106
+ fail("invalid_argument", `No storage target named "${input.targetId}". Targets: ${targets.map((target) => target.id).join(", ")}`);
107
+ return named;
108
+ }
109
+ const usage = await targetUsage(dataDir, targets);
110
+ const eligible = targets.filter((target) => {
111
+ if (input.requireChunkCapable && target.provider === "gdrive")
112
+ return false;
113
+ if (target.maxGb !== undefined && (usage.get(target.id) ?? 0) + input.bytes > target.maxGb * 1024 ** 3)
114
+ return false;
115
+ return true;
116
+ });
117
+ if (eligible.length === 0) {
118
+ fail("storage_not_configured", "Every storage target is either full (maxGb reached) or unable to take this seal. Raise a limit or add a target.");
119
+ }
120
+ const pinScore = (target) => (input.project && target.projects?.includes(input.project) ? 0 : 1);
121
+ eligible.sort((a, b) => pinScore(a) - pinScore(b) || defaultPriority(a) - defaultPriority(b));
122
+ return eligible[0];
123
+ }
124
+ /** Persist an explicit target list. Empty array removes the setting (back to single remoteStorage / managed). */
125
+ export async function setStorageTargets(dataDir, targets, options = {}) {
126
+ const { readFile, writeFile } = await import("node:fs/promises");
127
+ const { join } = await import("node:path");
128
+ const path = join(dataDir, "config.json");
129
+ const config = JSON.parse(await readFile(path, "utf8"));
130
+ if (targets.length === 0)
131
+ delete config.storageTargets;
132
+ else {
133
+ const ids = new Set();
134
+ for (const target of targets) {
135
+ if (!target.id || ids.has(target.id))
136
+ fail("invalid_argument", "Every storage target needs a unique id");
137
+ ids.add(target.id);
138
+ if (target.provider !== "vaultline" && target.provider !== "gdrive" && !target.bucket) {
139
+ fail("invalid_argument", `Target "${target.id}" (${target.provider}) needs a bucket`);
140
+ }
141
+ }
142
+ config.storageTargets = targets;
143
+ }
144
+ await writeFile(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
145
+ if (options.push !== false)
146
+ await pushTargetsToCloud(dataDir, targets);
147
+ }
@@ -0,0 +1,76 @@
1
+ import type { ArchiveRecordV2 } from "./types.js";
2
+ /**
3
+ * Seal straight into the bucket: read a slice, compress it, encrypt it, send
4
+ * it, forget it. The full ciphertext exists nowhere — not on this disk, not in
5
+ * this process — only in the bucket, chunk by chunk as it was made.
6
+ *
7
+ * This is the answer to "how much free space do I need to archive 5 GB":
8
+ * one chunk of it. The offload model (seal locally, upload, verify, delete)
9
+ * already keeps the disk clean *eventually*; this path never dirties it. The
10
+ * record is born offloaded — `objectPath` names a file that never existed —
11
+ * and every reader treats it exactly like an archive whose blob was offloaded
12
+ * after the fact: restore and search fetch from the bucket, ranged where the
13
+ * chunks allow it.
14
+ *
15
+ * Interruption is survivable. A spool journal (src/spool.ts) is written before
16
+ * the first byte leaves and updated at every provider durability point, so a
17
+ * crashed upload resumes from the last confirmed chunk instead of starting
18
+ * over — see `resumeSeal` for the safety argument.
19
+ *
20
+ * Own-bucket only, deliberately. A BYO provider takes our resumable/multipart
21
+ * streams with the machine's own credential. The managed tier's control plane
22
+ * vends one signed PUT for a known length — streaming there needs the signer
23
+ * to vend a resumable session or a scoped credential, which is control-plane
24
+ * work, not client work (docs/managed-streaming-contract.md); until then this
25
+ * refuses with the reason rather than pretending.
26
+ */
27
+ export type StreamSealOutcome = {
28
+ record: ArchiveRecordV2;
29
+ objectKey: string;
30
+ storedBytes: number;
31
+ /** Peak bytes this machine ever held for the ciphertext: one part buffer. */
32
+ heldAtMostBytes: number;
33
+ };
34
+ /**
35
+ * What this module needs a provider client to be. The third `uploadStream`
36
+ * argument and `abortMultipart` are optional so the pre-resume clients (and
37
+ * tests built against them) keep compiling; a client that ignores them simply
38
+ * never journals provider state, and its uploads restart from zero on a crash
39
+ * — the pre-resume behavior, not a new failure mode.
40
+ */
41
+ type StreamUploadOptions = {
42
+ resume?: unknown;
43
+ onState?: (state: unknown) => void | Promise<void>;
44
+ };
45
+ type StreamClient = {
46
+ uploadStream(objectKey: string, chunks: AsyncIterable<Buffer>, options?: StreamUploadOptions): Promise<{
47
+ remoteChecksum: string | null;
48
+ bytes: number;
49
+ }>;
50
+ head(lease: {
51
+ objectKey: string;
52
+ } & Record<string, unknown>): Promise<{
53
+ exists: boolean;
54
+ bytes: number;
55
+ checksum?: string;
56
+ }>;
57
+ abortMultipart?(objectKey: string, uploadId: string): Promise<void>;
58
+ /** The provider's own account of an in-flight multipart — resume's source of truth. */
59
+ listParts?(objectKey: string, uploadId: string): Promise<{
60
+ partNumber: number;
61
+ etag: string;
62
+ bytes: number;
63
+ }[]>;
64
+ /** The provider's own confirmed-byte count for a resumable session. */
65
+ resumableStatus?(session: string): Promise<{
66
+ confirmedBytes: number;
67
+ }>;
68
+ };
69
+ export declare function sealArchiveToCloud(dataDir: string, sourcePath: string, rawPhrase: string, agent: string, options?: {
70
+ /** Injected provider client, for tests against emulators. */
71
+ client?: StreamClient;
72
+ /** Resume an interrupted upload of this source when a journal for it exists. Defaults on; `--fresh` turns it off. */
73
+ resume?: boolean;
74
+ now?: number;
75
+ }): Promise<StreamSealOutcome>;
76
+ export {};