@mnstry/atelier 0.2.0-alpha.11 → 0.2.0-alpha.12

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 (81) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +10 -6
  3. package/contracts/atelier-obsidian-generation-manifest.v2.schema.json +372 -0
  4. package/contracts/compat-baseline.json +1 -1
  5. package/docs/assurance-controls.md +3 -2
  6. package/docs/install.md +3 -3
  7. package/docs/local-services.md +62 -11
  8. package/docs/obsidian-contract.md +669 -35
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +233 -50
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +23 -3
  13. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/case-only-collision.v2.json +160 -0
  14. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/flat-note-path.v2.json +160 -0
  15. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/hidden-folder.v2.json +160 -0
  16. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/identity-in-the-body.v2.json +160 -0
  17. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/link-breaking-name.v2.json +160 -0
  18. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-identity-region.v2.json +156 -0
  19. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/missing-layout-version.v2.json +159 -0
  20. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/note-named-like-a-folder.v2.json +160 -0
  21. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/unknown-region-kind.v2.json +160 -0
  22. package/fixtures/obsidian/contracts/generation-manifest-v2/invalid/v1-contract-version.v2.json +161 -0
  23. package/fixtures/obsidian/contracts/generation-manifest-v2/valid/three-notes.v2.json +160 -0
  24. package/fixtures/obsidian/edits/workspace.json +1 -1
  25. package/fixtures/obsidian/materialization/expected-full.json +21 -21
  26. package/fixtures/obsidian/materialization/expected-full.layout-1.json +99 -0
  27. package/fixtures/obsidian/materialization/expected-scoped.json +7 -7
  28. package/fixtures/obsidian/materialization/expected-scoped.layout-1.json +39 -0
  29. package/package.json +7 -3
  30. package/plugins/obsidian/main.js +444 -0
  31. package/plugins/obsidian/manifest.json +9 -0
  32. package/plugins/obsidian/styles.css +22 -0
  33. package/src/commands/obsidian.mjs +89 -10
  34. package/src/contracts/corpus.mjs +6 -0
  35. package/src/egress/forbidden-egress.mjs +2 -0
  36. package/src/project/private-state.mjs +10 -1
  37. package/src/projection/obsidian/contracts.mjs +224 -18
  38. package/src/projection/obsidian/edits/apply.mjs +12 -7
  39. package/src/projection/obsidian/edits/regions.mjs +137 -42
  40. package/src/projection/obsidian/materialize/index.mjs +6 -3
  41. package/src/projection/obsidian/materialize/path-registry.mjs +443 -57
  42. package/src/projection/obsidian/materialize/prepare-view.mjs +382 -131
  43. package/src/projection/obsidian/materialize/redaction.mjs +327 -0
  44. package/src/projection/obsidian/materialize/settings.mjs +113 -34
  45. package/src/projection/obsidian/plugin-bridge/bundle.mjs +50 -0
  46. package/src/projection/obsidian/plugin-bridge/channel.mjs +174 -0
  47. package/src/projection/obsidian/plugin-bridge/index.mjs +10 -0
  48. package/src/projection/obsidian/proposals/observation.mjs +10 -5
  49. package/src/projection/obsidian/publication/bridge-script.mjs +11 -5
  50. package/src/projection/obsidian/publication/index.mjs +2 -1
  51. package/src/projection/obsidian/publication/publisher.mjs +173 -26
  52. package/src/projection/obsidian/publication/transport.mjs +63 -8
  53. package/src/projection/obsidian/publication/vault-list.mjs +199 -0
  54. package/src/projection/obsidian/recovery/restart.mjs +3 -1
  55. package/src/projection/obsidian/recovery/store.mjs +11 -4
  56. package/src/projection/obsidian/selection-ui/focus.mjs +14 -7
  57. package/src/projection/obsidian/selection-ui/selection-state.mjs +2 -2
  58. package/src/projection/obsidian/selection-ui/selection.mjs +8 -3
  59. package/src/runtime/obsidian/app-capability.mjs +104 -12
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -39
  61. package/src/runtime/obsidian/app-registration.mjs +166 -0
  62. package/src/runtime/obsidian/engine.mjs +127 -11
  63. package/src/runtime/obsidian/extension-points.mjs +1 -1
  64. package/src/runtime/obsidian/index.mjs +5 -1
  65. package/src/runtime/obsidian/launch-plan.mjs +88 -0
  66. package/src/runtime/obsidian/lifecycle.mjs +105 -14
  67. package/src/runtime/obsidian/opening.mjs +227 -55
  68. package/src/runtime/obsidian/pending-edits.mjs +10 -0
  69. package/src/runtime/obsidian/plugin-channel.mjs +281 -0
  70. package/src/runtime/obsidian/plugin-choice.mjs +170 -0
  71. package/src/runtime/obsidian/plugin-drift.mjs +76 -0
  72. package/src/runtime/obsidian/plugin-presence.mjs +48 -0
  73. package/src/runtime/obsidian/service-main.mjs +18 -5
  74. package/src/runtime/obsidian/service-record.mjs +36 -0
  75. package/src/runtime/obsidian/service-server.mjs +50 -12
  76. package/src/runtime/obsidian/service.mjs +100 -6
  77. package/src/runtime/obsidian/state-store.mjs +13 -1
  78. package/src/server/local-sidecar.mjs +24 -5
  79. package/src/server/server.mjs +18 -5
  80. package/src/upgrade/upgrade.mjs +1 -1
  81. package/templates/external-project-workspace/pack-lifecycle.json +1 -1
@@ -0,0 +1,585 @@
1
+ # Atelier's Obsidian plugin
2
+
3
+ Atelier ships its own Obsidian plugin inside every vault it manages. Nobody
4
+ finds or installs it: the maintenance service puts it into the vault with the
5
+ notes, and Obsidian asks once per vault whether to trust the vault's plugins.
6
+ Until the person says yes, and whenever the plugin is absent, turned off or
7
+ running in an app that is too old, everything works as it did before through
8
+ the command-line path described in [obsidian.md](obsidian.md).
9
+
10
+ This is phase 1: the plugin is present, reports status and holds a channel to
11
+ the service. It decides nothing and writes nothing. Phase 2, planned at the
12
+ end of this document, moves the in-app part of publication into it.
13
+
14
+ The source is `plugins/obsidian/` in the package: `manifest.json`, `main.js`
15
+ and `styles.css`, plain JavaScript with no build step. The channel it speaks
16
+ is `src/projection/obsidian/plugin-bridge/channel.mjs`; the service's half is
17
+ `src/runtime/obsidian/plugin-channel.mjs`.
18
+
19
+ ## What it does
20
+
21
+ - A status bar item says how current the view is: `Atelier: current`,
22
+ `Atelier: updating`, `Atelier: held (N)` with the number of notes held for
23
+ an edit you made, `Atelier: stale` or `Atelier: service unreachable`. Before
24
+ the first answer it says `Atelier: connecting`; without channel data, or in
25
+ a copy of the vault rather than the vault Atelier maintains, it says
26
+ `Atelier: not set up`; in an app older than 1.13.7 it says
27
+ `Atelier: needs a newer Obsidian`. Between `not set up` and
28
+ `service unreachable` it moves only when two rounds in a row agree, so a
29
+ listener that answers one way and then the other does not make it flip;
30
+ every other change shows at once.
31
+ - The command "Atelier: show status", or a click on the status bar item, opens
32
+ a window with the view, its state and the reason in words, the committed and
33
+ the prepared generation, when freshness was last checked, the held, retained
34
+ and pending edits, the service address and state, and the plugin and app
35
+ versions.
36
+ - A notice appears when the view moves into a state that needs a person (held,
37
+ stale, service unreachable, app too old) and when it is current again.
38
+ `updating` is not announced: it is what every change looks like.
39
+ - While the vault is open the plugin tells the service so: it shakes hands
40
+ once (see "The channel"), then renews a lease every two seconds, and
41
+ releases it when the plugin unloads (the vault closes, the app quits, the
42
+ plugin is turned off). A lease that is not renewed lapses after six seconds.
43
+ The hello carries the plugin version, the app version (`apiVersion`, the
44
+ version of the app the plugin runs in), an id of this launch of the plugin,
45
+ and a proof of the real path of the vault the app has open. Both sides
46
+ take that path as the file system stores it (on a volume that folds
47
+ letter case, in the case it is stored in; on Windows, Node's own
48
+ resolution), so a data root given in another letter case, or an app that
49
+ has the vault in another, still proves the view's vault. Plugin 1.1.1
50
+ does so; 1.1.0 kept the app's spelling.
51
+
52
+ ## What it never does
53
+
54
+ - It writes no file. No note, no setting, and not its own `data.json`: it
55
+ never calls `saveData` and never uses the vault's write operations.
56
+ - It runs no code it receives. The service answers with JSON documents of
57
+ fixed shape and the plugin reads fields out of them.
58
+ - It reaches nothing but the maintenance service named in its `data.json`, on
59
+ the literal address `127.0.0.1` or `::1`, through Node's `http` module, with
60
+ no name to resolve. It sends no telemetry.
61
+ - It never sends its vault's key, and it sends nothing that names the vault or
62
+ the view until whatever listens at that address has proven that it holds
63
+ the key.
64
+ - It holds no state that matters: no manifest, no edit, no policy, no recovery
65
+ bytes. Removing it loses nothing.
66
+
67
+ The test suite holds the plugin to this: it loads `main.js` the way the app
68
+ does into a stand-in app whose every write throws, records every module the
69
+ plugin requires (`obsidian`, `http`, `fs`, `crypto`) and every request it
70
+ makes with its exact body, and fails a copy of the plugin that points a
71
+ request at a host from its data file (see "Evidence" below).
72
+
73
+ ## How it gets into a vault
74
+
75
+ The publisher's settings unit owns these paths of a vault, and nothing else
76
+ under `.obsidian/`:
77
+
78
+ | Path | Owned | How it is written |
79
+ | --- | --- | --- |
80
+ | `.obsidian/core-plugins.json` | the `publish` and `sync` keys, kept `false` | merged with the file on disk; every other key stays |
81
+ | `.obsidian/community-plugins.json` | the `atelier-projection` entry | appended to the list on disk when missing; every other entry stays, in its order |
82
+ | `.obsidian/plugins/atelier-projection/manifest.json`, `main.js`, `styles.css` | the whole file | the shipped bytes |
83
+ | `.obsidian/plugins/atelier-projection/data.json` | the whole file | this vault's channel: the service address, the view and the view's bearer |
84
+
85
+ The plugin files' digests are pinned in the generation manifest, under
86
+ `ext["mnstry.atelier.obsidian"].settings.pluginOwned`, beside the record of
87
+ the owned settings keys and entries. A prepared view that carries a plugin
88
+ file its manifest does not pin, or pins one it does not carry, is refused
89
+ before anything is written.
90
+
91
+ Plugin files go through the same conditional publication as notes, with two
92
+ differences. Each is compared with what the disk holds when the run begins
93
+ rather than with an earlier generation, so whatever occupies a plugin path (a
94
+ hand edit, an earlier release, something else entirely) is exchanged out into
95
+ recovery with a receipt and is never lost. And a plugin path that a person has
96
+ to repair never holds a view back: a symlinked folder, a directory where a
97
+ file should be, or a file or folder nobody may read (`path-unsafe`), a vault
98
+ root that is not private enough for the bearer (`vault-not-private`), or a
99
+ file that cannot be made or replaced there, in a plugin folder the person can
100
+ read but not write, say (`create-failed`, `exchange-failed`), is reported and
101
+ left alone while every note converges. Once it is repaired,
102
+ or when a person changed or removed a plugin file, the file is written again
103
+ at the maintenance service's next tick, with nothing changed at the view's
104
+ sources: the service compares the plugin files each committed generation pins
105
+ with the disk, has a view whose files differ prepared again, and a generation
106
+ that is already committed is published again, as it is, with its notes kept.
107
+ A drift left for the person is asked about once, and again once it changes;
108
+ where the plugin is turned off in the vault, a pinned file that is gone is no
109
+ drift. Publishing a committed generation again needs the app when one runs;
110
+ if that app does not qualify, nothing is written, the view stays `current`
111
+ (a committed generation needs no app, so an app that cannot be qualified
112
+ never makes a current view stale), and the file is written at a later
113
+ attempt, on the retry schedule of an unsettled view, once the app
114
+ qualifies. (The settings files are held to the same rule for a file nobody
115
+ may read.) A plugin file that another program changes while a publication runs
116
+ is a race and is handled like one on a note: that publication does not
117
+ commit, the view is tried again, and the file is planned from the disk anew
118
+ (`plugin-file-changed`); nothing reads a plugin file as an edit of a note.
119
+ Upgrades replace the plugin files the same way. The plugin's version in
120
+ `manifest.json` changes whenever its code does, and the test suite holds the
121
+ two together: the app keeps running the `main.js` it loaded until it reloads
122
+ the plugin, so one version must name one code.
123
+
124
+ `community-plugins.json` is a list the person owns, with Atelier's entry in it
125
+ only while the person wants the plugin in that vault (next section). A file
126
+ that is not a list is the person's to repair; it is reported as
127
+ `settings-invalid` and left alone. Atelier never removes plugin files from a
128
+ vault.
129
+
130
+ ### Turning it off, and on again
131
+
132
+ The person decides whether Atelier's plugin runs in a vault, and Atelier
133
+ follows. The decision is the one Obsidian itself records: the
134
+ `atelier-projection` entry of `community-plugins.json`.
135
+
136
+ - The first publication of a vault offers the entry. Once the app has it, a
137
+ list without it is the person's decision: turning the plugin off in
138
+ Settings, Community plugins, removes the entry, and uninstalling it there
139
+ removes the entry and deletes the plugin's folder. The app has the entry
140
+ when it was in place before an app next opened the vault (published while
141
+ no app held the vault), or once the plugin has run there. An entry
142
+ published while an app holds the vault is only `offered`: that app keeps
143
+ the list it read when it opened the vault and may write it back without the
144
+ entry, which is no decision of the person's, so the next publication offers
145
+ the entry again.
146
+ - From then on the vault's plugin is `off`. Atelier leaves
147
+ `community-plugins.json` exactly as the person left it and never adds the
148
+ entry back. It creates no plugin file that is not there, so a folder the
149
+ person deleted stays deleted, and it keeps the files that are still there
150
+ current, so turning the plugin back on gets the current code. The vault
151
+ works through the command-line path, and `status`, `open` and
152
+ `atelier obsidian plugin show` report the plugin as not present with reason
153
+ `turned-off-in-this-vault` and the way back. They read the vault's list, so
154
+ they say so as soon as the person has turned the plugin off; the decision
155
+ is recorded when the view is next prepared (`plugin show` marks a choice
156
+ not recorded yet as `pending`).
157
+ - There are two ways back. Turning "Atelier" on again in Obsidian's settings
158
+ (possible while its folder is there) lists the entry again; the view's next
159
+ publication records the plugin as on and owns the entry again.
160
+ `atelier obsidian plugin on --scope ID` records a request instead and asks
161
+ the running maintenance service for a tick that names the view: that tick
162
+ prepares and publishes the view again, which adds the entry back and makes
163
+ the plugin's files again (the way back after an uninstall), and the command
164
+ says so when it returns (`takesEffect: "published"`). With
165
+ `--adapter=obsidian-cli`, a service of an earlier release still running
166
+ after an upgrade is replaced by the installed one first, under the consent
167
+ already recorded, as `open` does; without it, such a service is only
168
+ reported (`service-outdated`) with that next step. A service of a later
169
+ release is never replaced; it is reported (`service-other-release`) with
170
+ `open`'s next step for it. Either next step is also the document's
171
+ `next`. With no service
172
+ running, the view's next publication does it: the one the next change at
173
+ its sources causes, or the one when the service next starts.
174
+ Obsidian reads `community-plugins.json` when it opens a vault, so a plugin
175
+ brought back this way runs from the next time the vault is opened; one
176
+ turned on in Obsidian's settings runs at once.
177
+ - The list is read before the view is prepared, and the entry is written only
178
+ over exactly the bytes it was read as. A change the person makes in
179
+ between, turning the plugin off while a publication runs for example, is
180
+ never written over: that settings unit stops (`settings-changed`), the
181
+ publication does not commit, and the view is tried again, when its
182
+ preparation reads the list as the person left it.
183
+ - A list that is missing, or that is not a list, decides nothing. A missing
184
+ list is written again with the entry, unless the vault's plugin is `off`.
185
+ Restricted mode leaves the list alone, and needs nothing from Atelier: no
186
+ community plugin runs.
187
+
188
+ The decision is kept owner-only in the workspace's private state, under
189
+ `state/plugin/choices/<view>.json`, as `requested`, `offered`, `on` or `off`,
190
+ with the reason (`entry-confirmed` or `entry-seen-by-the-app` for `on`,
191
+ `entry-removed-by-person` for `off`), so an entry the app never had is never
192
+ taken for the person's decision. A record that cannot be read counts as
193
+ `off`: nothing is added back on a guess.
194
+
195
+ ### The data file and the privacy of the vault
196
+
197
+ `data.json` names the address the service listens on and the key of this one
198
+ vault, its bearer:
199
+
200
+ ```json
201
+ {
202
+ "schema": "atelier-obsidian-plugin-data/v1",
203
+ "channel": { "host": "127.0.0.1", "port": 43123 },
204
+ "scopeId": "scope-example",
205
+ "bearer": "<43 characters>"
206
+ }
207
+ ```
208
+
209
+ The bearer is 32 random bytes, minted by the service the first time the view
210
+ is prepared and kept owner-only in `state/plugin/<view>.json` under the
211
+ workspace's private state. It is the key of the handshake below and never
212
+ crosses the wire. The data file is written with mode `0600`, and only into a
213
+ vault root that is private to this user (owned by this user, no group or other
214
+ permission bits). Atelier creates the vaults it places under its own data root
215
+ with mode `0700` and makes an existing one private before the first bearer
216
+ goes in. Any other vault root is only checked: when it is not private the data
217
+ file is not published (`vault-not-private`), the rest of the vault is, and the
218
+ plugin says `Atelier: not set up`. A vault path under the data root that is a
219
+ link to a folder somewhere else never receives the data file, private or not
220
+ (`vault-not-private`, reason `vault-root-is-a-link`), and its mode is never
221
+ changed; the view is published into it as ever.
222
+
223
+ Deleting `state/plugin/<view>.json` rotates the bearer. A session made with
224
+ the old one ends at its next request, and a handshake with it is refused; the
225
+ next publication of the view (its next change, or the next start of the
226
+ service) mints a new one and replaces the data file, which the plugin reads
227
+ again without a restart.
228
+
229
+ ## The trust prompt
230
+
231
+ A vault that carries community plugins asks, the first time an app profile
232
+ opens it:
233
+
234
+ > Do you trust the author of this vault?
235
+ >
236
+ > You're opening this vault for the first time, and it comes with some plugins.
237
+
238
+ with two buttons, "Browse vault in Restricted Mode" and "Trust author and
239
+ enable plugins". Obsidian keeps the answer for each vault in the app's own
240
+ storage for that app profile (not in the vault), so it is asked once per vault
241
+ and per profile.
242
+
243
+ - Trust: Obsidian enables the plugins the vault lists, Atelier's among them.
244
+ The status bar item appears and the service sees the vault open.
245
+ - Restricted mode: no community plugin runs. Obsidian asks again the next time
246
+ the vault is opened, until restricted mode is switched on for good in
247
+ Settings, Community plugins. Nothing of Atelier depends on the answer:
248
+ maintenance, `open` and `status` work through the command-line path, and
249
+ `status` reports the plugin as not present.
250
+
251
+ Only the person answers this prompt. Atelier does not answer it, write the
252
+ app's storage, or change the app profile. The isolated real-app test answers
253
+ it in a disposable app profile only; see "Evidence".
254
+
255
+ ## The channel
256
+
257
+ The service's loopback listener (see [local-services.md](local-services.md))
258
+ answers five plugin commands besides its own four operations. Every one is a
259
+ `POST` of a JSON object of at most 1 KiB with exactly the fields of its
260
+ command, and none carries a credential in a header: the plugin proves it holds
261
+ the vault's key without sending it, and the service proves it first
262
+ (protocol `atelier-obsidian-plugin-channel/v2`).
263
+
264
+ 1. The plugin sends a fresh 32-byte nonce, the time it made it, and a hint at
265
+ its key: an HMAC of both under the key. The hint names no vault, and two
266
+ hints of one vault do not look alike. The service computes the hint for
267
+ each vault's key it holds, in constant time for every one of them, and
268
+ answers only for a key it holds, only within thirty seconds of the time the
269
+ challenge names, and each nonce only once: its own nonce, a handshake id,
270
+ and a proof, an HMAC under the key over the view, its own exact address,
271
+ both nonces and the handshake id. At most four handshakes wait for their
272
+ hello per view, so a view's challenges never hold up another view's. A
273
+ challenge recorded by a program that took the port while the service was
274
+ down is therefore worth one answer, and only for thirty seconds, and the
275
+ answer leads nowhere without the key.
276
+ 2. The plugin checks that proof, in constant time, against the address in
277
+ its data file. A listener that cannot make it (a program that took the
278
+ port while the service was down, or one relaying a service that listens
279
+ elsewhere) is told nothing more: the plugin shows the service as
280
+ unreachable (`listener-not-proven`) and tries again at its next round.
281
+ 3. Only then does the plugin say hello: its own proof under the key, over the
282
+ same values and everything the hello carries (plugin version, app version,
283
+ the id of this launch, and a proof of the vault path under the session key,
284
+ so the path itself is never sent). A handshake is used once, whatever the
285
+ hello proves, and lapses after five seconds.
286
+ 4. Both derive a key for this session from the same values. Every later
287
+ command carries the session, a counter that only goes up, and a MAC under
288
+ the session key over the command, the session and the counter; every
289
+ answer the service gives is the exact text of its document with a MAC over
290
+ it, the command and the request's counter. The plugin believes no answer
291
+ that does not verify. Any answer to a command that is not sealed (an
292
+ error, which nobody vouches for, a timeout, or an answer that does not
293
+ verify) ends the session in the plugin, which shakes hands again: at once
294
+ when a renewal is refused as an unknown session or as not authenticated,
295
+ at its next round otherwise. A listener that cannot seal therefore
296
+ receives one command of a session at most, besides its release. A session
297
+ ends when its lease lapses, when it is released, after fifteen minutes (the
298
+ plugin shakes hands again), or at its next request after its vault's key
299
+ was rotated.
300
+
301
+ | Command | Fields | Answer |
302
+ | --- | --- | --- |
303
+ | `POST /plugin/challenge` | `protocol`, `keyHint`, `clientNonce`, `issuedAt` | `protocol`, `handshakeId`, `serverNonce`, `serverProof` |
304
+ | `POST /plugin/hello` | `handshakeId`, `pluginVersion`, `appVersion`, `instanceId`, `vaultProof`, `clientProof` | sealed: a session identity, the lease time and the renewal interval |
305
+ | `POST /plugin/lease` | `sessionId`, `counter`, `mac` | sealed: the renewed lease |
306
+ | `POST /plugin/release` | `sessionId`, `counter`, `mac` | sealed: whether the session was released |
307
+ | `POST /plugin/status` | `sessionId`, `counter`, `mac` | sealed: the view's state, reason, verification, committed and prepared generation, held notes (counted, not named), retained edits, open pending edits, and the service's own state |
308
+
309
+ Refusals, before anything else is looked at: `Host` other than the listener's
310
+ literal loopback authority, a cross-site `Origin` or `Sec-Fetch-Site`, a path
311
+ other than one of the nine exactly (a query included), or another method (the
312
+ listener's rules for every request). Then:
313
+
314
+ | Case | Status | Code |
315
+ | --- | --- | --- |
316
+ | a credential in the `Authorization` header, the service's runtime bearer included | 400 | `plugin-command-takes-no-bearer` |
317
+ | a body over 1 KiB | 413 | `payload-too-large` |
318
+ | a body that is not a JSON object | 400 | `payload-not-json-object` |
319
+ | a field too many or missing, or of the wrong shape (a challenge that names the view, say) | 400 | `request-malformed` |
320
+ | a challenge in another protocol | 409 | `protocol-unsupported` |
321
+ | a challenge whose hint matches no key the service holds | 401 | `plugin-key-unknown` |
322
+ | a challenge whose time is more than thirty seconds from the service's clock | 401 | `challenge-stale` |
323
+ | a challenge whose nonce the service has already answered for that view | 401 | `challenge-replayed` |
324
+ | four handshakes of that view already waiting for their hello | 429 | `too-many-handshakes` |
325
+ | a hello for a handshake that is unknown, used, lapsed, or was made at another address | 401 | `handshake-unknown` |
326
+ | a hello whose proof does not verify under the key | 401 | `plugin-not-authenticated` |
327
+ | a hello that proves a vault path other than this view's | 409 | `wrong-vault` |
328
+ | a ninth live session for one view | 429 | `too-many-sessions` |
329
+ | a command for a session the service does not know, or whose key was rotated | 409 | `session-unknown` |
330
+ | a command whose MAC does not verify | 401 | `request-not-authenticated` |
331
+ | a command with a counter already seen | 401 | `request-replayed` |
332
+
333
+ The service keeps handshakes and sessions in memory. A service that started
334
+ again knows neither; the plugin's next lease is refused with
335
+ `session-unknown`, and it shakes hands again.
336
+
337
+ What the person sees of a refused challenge: a 401 is a key the service does
338
+ not know (`Atelier: not set up`, reason "key not known to the service"; the
339
+ data file is older than a rotated key, say), unless the answer says the
340
+ challenge was `challenge-stale` or `challenge-replayed`. The key is not in
341
+ question then: the plugin shows the service as unreachable with that reason,
342
+ and its next round tries again with a fresh challenge. None of these answers
343
+ is authenticated, so they only ever decide what is shown.
344
+
345
+ ## Security argument
346
+
347
+ - Reach. The listener binds a literal loopback address and refuses any `Host`
348
+ but its own authority, so nothing off the machine and no name that resolves
349
+ to loopback reaches it. A web page, including a note rendered in the app,
350
+ sends an `Origin` or `Sec-Fetch-Site` that is refused, and it cannot compute
351
+ a proof. The plugin's own requests come from Node's `http` module and carry
352
+ neither header.
353
+ - Authenticity, both ways. The service answers a challenge only for a key it
354
+ holds and proves it over its own exact address; the plugin proves the same
355
+ key over the same values; every later request and answer is sealed with the
356
+ session's key. Comparisons are constant time. A proof or a MAC cannot stand
357
+ in for another (each has its own label), a challenge is answered once and
358
+ only while fresh, a handshake is used once, and a counter is accepted once,
359
+ so nothing recorded can be replayed for any use.
360
+ - Secrecy. The key lives in two owner-only places, the private state of the
361
+ workspace and the vault's `data.json` (`0600`, in a vault root that is
362
+ `0700`), and never crosses the wire. Another user of the machine cannot read
363
+ it, and a program of any user that takes the service's port while it is
364
+ down learns a nonce, a hint it cannot link to a vault, and of a session
365
+ that ended with the service one sealed renewal or status request at most
366
+ (and its release, when the plugin unloads meanwhile): no key, no view, no
367
+ path, no session and no app version it can use (the squatter tests in
368
+ "Evidence"). Any process that runs as this user can read the data file,
369
+ including other community plugins in the same app: the key separates vaults
370
+ and processes of one person, it does not authenticate a person. That is the
371
+ same boundary the service's runtime bearer has. A vault copied or
372
+ synchronized to another machine carries its key there, where it reaches
373
+ nothing: the service answers on this machine's loopback address only.
374
+ - Blast radius. Whoever holds a vault's key can make that view look open, or
375
+ closed, take its eight sessions for as long as it keeps renewing them, and
376
+ claim an app version for it. A claimed version only admits the command-line
377
+ path for that view: publication still needs the app to answer for the vault
378
+ through its own channel, and the in-app step still refuses on an app that
379
+ lacks the saved-content field it relies on (`unsupported-app`). Nothing a
380
+ key holder sends writes a file, starts a publication or changes an edit, and
381
+ the key reaches nothing of the service itself: not its status, tick or stop.
382
+ - Cost. A request without a key costs the service one look at the bearer
383
+ directory (the bearers are kept in memory and read again only when that
384
+ directory changed) and one HMAC per vault; at most four handshakes wait per
385
+ view, and recorded challenges can keep them filled for at most about
386
+ thirty seconds after a restart, since a challenge is answered only within
387
+ thirty seconds of the time it names.
388
+ - Egress. `plugins/` is in the egress scan, which fails a request whose target
389
+ is not a literal loopback address, and the release audit requires the three
390
+ plugin files in the package and scans them in the tarball.
391
+
392
+ ## When the plugin is not there
393
+
394
+ If the plugin is not loaded (the person chose restricted mode or has not
395
+ answered the prompt yet, the app is older than 1.13.7, or the person turned it
396
+ off in the vault), nothing changes: the service coordinates through the
397
+ command-line path exactly as before, `open` asks the command-line tool for the
398
+ app version, and `status` reports the plugin as not present with the reason
399
+ (`no-live-lease`, `turned-off-in-this-vault` or `service-not-running`).
400
+
401
+ ## The app version a plugin reports
402
+
403
+ The plugin runs inside the app whose version it reports, so its version is
404
+ the app's. The command-line tool, though, reaches the app a publication
405
+ coordinates with, which may be another app holding the same vault, so its own
406
+ answer decides wherever it gives one:
407
+
408
+ - The service's adapter factory asks the probe as it would without a plugin,
409
+ through the same remembered answer. Where the tool answers with a version,
410
+ that version qualifies or refuses the app, whatever the plugin reports.
411
+ Where it gives none (no vault open yet, as it says while a vault is still
412
+ loading, or no answer in time), the version the plugin reports stands in,
413
+ reason `plugin-reported`, while one launch of the plugin alone holds the
414
+ view. Whether the app is installed where Atelier looks and has its
415
+ command-line tool is the probe's answer either way. The adapter still
416
+ coordinates through the command-line tool, which must answer for exactly
417
+ this vault before anything is published.
418
+ - `open` does the same: the plugin's version stands in only where the tool
419
+ gives none. It asks the service about the plugin at most once a second
420
+ while it waits for the app. A version only the plugin reported is not the
421
+ command line answering, so `open` then asks the command line nothing to
422
+ find the vault: it reads the app's settings file, as with no vault open,
423
+ and opens by path the vault the app lists. Where the settings file does
424
+ not show it (a sandboxed build, say), or where it lists it and, once the
425
+ vault is opened, the command line still does not answer for it while only
426
+ the plugin reports the version, `open` answers at once
427
+ `app-cli-unavailable` / `vault-open-cli-silent`: the plugin shows the vault
428
+ open, so the command line is what did not answer, and waiting for the app
429
+ would change nothing.
430
+ - Whether an app runs is the process table's answer, which says no only when
431
+ it finds no Obsidian process at all. A lease outlives its app by up to the
432
+ lease time (six seconds after a crash), and any process that holds the
433
+ vault's key can hold one, so while the process table shows no app, a
434
+ plugin's report counts for nothing, in the adapter factory and in `open`.
435
+ The probe's own answer stands (`app-not-running-version-not-needed`): the
436
+ publication goes to the files, and an adapter qualified on that answer
437
+ never coordinates with an app found running later (`app-version-unchecked`),
438
+ so an app started since, perhaps updated as it restarted, is asked for its
439
+ own version at the next qualification.
440
+ - With two launches holding one view (two app profiles with the same vault
441
+ open, both running the plugin), neither plugin's version decides.
442
+ - An app below the floor refuses as before (`app-version-unsupported`,
443
+ `below-minimum-version`), whoever reported the version.
444
+
445
+ What remains: where the tool gives no version but still coordinates (its
446
+ `version` call timed out, say), and a second app holds the same vault without
447
+ the plugin, the plugin's version may belong to the other app. The in-app step
448
+ still refuses an app without the saved-content field it relies on, for a
449
+ note open in the editor.
450
+
451
+ `atelier obsidian status` and `open` report the plugin for each view:
452
+ `{ present, reason, appVersion, pluginVersion, sessions }`, and a line such as
453
+ `plugin present (Obsidian 1.13.7)`.
454
+
455
+ ## Evidence
456
+
457
+ - `test/obsidian-plugin.test.mjs` runs the shipped `main.js` in a stand-in app
458
+ against a real listener on an ephemeral loopback port: parity of the
459
+ plugin's constants and of every proof and MAC with the channel contract (a
460
+ plugin that computes any one of them otherwise gets nowhere); the status
461
+ bar, the status window and the notices through every state; lease renewal,
462
+ release and lapse; a service restart; a republished data file; a rotated
463
+ key; missing or unusable channel data; the app floor; a program squatting
464
+ the service's address, which learns no key, no view and no path and, with
465
+ everything it received replayed against the restarted service, opens no
466
+ session and sets no app version; an answer relayed from a service at
467
+ another address; answers that are not sealed, and a listener that answers
468
+ every command with an error, which gets one renewal of a session and
469
+ nothing more of it; answers that alternate between the two failures, which
470
+ do not make the status bar flip; an unload while a hello is
471
+ under way; the plugin's own timer (a renewal every two seconds, rounds that
472
+ never overlap, nothing after an unload); the refusal table above with
473
+ mutation controls that must fail
474
+ it; bearer minting, rotation and the bearer cache; publication of the
475
+ plugin files, entries and data file, with a person's settings kept, the
476
+ person's choice followed (an entry the app had and the person removed is
477
+ not added back; one published while an app held the vault is only offered
478
+ until the plugin runs there; `plugin on` publishing the view at once while
479
+ the service runs),
480
+ displaced bytes kept in recovery, the privacy rule of the vault root, a
481
+ linked vault root, unreadable files, a drifted plugin file written again
482
+ (at the service's next tick, with nothing changed at the sources, and a
483
+ drift left for the person asked about once),
484
+ and an upgrade; the service publishing the plugin and the plugin it
485
+ published holding the view; qualification from a plugin report, with one
486
+ launch or two; and `status` and `open` reporting it. A spawn guard refuses
487
+ any child of the suite that could reach an Obsidian with the developer's
488
+ own `HOME`.
489
+ - The same file has three real-app tests, skipped unless
490
+ `ATELIER_OBSIDIAN_PLUGIN=1` is set on a desktop host with Obsidian
491
+ installed. Each publishes a synthetic vault through the maintenance service
492
+ and opens it in a disposable Obsidian instance (private `HOME`, private
493
+ profile, mock keychain, a copy of the pinned app archive when
494
+ `ATELIER_OBSIDIAN_ASAR` names one). The first checks that the trust prompt
495
+ is shown, answers it the way a person does, by pressing "Trust author and
496
+ enable plugins" in that window through the command-line tool's `eval`, and
497
+ asserts that the service sees the lease with the app's version, that the
498
+ status bar item says `Atelier: current`, that a publication made while the
499
+ plugin holds the view qualifies the app from the command-line tool's own
500
+ version (reason `meets-minimum-version`), that
501
+ a rotated key reaches the running plugin, and that quitting the app ends the
502
+ presence. The second declines the prompt and publishes through the
503
+ command-line path as before. The third uninstalls the plugin in the app and
504
+ follows it until `atelier obsidian plugin on` brings it back at once (the
505
+ running service publishes the view on a tick that names it), offered while
506
+ that app holds the vault and confirmed when the plugin runs after a
507
+ restart. They never touch another app profile, and they end each disposable
508
+ instance by its profile path.
509
+
510
+ The disposable window takes focus when it opens, so input meant for another
511
+ window can answer its prompt before the test looks. The first test then
512
+ fails and says so; the third, whose subject comes after trust, accepts a
513
+ vault trusted that way. The desktop procedures
514
+ (`scripts/obsidian/desktop-receipts.mjs`) decline the prompt as the second
515
+ test does. When the command-line tool loses the reply to that press, a
516
+ prompt that is gone proves nothing by itself: the app's own restricted mode
517
+ says how it was answered, and a procedure refuses to run with community
518
+ plugins on (`community-plugins-on`), ending the instance it started.
519
+
520
+ Pressing the button in the prompt was chosen over the alternatives because
521
+ it exercises the prompt a person sees and needs no knowledge of the app's
522
+ storage. Writing the profile's local storage would mean editing a LevelDB
523
+ database behind the app's back, and calling `app.plugins.setEnable(true)`
524
+ would skip the prompt altogether.
525
+
526
+ ## Phase 2 plan
527
+
528
+ Phase 2 moves the in-app critical section of publication into the plugin, and
529
+ closes gates G20 and G21 of the initiative. Nothing below is implemented.
530
+
531
+ 1. Commands. The channel gains the publication protocol's three operations,
532
+ `inspect`, `publish` (`replace` and `remove`) and `collect`, with the
533
+ payloads the command-line path validates today (`validatePayload`), and a
534
+ protocol variant, `obsidian-plugin-critical-section/v1`, recorded in every
535
+ journal it writes. The service stays the only client that starts work; the
536
+ plugin fetches work with a bounded long poll (`POST /plugin/work`) and
537
+ answers with `POST /plugin/result`, both sealed under the session's key
538
+ like every command of phase 1, so the plugin never listens on a port and
539
+ takes work only from a service that proved it holds the vault's key.
540
+ At most one operation per vault is in flight, every operation has an
541
+ identity, a lost answer is recovered with `collect`, and a publish is never
542
+ sent twice.
543
+ 2. The critical section. The plugin runs the same synchronous body the
544
+ command-line path sends through `eval` (`criticalSection` in
545
+ `bridge-script.mjs`), shipped in `main.js` rather than sent: refuse on a
546
+ dirty or differing editor in any window, refuse on changed bytes on disk,
547
+ exchange atomically, update open editors in one transaction of minimal
548
+ hunks and record `lastSavedData`, record the outcome, reply at once. The
549
+ late-writer re-check and the manifest commit stay in the publisher.
550
+ 3. Path selection. With a live lease from a plugin whose protocol matches, the
551
+ publisher uses the plugin channel; otherwise the command-line path, and
552
+ with no app running, the app-free path, exactly as today. A refusal is
553
+ never relaxed by switching paths.
554
+ 4. Live edit preservation. The plugin forwards the vault's `modify` events and
555
+ the editor's dirty state for Atelier's notes to the service as they happen,
556
+ so an edit is preserved in recovery before any maintenance tick looks for
557
+ it. The service still decides what an edit is.
558
+ 5. G20. The complete G00 interleaving suite (sixteen interleavings, racing
559
+ cases repeated) and the G04 production-publisher suite run in plugin mode
560
+ on a real isolated app, unchanged, with zero lost bytes. A case that passes
561
+ on the command-line path and fails in plugin mode fails the gate; the suite
562
+ is not narrowed.
563
+ 6. G21. Apply and Discard in the status window for held edits, forwarded to
564
+ the service's existing apply operation, which decides; then the procedure
565
+ AP-08: enable, disable, uninstall and an app below the floor on one vault
566
+ with pending edits and recovery bytes, comparing manifests, path
567
+ allocations, pending edits and recovery digests with the command-line path
568
+ after each step.
569
+
570
+ Open questions for phase 2: when, if ever, plugin files are removed from a
571
+ vault; and how an upgraded service answers an older `main.js` that the app
572
+ keeps running until it reloads the plugin (the hello carries the plugin
573
+ version and the protocol for that).
574
+
575
+ ### Deviations from the track plan
576
+
577
+ The obsidian-plugin track planned an optional enhanced mode. The owner's
578
+ decision of 2026-09-24 made the plugin the primary in-app half, shipped in
579
+ every managed vault, with the command-line path as the fallback. Phase 1 here
580
+ therefore builds the plugin, its delivery and a presence and status channel
581
+ before the publication commands the plan's first phase describes; the parity
582
+ held today is between the plugin's constants and the channel contract, and the
583
+ refusal parity with the command-line path belongs to phase 2, when the plugin
584
+ carries publication commands. Fixtures are generated in temporary directories
585
+ rather than kept under `fixtures/obsidian/plugin/`.