@hydra-acp/archiver 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # hydra-acp-archiver
2
2
 
3
- Sync extension for [hydra-acp](https://github.com/smagnuso/hydra-acp). Keeps your agent sessions in sync across the machines you work on by uploading session bundles to a shared backend (Google Drive, plain filesystem) after every turn and importing peers' bundles in the background.
3
+ Sync extension for [hydra-acp](https://github.com/smagnuso/hydra-acp). Keeps your agent sessions in sync across the machines you work on by uploading session bundles to a shared backend (Google Drive, S3, plain filesystem) after every turn and importing peers' bundles in the background. Supports optional AES-256-GCM encryption so data at rest is unreadable without your shared key.
4
4
 
5
5
  Runs as a daemon-managed process, so sessions started on machine A become available on machine B without any manual export/import.
6
6
 
@@ -78,7 +78,7 @@ You provide your own OAuth client (Google's terms make it impractical to ship a
78
78
  6. Run:
79
79
 
80
80
  ```sh
81
- hydra-acp-archiver login
81
+ hydra-acp-archiver gdrive login
82
82
  ```
83
83
 
84
84
  Your browser opens to Google's consent screen. The "Google hasn't verified this app" interstitial is expected for an unverified personal-use client — click **Advanced → Go to (unsafe)** and approve. The redirect lands on a transient local server, the archiver writes `~/.hydra-acp/archiver-google-token.json` (mode 0600), and you're done.
@@ -91,6 +91,44 @@ Repeat the same login flow on each machine that should sync, using the **same Go
91
91
 
92
92
  If you want a different Drive folder per "team" or "context," set `HYDRA_ACP_ARCHIVER_DRIVE_FOLDER` on every participating machine to the same value.
93
93
 
94
+ ## S3 backend
95
+
96
+ Works with any S3-compatible store: AWS S3, Cloudflare R2, Backblaze B2, MinIO, Wasabi, etc.
97
+
98
+ Credentials come from the standard AWS SDK chain — environment variables (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), `~/.aws/credentials`, or an IAM role. Set `AWS_PROFILE` to use a non-default credentials profile.
99
+
100
+ ```json
101
+ {
102
+ "extensions": {
103
+ "hydra-acp-archiver": {
104
+ "command": ["node"],
105
+ "args": ["/home/you/dev/hydra-acp-archiver/dist/index.js"],
106
+ "env": {
107
+ "HYDRA_ACP_ARCHIVER_BACKEND": "s3",
108
+ "HYDRA_ACP_ARCHIVER_S3_BUCKET": "my-hydra-archive",
109
+ "HYDRA_ACP_ARCHIVER_S3_REGION": "us-east-1"
110
+ }
111
+ }
112
+ }
113
+ }
114
+ ```
115
+
116
+ For S3-compatible endpoints (R2, MinIO, B2):
117
+
118
+ ```json
119
+ "HYDRA_ACP_ARCHIVER_S3_ENDPOINT": "https://<accountid>.r2.cloudflarestorage.com"
120
+ ```
121
+
122
+ **Multi-machine setup**: point every machine at the same bucket.
123
+
124
+ **Data separation**: the archiver sets a prefix automatically so users sharing a bucket don't see each other's sessions:
125
+ - **Encryption on**: prefix defaults to the key fingerprint (e.g. `a1b2c3d4e5f6a7b8/`). Everyone sharing the same key lands in the same namespace; different keys land in different namespaces. Rotating the key changes the prefix, causing a full re-upload with the new key.
126
+ - **Encryption off**: prefix defaults to your OS username (e.g. `alice/`).
127
+
128
+ Set `HYDRA_ACP_ARCHIVER_PREFIX` explicitly to override the default.
129
+
130
+ **Delete semantics**: blobs are hard-deleted. Enable [S3 bucket versioning](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) if you want recoverability.
131
+
94
132
  ## Filesystem backend
95
133
 
96
134
  Useful for testing locally, or for pointing at a folder that a separate sync tool (Syncthing, Dropbox client) already mirrors across your machines.
@@ -110,13 +148,120 @@ Useful for testing locally, or for pointing at a folder that a separate sync too
110
148
  }
111
149
  ```
112
150
 
151
+ ## Encryption
152
+
153
+ All three backends support optional AES-256-GCM encryption. When enabled, blobs are encrypted before upload and decrypted after download — the backend never sees plaintext.
154
+
155
+ Generate a key on one machine:
156
+
157
+ ```sh
158
+ hydra-acp-archiver keygen
159
+ ```
160
+
161
+ This writes a 32-byte key as a hex file (mode 0600) and prints:
162
+
163
+ ```
164
+ key written to ~/.hydra-acp/archiver-key
165
+ key fingerprint: a1b2c3d4e5f6a7b8
166
+
167
+ Copy ~/.hydra-acp/archiver-key to every machine that should share this archive.
168
+ Then add to your extension env config:
169
+ "HYDRA_ACP_ARCHIVER_KEY_PATH": "/home/you/.hydra-acp/archiver-key"
170
+ ```
171
+
172
+ Copy the key file to each machine, then set `HYDRA_ACP_ARCHIVER_KEY_PATH` in every extension env block. The fingerprint lets you verify all machines have the same key.
173
+
174
+ **Key mismatch**: if a blob was encrypted with a different key, the archiver logs `key mismatch` and skips that blob rather than failing with a confusing crypto error.
175
+
176
+ **Enabling encryption on an existing archive**: the key fingerprint becomes the new prefix, so old unencrypted blobs (written under a different prefix) are simply ignored — no decryption errors. The cold sweep re-uploads all sessions under the new prefix. To clean up old blobs, remove them from the backend manually.
177
+
178
+ ## Prefix and namespace isolation
179
+
180
+ Every backend supports an optional namespace prefix. The archiver sets one automatically so multiple users sharing the same storage container don't interfere with each other.
181
+
182
+ ### How the prefix is chosen
183
+
184
+ 1. **`HYDRA_ACP_ARCHIVER_PREFIX` is set** — that value is used verbatim. Set it to `""` to disable the prefix entirely (not recommended on shared storage).
185
+ 2. **Encryption is on, no explicit prefix** — the key fingerprint (first 8 bytes of SHA-256 of the key, encoded as 16 hex chars followed by `/`) is used, e.g. `a1b2c3d4e5f6a7b8/`. Everyone sharing the same key lands in the same namespace; different keys land in different namespaces. Rotating the key changes the prefix, which isolates new blobs from old ones.
186
+ 3. **Encryption off, no explicit prefix** — the OS username (sanitized to lowercase alphanumeric + hyphens, followed by `/`) is used, e.g. `alice/`.
187
+
188
+ ### Host segregation
189
+
190
+ Within the user prefix, each machine writes to its own subdirectory named by its host ID. The full storage path is `<user-prefix><hostId>/<lineageId>.hydra.archive`. For example:
191
+
192
+ ```
193
+ a1b2c3d4e5f6a7b8/
194
+ alice-macbook/uuid1.hydra.archive
195
+ alice-macbook/uuid2.hydra.archive
196
+ alice-desktop/uuid3.hydra.archive
197
+ ```
198
+
199
+ The pull loop reads across all host subdirectories (seeing all peers' sessions) but skips its own host's files entirely — cheaper than parsing envelopes for self-loop detection. The session-ID check remains as a second layer for edge cases.
200
+
201
+ Set `HYDRA_ACP_ARCHIVER_HOST_ID` to override the default (`os.hostname()` sanitized). Useful in containers or after a machine rename. Use the same value consistently across daemon restarts on a machine — changing it creates a new subdirectory and triggers a full re-upload.
202
+
203
+ ### What the prefix does per backend
204
+
205
+ | Backend | Prefix behaviour |
206
+ |---|---|
207
+ | **S3** | Prepended to the S3 object key. `ListObjectsV2` is called with the `Prefix` parameter so only matching objects are fetched — efficient on large shared buckets. |
208
+ | **fs** | Resolved as a subdirectory of the archive directory. Files land at `<dir>/<prefix>/<key>`. Each prefix gets its own isolated directory. |
209
+ | **Google Drive** | Prepended to the Drive filename. Drive has no native directory structure within a folder, so `alice/uuid.hydra.archive` is literally the filename. `list()` filters client-side. |
210
+
211
+ ### Key rotation and re-upload
212
+
213
+ When encryption is enabled and you regenerate the key (`hydra-acp-archiver keygen`), the fingerprint prefix changes. The new prefix namespace is empty, so the cold sweep on the next daemon start re-uploads all sessions encrypted with the new key. Old blobs under the previous prefix are simply ignored — no decryption errors, no manual cleanup required (though you can delete the old prefix from the bucket/dir when convenient).
214
+
215
+ ## Configuration file
216
+
217
+ Instead of setting environment variables in `config.json`, you can use a plain-text config file at `~/.hydra-acp/archiver.conf` (override with `HYDRA_ACP_ARCHIVER_CONF`). Environment variables always take precedence over file values.
218
+
219
+ ```sh
220
+ # ~/.hydra-acp/archiver.conf
221
+ # All keys are optional — omit any you don't need.
222
+ # Env vars override these values when both are set.
223
+
224
+ BACKEND=s3
225
+
226
+ # S3
227
+ S3_BUCKET=my-hydra-archive
228
+ S3_REGION=us-east-1
229
+ # S3_ENDPOINT=https://<accountid>.r2.cloudflarestorage.com
230
+
231
+ # Encryption
232
+ KEY_PATH=/home/you/.hydra-acp/archiver-key
233
+
234
+ # Identity
235
+ HOST_ID=alice-macbook
236
+
237
+ # Google Drive
238
+ # GOOGLE_CREDENTIALS=/home/you/.hydra-acp/archiver-google-credentials.json
239
+ # DRIVE_FOLDER=hydra-acp-archive
240
+
241
+ # Tuning (rarely needed)
242
+ # DEBOUNCE_MS=5000
243
+ # PULL_MS=60000
244
+ # DEBUG=false
245
+ ```
246
+
247
+ Restrict permissions on the file if it contains sensitive paths: `chmod 600 ~/.hydra-acp/archiver.conf`.
248
+
249
+ The file is optional — if it doesn't exist the archiver falls back to environment variables only.
250
+
113
251
  ## Environment variables
114
252
 
115
253
  | Variable | Default | Notes |
116
254
  | --- | --- | --- |
117
- | `HYDRA_ACP_ARCHIVER_BACKEND` | `google-drive` | `google-drive` \| `fs` |
255
+ | `HYDRA_ACP_ARCHIVER_CONF` | `~/.hydra-acp/archiver.conf` | Path to the config file |
256
+ | `HYDRA_ACP_ARCHIVER_BACKEND` | `google-drive` | `google-drive` \| `fs` \| `s3` |
118
257
  | `HYDRA_ACP_ARCHIVER_DRIVE_FOLDER` | `hydra-acp-archive` | Drive folder name |
119
258
  | `HYDRA_ACP_ARCHIVER_FS_DIR` | `~/.hydra-acp/archive` | Used when backend is `fs` |
259
+ | `HYDRA_ACP_ARCHIVER_S3_BUCKET` | — | Bucket name (required for `s3`) |
260
+ | `HYDRA_ACP_ARCHIVER_S3_REGION` | AWS SDK default | AWS region |
261
+ | `HYDRA_ACP_ARCHIVER_S3_ENDPOINT` | — | Custom endpoint for R2/MinIO/B2 |
262
+ | `HYDRA_ACP_ARCHIVER_PREFIX` | auto (fingerprint or username) | User-level namespace prefix applied by all backends |
263
+ | `HYDRA_ACP_ARCHIVER_HOST_ID` | `os.hostname()` (sanitized) | Host identifier used as a subdirectory under the user prefix |
264
+ | `HYDRA_ACP_ARCHIVER_KEY_PATH` | — | Path to encryption key file; if unset, encryption is off |
120
265
  | `HYDRA_ACP_ARCHIVER_GOOGLE_CREDENTIALS` | `~/.hydra-acp/archiver-google-credentials.json` | OAuth client JSON from GCP |
121
266
  | `HYDRA_ACP_ARCHIVER_CONFIG` | `~/.hydra-acp/archiver.config.js` | Rule file (optional) |
122
267
  | `HYDRA_ACP_ARCHIVER_POLL_MS` | `2000` | Session discovery cadence |
@@ -155,15 +300,16 @@ Return `false` to skip an upload. Any other value (including `undefined`) archiv
155
300
  - `~/.hydra-acp/archiver-state.json` — per-lineage cache of last uploaded hash + last seen remote upload, used for self-loop suppression. Safe to delete; archiver will rebuild it.
156
301
  - `~/.hydra-acp/archiver-google-credentials.json` — OAuth client JSON you downloaded.
157
302
  - `~/.hydra-acp/archiver-google-token.json` — refresh + access token (mode 0600). Re-run `hydra-acp-archiver login` to refresh.
303
+ - `~/.hydra-acp/archiver-key` — encryption key (mode 0600), written by `hydra-acp-archiver keygen`. Copy this file to every machine in your sync group. Keep it safe — losing it means losing access to encrypted blobs.
158
304
 
159
305
  ## Troubleshooting
160
306
 
161
307
  - **`Missing HYDRA_ACP_TOKEN env var`** — you ran the archiver directly instead of via the daemon. Run it as a registered extension.
162
- - **`No Google OAuth token at …`** — run `hydra-acp-archiver login` first.
308
+ - **`No Google OAuth token at …`** — run `hydra-acp-archiver gdrive login` first.
163
309
  - **`OAuth credentials file not found`** — follow the **First-time Google setup** steps to download the client JSON from GCP Console.
164
310
  - **Files aren't appearing in Drive** — check `~/.hydra-acp/extensions/hydra-acp-archiver.log` for errors. Common: consent-screen test-user list doesn't include your Google account.
165
311
  - **Two machines kept overwriting each other** — that's last-writer-wins working as designed if both are actively editing the same session. Avoid editing the same session on two machines simultaneously; one of them will lose its diff. A future `activeOn` claim-lock will close this gap.
166
312
 
167
313
  ## Backends
168
314
 
169
- Current: `google-drive`, `fs`. Adding a new one means implementing the `SyncBackend` interface in `src/backend/types.ts` and wiring it into `src/backend/factory.ts`.
315
+ Current: `google-drive`, `fs`, `s3`. Adding a new one means implementing the `SyncBackend` interface in `src/backend/types.ts` and wiring it into `src/backend/factory.ts`. Encryption is handled by the `EncryptedBackend` wrapper in `src/backend/encrypted.ts` — new backends get it for free.
@@ -0,0 +1,59 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, } from "node:crypto";
2
+ const VERSION = 1;
3
+ const VERSION_LEN = 1;
4
+ const FINGERPRINT_LEN = 8;
5
+ const IV_LEN = 12;
6
+ const TAG_LEN = 16;
7
+ // Offset to the start of ciphertext within a blob.
8
+ const HEADER_LEN = VERSION_LEN + FINGERPRINT_LEN + IV_LEN;
9
+ export class EncryptedBackend {
10
+ inner;
11
+ key;
12
+ keyFingerprint;
13
+ constructor(inner, key) {
14
+ this.inner = inner;
15
+ this.key = key;
16
+ if (key.length !== 32)
17
+ throw new Error("encryption key must be 32 bytes");
18
+ this.keyFingerprint = createHash("sha256").update(key).digest().subarray(0, FINGERPRINT_LEN);
19
+ }
20
+ init() {
21
+ return this.inner.init();
22
+ }
23
+ list() {
24
+ return this.inner.list();
25
+ }
26
+ delete(key) {
27
+ return this.inner.delete(key);
28
+ }
29
+ async put(key, data) {
30
+ const iv = randomBytes(IV_LEN);
31
+ const cipher = createCipheriv("aes-256-gcm", this.key, iv);
32
+ const ciphertext = Buffer.concat([cipher.update(data), cipher.final()]);
33
+ const tag = cipher.getAuthTag();
34
+ await this.inner.put(key, Buffer.concat([Buffer.from([VERSION]), this.keyFingerprint, iv, ciphertext, tag]));
35
+ }
36
+ async get(key) {
37
+ const blob = await this.inner.get(key);
38
+ if (blob.length < HEADER_LEN + TAG_LEN)
39
+ throw new Error(`encrypted blob too short for key ${key}`);
40
+ const version = blob[0];
41
+ if (version !== VERSION)
42
+ throw new Error(`unsupported encryption version ${version} for key ${key}: upgrade the archiver`);
43
+ const fingerprint = blob.subarray(VERSION_LEN, VERSION_LEN + FINGERPRINT_LEN);
44
+ if (!fingerprint.equals(this.keyFingerprint))
45
+ throw new Error(`key mismatch: blob ${key} was encrypted with a different key`);
46
+ const iv = blob.subarray(VERSION_LEN + FINGERPRINT_LEN, HEADER_LEN);
47
+ const tag = blob.subarray(blob.length - TAG_LEN);
48
+ const ciphertext = blob.subarray(HEADER_LEN, blob.length - TAG_LEN);
49
+ const decipher = createDecipheriv("aes-256-gcm", this.key, iv);
50
+ decipher.setAuthTag(tag);
51
+ try {
52
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
53
+ }
54
+ catch {
55
+ throw new Error(`blob ${key} failed authentication — data may be corrupted`);
56
+ }
57
+ }
58
+ }
59
+ //# sourceMappingURL=encrypted.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"encrypted.js","sourceRoot":"","sources":["../../src/backend/encrypted.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,UAAU,EACV,WAAW,GACZ,MAAM,aAAa,CAAC;AAGrB,MAAM,OAAO,GAAG,CAAC,CAAC;AAClB,MAAM,WAAW,GAAG,CAAC,CAAC;AACtB,MAAM,eAAe,GAAG,CAAC,CAAC;AAC1B,MAAM,MAAM,GAAG,EAAE,CAAC;AAClB,MAAM,OAAO,GAAG,EAAE,CAAC;AACnB,mDAAmD;AACnD,MAAM,UAAU,GAAG,WAAW,GAAG,eAAe,GAAG,MAAM,CAAC;AAE1D,MAAM,OAAO,gBAAgB;IAIR;IACA;IAJF,cAAc,CAAS;IAExC,YACmB,KAAkB,EAClB,GAAW;QADX,UAAK,GAAL,KAAK,CAAa;QAClB,QAAG,GAAH,GAAG,CAAQ;QAE5B,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE;YACnB,MAAM,IAAI,KAAK,CAAC,iCAAiC,CAAC,CAAC;QACrD,IAAI,CAAC,cAAc,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,CAAC,EAAE,eAAe,CAAC,CAAC;IAC/F,CAAC;IAED,IAAI;QACF,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;IAC3B,CAAC;IAED,IAAI;QACF,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;IAC3B,CAAC;IAED,MAAM,CAAC,GAAW;QAChB,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAChC,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,IAAY;QACjC,MAAM,EAAE,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC;QAC/B,MAAM,MAAM,GAAG,cAAc,CAAC,aAAa,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QAC3D,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QACxE,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;QAChC,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAClB,GAAG,EACH,MAAM,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,EAAE,EAAE,UAAU,EAAE,GAAG,CAAC,CAAC,CAClF,CAAC;IACJ,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACvC,IAAI,IAAI,CAAC,MAAM,GAAG,UAAU,GAAG,OAAO;YACpC,MAAM,IAAI,KAAK,CAAC,oCAAoC,GAAG,EAAE,CAAC,CAAC;QAE7D,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACxB,IAAI,OAAO,KAAK,OAAO;YACrB,MAAM,IAAI,KAAK,CAAC,kCAAkC,OAAO,YAAY,GAAG,wBAAwB,CAAC,CAAC;QAEpG,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,WAAW,GAAG,eAAe,CAAC,CAAC;QAC9E,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC;YAC1C,MAAM,IAAI,KAAK,CAAC,sBAAsB,GAAG,qCAAqC,CAAC,CAAC;QAElF,MAAM,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC,WAAW,GAAG,eAAe,EAAE,UAAU,CAAC,CAAC;QACpE,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,CAAC;QACjD,MAAM,UAAU,GAAG,IAAI,CAAC,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,CAAC;QAEpE,MAAM,QAAQ,GAAG,gBAAgB,CAAC,aAAa,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;QAC/D,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QACzB,IAAI,CAAC;YACH,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QACxE,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,IAAI,KAAK,CAAC,QAAQ,GAAG,gDAAgD,CAAC,CAAC;QAC/E,CAAC;IACH,CAAC;CACF"}
@@ -1,14 +1,23 @@
1
1
  import { FsBackend } from "./fs.js";
2
2
  import { GoogleDriveBackend } from "./google-drive.js";
3
+ import { S3Backend } from "./s3.js";
3
4
  export function makeBackend(config) {
4
5
  switch (config.backend) {
5
6
  case "fs":
6
- return new FsBackend({ dir: config.fsDir });
7
+ return new FsBackend({ dir: config.fsDir, prefix: config.prefix });
7
8
  case "google-drive":
8
9
  return new GoogleDriveBackend({
9
10
  credentialsPath: config.credentialsPath,
10
11
  tokenPath: config.tokenPath,
11
12
  folderName: config.driveFolderName,
13
+ prefix: config.prefix,
14
+ });
15
+ case "s3":
16
+ return new S3Backend({
17
+ bucket: config.s3Bucket,
18
+ region: config.s3Region,
19
+ endpoint: config.s3Endpoint,
20
+ prefix: config.prefix,
12
21
  });
13
22
  default: {
14
23
  const exhaustive = config.backend;
@@ -1 +1 @@
1
- {"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/backend/factory.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAGvD,MAAM,UAAU,WAAW,CAAC,MAAc;IACxC,QAAQ,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,KAAK,IAAI;YACP,OAAO,IAAI,SAAS,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;QAC9C,KAAK,cAAc;YACjB,OAAO,IAAI,kBAAkB,CAAC;gBAC5B,eAAe,EAAE,MAAM,CAAC,eAAe;gBACvC,SAAS,EAAE,MAAM,CAAC,SAAS;gBAC3B,UAAU,EAAE,MAAM,CAAC,eAAe;aACnC,CAAC,CAAC;QACL,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,MAAM,CAAC,OAAO,CAAC;YACzC,MAAM,IAAI,KAAK,CAAC,oBAAoB,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;QAC5D,CAAC;IACH,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/backend/factory.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAGpC,MAAM,UAAU,WAAW,CAAC,MAAc;IACxC,QAAQ,MAAM,CAAC,OAAO,EAAE,CAAC;QACvB,KAAK,IAAI;YACP,OAAO,IAAI,SAAS,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;QACrE,KAAK,cAAc;YACjB,OAAO,IAAI,kBAAkB,CAAC;gBAC5B,eAAe,EAAE,MAAM,CAAC,eAAe;gBACvC,SAAS,EAAE,MAAM,CAAC,SAAS;gBAC3B,UAAU,EAAE,MAAM,CAAC,eAAe;gBAClC,MAAM,EAAE,MAAM,CAAC,MAAM;aACtB,CAAC,CAAC;QACL,KAAK,IAAI;YACP,OAAO,IAAI,SAAS,CAAC;gBACnB,MAAM,EAAE,MAAM,CAAC,QAAQ;gBACvB,MAAM,EAAE,MAAM,CAAC,QAAQ;gBACvB,QAAQ,EAAE,MAAM,CAAC,UAAU;gBAC3B,MAAM,EAAE,MAAM,CAAC,MAAM;aACtB,CAAC,CAAC;QACL,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,MAAM,CAAC,OAAO,CAAC;YACzC,MAAM,IAAI,KAAK,CAAC,oBAAoB,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;QAC5D,CAAC;IACH,CAAC;AACH,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import { mkdir, readFile, readdir, rename, stat, unlink, writeFile, } from "node:fs/promises";
2
- import { resolve } from "node:path";
2
+ import { dirname, resolve } from "node:path";
3
3
  import { logger } from "../util/log.js";
4
4
  const log = logger("backend.fs");
5
5
  // Filesystem-backed implementation. Useful for tests, for users who want
@@ -7,17 +7,22 @@ const log = logger("backend.fs");
7
7
  // directory, and for development against a local daemon.
8
8
  export class FsBackend {
9
9
  opts;
10
+ root;
10
11
  constructor(opts) {
11
12
  this.opts = opts;
13
+ this.root = opts.prefix !== "" ? resolve(opts.dir, opts.prefix) : opts.dir;
12
14
  }
13
15
  async init() {
14
- await mkdir(this.opts.dir, { recursive: true });
15
- log.info(`fs backend ready at ${this.opts.dir}`);
16
+ await mkdir(this.root, { recursive: true });
17
+ log.info(`fs backend ready at ${this.root}`);
16
18
  }
17
19
  async list() {
20
+ return this.walk(this.root, "");
21
+ }
22
+ async walk(dir, rel) {
18
23
  let names;
19
24
  try {
20
- names = await readdir(this.opts.dir);
25
+ names = await readdir(dir);
21
26
  }
22
27
  catch (err) {
23
28
  const e = err;
@@ -31,17 +36,16 @@ export class FsBackend {
31
36
  if (name.startsWith(".") || name.endsWith(".tmp")) {
32
37
  continue;
33
38
  }
34
- const full = resolve(this.opts.dir, name);
39
+ const full = resolve(dir, name);
40
+ const entryRel = rel !== "" ? `${rel}/${name}` : name;
35
41
  try {
36
42
  const s = await stat(full);
37
- if (!s.isFile()) {
38
- continue;
43
+ if (s.isDirectory()) {
44
+ out.push(...await this.walk(full, entryRel));
45
+ }
46
+ else if (s.isFile()) {
47
+ out.push({ key: entryRel, size: s.size, modifiedAt: s.mtime.toISOString() });
39
48
  }
40
- out.push({
41
- key: name,
42
- size: s.size,
43
- modifiedAt: s.mtime.toISOString(),
44
- });
45
49
  }
46
50
  catch (err) {
47
51
  log.debug(`stat ${full}: ${err.message}`);
@@ -50,17 +54,18 @@ export class FsBackend {
50
54
  return out;
51
55
  }
52
56
  async get(key) {
53
- return readFile(resolve(this.opts.dir, key));
57
+ return readFile(resolve(this.root, key));
54
58
  }
55
59
  async put(key, data) {
56
- const dest = resolve(this.opts.dir, key);
60
+ const dest = resolve(this.root, key);
57
61
  const tmp = `${dest}.tmp`;
62
+ await mkdir(dirname(dest), { recursive: true });
58
63
  await writeFile(tmp, data);
59
64
  await rename(tmp, dest);
60
65
  }
61
66
  async delete(key) {
62
67
  try {
63
- await unlink(resolve(this.opts.dir, key));
68
+ await unlink(resolve(this.root, key));
64
69
  }
65
70
  catch (err) {
66
71
  const e = err;
@@ -1 +1 @@
1
- {"version":3,"file":"fs.js","sourceRoot":"","sources":["../../src/backend/fs.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fs.js","sourceRoot":"","sources":["../../src/backend/fs.ts"],"names":[],"mappings":"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"}
@@ -8,10 +8,21 @@ const FILE_MIME = "application/json";
8
8
  // Drive-backed implementation. Uses the drive.file scope, which limits
9
9
  // visibility to files the app created plus those explicitly handed to
10
10
  // it — so the archiver never sees the user's other Drive contents.
11
+ //
12
+ // The prefix is resolved into real Drive subfolders on init(). A prefix
13
+ // of "a1b2c3d4/alice-macbook/" creates:
14
+ // hydra-acp-archive/
15
+ // a1b2c3d4/
16
+ // alice-macbook/
17
+ // <lineageId>.hydra.archive
18
+ //
19
+ // list() recurses into all immediate subfolders of the prefix folder so the
20
+ // sync backend (prefix = "user/") can see files from all host subfolders.
11
21
  export class GoogleDriveBackend {
12
22
  opts;
13
23
  drive;
14
- folderId;
24
+ // The Drive folder ID corresponding to the resolved prefix path.
25
+ prefixFolderId;
15
26
  constructor(opts) {
16
27
  this.opts = opts;
17
28
  }
@@ -21,39 +32,24 @@ export class GoogleDriveBackend {
21
32
  tokenPath: this.opts.tokenPath,
22
33
  });
23
34
  this.drive = google.drive({ version: "v3", auth });
24
- this.folderId = await this.ensureFolder(this.opts.folderName);
25
- log.info(`google-drive backend ready: folder="${this.opts.folderName}" id=${this.folderId}`);
35
+ // Walk the root folder name + each prefix segment, ensuring each exists.
36
+ let currentId = await this.ensureFolder(this.opts.folderName, undefined);
37
+ const segments = this.opts.prefix
38
+ .split("/")
39
+ .filter((s) => s !== "");
40
+ for (const seg of segments) {
41
+ currentId = await this.ensureFolder(seg, currentId);
42
+ }
43
+ this.prefixFolderId = currentId;
44
+ log.info(`google-drive backend ready: folder="${this.opts.folderName}" prefix="${this.opts.prefix}" id=${this.prefixFolderId}`);
26
45
  }
27
46
  async list() {
28
- const drive = this.requireDrive();
29
- const parent = this.requireFolderId();
30
- const entries = [];
31
- let pageToken;
32
- do {
33
- const res = await drive.files.list({
34
- q: `'${parent}' in parents and trashed = false`,
35
- fields: "nextPageToken, files(id, name, size, modifiedTime)",
36
- spaces: "drive",
37
- pageSize: 200,
38
- ...(pageToken !== undefined ? { pageToken } : {}),
39
- });
40
- for (const f of res.data.files ?? []) {
41
- if (!f.name) {
42
- continue;
43
- }
44
- entries.push({
45
- key: f.name,
46
- size: typeof f.size === "string" ? Number.parseInt(f.size, 10) : 0,
47
- modifiedAt: f.modifiedTime ?? new Date(0).toISOString(),
48
- });
49
- }
50
- pageToken = res.data.nextPageToken ?? undefined;
51
- } while (pageToken);
52
- return entries;
47
+ return this.listFolder(this.requirePrefixFolderId(), "");
53
48
  }
54
49
  async get(key) {
55
50
  const drive = this.requireDrive();
56
- const fileId = await this.findFileId(key);
51
+ const { parentId, name } = await this.resolveKey(key);
52
+ const fileId = await this.findFileId(name, parentId);
57
53
  if (!fileId) {
58
54
  throw new Error(`google-drive: no file named ${key}`);
59
55
  }
@@ -62,85 +58,120 @@ export class GoogleDriveBackend {
62
58
  }
63
59
  async put(key, data) {
64
60
  const drive = this.requireDrive();
65
- const parent = this.requireFolderId();
66
- const existing = await this.findFileId(key);
67
- const media = {
68
- mimeType: FILE_MIME,
69
- body: Readable.from(data),
70
- };
61
+ const { parentId, name } = await this.resolveKey(key);
62
+ const existing = await this.findFileId(name, parentId);
63
+ const media = { mimeType: FILE_MIME, body: Readable.from(data) };
71
64
  if (existing) {
72
- await drive.files.update({
73
- fileId: existing,
74
- media,
75
- });
65
+ await drive.files.update({ fileId: existing, media });
76
66
  return;
77
67
  }
78
68
  await drive.files.create({
79
- requestBody: {
80
- name: key,
81
- mimeType: FILE_MIME,
82
- parents: [parent],
83
- },
69
+ requestBody: { name, mimeType: FILE_MIME, parents: [parentId] },
84
70
  media,
85
71
  fields: "id",
86
72
  });
87
73
  }
88
74
  async delete(key) {
89
- const drive = this.requireDrive();
90
- const fileId = await this.findFileId(key);
91
- if (!fileId) {
75
+ const { parentId, name } = await this.resolveKey(key);
76
+ const fileId = await this.findFileId(name, parentId);
77
+ if (!fileId)
92
78
  return;
93
- }
94
79
  // Trash rather than hard-delete — recoverable for 30 days.
95
- await drive.files.update({
80
+ await this.requireDrive().files.update({
96
81
  fileId,
97
82
  requestBody: { trashed: true },
98
83
  });
99
84
  }
100
85
  requireDrive() {
101
- if (!this.drive) {
86
+ if (!this.drive)
102
87
  throw new Error("GoogleDriveBackend used before init()");
103
- }
104
88
  return this.drive;
105
89
  }
106
- requireFolderId() {
107
- if (!this.folderId) {
90
+ requirePrefixFolderId() {
91
+ if (!this.prefixFolderId)
108
92
  throw new Error("GoogleDriveBackend used before init()");
93
+ return this.prefixFolderId;
94
+ }
95
+ // Resolve a key that may contain a host subdirectory segment
96
+ // (e.g. "alice-macbook/uuid.hydra.archive") into a (parentId, filename)
97
+ // pair, creating intermediate folders as needed.
98
+ async resolveKey(key) {
99
+ const parts = key.split("/");
100
+ const name = parts[parts.length - 1];
101
+ let parentId = this.requirePrefixFolderId();
102
+ for (const seg of parts.slice(0, -1)) {
103
+ parentId = await this.ensureFolder(seg, parentId);
109
104
  }
110
- return this.folderId;
105
+ return { parentId, name };
111
106
  }
112
- async ensureFolder(name) {
107
+ // Recursive list: returns files in folderId and all immediate subfolders,
108
+ // with keys relative to folderId (e.g. "alice-macbook/uuid.hydra.archive").
109
+ async listFolder(folderId, relPath) {
110
+ const drive = this.requireDrive();
111
+ const entries = [];
112
+ let pageToken;
113
+ do {
114
+ const res = await drive.files.list({
115
+ q: `'${folderId}' in parents and trashed = false`,
116
+ fields: "nextPageToken, files(id, name, size, modifiedTime, mimeType)",
117
+ spaces: "drive",
118
+ pageSize: 200,
119
+ ...(pageToken !== undefined ? { pageToken } : {}),
120
+ });
121
+ for (const f of res.data.files ?? []) {
122
+ if (!f.name)
123
+ continue;
124
+ const entryRel = relPath !== "" ? `${relPath}/${f.name}` : f.name;
125
+ if (f.mimeType === FOLDER_MIME) {
126
+ entries.push(...await this.listFolder(f.id, entryRel));
127
+ }
128
+ else {
129
+ entries.push({
130
+ key: entryRel,
131
+ size: typeof f.size === "string" ? Number.parseInt(f.size, 10) : 0,
132
+ modifiedAt: f.modifiedTime ?? new Date(0).toISOString(),
133
+ });
134
+ }
135
+ }
136
+ pageToken = res.data.nextPageToken ?? undefined;
137
+ } while (pageToken);
138
+ return entries;
139
+ }
140
+ // Ensure a folder with the given name exists under parentId (or at root if
141
+ // parentId is undefined). Returns the folder's Drive ID.
142
+ async ensureFolder(name, parentId) {
113
143
  const drive = this.requireDrive();
114
144
  const escaped = name.replace(/'/g, "\\'");
145
+ const parentClause = parentId !== undefined
146
+ ? `'${parentId}' in parents and `
147
+ : "";
115
148
  const res = await drive.files.list({
116
- q: `name = '${escaped}' and mimeType = '${FOLDER_MIME}' and trashed = false`,
149
+ q: `${parentClause}name = '${escaped}' and mimeType = '${FOLDER_MIME}' and trashed = false`,
117
150
  fields: "files(id, name)",
118
151
  spaces: "drive",
119
152
  pageSize: 10,
120
153
  });
121
154
  const existing = res.data.files?.[0];
122
- if (existing?.id) {
155
+ if (existing?.id)
123
156
  return existing.id;
124
- }
125
157
  const created = await drive.files.create({
126
158
  requestBody: {
127
159
  name,
128
160
  mimeType: FOLDER_MIME,
161
+ ...(parentId !== undefined ? { parents: [parentId] } : {}),
129
162
  },
130
163
  fields: "id",
131
164
  });
132
- if (!created.data.id) {
165
+ if (!created.data.id)
133
166
  throw new Error("google-drive: folder create returned no id");
134
- }
135
167
  log.info(`created Drive folder "${name}" id=${created.data.id}`);
136
168
  return created.data.id;
137
169
  }
138
- async findFileId(key) {
170
+ async findFileId(name, parentId) {
139
171
  const drive = this.requireDrive();
140
- const parent = this.requireFolderId();
141
- const escaped = key.replace(/'/g, "\\'");
172
+ const escaped = name.replace(/'/g, "\\'");
142
173
  const res = await drive.files.list({
143
- q: `'${parent}' in parents and name = '${escaped}' and trashed = false`,
174
+ q: `'${parentId}' in parents and name = '${escaped}' and trashed = false`,
144
175
  fields: "files(id, name)",
145
176
  spaces: "drive",
146
177
  pageSize: 2,