@statewalker/file-mounts.core 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +316 -2
- package/dist/changes.d.ts +16 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +18 -0
- package/dist/changes.js.map +1 -0
- package/dist/connection-test.d.ts +31 -0
- package/dist/connection-test.d.ts.map +1 -0
- package/dist/connection-test.js +152 -0
- package/dist/connection-test.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/key-value-store.d.ts +30 -0
- package/dist/key-value-store.d.ts.map +1 -0
- package/dist/key-value-store.js +55 -0
- package/dist/key-value-store.js.map +1 -0
- package/dist/local-folder-mount-type.d.ts +13 -0
- package/dist/local-folder-mount-type.d.ts.map +1 -0
- package/dist/local-folder-mount-type.js +64 -0
- package/dist/local-folder-mount-type.js.map +1 -0
- package/dist/main-storage.d.ts +8 -0
- package/dist/main-storage.d.ts.map +1 -0
- package/dist/main-storage.js +20 -0
- package/dist/main-storage.js.map +1 -0
- package/dist/memory-mount-type.d.ts +6 -0
- package/dist/memory-mount-type.d.ts.map +1 -0
- package/dist/memory-mount-type.js +24 -0
- package/dist/memory-mount-type.js.map +1 -0
- package/dist/mount-config.d.ts +57 -0
- package/dist/mount-config.d.ts.map +1 -0
- package/dist/mount-config.js +119 -0
- package/dist/mount-config.js.map +1 -0
- package/dist/mount-settings-file.d.ts +16 -0
- package/dist/mount-settings-file.d.ts.map +1 -0
- package/dist/mount-settings-file.js +72 -0
- package/dist/mount-settings-file.js.map +1 -0
- package/dist/mount-table.d.ts +50 -0
- package/dist/mount-table.d.ts.map +1 -0
- package/dist/mount-table.js +203 -0
- package/dist/mount-table.js.map +1 -0
- package/dist/mount-type.d.ts +45 -0
- package/dist/mount-type.d.ts.map +1 -0
- package/dist/mount-type.js +12 -0
- package/dist/mount-type.js.map +1 -0
- package/dist/opfs-mount-type.d.ts +6 -0
- package/dist/opfs-mount-type.d.ts.map +1 -0
- package/dist/opfs-mount-type.js +22 -0
- package/dist/opfs-mount-type.js.map +1 -0
- package/dist/passkeys.d.ts +55 -0
- package/dist/passkeys.d.ts.map +1 -0
- package/dist/passkeys.js +155 -0
- package/dist/passkeys.js.map +1 -0
- package/dist/problem.d.ts +21 -0
- package/dist/problem.d.ts.map +1 -0
- package/dist/problem.js +23 -0
- package/dist/problem.js.map +1 -0
- package/dist/profiles.d.ts +37 -0
- package/dist/profiles.d.ts.map +1 -0
- package/dist/profiles.js +115 -0
- package/dist/profiles.js.map +1 -0
- package/dist/project-archive.d.ts +16 -0
- package/dist/project-archive.d.ts.map +1 -0
- package/dist/project-archive.js +47 -0
- package/dist/project-archive.js.map +1 -0
- package/dist/s3-problems.d.ts +22 -0
- package/dist/s3-problems.d.ts.map +1 -0
- package/dist/s3-problems.js +90 -0
- package/dist/s3-problems.js.map +1 -0
- package/dist/s3.d.ts +33 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +214 -0
- package/dist/s3.js.map +1 -0
- package/dist/secret-vault.d.ts +81 -0
- package/dist/secret-vault.d.ts.map +1 -0
- package/dist/secret-vault.js +214 -0
- package/dist/secret-vault.js.map +1 -0
- package/dist/unavailable-files.d.ts +7 -0
- package/dist/unavailable-files.d.ts.map +1 -0
- package/dist/unavailable-files.js +27 -0
- package/dist/unavailable-files.js.map +1 -0
- package/dist/vault-records.d.ts +31 -0
- package/dist/vault-records.d.ts.map +1 -0
- package/dist/vault-records.js +124 -0
- package/dist/vault-records.js.map +1 -0
- package/package.json +51 -3
- package/src/changes.ts +28 -0
- package/src/connection-test.ts +160 -0
- package/src/index.ts +19 -0
- package/src/key-value-store.ts +61 -0
- package/src/local-folder-mount-type.ts +75 -0
- package/src/main-storage.ts +18 -0
- package/src/memory-mount-type.ts +22 -0
- package/src/mount-config.ts +173 -0
- package/src/mount-settings-file.ts +81 -0
- package/src/mount-table.ts +228 -0
- package/src/mount-type.ts +41 -0
- package/src/opfs-mount-type.ts +21 -0
- package/src/passkeys.ts +167 -0
- package/src/problem.ts +36 -0
- package/src/profiles.ts +157 -0
- package/src/project-archive.ts +64 -0
- package/src/s3-problems.ts +100 -0
- package/src/s3.ts +224 -0
- package/src/secret-vault.ts +294 -0
- package/src/unavailable-files.ts +26 -0
- package/src/vault-records.ts +188 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) {{year}} Mikhail Kotelnikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,317 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @statewalker/file-mounts.core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## What it is
|
|
4
|
+
|
|
5
|
+
Storages mounted side by side as one `FilesApi`: a folder of the computer, the browser's private
|
|
6
|
+
storage, an S3 bucket, or memory. Each storage appears at `/<key>` under a read-only root that lists
|
|
7
|
+
them. The package also holds the profiles, each with an encrypted vault for its S3 keys and mounts
|
|
8
|
+
(`/.settings/mounts.json` in a project is only the project's archive copy of the mounts), and a
|
|
9
|
+
five-step S3 connection test that says what is wrong with a bucket instead of "failed to fetch".
|
|
10
|
+
It is React-free; a view layer reads the table's `list()` and `onUpdate`.
|
|
11
|
+
|
|
12
|
+
## Why it exists
|
|
13
|
+
|
|
14
|
+
A files app talks to several storages at once and must keep working when one of them does not. Three
|
|
15
|
+
things follow from that:
|
|
16
|
+
|
|
17
|
+
- **A broken mount stays in the table.** An unusable storage (locked vault, folder access to be given
|
|
18
|
+
again, wrong keys, no answer) is mounted anyway, with a status. Every operation on it raises
|
|
19
|
+
`MountUnavailable` carrying the reason. An empty folder would look like data loss and invite
|
|
20
|
+
the person to "fix" it by saving over it.
|
|
21
|
+
- **S3 is called straight from the browser.** The bucket needs a CORS rule naming the page's address,
|
|
22
|
+
and the browser reports a missing rule exactly like a dead host. The connection test tells them apart
|
|
23
|
+
and shows the rule to paste.
|
|
24
|
+
- **The person's keys and storages must not sit in plain text.** Each profile's data lives in one encrypted IndexedDB record, unlocked by a password (or a passkey).
|
|
25
|
+
|
|
26
|
+
## How to use
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import {
|
|
30
|
+
FilesMountTable, IdbStore, Profiles, firstProfileData, localFolderMountType,
|
|
31
|
+
memoryMountType, opfsMountType, s3MountType,
|
|
32
|
+
} from "@statewalker/file-mounts.core";
|
|
33
|
+
|
|
34
|
+
const records = new IdbStore<unknown>("statewalker-files");
|
|
35
|
+
const profiles = new Profiles({ records });
|
|
36
|
+
const [first] = await profiles.load();
|
|
37
|
+
const vault = first
|
|
38
|
+
? await profiles.open(first.id) // unlocked when remembered; else vault.unlock(password, remember)
|
|
39
|
+
: await profiles.create("Personal", password, true, firstProfileData(opfsMountType().isAvailable().ok));
|
|
40
|
+
|
|
41
|
+
const table = new FilesMountTable({
|
|
42
|
+
types: [
|
|
43
|
+
opfsMountType(),
|
|
44
|
+
localFolderMountType(new IdbStore("folder-handles")),
|
|
45
|
+
s3MountType({ origin: location.origin }),
|
|
46
|
+
memoryMountType(),
|
|
47
|
+
],
|
|
48
|
+
secrets: vault,
|
|
49
|
+
});
|
|
50
|
+
await table.apply((await vault.read()).mounts); // creates the mounts, in parallel
|
|
51
|
+
table.files; // one FilesApi: /<key>/... for every mount
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
After the person unlocks the vault, call `table.apply((await vault.read()).mounts)` again: mounts that were `locked` are
|
|
55
|
+
created, mounts that are already `mounted` are kept. `apply` returns the `FileChange[]` of the mount
|
|
56
|
+
roots it added, replaced or removed; nothing else announces them, so pass the list to the change hub,
|
|
57
|
+
or open panels keep showing "Locked" until they are navigated again:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { newFileChanges } from "@statewalker/file-mounts.core";
|
|
61
|
+
|
|
62
|
+
const changes = newFileChanges(); // the hub panels subscribe to
|
|
63
|
+
if (await vault.unlock(password, remember)) changes.emit(await table.apply((await vault.read()).mounts));
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`table.reconnect(key)` returns only the status: after it, emit an `updated` change for `/<key>`.
|
|
67
|
+
Inside a click, `table.apply(mounts, { interactive: true })` or `table.reconnect(key)` may show a
|
|
68
|
+
permission prompt.
|
|
69
|
+
|
|
70
|
+
`vault.lock()` does not unmount S3 storages that are already mounted: their client holds the keys
|
|
71
|
+
until the next `apply` that recreates them, or a reload. Likewise, keys changed in the vault for a
|
|
72
|
+
config that did not change are not picked up by `apply` (the mount is kept as is); `reconnect(key)`
|
|
73
|
+
creates it again with the new keys.
|
|
74
|
+
|
|
75
|
+
The main mount's project keeps archive copies of the profile. After each `vault.update(...)` that
|
|
76
|
+
changes mounts or preferences, write them; *Import from this project* merges them back:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
import { archiveProfile, importProfile, mergeImported } from "@statewalker/file-mounts.core";
|
|
80
|
+
|
|
81
|
+
await archiveProfile(mainFiles, await vault.read()); // mainFiles: the main mount's FilesApi
|
|
82
|
+
|
|
83
|
+
const archive = await importProfile(mainFiles); // show archive.problem when set
|
|
84
|
+
await vault.update((d) => mergeImported(d, archive));
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Passkeys unlock a profile beside its password (WebAuthn PRF). A `PasskeyProblem`'s `message` is what to show the person:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { webAuthnPasskeys } from "@statewalker/file-mounts.core";
|
|
91
|
+
|
|
92
|
+
const passkeys = webAuthnPasskeys({ rpName: "Files" });
|
|
93
|
+
if (await passkeys.available()) await profiles.addPasskey(vault, password, passkeys);
|
|
94
|
+
const unlocked = await profiles.unlockWithPasskey(passkeys, remember); // finds the profile itself
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## What each mount type needs
|
|
98
|
+
|
|
99
|
+
| Type | Needs | Notes |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `local-folder` | Chromium (Chrome, Edge) | After a page reload the folder needs one click: `table.reconnect(key)` inside the handler. |
|
|
102
|
+
| `opfs` | A browser with `navigator.storage.getDirectory` | Lives in the page's private storage, in the folder `mounts/<key>`. |
|
|
103
|
+
| `s3` | An unlocked vault, a bucket with a CORS rule | Fields: endpoint, region, bucket, prefix, path-style, access key ID, secret access key (the last two go to the vault, as accounts `<key>/accessKeyId` and `<key>/secretAccessKey`). |
|
|
104
|
+
| `memory` | nothing | Gone on reload; used by tests and demos. |
|
|
105
|
+
|
|
106
|
+
A mount saved with `readOnly: true` is wrapped so writes throw; `mounted: false` keeps the settings
|
|
107
|
+
without mounting.
|
|
108
|
+
|
|
109
|
+
The mount key is also where a storage keeps what belongs to it: OPFS data at `mounts/<key>`, S3 keys
|
|
110
|
+
in the vault under `<key>/<field>`. Changing a mount's key therefore shows an empty OPFS storage, and
|
|
111
|
+
an S3 storage that says `Enter the keys for this storage in Settings → Storage.` The old data and keys
|
|
112
|
+
are not deleted; they stay under the old key, unreachable until a mount uses that key again.
|
|
113
|
+
|
|
114
|
+
## How S3 settings are tested and why the messages are what they are
|
|
115
|
+
|
|
116
|
+
`testConnection` runs five checks in order and stops at the first failure:
|
|
117
|
+
|
|
118
|
+
1. **The address answers.** A signed `ListObjectsV2` with `MaxKeys: 0` on the bucket; any HTTP answer,
|
|
119
|
+
even a refusal, counts. It is bucket-scoped because the bucket's CORS rule applies to it; a
|
|
120
|
+
service-level `ListBuckets` gets no CORS headers from AWS or R2 and would always look like CORS.
|
|
121
|
+
2. **The keys are accepted.** The same call: `InvalidAccessKeyId`, `SignatureDoesNotMatch` and
|
|
122
|
+
`RequestTimeTooSkewed` fail here. `AccessDenied` and `NoSuchBucket` still mean the keys were
|
|
123
|
+
recognised; the next checks report those.
|
|
124
|
+
3. **The bucket exists.** `HeadBucket`; a 403 passes, because listing says whether the bucket may be read.
|
|
125
|
+
4. **Listing is allowed.** `ListObjectsV2` under the prefix.
|
|
126
|
+
5. **Writing is allowed.** Puts and deletes a small object `<prefix>.<key>-write-test-<uuid>`. Skipped
|
|
127
|
+
for read-only mounts, so a read-only key is not reported as broken.
|
|
128
|
+
|
|
129
|
+
Each check has a 15 s limit. The time is the point at which a person stops believing the page is
|
|
130
|
+
working; a longer wait looks like a hang.
|
|
131
|
+
|
|
132
|
+
A network failure is followed by an unsigned `no-cors` request to the endpoint. If that answers, the
|
|
133
|
+
host is up and the browser blocked the signed call: the problem is `cors`, and the report carries the
|
|
134
|
+
rule for the page's origin. If it does not answer, the problem is `unreachable`. Without this second
|
|
135
|
+
request the two cases are indistinguishable in a browser.
|
|
136
|
+
|
|
137
|
+
When the region is left empty, which the "Other" and R2 presets allow, the client signs with
|
|
138
|
+
`us-east-1`, which is what S3-compatible servers expect by default. The AWS, MinIO / RustFS, B2 and
|
|
139
|
+
Wasabi presets require a region in the form, so those are never signed with a guessed one.
|
|
140
|
+
|
|
141
|
+
Messages, as the person sees them:
|
|
142
|
+
|
|
143
|
+
| Situation | Message |
|
|
144
|
+
|---|---|
|
|
145
|
+
| Wrong keys | `Wrong access key or secret.` |
|
|
146
|
+
| Clock off | `This computer's clock is off by more than 15 minutes; S3 refuses the request.` |
|
|
147
|
+
| No such bucket | ``Bucket `name` not found at this endpoint.`` |
|
|
148
|
+
| Not allowed to list / write | `These keys can't list this bucket.` / `These keys can't write to this bucket.` |
|
|
149
|
+
| CORS rule missing | `The bucket doesn't allow this site. Add this CORS rule to the bucket:` (followed by the rule) |
|
|
150
|
+
| Host dead | ``No answer from `endpoint`.`` |
|
|
151
|
+
| Hang | `No answer in 15 seconds.` |
|
|
152
|
+
| Vault locked | `Locked: unlock to use the S3 storages.` |
|
|
153
|
+
| S3 keys not entered, or no vault created yet | `Enter the keys for this storage in Settings → Storage.` |
|
|
154
|
+
| Folder access lost | ``Click Reconnect to give access to `folder` again.`` |
|
|
155
|
+
| Browser without folders | `This browser can't open local folders; use Chrome or Edge.` |
|
|
156
|
+
|
|
157
|
+
When an S3 mount is created, one listing runs at once, so wrong keys, a missing bucket or CORS show as
|
|
158
|
+
the mount's status and not as an empty folder later.
|
|
159
|
+
|
|
160
|
+
## Examples
|
|
161
|
+
|
|
162
|
+
Test settings before saving them:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { testConnection } from "@statewalker/file-mounts.core";
|
|
166
|
+
|
|
167
|
+
const report = await testConnection(
|
|
168
|
+
{ key: "archive", name: "Archive", type: "s3", config: { endpoint: "http://127.0.0.1:9000", bucket: "files" } },
|
|
169
|
+
{ accessKeyId: "...", secretAccessKey: "..." },
|
|
170
|
+
{ origin: location.origin },
|
|
171
|
+
);
|
|
172
|
+
if (!report.ok) console.log(report.problem?.message, report.problem?.fix?.json);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Read a mount that cannot be used:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { MountUnavailable } from "@statewalker/file-mounts.core";
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
for await (const _ of table.files.list("/archive")) break;
|
|
182
|
+
} catch (e) {
|
|
183
|
+
if (e instanceof MountUnavailable) console.log(e.problem.cause, e.problem.message);
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Store a secret and change the password:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const vault = await profiles.create("Work", "correct horse", true); // remember: silent unlock after a reload
|
|
191
|
+
await vault.set("archive/accessKeyId", "AKIA...");
|
|
192
|
+
await vault.changePassword("correct horse", "battery staple"); // false when the old one is wrong
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Internals
|
|
196
|
+
|
|
197
|
+
### How the vault protects the keys
|
|
198
|
+
|
|
199
|
+
Each profile has one record, `vault:<id>`, in a single `KeyValueStore` (IndexedDB). It holds a sealed
|
|
200
|
+
`ProfileData` (secrets, mounts, the main storage, preferences) and the wrapped data key. A random
|
|
201
|
+
AES-GCM-256 data key seals the data; a key derived from the password with PBKDF2-SHA256 wraps the data
|
|
202
|
+
key. A password change re-wraps the one data key and leaves the sealed data alone. Accounts are named
|
|
203
|
+
`<mount key>/<field>`. The profile id is bound into both ciphertexts as additional data, so a record
|
|
204
|
+
copied under another profile does not decrypt. Kept in clear are the profile list (`profiles`: ids
|
|
205
|
+
and names) and, in each vault record, the passkey credential ids and creation times and the KDF
|
|
206
|
+
parameters (salt, iterations). None of these is a secret.
|
|
207
|
+
|
|
208
|
+
The default is 600 000 PBKDF2 iterations: the current OWASP guidance for SHA-256, and a fraction of a
|
|
209
|
+
second on a laptop. Fewer would make a stolen record cheaper to guess against; more makes every unlock
|
|
210
|
+
slow on a phone. Tests pass `iterations: 1000`.
|
|
211
|
+
|
|
212
|
+
*Remember* stores the **data key**, non-extractable, at `remembered:<id>`, so the next open unlocks
|
|
213
|
+
silently. It is the data key and not the password-derived key because it survives a password change
|
|
214
|
+
and works after any kind of unlock; and whoever can read this browser's IndexedDB could use a
|
|
215
|
+
remembered password key just as well. Writes take a Web Lock per profile and each write re-reads the
|
|
216
|
+
record, so two tabs changing different things keep both.
|
|
217
|
+
|
|
218
|
+
A wrong password never changes anything: `unlock` and `changePassword` return `false` and the state
|
|
219
|
+
stays as it was. Both check the password against the record as it is now, so after another tab changed
|
|
220
|
+
the password, the new one works in this tab too, without a reload.
|
|
221
|
+
|
|
222
|
+
A lost password cannot be recovered. `profiles.remove(id)` deletes the vault and its remembered key; the
|
|
223
|
+
files in the storages are not touched, but the mounts and keys must be entered again. Using a removed
|
|
224
|
+
profile's vault fails with `This profile was removed.`
|
|
225
|
+
|
|
226
|
+
### A passkey unlocks the vault through PRF, and the password stays
|
|
227
|
+
|
|
228
|
+
The authenticator returns the same 32-byte PRF secret for this site and the fixed input
|
|
229
|
+
`statewalker-files vault v1`, after verifying the person. HKDF-SHA256 with a random salt per wrap turns
|
|
230
|
+
that secret into the key that wraps the data key, next to the password wrap. The passkey's user handle
|
|
231
|
+
is the profile id and its user name the profile's name, so one *Unlock with a passkey* finds the
|
|
232
|
+
profile. The password is checked before the passkey prompt, so a wrong one leaves no orphan passkey.
|
|
233
|
+
|
|
234
|
+
Support differs: Chromium with security keys and phones, Safari 18+ with Apple Passwords, Windows Hello
|
|
235
|
+
only on recent builds, Firefox unclear. `available()` asks `getClientCapabilities`. An authenticator
|
|
236
|
+
that registers a passkey without PRF leaves that passkey in its own list though it unlocks nothing;
|
|
237
|
+
adding then fails with "This browser can't use passkeys to unlock; use the password." Removing a passkey
|
|
238
|
+
from the profile does not delete it from the authenticator, which keeps offering it and gets "This
|
|
239
|
+
passkey isn't linked to a profile on this browser." There is no server, so the challenge is random and
|
|
240
|
+
nothing is verified beyond the PRF secret. Keep the password: a deleted passkey would otherwise lock
|
|
241
|
+
the person out.
|
|
242
|
+
|
|
243
|
+
### How the table keeps working when one storage does not
|
|
244
|
+
|
|
245
|
+
`apply` and `reconnect` run one after another through a queue; mounts of one `apply` are created in
|
|
246
|
+
parallel. `apply` validates every key first and rejects before changing anything. A create that does
|
|
247
|
+
not settle in 15 s (`createTimeoutMs`) is `failed` with `No answer in 15 seconds.`; other mounts are
|
|
248
|
+
unaffected, and the queue moves on. The message names 15 seconds whatever `createTimeoutMs` is set to.
|
|
249
|
+
|
|
250
|
+
The one exception is a type that declares `prompts: true` (only `local-folder`), created
|
|
251
|
+
interactively (`reconnect`, or `apply` with `interactive: true`): it waits on a browser permission
|
|
252
|
+
prompt, which always settles when the person answers, and timing it out would fail a folder while
|
|
253
|
+
they are still reading the prompt. Every other type is timed out even inside a click, because an S3
|
|
254
|
+
host that never answers would otherwise hold the queue, and every later `apply` and `reconnect`,
|
|
255
|
+
forever.
|
|
256
|
+
|
|
257
|
+
### The main storage is the project, and keeps an archive of the profile
|
|
258
|
+
|
|
259
|
+
`canBeMain(config)` is true for `opfs`, `local-folder` and `memory`, and false for `s3`. The main
|
|
260
|
+
storage is the open project and must open before anything else; an S3 storage is locked until the
|
|
261
|
+
vault opens, so it cannot be the one that opens first. `firstProfileData(opfs)` is what a new profile
|
|
262
|
+
starts with: one "Browser storage" mount as main, or a memory mount where OPFS is missing.
|
|
263
|
+
|
|
264
|
+
`archiveProfile` writes `/.settings/mounts.json` and `/.settings/preferences.json` into the project.
|
|
265
|
+
Secrets never go there: the mounts file holds configs only, and the keys stay in the vault. The vault
|
|
266
|
+
always wins: `importProfile` reads the copies only on an explicit import, and `mergeImported` keeps
|
|
267
|
+
the profile's own mounts and preference values, adding only the mounts with unknown keys and the
|
|
268
|
+
preferences it lacks. A damaged copy brings in what can be read, and `problem` says what was left
|
|
269
|
+
out, for example `1 saved storage could not be read and was left out. The archived preferences are
|
|
270
|
+
damaged; they were left out.`
|
|
271
|
+
|
|
272
|
+
### A save over a damaged mounts.json first keeps a backup of it
|
|
273
|
+
|
|
274
|
+
`mounts.json` is the project's archive copy of the mounts. `loadMountConfigs` returns the mounts in `/.settings/mounts.json`. A file that is not valid JSON or not a
|
|
275
|
+
list loads as no mounts, with `problem: "The storage settings file is damaged; it is kept as is until you save."`.
|
|
276
|
+
Entries that cannot be read are left out with a problem count, for example
|
|
277
|
+
`2 saved storages could not be read and were left out.` Loading never rewrites the file.
|
|
278
|
+
|
|
279
|
+
The next `saveMountConfigs` writes only the mounts it is given, so the entries left out would be
|
|
280
|
+
gone. Before writing, it reads the file again; if it does not load cleanly (by the same rules), its
|
|
281
|
+
exact text is first copied to `mounts.damaged-<timestamp>.json` in the same folder (for example
|
|
282
|
+
`/.settings/mounts.damaged-2026-10-11T09-30-00-000Z.json`). Nothing removes these backups; a file there
|
|
283
|
+
means a save replaced settings that could not be read, and the lost entries can be recovered from it
|
|
284
|
+
by hand.
|
|
285
|
+
|
|
286
|
+
### Constraints
|
|
287
|
+
|
|
288
|
+
- Mount keys are validated (`validateKey`); the key is the first path segment.
|
|
289
|
+
- S3 object stores have no real folders: the prefix is part of every key, and a mount sees only below it.
|
|
290
|
+
- Dependencies: `@statewalker/webrun-files*` for the `FilesApi` contract and the composite, memory,
|
|
291
|
+
browser and S3 implementations; `@aws-sdk/client-s3` for signing and the checks;
|
|
292
|
+
`@statewalker/shared-baseclass` for `onUpdate`.
|
|
293
|
+
|
|
294
|
+
## Running the S3 suite against a real server
|
|
295
|
+
|
|
296
|
+
The unit suite needs no S3 server or Docker:
|
|
297
|
+
|
|
298
|
+
```
|
|
299
|
+
pnpm --filter @statewalker/file-mounts.core test
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The integration suite starts a RustFS container (`rustfs/rustfs:1.0.0`, a free local port, root keys
|
|
303
|
+
`rustfsadmin`), creates a bucket, and checks: all five checks pass, read-only skips the write, wrong
|
|
304
|
+
keys, missing bucket, files land under the prefix, a copy from one storage into S3. Docker is required;
|
|
305
|
+
without it the tests are skipped. Set `RUSTFS_IMAGE` to use another image.
|
|
306
|
+
|
|
307
|
+
```
|
|
308
|
+
pnpm --filter @statewalker/file-mounts.core test:integration
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The container is removed when the suite ends. A restricted user (denied write) and CORS are not
|
|
312
|
+
produced here: RustFS's root keys can do everything and Node does not enforce CORS. Those outcomes are
|
|
313
|
+
covered by the classifier unit tests.
|
|
314
|
+
|
|
315
|
+
## License
|
|
316
|
+
|
|
317
|
+
MIT
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/changes.d.ts
|
|
2
|
+
/** One change to the files: what an operation or a mount change did. */
|
|
3
|
+
export interface FileChange {
|
|
4
|
+
type: "added" | "updated" | "deleted";
|
|
5
|
+
path: string;
|
|
6
|
+
}
|
|
7
|
+
/** The one stream of changes: panels refresh from it, an indexer will follow it. */
|
|
8
|
+
export interface FileChanges {
|
|
9
|
+
subscribe(listener: (changes: readonly FileChange[]) => void): () => void;
|
|
10
|
+
}
|
|
11
|
+
export interface FileChangesHub extends FileChanges {
|
|
12
|
+
emit(changes: readonly FileChange[]): void;
|
|
13
|
+
}
|
|
14
|
+
export declare function newFileChanges(): FileChangesHub;
|
|
15
|
+
//#endregion
|
|
16
|
+
//# sourceMappingURL=changes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changes.d.ts","names":[],"sources":["../src/changes.ts"],"mappings":";;iBACiB;EACf;EACA;;;iBAIe;EACf,UAAU,WAAW,kBAAkB;;iBAGxB,uBAAuB;EACtC,KAAK,kBAAkB;;wBAGT,kBAAkB"}
|
package/dist/changes.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/changes.ts
|
|
2
|
+
function newFileChanges() {
|
|
3
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
4
|
+
return {
|
|
5
|
+
subscribe(listener) {
|
|
6
|
+
listeners.add(listener);
|
|
7
|
+
return () => listeners.delete(listener);
|
|
8
|
+
},
|
|
9
|
+
emit(changes) {
|
|
10
|
+
if (!changes.length) return;
|
|
11
|
+
for (const listener of [...listeners]) listener(changes);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { newFileChanges };
|
|
17
|
+
|
|
18
|
+
//# sourceMappingURL=changes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changes.js","names":[],"sources":["../src/changes.ts"],"sourcesContent":["/** One change to the files: what an operation or a mount change did. */\nexport interface FileChange {\n type: \"added\" | \"updated\" | \"deleted\";\n path: string;\n}\n\n/** The one stream of changes: panels refresh from it, an indexer will follow it. */\nexport interface FileChanges {\n subscribe(listener: (changes: readonly FileChange[]) => void): () => void;\n}\n\nexport interface FileChangesHub extends FileChanges {\n emit(changes: readonly FileChange[]): void;\n}\n\nexport function newFileChanges(): FileChangesHub {\n const listeners = new Set<(changes: readonly FileChange[]) => void>();\n return {\n subscribe(listener) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n emit(changes) {\n if (!changes.length) return;\n for (const listener of [...listeners]) listener(changes);\n },\n };\n}\n"],"mappings":";AAeA,SAAgB,iBAAiC;CAC/C,MAAM,4BAAY,IAAI,IAA8C;CACpE,OAAO;EACL,UAAU,UAAU;GAClB,UAAU,IAAI,QAAQ;GACtB,aAAa,UAAU,OAAO,QAAQ;EACxC;EACA,KAAK,SAAS;GACZ,IAAI,CAAC,QAAQ,QAAQ;GACrB,KAAK,MAAM,YAAY,CAAC,GAAG,SAAS,GAAG,SAAS,OAAO;EACzD;CACF;AACF"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Problem } from "./problem.js";
|
|
2
|
+
import { S3Form } from "./mount-config.js";
|
|
3
|
+
import { CheckId } from "./s3-problems.js";
|
|
4
|
+
//#region src/connection-test.d.ts
|
|
5
|
+
export interface S3Probe {
|
|
6
|
+
/** An unsigned no-cors fetch: true when the host answers at all. */
|
|
7
|
+
hostAnswers(): Promise<boolean>;
|
|
8
|
+
/** Throws an S3-like error when the check fails. */
|
|
9
|
+
check(id: CheckId): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface ConnectionReport {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
checks: {
|
|
14
|
+
id: CheckId;
|
|
15
|
+
label: string;
|
|
16
|
+
state: "ok" | "failed" | "skipped" | "not-run";
|
|
17
|
+
}[];
|
|
18
|
+
problem?: Problem;
|
|
19
|
+
}
|
|
20
|
+
export declare const CHECK_LABELS: Record<CheckId, string>;
|
|
21
|
+
export declare const CHECK_ORDER: CheckId[];
|
|
22
|
+
export declare function runConnectionTest(probe: S3Probe, form: Pick<S3Form, "endpoint" | "bucket" | "readOnly">, opts: {
|
|
23
|
+
origin: string;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
}): Promise<ConnectionReport>;
|
|
27
|
+
export type ProbeScenario = "ok" | "bad-keys" | "clock-skew" | "no-bucket" | "denied-list" | "denied-write" | "cors" | "unreachable" | "timeout";
|
|
28
|
+
/** A scripted probe for stories and tests; each check waits `delayMs` so progress is visible. */
|
|
29
|
+
export declare function fakeProbe(scenario: ProbeScenario, delayMs?: number): S3Probe;
|
|
30
|
+
//#endregion
|
|
31
|
+
//# sourceMappingURL=connection-test.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection-test.d.ts","names":[],"sources":["../src/connection-test.ts"],"mappings":";;;;iBAIiB;;EAEf,eAAe;;EAEf,MAAM,IAAI,UAAU;;iBAGL;EACf;EACA;IAAU,IAAI;IAAS;IAAe;;EACtC,UAAU;;qBAGC,cAAc,OAAO;qBAOrB,aAAa;wBAiDJ,kBACpB,OAAO,SACP,MAAM,KAAK,6CACX;EAAQ;EAAgB,SAAS;EAAa;IAC7C,QAAQ;YA+BC;;wBAqCI,UAAU,UAAU,eAAe,mBAAc"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { classifyS3Error } from "./s3-problems.js";
|
|
2
|
+
//#region src/connection-test.ts
|
|
3
|
+
const CHECK_LABELS = {
|
|
4
|
+
endpoint: "The address answers",
|
|
5
|
+
keys: "The keys are accepted",
|
|
6
|
+
bucket: "The bucket exists",
|
|
7
|
+
list: "Listing is allowed",
|
|
8
|
+
write: "Writing is allowed"
|
|
9
|
+
};
|
|
10
|
+
const CHECK_ORDER = [
|
|
11
|
+
"endpoint",
|
|
12
|
+
"keys",
|
|
13
|
+
"bucket",
|
|
14
|
+
"list",
|
|
15
|
+
"write"
|
|
16
|
+
];
|
|
17
|
+
const CANCELLED = Symbol("cancelled");
|
|
18
|
+
function isNetworkError(error) {
|
|
19
|
+
const e = error;
|
|
20
|
+
return e?.name === "NetworkError" || e?.name === "TypeError" && !!e.message?.includes("Failed to fetch");
|
|
21
|
+
}
|
|
22
|
+
/** Runs one check, ending it with a TimeoutError after `timeoutMs` or with CANCELLED on abort. */
|
|
23
|
+
function runCheck(probe, id, timeoutMs, signal) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const finish = () => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
signal?.removeEventListener("abort", onAbort);
|
|
28
|
+
};
|
|
29
|
+
const onAbort = () => {
|
|
30
|
+
finish();
|
|
31
|
+
reject(CANCELLED);
|
|
32
|
+
};
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
finish();
|
|
35
|
+
const timeout = /* @__PURE__ */ new Error("The check timed out");
|
|
36
|
+
timeout.name = "TimeoutError";
|
|
37
|
+
reject(timeout);
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
if (signal?.aborted) return onAbort();
|
|
40
|
+
signal?.addEventListener("abort", onAbort);
|
|
41
|
+
probe.check(id).then(() => {
|
|
42
|
+
finish();
|
|
43
|
+
resolve();
|
|
44
|
+
}, (error) => {
|
|
45
|
+
finish();
|
|
46
|
+
reject(error);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async function runConnectionTest(probe, form, opts) {
|
|
51
|
+
const checks = CHECK_ORDER.map((id) => ({
|
|
52
|
+
id,
|
|
53
|
+
label: CHECK_LABELS[id],
|
|
54
|
+
state: "not-run"
|
|
55
|
+
}));
|
|
56
|
+
for (const entry of checks) {
|
|
57
|
+
if (entry.id === "write" && form.readOnly) {
|
|
58
|
+
entry.state = "skipped";
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await runCheck(probe, entry.id, opts.timeoutMs ?? 15e3, opts.signal);
|
|
63
|
+
entry.state = "ok";
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error === CANCELLED) return {
|
|
66
|
+
ok: false,
|
|
67
|
+
checks
|
|
68
|
+
};
|
|
69
|
+
entry.state = "failed";
|
|
70
|
+
const hostAnswers = isNetworkError(error) ? await probe.hostAnswers() : void 0;
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
checks,
|
|
74
|
+
problem: classifyS3Error(error, {
|
|
75
|
+
check: entry.id,
|
|
76
|
+
endpoint: form.endpoint,
|
|
77
|
+
bucket: form.bucket,
|
|
78
|
+
origin: opts.origin,
|
|
79
|
+
hostAnswers
|
|
80
|
+
})
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
checks
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const FAILURES = {
|
|
90
|
+
"bad-keys": {
|
|
91
|
+
id: "keys",
|
|
92
|
+
error: () => ({
|
|
93
|
+
name: "InvalidAccessKeyId",
|
|
94
|
+
message: "The access key ID does not exist."
|
|
95
|
+
})
|
|
96
|
+
},
|
|
97
|
+
"clock-skew": {
|
|
98
|
+
id: "keys",
|
|
99
|
+
error: () => ({
|
|
100
|
+
name: "RequestTimeTooSkewed",
|
|
101
|
+
message: "The difference between the request time and the server's time is too large."
|
|
102
|
+
})
|
|
103
|
+
},
|
|
104
|
+
"no-bucket": {
|
|
105
|
+
id: "bucket",
|
|
106
|
+
error: () => ({
|
|
107
|
+
name: "NoSuchBucket",
|
|
108
|
+
message: "The specified bucket does not exist."
|
|
109
|
+
})
|
|
110
|
+
},
|
|
111
|
+
"denied-list": {
|
|
112
|
+
id: "list",
|
|
113
|
+
error: () => ({
|
|
114
|
+
name: "AccessDenied",
|
|
115
|
+
message: "Access Denied"
|
|
116
|
+
})
|
|
117
|
+
},
|
|
118
|
+
"denied-write": {
|
|
119
|
+
id: "write",
|
|
120
|
+
error: () => ({
|
|
121
|
+
name: "AccessDenied",
|
|
122
|
+
message: "Access Denied"
|
|
123
|
+
})
|
|
124
|
+
},
|
|
125
|
+
cors: {
|
|
126
|
+
id: "endpoint",
|
|
127
|
+
error: () => /* @__PURE__ */ new TypeError("Failed to fetch")
|
|
128
|
+
},
|
|
129
|
+
unreachable: {
|
|
130
|
+
id: "endpoint",
|
|
131
|
+
error: () => /* @__PURE__ */ new TypeError("Failed to fetch")
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
/** A scripted probe for stories and tests; each check waits `delayMs` so progress is visible. */
|
|
135
|
+
function fakeProbe(scenario, delayMs = 0) {
|
|
136
|
+
const wait = () => delayMs > 0 ? new Promise((r) => setTimeout(r, delayMs)) : Promise.resolve();
|
|
137
|
+
return {
|
|
138
|
+
async hostAnswers() {
|
|
139
|
+
return scenario === "cors";
|
|
140
|
+
},
|
|
141
|
+
async check(id) {
|
|
142
|
+
await wait();
|
|
143
|
+
if (scenario === "timeout" && id === "endpoint") return new Promise(() => {});
|
|
144
|
+
const failure = FAILURES[scenario];
|
|
145
|
+
if (failure && failure.id === id) throw failure.error();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
export { CHECK_LABELS, CHECK_ORDER, fakeProbe, runConnectionTest };
|
|
151
|
+
|
|
152
|
+
//# sourceMappingURL=connection-test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection-test.js","names":[],"sources":["../src/connection-test.ts"],"sourcesContent":["import type { S3Form } from \"./mount-config.js\";\nimport type { Problem } from \"./problem.js\";\nimport { type CheckId, classifyS3Error } from \"./s3-problems.js\";\n\nexport interface S3Probe {\n /** An unsigned no-cors fetch: true when the host answers at all. */\n hostAnswers(): Promise<boolean>;\n /** Throws an S3-like error when the check fails. */\n check(id: CheckId): Promise<void>;\n}\n\nexport interface ConnectionReport {\n ok: boolean;\n checks: { id: CheckId; label: string; state: \"ok\" | \"failed\" | \"skipped\" | \"not-run\" }[];\n problem?: Problem;\n}\n\nexport const CHECK_LABELS: Record<CheckId, string> = {\n endpoint: \"The address answers\",\n keys: \"The keys are accepted\",\n bucket: \"The bucket exists\",\n list: \"Listing is allowed\",\n write: \"Writing is allowed\",\n};\nexport const CHECK_ORDER: CheckId[] = [\"endpoint\", \"keys\", \"bucket\", \"list\", \"write\"];\n\nconst CANCELLED = Symbol(\"cancelled\");\n\nfunction isNetworkError(error: unknown): boolean {\n const e = error as { name?: string; message?: string } | null;\n return (\n e?.name === \"NetworkError\" ||\n (e?.name === \"TypeError\" && !!e.message?.includes(\"Failed to fetch\"))\n );\n}\n\n/** Runs one check, ending it with a TimeoutError after `timeoutMs` or with CANCELLED on abort. */\nfunction runCheck(\n probe: S3Probe,\n id: CheckId,\n timeoutMs: number,\n signal?: AbortSignal,\n): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const finish = () => {\n clearTimeout(timer);\n signal?.removeEventListener(\"abort\", onAbort);\n };\n const onAbort = () => {\n finish();\n reject(CANCELLED);\n };\n const timer = setTimeout(() => {\n finish();\n const timeout = new Error(\"The check timed out\");\n timeout.name = \"TimeoutError\";\n reject(timeout);\n }, timeoutMs);\n if (signal?.aborted) return onAbort();\n signal?.addEventListener(\"abort\", onAbort);\n probe.check(id).then(\n () => {\n finish();\n resolve();\n },\n (error) => {\n finish();\n reject(error);\n },\n );\n });\n}\n\nexport async function runConnectionTest(\n probe: S3Probe,\n form: Pick<S3Form, \"endpoint\" | \"bucket\" | \"readOnly\">,\n opts: { origin: string; signal?: AbortSignal; timeoutMs?: number },\n): Promise<ConnectionReport> {\n const checks: ConnectionReport[\"checks\"] = CHECK_ORDER.map((id) => ({\n id,\n label: CHECK_LABELS[id],\n state: \"not-run\",\n }));\n for (const entry of checks) {\n if (entry.id === \"write\" && form.readOnly) {\n entry.state = \"skipped\";\n continue;\n }\n try {\n await runCheck(probe, entry.id, opts.timeoutMs ?? 15000, opts.signal);\n entry.state = \"ok\";\n } catch (error) {\n if (error === CANCELLED) return { ok: false, checks };\n entry.state = \"failed\";\n const hostAnswers = isNetworkError(error) ? await probe.hostAnswers() : undefined;\n const problem = classifyS3Error(error, {\n check: entry.id,\n endpoint: form.endpoint,\n bucket: form.bucket,\n origin: opts.origin,\n hostAnswers,\n });\n return { ok: false, checks, problem };\n }\n }\n return { ok: true, checks };\n}\n\nexport type ProbeScenario =\n | \"ok\"\n | \"bad-keys\"\n | \"clock-skew\"\n | \"no-bucket\"\n | \"denied-list\"\n | \"denied-write\"\n | \"cors\"\n | \"unreachable\"\n | \"timeout\";\n\nconst FAILURES: Partial<Record<ProbeScenario, { id: CheckId; error: () => unknown }>> = {\n \"bad-keys\": {\n id: \"keys\",\n error: () => ({ name: \"InvalidAccessKeyId\", message: \"The access key ID does not exist.\" }),\n },\n \"clock-skew\": {\n id: \"keys\",\n error: () => ({\n name: \"RequestTimeTooSkewed\",\n message: \"The difference between the request time and the server's time is too large.\",\n }),\n },\n \"no-bucket\": {\n id: \"bucket\",\n error: () => ({ name: \"NoSuchBucket\", message: \"The specified bucket does not exist.\" }),\n },\n \"denied-list\": { id: \"list\", error: () => ({ name: \"AccessDenied\", message: \"Access Denied\" }) },\n \"denied-write\": {\n id: \"write\",\n error: () => ({ name: \"AccessDenied\", message: \"Access Denied\" }),\n },\n cors: { id: \"endpoint\", error: () => new TypeError(\"Failed to fetch\") },\n unreachable: { id: \"endpoint\", error: () => new TypeError(\"Failed to fetch\") },\n};\n\n/** A scripted probe for stories and tests; each check waits `delayMs` so progress is visible. */\nexport function fakeProbe(scenario: ProbeScenario, delayMs = 0): S3Probe {\n const wait = () =>\n delayMs > 0 ? new Promise<void>((r) => setTimeout(r, delayMs)) : Promise.resolve();\n return {\n async hostAnswers() {\n return scenario === \"cors\";\n },\n async check(id) {\n await wait();\n if (scenario === \"timeout\" && id === \"endpoint\") return new Promise<void>(() => {});\n const failure = FAILURES[scenario];\n if (failure && failure.id === id) throw failure.error();\n },\n };\n}\n"],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FileChange, FileChanges, FileChangesHub, newFileChanges } from "./changes.js";
|
|
2
|
+
import { MountUnavailable, Problem, ProblemCause, problemOf } from "./problem.js";
|
|
3
|
+
import { MountConfig, MountStatus, MountTypeId, S3Form, S3Preset, S3PresetId, S3_PRESETS, applyPreset, keyFromName, normalizeEndpoint, normalizePrefix, validateKey, validateS3Form } from "./mount-config.js";
|
|
4
|
+
import { CheckId, S3ErrorLike, classifyS3Error, corsRule } from "./s3-problems.js";
|
|
5
|
+
import { CHECK_LABELS, CHECK_ORDER, ConnectionReport, ProbeScenario, S3Probe, fakeProbe, runConnectionTest } from "./connection-test.js";
|
|
6
|
+
import { IdbStore, KeyValueStore, MemoryStore } from "./key-value-store.js";
|
|
7
|
+
import { MountContext, MountField, MountType, NeedsUserGesture } from "./mount-type.js";
|
|
8
|
+
import { localFolderMountType } from "./local-folder-mount-type.js";
|
|
9
|
+
import { ProfileData, SecretStore, SecretVault, SecretsLocked, VaultState } from "./secret-vault.js";
|
|
10
|
+
import { canBeMain, firstProfileData } from "./main-storage.js";
|
|
11
|
+
import { memoryMountType } from "./memory-mount-type.js";
|
|
12
|
+
import { MOUNTS_FILE, loadMountConfigs, saveMountConfigs } from "./mount-settings-file.js";
|
|
13
|
+
import { FilesMountTable, MountTable } from "./mount-table.js";
|
|
14
|
+
import { opfsMountType } from "./opfs-mount-type.js";
|
|
15
|
+
import { MemoryAuthenticator, PASSKEY_MESSAGES, PasskeyAuthenticator, PasskeyProblem, memoryAuthenticator, webAuthnPasskeys } from "./passkeys.js";
|
|
16
|
+
import { ProfileInfo, Profiles, validateProfileName } from "./profiles.js";
|
|
17
|
+
import { PROJECT_SYSTEM_DIR, ProjectArchive, archiveProfile, importProfile, mergeImported } from "./project-archive.js";
|
|
18
|
+
import { S3Keys, S3Settings, newS3Probe, s3MountType, s3Settings, testConnection } from "./s3.js";
|
|
19
|
+
import { unavailableFiles } from "./unavailable-files.js";
|
|
20
|
+
export { CHECK_LABELS, CHECK_ORDER, CheckId, ConnectionReport, FileChange, FileChanges, FileChangesHub, FilesMountTable, IdbStore, KeyValueStore, MOUNTS_FILE, MemoryAuthenticator, MemoryStore, MountConfig, MountContext, MountField, MountStatus, MountTable, MountType, MountTypeId, MountUnavailable, NeedsUserGesture, PASSKEY_MESSAGES, PROJECT_SYSTEM_DIR, PasskeyAuthenticator, PasskeyProblem, ProbeScenario, Problem, ProblemCause, ProfileData, ProfileInfo, Profiles, ProjectArchive, S3ErrorLike, S3Form, S3Keys, S3Preset, S3PresetId, S3Probe, S3Settings, S3_PRESETS, SecretStore, SecretVault, SecretsLocked, VaultState, applyPreset, archiveProfile, canBeMain, classifyS3Error, corsRule, fakeProbe, firstProfileData, importProfile, keyFromName, loadMountConfigs, localFolderMountType, memoryAuthenticator, memoryMountType, mergeImported, newFileChanges, newS3Probe, normalizeEndpoint, normalizePrefix, opfsMountType, problemOf, runConnectionTest, s3MountType, s3Settings, saveMountConfigs, testConnection, unavailableFiles, validateKey, validateProfileName, validateS3Form, webAuthnPasskeys };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { newFileChanges } from "./changes.js";
|
|
2
|
+
import { classifyS3Error, corsRule } from "./s3-problems.js";
|
|
3
|
+
import { CHECK_LABELS, CHECK_ORDER, fakeProbe, runConnectionTest } from "./connection-test.js";
|
|
4
|
+
import { IdbStore, MemoryStore } from "./key-value-store.js";
|
|
5
|
+
import { NeedsUserGesture } from "./mount-type.js";
|
|
6
|
+
import { MountUnavailable, problemOf } from "./problem.js";
|
|
7
|
+
import { localFolderMountType } from "./local-folder-mount-type.js";
|
|
8
|
+
import { canBeMain, firstProfileData } from "./main-storage.js";
|
|
9
|
+
import { memoryMountType } from "./memory-mount-type.js";
|
|
10
|
+
import { S3_PRESETS, applyPreset, keyFromName, normalizeEndpoint, normalizePrefix, validateKey, validateS3Form } from "./mount-config.js";
|
|
11
|
+
import { MOUNTS_FILE, loadMountConfigs, saveMountConfigs } from "./mount-settings-file.js";
|
|
12
|
+
import { SecretVault, SecretsLocked } from "./secret-vault.js";
|
|
13
|
+
import { unavailableFiles } from "./unavailable-files.js";
|
|
14
|
+
import { FilesMountTable } from "./mount-table.js";
|
|
15
|
+
import { opfsMountType } from "./opfs-mount-type.js";
|
|
16
|
+
import { PASSKEY_MESSAGES, PasskeyProblem, memoryAuthenticator, webAuthnPasskeys } from "./passkeys.js";
|
|
17
|
+
import { Profiles, validateProfileName } from "./profiles.js";
|
|
18
|
+
import { PROJECT_SYSTEM_DIR, archiveProfile, importProfile, mergeImported } from "./project-archive.js";
|
|
19
|
+
import { newS3Probe, s3MountType, s3Settings, testConnection } from "./s3.js";
|
|
20
|
+
export { CHECK_LABELS, CHECK_ORDER, FilesMountTable, IdbStore, MOUNTS_FILE, MemoryStore, MountUnavailable, NeedsUserGesture, PASSKEY_MESSAGES, PROJECT_SYSTEM_DIR, PasskeyProblem, Profiles, S3_PRESETS, SecretVault, SecretsLocked, applyPreset, archiveProfile, canBeMain, classifyS3Error, corsRule, fakeProbe, firstProfileData, importProfile, keyFromName, loadMountConfigs, localFolderMountType, memoryAuthenticator, memoryMountType, mergeImported, newFileChanges, newS3Probe, normalizeEndpoint, normalizePrefix, opfsMountType, problemOf, runConnectionTest, s3MountType, s3Settings, saveMountConfigs, testConnection, unavailableFiles, validateKey, validateProfileName, validateS3Form, webAuthnPasskeys };
|