@mnstry/atelier 0.2.0-alpha.10 → 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 +385 -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 +681 -39
  9. package/docs/obsidian-plugin.md +585 -0
  10. package/docs/obsidian.md +292 -48
  11. package/docs/release-engineering.md +7 -1
  12. package/docs/upgrade.md +32 -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 +124 -17
  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 +115 -14
  60. package/src/runtime/obsidian/app-production-seams.mjs +126 -37
  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 +240 -51
  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 +19 -4
  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,174 @@
1
+ import { createHmac } from 'node:crypto'
2
+
3
+ // The channel between Atelier's own plugin, inside a vault Atelier manages,
4
+ // and the maintenance service of the workspace that manages that vault.
5
+ //
6
+ // A closed command surface over the service's literal loopback listener:
7
+ //
8
+ // POST /plugin/challenge a fresh nonce and the time it was made, and a hint
9
+ // at which vault's key it means that only the holder
10
+ // of that key can read; the service answers, once
11
+ // and only while it is fresh, with its own nonce and
12
+ // a proof that it holds the key
13
+ // POST /plugin/hello only after the plugin checked that proof: its own
14
+ // proof, the plugin and app versions, an id of this
15
+ // launch of the plugin, and a proof of the vault path
16
+ // the app has open (the path itself is never sent)
17
+ // POST /plugin/lease "this vault is open in an app", renewed every two
18
+ // seconds; it lapses on its own six seconds later
19
+ // POST /plugin/release the vault closed, or the plugin unloaded
20
+ // POST /plugin/status freshness, reason and held edits of this view
21
+ //
22
+ // The key is the vault's bearer, which Atelier writes into the plugin's data
23
+ // file of that vault only and keeps in private state. It never crosses the
24
+ // wire, and neither does anything that names the view or the vault before the
25
+ // listener proved it holds the key: a program squatting the service's port
26
+ // while the service is down learns a nonce and a hint it cannot link to a
27
+ // vault, and cannot answer. Both proofs bind the listener's exact address, so
28
+ // an answer relayed from a service listening elsewhere does not verify.
29
+ //
30
+ // A challenge recorded by such a program and sent to the service later is
31
+ // worth little: the service answers a challenge only within thirty seconds of
32
+ // the time it names, and a nonce only once, and at most four handshakes wait
33
+ // for their hello per view, so one view's challenges never hold up another's.
34
+ //
35
+ // The handshake derives a key for one session. Every later request carries
36
+ // the session, a counter that only goes up, and a MAC over both and the
37
+ // command under that key; every answer carries a MAC over the request's counter
38
+ // and the exact bytes of the answer. A handshake is used once and lapses after
39
+ // five seconds, a session after fifteen minutes. Nothing here takes a path to
40
+ // act on, a command or code, and every command maps to state the service
41
+ // already keeps (see docs/obsidian-plugin.md).
42
+ //
43
+ // The plugin cannot import this module: it runs inside the app, from the
44
+ // vault. It carries the same constants and the same computations, and the test
45
+ // suite holds the two to each other: a plugin that computes any one of them
46
+ // otherwise cannot complete a handshake.
47
+
48
+ export const PLUGIN_ID = 'atelier-projection'
49
+ export const PLUGIN_DIRECTORY = `.obsidian/plugins/${PLUGIN_ID}`
50
+ // Shipped with the package under plugins/obsidian/ and copied into every vault unchanged.
51
+ export const PLUGIN_SOURCE_FILES = Object.freeze(['manifest.json', 'main.js', 'styles.css'])
52
+ // Written per vault: the channel address and that vault's bearer.
53
+ export const PLUGIN_DATA_FILE = 'data.json'
54
+ export const PLUGIN_DATA_PATH = `${PLUGIN_DIRECTORY}/${PLUGIN_DATA_FILE}`
55
+ // The data file holds the vault's key: only the person who runs the vault reads it. The app reads the others.
56
+ export const PLUGIN_DATA_MODE = 0o600
57
+ export const PLUGIN_SOURCE_MODE = 0o644
58
+
59
+ export const PLUGIN_CHANNEL_PROTOCOL = 'atelier-obsidian-plugin-channel/v2'
60
+ export const PLUGIN_DATA_SCHEMA = 'atelier-obsidian-plugin-data/v1'
61
+ export const PLUGIN_STATUS_SCHEMA = 'atelier-obsidian-plugin-status/v1'
62
+
63
+ export const PLUGIN_COMMANDS = Object.freeze(['challenge', 'hello', 'lease', 'release', 'status'])
64
+ export const PLUGIN_ROUTES = Object.freeze(Object.fromEntries(PLUGIN_COMMANDS.map((command) => [command, `/plugin/${command}`])))
65
+ export const PLUGIN_REQUEST_FIELDS = Object.freeze({
66
+ challenge: Object.freeze(['protocol', 'keyHint', 'clientNonce', 'issuedAt']),
67
+ hello: Object.freeze(['handshakeId', 'pluginVersion', 'appVersion', 'instanceId', 'vaultProof', 'clientProof']),
68
+ lease: Object.freeze(['sessionId', 'counter', 'mac']),
69
+ release: Object.freeze(['sessionId', 'counter', 'mac']),
70
+ status: Object.freeze(['sessionId', 'counter', 'mac']),
71
+ })
72
+
73
+ export const PLUGIN_MAX_REQUEST_BYTES = 1024
74
+ export const PLUGIN_RENEW_INTERVAL_MS = 2000
75
+ // Three renewals may be missed before a vault stops counting as open.
76
+ export const PLUGIN_LEASE_TTL_MS = 3 * PLUGIN_RENEW_INTERVAL_MS
77
+ export const PLUGIN_MAX_SESSIONS_PER_SCOPE = 8
78
+ export const PLUGIN_HANDSHAKE_TTL_MS = 5000
79
+ // A challenge is answered only this close to the time it names (both sides read one machine's clock).
80
+ export const PLUGIN_CHALLENGE_WINDOW_MS = 30 * 1000
81
+ export const PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE = 4
82
+ // A session key is short-lived: the plugin shakes hands again after this long.
83
+ export const PLUGIN_MAX_SESSION_AGE_MS = 15 * 60 * 1000
84
+ // The app version the plugin needs, the floor of the publication protocol (app-capability.mjs).
85
+ export const PLUGIN_MINIMUM_APP_VERSION = '1.13.7'
86
+
87
+ export const PLUGIN_BEARER = /^[A-Za-z0-9_-]{43}$/
88
+ export const PLUGIN_NONCE = /^[0-9a-f]{64}$/
89
+ // A key hint, a proof and a MAC are each one HMAC-SHA256, in hex.
90
+ export const PLUGIN_MAC = /^[0-9a-f]{64}$/
91
+ export const PLUGIN_HANDSHAKE_ID = /^ph-[0-9a-f]{32}$/
92
+ export const PLUGIN_SESSION_ID = /^ps-[0-9a-f]{32}$/
93
+ export const PLUGIN_INSTANCE_ID = /^pi-[0-9a-f]{32}$/
94
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
95
+ const VERSION_TEXT = /^[0-9A-Za-z.+-]{1,40}$/
96
+ const LOOPBACK_HOSTS = Object.freeze(['127.0.0.1', '::1'])
97
+
98
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
99
+
100
+ // The route of a request path, or null. Exact paths only: no query, no prefix.
101
+ export function pluginCommandOf(requestPath) {
102
+ return PLUGIN_COMMANDS.find((command) => PLUGIN_ROUTES[command] === requestPath) ?? null
103
+ }
104
+
105
+ // { ok: true, body } or { ok: false, code }. The body has exactly the fields
106
+ // of its command, each of its own shape; anything else refuses.
107
+ export function validatePluginRequest(command, body) {
108
+ const fields = PLUGIN_REQUEST_FIELDS[command]
109
+ if (fields === undefined) return { ok: false, code: 'unknown-command' }
110
+ if (!isPlainObject(body)) return { ok: false, code: 'request-malformed' }
111
+ const keys = Object.keys(body)
112
+ if (keys.length !== fields.length || !fields.every((field) => Object.hasOwn(body, field))) return { ok: false, code: 'request-malformed' }
113
+ const shaped = (field, pattern) => typeof body[field] === 'string' && pattern.test(body[field])
114
+ if (command === 'challenge') {
115
+ if (body.protocol !== PLUGIN_CHANNEL_PROTOCOL) return { ok: false, code: 'protocol-unsupported' }
116
+ if (!shaped('keyHint', PLUGIN_MAC) || !shaped('clientNonce', PLUGIN_NONCE) || !Number.isSafeInteger(body.issuedAt) || body.issuedAt < 1) return { ok: false, code: 'request-malformed' }
117
+ } else if (command === 'hello') {
118
+ if (!shaped('handshakeId', PLUGIN_HANDSHAKE_ID) || !shaped('instanceId', PLUGIN_INSTANCE_ID) || !shaped('vaultProof', PLUGIN_MAC) || !shaped('clientProof', PLUGIN_MAC)) return { ok: false, code: 'request-malformed' }
119
+ if (!shaped('pluginVersion', VERSION_TEXT) || !shaped('appVersion', VERSION_TEXT)) return { ok: false, code: 'request-malformed' }
120
+ } else if (!shaped('sessionId', PLUGIN_SESSION_ID) || !shaped('mac', PLUGIN_MAC) || !Number.isSafeInteger(body.counter) || body.counter < 1) {
121
+ return { ok: false, code: 'request-malformed' }
122
+ }
123
+ return { ok: true, body }
124
+ }
125
+
126
+ // The computations of the handshake and the session, shared with the plugin.
127
+ // Every input but a vault path is one of the shapes above, without a line
128
+ // break, and a vault path is the only input of its computation, so joining
129
+ // them by lines is unambiguous. `key` is the vault's bearer text, or a
130
+ // session key.
131
+ const transcript = (label, fields) => [PLUGIN_CHANNEL_PROTOCOL, label, ...fields.map(String)].join('\n')
132
+ const hmac = (key, label, fields) => createHmac('sha256', key).update(transcript(label, fields), 'utf8').digest()
133
+ const hmacHex = (key, label, fields) => hmac(key, label, fields).toString('hex')
134
+
135
+ // A hint at the vault's key, fresh with every nonce and bound to the time the
136
+ // challenge names: the service finds the key it names by computing it for each
137
+ // key it holds, and a listener that holds none cannot tell two hints of one
138
+ // vault apart, nor move one to another time.
139
+ export const pluginKeyHint = ({ bearer, clientNonce, issuedAt }) => hmacHex(bearer, 'key-hint', [clientNonce, issuedAt])
140
+ // What binds a handshake: the view, the listener's exact address, both nonces and the handshake.
141
+ const handshakeFields = ({ scopeId, authority, clientNonce, serverNonce, handshakeId }) => [scopeId, authority, clientNonce, serverNonce, handshakeId]
142
+ export const pluginServerProof = (input) => hmacHex(input.bearer, 'server-proof', handshakeFields(input))
143
+ export const pluginSessionKey = (input) => hmac(input.bearer, 'session-key', handshakeFields(input))
144
+ // The vault the app has open, proven under the session key rather than named.
145
+ export const pluginVaultProof = ({ sessionKey, vaultPath }) => hmacHex(sessionKey, 'vault', [vaultPath])
146
+ export const pluginClientProof = (input) => hmacHex(input.bearer, 'client-proof', [...handshakeFields(input), input.pluginVersion, input.appVersion, input.instanceId, input.vaultProof])
147
+ export const pluginRequestMac = ({ sessionKey, command, sessionId, counter }) => hmacHex(sessionKey, 'request', [command, sessionId, counter])
148
+ // An answer is the exact text of its JSON document and a MAC over it, the command and the request's counter.
149
+ export const pluginResponseMac = ({ sessionKey, command, sessionId, counter, payload }) => hmacHex(sessionKey, 'response', [command, sessionId, counter, payload])
150
+
151
+ // The plugin's data file of one vault: where the service listens and the
152
+ // bearer of this vault. Canonical bytes, so an unchanged channel republishes
153
+ // nothing.
154
+ export function pluginDataDocument({ host, port, scopeId, bearer }) {
155
+ const document = { schema: PLUGIN_DATA_SCHEMA, channel: { host, port }, scopeId, bearer }
156
+ if (validatePluginData(document) === null) throw new TypeError('the plugin data needs a literal loopback address, a port, a view and a bearer')
157
+ return document
158
+ }
159
+
160
+ export function pluginDataBytes(input) {
161
+ const { schema, channel, scopeId, bearer } = pluginDataDocument(input)
162
+ return Buffer.from(`${JSON.stringify({ schema, channel: { host: channel.host, port: channel.port }, scopeId, bearer }, null, 2)}\n`, 'utf8')
163
+ }
164
+
165
+ // The document when it is exactly a plugin data document, else null.
166
+ export function validatePluginData(document) {
167
+ if (!isPlainObject(document) || Object.keys(document).sort().join(',') !== 'bearer,channel,schema,scopeId') return null
168
+ if (document.schema !== PLUGIN_DATA_SCHEMA || typeof document.scopeId !== 'string' || !IDENTIFIER.test(document.scopeId)) return null
169
+ if (typeof document.bearer !== 'string' || !PLUGIN_BEARER.test(document.bearer)) return null
170
+ const { channel } = document
171
+ if (!isPlainObject(channel) || Object.keys(channel).sort().join(',') !== 'host,port') return null
172
+ if (!LOOPBACK_HOSTS.includes(channel.host) || !Number.isInteger(channel.port) || channel.port < 1024 || channel.port > 65535) return null
173
+ return document
174
+ }
@@ -0,0 +1,10 @@
1
+ // Atelier's own Obsidian plugin: the channel it speaks and the files that put
2
+ // it into a vault. The plugin source ships under plugins/obsidian/.
3
+ export {
4
+ PLUGIN_BEARER, PLUGIN_CHALLENGE_WINDOW_MS, PLUGIN_CHANNEL_PROTOCOL, PLUGIN_COMMANDS, PLUGIN_DATA_FILE, PLUGIN_DATA_PATH, PLUGIN_DATA_SCHEMA, PLUGIN_DIRECTORY, PLUGIN_HANDSHAKE_ID, PLUGIN_HANDSHAKE_TTL_MS,
5
+ PLUGIN_ID, PLUGIN_INSTANCE_ID, PLUGIN_LEASE_TTL_MS, PLUGIN_MAC, PLUGIN_MAX_PENDING_HANDSHAKES_PER_SCOPE, PLUGIN_MAX_REQUEST_BYTES, PLUGIN_MAX_SESSION_AGE_MS, PLUGIN_MAX_SESSIONS_PER_SCOPE,
6
+ PLUGIN_MINIMUM_APP_VERSION, PLUGIN_NONCE, PLUGIN_RENEW_INTERVAL_MS, PLUGIN_REQUEST_FIELDS, PLUGIN_ROUTES, PLUGIN_SESSION_ID, PLUGIN_SOURCE_FILES,
7
+ PLUGIN_STATUS_SCHEMA, pluginClientProof, pluginCommandOf, pluginDataBytes, pluginDataDocument, pluginKeyHint, pluginRequestMac, pluginResponseMac, pluginServerProof, pluginSessionKey,
8
+ pluginVaultProof, validatePluginData, validatePluginRequest,
9
+ } from './channel.mjs'
10
+ export { PLUGIN_DATA_MODE, PLUGIN_SOURCE_MODE, PLUGIN_SOURCE_ROOT, preparePluginFiles, readPluginSource } from './bundle.mjs'
@@ -5,7 +5,7 @@ import { compareText, isoTime } from '../../../runtime/obsidian/documents.mjs'
5
5
  import { readObsidianEnablement } from '../../../runtime/obsidian/enablement.mjs'
6
6
  import { ObsidianMaintenanceRefusal } from '../../../runtime/obsidian/errors.mjs'
7
7
  import { OPEN_EDIT_STATES, createMaintenanceStateStore } from '../../../runtime/obsidian/state-store.mjs'
8
- import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal } from '../contracts.mjs'
8
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, manifestLayoutVersion } from '../contracts.mjs'
9
9
  import { SOURCE_APPLY_PRIMITIVES, SourceApplyRefusal, locateSource } from '../edits/apply.mjs'
10
10
  import { EditArbitrationRefusal } from '../edits/arbitrate.mjs'
11
11
  import { createEditObserverForOracleTests, editIdempotencyKey, observeEdit } from '../edits/observe.mjs'
@@ -131,17 +131,22 @@ export function createTickObservation(options = {}) {
131
131
  const kept = retained(store, noteEntry.noteDigest)
132
132
  if (kept) return kept
133
133
  workspace.prepared ??= new Map()
134
- if (!workspace.prepared.has(scope.scopeId)) {
134
+ // Prepared in the vault layout of the edit's generation, once per generation.
135
+ const preparedKey = `${scope.scopeId}\u0000${manifest.generationId}`
136
+ if (!workspace.prepared.has(preparedKey)) {
135
137
  let files = []
136
138
  try {
137
139
  const { graph, profile } = workspace.corpus()
138
140
  const snapshot = seams.captureSnapshot({ project: workspace.project, graph, workspaceId: workspace.workspaceId, index: new Map(), configDigest: manifest.ext?.[EXT]?.configDigest ?? `sha256:${'0'.repeat(64)}`, capturedAt: now })
139
141
  const persistentPathRegistry = createMaintenanceStateStore({ workspaceRoot: workspace.workspaceRoot, workspaceId: workspace.workspaceId }).readPathRegistry()
140
- files = seams.prepareView({ snapshot, profile, scope, persistentPathRegistry, priorManifest: manifest, existingSettings: null, clock: workspace.clock, vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8') }).files
142
+ files = seams.prepareView({
143
+ snapshot, profile, scope, persistentPathRegistry, priorManifest: manifest, existingSettings: null, clock: workspace.clock,
144
+ vaultRootBytes: Buffer.byteLength(store.vaultRoot, 'utf8'), layout: manifestLayoutVersion(manifest),
145
+ }).files
141
146
  } catch (error) { if (!isTyped(error) && !GONE.has(error?.code)) throw error }
142
- workspace.prepared.set(scope.scopeId, files)
147
+ workspace.prepared.set(preparedKey, files)
143
148
  }
144
- const file = workspace.prepared.get(scope.scopeId).find((item) => item.path === noteEntry.path && item.digest === noteEntry.noteDigest)
149
+ const file = workspace.prepared.get(preparedKey).find((item) => item.path === noteEntry.path && item.digest === noteEntry.noteDigest)
145
150
  return file ? file.bytes : null
146
151
  }
147
152
 
@@ -1,4 +1,5 @@
1
1
  import { createRequire } from 'node:module'
2
+ import { PLUGIN_OWNED_PATHS, POLICY_SETTINGS_PATHS } from '../materialize/settings.mjs'
2
3
  import { EXCHANGE_CONSTANTS } from './exchange.mjs'
3
4
 
4
5
  // The fixed script that performs one conditional publication. It runs in two
@@ -26,11 +27,14 @@ const KEYS = {
26
27
  const isAbsolute = (value) => typeof value === 'string' && value.startsWith('/') && !value.includes('\u0000')
27
28
 
28
29
  // A vault-relative path the publisher may address: visible files only, plus
29
- // the one policy-owned settings file. Every other settings path is the
30
- // person's and cannot be named in a payload at all.
30
+ // the settings Atelier owns (the two policy settings files and its own
31
+ // plugin's files). Every other settings path is the person's and cannot be
32
+ // named in a payload at all.
33
+ const ATELIER_OWNED_SETTINGS = new Set([...POLICY_SETTINGS_PATHS, ...PLUGIN_OWNED_PATHS])
34
+
31
35
  export function isAddressableVaultPath(value) {
32
36
  if (typeof value !== 'string' || value.length === 0 || value.length > 1024 || value.startsWith('/') || value.includes('\\') || value.includes('\u0000')) return false
33
- if (value === POLICY_SETTINGS_PATH) return true
37
+ if (ATELIER_OWNED_SETTINGS.has(value)) return true
34
38
  return value.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && !part.startsWith('.'))
35
39
  }
36
40
 
@@ -43,7 +47,7 @@ export function validatePayload(payload) {
43
47
  for (const key of Object.keys(payload)) if (!allowed.includes(key)) fail(`unknown key ${key}`)
44
48
  for (const key of allowed) if (payload[key] === undefined) fail(`missing key ${key}`)
45
49
  if (!isAbsolute(payload.vaultRoot)) fail('vaultRoot must be absolute')
46
- if (!isAddressableVaultPath(payload.path)) fail('path must be a relative visible path or the policy settings file')
50
+ if (!isAddressableVaultPath(payload.path)) fail('path must be a relative visible path or a settings file Atelier owns')
47
51
  if (payload.op === 'publish') {
48
52
  if (!OPERATION_ID.test(payload.operationId)) fail('operationId required')
49
53
  if (!SHA.test(payload.baseSha256)) fail('baseSha256 required')
@@ -76,7 +80,9 @@ export function criticalSection(P, host) {
76
80
  const trace = []
77
81
  const step = (event, detail) => trace.push({ ms: Number((host.performance.now() - t0).toFixed(3)), event, ...(detail || {}) })
78
82
  const crash = (point) => { if (host.crashSeam && host.crashSeam.at === point) host.crashSeam.halt(point) }
79
- const real = (target) => { try { return fs.realpathSync(target) } catch (error) { return null } }
83
+ // As the file system stores the path, except on Windows (see realPathAsStored): an app that holds the vault under
84
+ // another spelling of its folder (a letter case, on a volume that folds it) holds this vault.
85
+ const real = (target) => { try { return host.process.platform !== 'win32' && fs.realpathSync.native ? fs.realpathSync.native(target) : fs.realpathSync(target) } catch (error) { return null } }
80
86
  const vaultBasePath = app ? real(app.vault.adapter.getBasePath()) : real(P.vaultRoot)
81
87
  const full = nodePath.join(P.vaultRoot, P.path)
82
88
  const views = () => {
@@ -1,5 +1,6 @@
1
1
  // Conditional publication of a prepared view into an editable Obsidian vault.
2
2
  export { PROTOCOL_ID, buildEvalCode, createInProcessHost, criticalSection, isAddressableVaultPath, runInProcess, validatePayload } from './bridge-script.mjs'
3
3
  export { EXCHANGE_CONSTANTS, ExchangeRefusal, exchangeFiles, probeExchange, resetExchangeProbeCache, resolveExchange } from './exchange.mjs'
4
- export { TransportTimeout, createDirectAdapter, createEditorAdapter, createInProcessCall, createObsidianCliAdapter, createObsidianCliCall, defaultCliPath, defaultObsidianProcessProbe } from './transport.mjs'
4
+ export { NEUTRAL_DIRECTORY, TransportTimeout, createDirectAdapter, createEditorAdapter, createInProcessCall, createObsidianCliAdapter, createObsidianCliCall, defaultCliPath, defaultObsidianProcessProbe, publicationRoute } from './transport.mjs'
5
+ export { MAX_OBSIDIAN_SETTINGS_BYTES, OBSIDIAN_SETTINGS_FILE, enclosingVaults, findVaultEntry, obsidianSandboxedBuild, obsidianUserDataDir, readObsidianSettings, vaultRoute } from './vault-list.mjs'
5
6
  export { publishView } from './publisher.mjs'
@@ -3,8 +3,9 @@ import fsp from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { setTimeout as sleep } from 'node:timers/promises'
5
5
  import { acquirePrivateLock, publishPrivateFile, syncPrivateDirectory } from '../../../project/durable-state.mjs'
6
- import { ObsidianContractRefusal, assertObsidianContract } from '../contracts.mjs'
7
- import { isPolicySettingsPath, isUserOwnedSettingsPath, prepareSettings } from '../materialize/settings.mjs'
6
+ import { OBSIDIAN_EXT_KEY, ObsidianContractRefusal, assertObsidianContract, manifestLayoutVersion } from '../contracts.mjs'
7
+ import { isPluginOwnedPath, isPolicySettingsPath, isUserOwnedSettingsPath, preparePolicySettingsFile } from '../materialize/settings.mjs'
8
+ import { PLUGIN_DATA_MODE, PLUGIN_DATA_PATH, PLUGIN_SOURCE_MODE } from '../plugin-bridge/channel.mjs'
8
9
  import { createJournal, newJournalId } from '../recovery/journal.mjs'
9
10
  import { recheckDisplacedFiles } from '../recovery/late-writer.mjs'
10
11
  import { publishedSinceCommit, reconcileUnit, recordDisplaced, recoverPublicationsLocked, retireStagedFile } from '../recovery/restart.mjs'
@@ -36,14 +37,30 @@ import { createDirectAdapter } from './transport.mjs'
36
37
  // from `recovery/<journalId>/<unit>/`, never from staging. Staging holds only
37
38
  // generated bytes that no exchange names: every candidate while it is written,
38
39
  // and the candidates of created files until they are linked into the vault.
40
+ //
41
+ // Atelier's own plugin files (kind `plugin`) go through the same units, with
42
+ // two differences: each is compared with what the disk holds when the run
43
+ // begins rather than with an earlier generation, so whatever is there is
44
+ // displaced to recovery and never lost, and a plugin path that a person has to
45
+ // repair never holds the view back. The plugin's data file names a bearer and
46
+ // is written only into a vault root that is private to this user.
39
47
 
40
48
  const hex = (digest) => digest.slice('sha256:'.length)
41
49
  const iso = (clock) => { const value = clock(); return (value instanceof Date ? value : new Date(value)).toISOString() }
42
50
  const REFUSED_BY_EDIT = new Set(['editor-edit', 'disk-changed'])
43
51
  const unreleased = new Map()
44
52
 
53
+ // A note another program replaces by rename can change between the check of its leaf and the open
54
+ // (ELEAFCHANGED). Every rename leaves a complete file, so it is read again; one that keeps changing is left to
55
+ // publishView, which reports it for that unit only.
56
+ const READ_ATTEMPTS = 5
45
57
  function readNote(file) {
46
- try { return readFileBytes(file) } catch (error) { if (error.code === 'ENOENT') return null; throw error }
58
+ for (let attempt = 1; ; attempt += 1) {
59
+ try { return readFileBytes(file) } catch (error) {
60
+ if (error.code === 'ENOENT') return null
61
+ if (error.code !== 'ELEAFCHANGED' || attempt >= READ_ATTEMPTS) throw error
62
+ }
63
+ }
47
64
  }
48
65
 
49
66
  function validatePreparedView(preparedView, store) {
@@ -61,22 +78,35 @@ function validatePreparedView(preparedView, store) {
61
78
  if (expected.has(declared)) refuse('invalid-prepared-view', 'the manifest declares one path more than once')
62
79
  expected.set(declared, fileDigest)
63
80
  }
81
+ // Atelier's plugin files are pinned by the settings unit's record in the manifest.
82
+ const pinnedPlugin = manifest.ext?.[OBSIDIAN_EXT_KEY]?.settings?.pluginOwned?.files
83
+ const pinned = new Map(Array.isArray(pinnedPlugin) ? pinnedPlugin.map((file) => [file?.path, file?.digest]) : [])
64
84
  const seen = new Set()
65
85
  for (const file of preparedView.files) {
66
86
  if (!isAddressableVaultPath(file.path) || isUserOwnedSettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared path is outside what the publisher may write')
67
87
  if (seen.has(file.path)) refuse('invalid-prepared-view', 'a prepared path appears twice')
68
88
  seen.add(file.path)
69
89
  if (file.kind === 'settings') {
70
- if (!isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'only the policy settings file may be prepared as settings')
90
+ if (!isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'only a policy settings file may be prepared as settings')
91
+ continue
92
+ }
93
+ if (file.kind === 'plugin') {
94
+ if (!isPluginOwnedPath(file.path) || !Buffer.isBuffer(file.bytes) || sha256Digest(file.bytes) !== file.digest || pinned.get(file.path) !== file.digest) {
95
+ refuse('invalid-prepared-view', 'a plugin file, its digest and the digest the manifest pins disagree')
96
+ }
97
+ // Read by the app, never run by anyone: the data file owner-only, the others readable.
98
+ if (file.mode !== (file.path === PLUGIN_DATA_PATH ? PLUGIN_DATA_MODE : PLUGIN_SOURCE_MODE)) refuse('invalid-prepared-view', 'a plugin file has a mode Atelier never gives one')
99
+ pinned.delete(file.path)
71
100
  continue
72
101
  }
73
- if (!['note', 'attachment'].includes(file.kind) || isPolicySettingsPath(file.path)) refuse('invalid-prepared-view', 'a prepared file has an unknown kind')
102
+ if (!['note', 'attachment'].includes(file.kind) || isPolicySettingsPath(file.path) || isPluginOwnedPath(file.path)) refuse('invalid-prepared-view', 'a prepared file has an unknown kind')
74
103
  if (!Buffer.isBuffer(file.bytes) || sha256Digest(file.bytes) !== file.digest || expected.get(file.path) !== file.digest) {
75
104
  refuse('invalid-prepared-view', 'prepared bytes, their digest and the manifest disagree')
76
105
  }
77
106
  expected.delete(file.path)
78
107
  }
79
108
  if (expected.size > 0) refuse('invalid-prepared-view', 'the manifest names a file the prepared view does not carry')
109
+ if (pinned.size > 0) refuse('invalid-prepared-view', 'the manifest pins a plugin file the prepared view does not carry')
80
110
  let parsedBytes = null
81
111
  if (Buffer.isBuffer(preparedView.manifestBytes)) {
82
112
  try { parsedBytes = JSON.parse(preparedView.manifestBytes.toString('utf8')) } catch { refuse('invalid-prepared-view', 'manifest bytes are not JSON') }
@@ -169,7 +199,35 @@ async function holdsCandidateBytes(vaultRoot, relativePath, candidateDigest) {
169
199
  }
170
200
  }
171
201
 
172
- function planUnits({ files, priorManifest, pointer, ledger }) {
202
+ // The digest of what a vault path holds now: null when it is absent, sits
203
+ // under a parent that is not a real directory, is not a regular file or cannot
204
+ // be read. Only Atelier's plugin files are planned from it.
205
+ function currentDigestOf(vaultRoot, relativePath) {
206
+ let current = vaultRoot
207
+ for (const part of relativePath.split('/').slice(0, -1)) {
208
+ current = path.join(current, part)
209
+ const stat = fs.lstatSync(current, { throwIfNoEntry: false })
210
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return null
211
+ }
212
+ try { return readFileDigest(path.join(vaultRoot, relativePath)) } catch { return null }
213
+ }
214
+
215
+ // A generation that is already committed is published again, as it is, when a
216
+ // plugin file it carries is not on disk as it pins it: the person repaired a
217
+ // path Atelier had to leave, or removed or changed a file. The bytes are
218
+ // Atelier's and nothing else of the view changed, so the generation stays the
219
+ // same; its notes are kept, and its plugin units are planned from the disk as
220
+ // ever. A file prepared only where present that is gone is not a drift.
221
+ function pluginFilesDrifted(preparedView, store) {
222
+ return preparedView.files.some((file) => {
223
+ if (file.kind !== 'plugin') return false
224
+ const current = currentDigestOf(store.vaultRoot, file.path)
225
+ return current !== file.digest && !(current === null && file.onlyIfPresent === true)
226
+ })
227
+ }
228
+
229
+ // One unit per file the publication creates, keeps, replaces or removes. Exported for the oracle tests.
230
+ export function planUnits({ files, priorManifest, pointer, ledger, pluginDisk = new Map() }) {
173
231
  const trusted = new Map()
174
232
  for (const note of priorManifest?.notes ?? []) trusted.set(note.path, note.noteDigest)
175
233
  for (const attachment of priorManifest?.attachments ?? []) trusted.set(attachment.path, attachment.digest)
@@ -177,17 +235,30 @@ function planUnits({ files, priorManifest, pointer, ledger }) {
177
235
  for (const item of pointer?.retained ?? []) if (item.priorDigest && !item.movedToRecovery) trusted.set(item.path, item.priorDigest)
178
236
  for (const [notePath, digest] of ledger) { if (digest === null) trusted.delete(notePath); else trusted.set(notePath, digest) }
179
237
 
238
+ // A file leaves as an attachment when the prior generation listed it as one. Layout 1 kept every file under
239
+ // `attachments/`, so there that folder says it too (of a file an interrupted run published); in layout 2 it is only
240
+ // a repository's folder.
241
+ const priorAttachments = new Set((priorManifest?.attachments ?? []).map((attachment) => attachment.path))
242
+ const underAttachments = !priorManifest || manifestLayoutVersion(priorManifest) === 1
180
243
  const units = []
181
244
  const present = new Set()
182
245
  for (const file of files) {
183
246
  present.add(file.path)
184
- if (file.kind === 'settings') { units.push({ path: file.path, kind: 'settings', op: 'settings' }); continue }
247
+ // A settings file prepared from the bytes it held when the view was prepared carries their digest: it is written only over them.
248
+ if (file.kind === 'settings') { units.push({ path: file.path, kind: 'settings', op: 'settings', ...(Object.hasOwn(file, 'expectedDigest') ? { expectedDigest: file.expectedDigest } : {}) }); continue }
249
+ if (file.kind === 'plugin') {
250
+ const current = pluginDisk.get(file.path) ?? null
251
+ // A plugin file the person removed while the plugin is off in this vault stays removed.
252
+ if (current === null && file.onlyIfPresent === true) { units.push({ path: file.path, kind: 'plugin', op: 'leave-absent' }); continue }
253
+ units.push({ path: file.path, kind: 'plugin', op: current === null ? 'create' : current === file.digest ? 'keep' : 'replace', baseDigest: current, candidateDigest: file.digest, bytes: file.bytes, mode: file.mode })
254
+ continue
255
+ }
185
256
  const base = trusted.get(file.path)
186
257
  const op = base === undefined ? 'create' : base === file.digest ? 'keep' : 'replace'
187
258
  units.push({ path: file.path, kind: file.kind, op, baseDigest: base ?? null, candidateDigest: file.digest, bytes: file.bytes })
188
259
  }
189
260
  for (const [notePath, digest] of [...trusted].sort(([left], [right]) => (left < right ? -1 : 1))) {
190
- if (!present.has(notePath)) units.push({ path: notePath, kind: notePath.startsWith('attachments/') ? 'attachment' : 'note', op: 'remove', baseDigest: digest, candidateDigest: null })
261
+ if (!present.has(notePath)) units.push({ path: notePath, kind: priorAttachments.has(notePath) || (underAttachments && notePath.startsWith('attachments/')) ? 'attachment' : 'note', op: 'remove', baseDigest: digest, candidateDigest: null })
191
262
  }
192
263
  units.forEach((unit, index) => { unit.unit = index })
193
264
  return units
@@ -224,14 +295,15 @@ export async function publishView(options = {}) {
224
295
  acquire(store.vaultLockPath, () => acquireVaultLock(store), 'into this vault')
225
296
  const recovered = recoverPublicationsLocked({ store, clock })
226
297
  const pointer = store.readCurrent()
227
- if (pointer?.generationId === manifest.generationId) {
298
+ if (pointer?.generationId === manifest.generationId && !pluginFilesDrifted(preparedView, store)) {
228
299
  return { state: 'committed', alreadyCommitted: true, generationId: manifest.generationId, journalId: pointer.journalId, notes: [], retainedEdits: pointer.retained ?? [], lateWriters: [], recovered }
229
300
  }
230
301
  if ((pointer?.generationId ?? null) !== expectedGeneration) refuse('generation-mismatch', 'the committed generation is not the one this publication expects', { committed: pointer?.generationId ?? null })
231
302
 
232
- // Path selection.
303
+ // Path selection. An app with this vault open in several windows is refused as such: publishing through one of
304
+ // them would leave the others uncoordinated.
233
305
  const probe = await adapter.probe({ vaultRoot: store.vaultRoot })
234
- if (probe.state !== 'coordinated' && probe.state !== 'absent') refuse('editor-uncoordinated', `an Obsidian process may have this vault open and cannot be coordinated with: ${probe.reason}`)
306
+ if (probe.state !== 'coordinated' && probe.state !== 'absent') refuse(probe.code === 'vault-open-in-several-windows' ? probe.code : 'editor-uncoordinated', `an Obsidian process may have this vault open and cannot be coordinated with: ${probe.reason}`)
235
307
  const mode = probe.state === 'coordinated' ? 'in-app' : 'direct'
236
308
  const channel = mode === 'in-app' ? adapter : createDirectAdapter({ crashSeam: seam })
237
309
 
@@ -247,7 +319,8 @@ export async function publishView(options = {}) {
247
319
  const exchange = probeExchange({ directory: probeDir, ...exchangeOptions })
248
320
  if (!exchange.supported) refuse(exchange.code, exchange.message)
249
321
 
250
- const units = planUnits({ files: preparedView.files, priorManifest: store.readCurrentManifest(), pointer, ledger: publishedSinceCommit({ store }) })
322
+ const pluginDisk = new Map(preparedView.files.filter((file) => file.kind === 'plugin').map((file) => [file.path, currentDigestOf(store.vaultRoot, file.path)]))
323
+ const units = planUnits({ files: preparedView.files, priorManifest: store.readCurrentManifest(), pointer, ledger: publishedSinceCommit({ store }), pluginDisk })
251
324
 
252
325
  // Stage every known candidate before anything is touched.
253
326
  const journalId = newJournalId(clock)
@@ -259,7 +332,7 @@ export async function publishView(options = {}) {
259
332
  for (const unit of units.filter((item) => item.op === 'replace' || item.op === 'create')) {
260
333
  const existing = fs.lstatSync(path.join(store.vaultRoot, unit.path), { throwIfNoEntry: false })
261
334
  unit.preparedPath = store.preparedPath(journalId, unit.unit)
262
- stageCandidate(unit.preparedPath, unit.bytes, existing?.isFile() ? existing.mode & 0o777 : 0o644, created)
335
+ stageCandidate(unit.preparedPath, unit.bytes, unit.mode ?? (existing?.isFile() ? existing.mode & 0o777 : 0o644), created)
263
336
  // A replacement is exchanged, and the exchange leaves the displaced bytes at this path: it is in recovery.
264
337
  unit.stagedPath = unit.op === 'replace' ? store.exchangeCandidatePath(journalId, unit.unit) : unit.preparedPath
265
338
  }
@@ -270,7 +343,7 @@ export async function publishView(options = {}) {
270
343
  detail: { mode, manifestRef: store.ref(manifestFile), manifestDigest: sha256Digest(manifestBytes),
271
344
  // The units this run may write. A kept note is compared and never written; the one case that writes
272
345
  // it after all (a kept note gone missing) is a late candidate with its own write-ahead entry.
273
- units: units.filter((item) => item.op !== 'keep').map(({ unit, path: unitPath, kind, op }) => ({ unit, path: unitPath, kind, op })),
346
+ units: units.filter((item) => item.op !== 'keep' && item.op !== 'leave-absent').map(({ unit, path: unitPath, kind, op }) => ({ unit, path: unitPath, kind, op })),
274
347
  staged: units.filter((item) => item.stagedPath).map((item) => ({ unit: item.unit, path: item.path, candidateDigest: item.candidateDigest, stagedRef: store.ref(item.stagedPath),
275
348
  ...(item.stagedPath === item.preparedPath ? {} : { preparedRef: store.ref(item.preparedPath) }) })) } })
276
349
  crash('before-candidate-move')
@@ -317,7 +390,12 @@ export async function publishView(options = {}) {
317
390
  results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: context.uncoordinated ? 'editor-uncoordinated' : 'unchanged', blocking: false })
318
391
  continue
319
392
  }
320
- results.push(await publishUnit(unit, context))
393
+ try { results.push(await publishUnit(unit, context)) } catch (error) {
394
+ if (error.code !== 'ELEAFCHANGED') throw error
395
+ // Another program kept replacing this file while it was read: its bytes are left as they are, the unit's
396
+ // journal stays open for recovery like an interrupted one, and the other units go on.
397
+ results.push({ path: unit.path, kind: unit.kind, op: unit.op, outcome: 'disk-changed', blocking: true })
398
+ }
321
399
  }
322
400
 
323
401
  const blocking = results.filter((result) => result.blocking)
@@ -356,21 +434,72 @@ export async function publishView(options = {}) {
356
434
  }
357
435
 
358
436
  // ---------------------------------------------------------------------------
359
- // One note, attachment or settings file
437
+ // One note, attachment, settings file or plugin file
360
438
  // ---------------------------------------------------------------------------
361
439
 
440
+ // Atelier's own plugin never waits on a person: a plugin path that cannot be
441
+ // written safely, a vault root not private enough for the bearer, or a file
442
+ // that could not be made or replaced there (a plugin folder the person can read
443
+ // but not write, say) is reported and left for the person while every note
444
+ // converges, and its candidate is retired at once. A plugin file that another
445
+ // writer changed under this run is a race: it blocks like a note, so the view
446
+ // is tried again and the file is planned from the disk anew. Nothing reads a
447
+ // plugin file as a person's edit of a note: its outcomes carry their own names.
448
+ const PLUGIN_LEFT_FOR_A_PERSON = new Set(['path-unsafe', 'vault-not-private', 'create-failed', 'exchange-failed'])
449
+ // A leaf or a parent nobody may look into or read (another owner, mode 000) or a link loop: the person's to repair,
450
+ // like a leaf that is not a file. It is reported, never thrown, so the rest of the view and every later view go on.
451
+ const UNREADABLE_PATH = new Set(['EACCES', 'EPERM', 'ELOOP'])
452
+ const PLUGIN_FILE_CHANGED = new Set(['edit-kept', 'disk-changed', 'editor-edit'])
453
+
362
454
  async function publishUnit(unit, context) {
455
+ let result
456
+ try { result = await publishOneUnit(unit, context) } catch (error) {
457
+ // A plugin or settings file another program kept replacing while it was opened (see readNote) is a race of that
458
+ // file, like one that changed under the run: blocking, tried again, and never taken for a person's edit of a note.
459
+ if (error?.code !== 'ELEAFCHANGED' || (unit.kind !== 'plugin' && unit.kind !== 'settings')) throw error
460
+ // Its candidate is left as #83 leaves a note's: the unit's journal stays open for recovery like an interrupted one.
461
+ result = { path: unit.path, kind: unit.kind, op: unit.op, outcome: unit.kind === 'settings' ? 'settings-changed' : 'disk-changed', blocking: true }
462
+ }
463
+ if (unit.kind !== 'plugin') return result
464
+ const { changedAfterPublication, retained: _retained, ...rest } = result
465
+ if (rest.outcome === 'path-unsafe' && unit.stagedPath) retire(unit, context)
466
+ const changed = PLUGIN_FILE_CHANGED.has(rest.outcome)
467
+ return { ...rest, outcome: changed ? 'plugin-file-changed' : rest.outcome, blocking: changed || (rest.blocking && !PLUGIN_LEFT_FOR_A_PERSON.has(rest.outcome)), ...(changedAfterPublication ? { pluginFileChanged: true } : {}) }
468
+ }
469
+
470
+ // The vault root holds the plugin's bearer once its data file is there, so it
471
+ // must be private to this user. A vault root Atelier placed under its own data
472
+ // root is made private here; any other one is only looked at, and a vault path
473
+ // under the data root that is a link to somewhere else never receives it.
474
+ function ensurePrivateVaultRoot(store) {
475
+ if (store.linkedVaultRoot === true) return { private: false, reason: 'vault-root-is-a-link' }
476
+ const uid = typeof process.getuid === 'function' ? process.getuid() : null
477
+ let stat = fs.lstatSync(store.vaultRoot, { throwIfNoEntry: false })
478
+ if (!stat?.isDirectory() || uid === null || stat.uid !== uid) return { private: false, reason: 'vault-root-not-owned' }
479
+ if ((stat.mode & 0o077) !== 0 && store.managedVaultRoot === true) {
480
+ try { fs.chmodSync(store.vaultRoot, stat.mode & 0o700) } catch { /* looked at again below */ }
481
+ stat = fs.lstatSync(store.vaultRoot, { throwIfNoEntry: false })
482
+ }
483
+ return stat?.isDirectory() && (stat.mode & 0o077) === 0 ? { private: true } : { private: false, reason: 'vault-root-not-private' }
484
+ }
485
+
486
+ async function publishOneUnit(unit, context) {
363
487
  const { store, journal, journalId, clock } = context
364
488
  const note = path.join(store.vaultRoot, unit.path)
365
489
  const outcome = (code, extra = {}) => ({ path: unit.path, kind: unit.kind, op: unit.op, outcome: code, blocking: false, ...extra })
490
+ if (unit.op === 'leave-absent') return outcome('left-absent')
366
491
  const observe = (bytes) => store.retainObject(bytes)
367
492
  if (context.uncoordinated) return outcome('editor-uncoordinated', { blocking: unit.op !== 'keep' })
368
493
  // A settings path that cannot be written safely (a symlinked `.obsidian`,
369
494
  // say) is reported and left alone; it must not keep every note from
370
495
  // converging while the person repairs it. Notes and attachments block.
371
496
  const unsafeBlocks = unit.op !== 'settings'
372
- if (!ensureParents(store.vaultRoot, unit.path)) return outcome('path-unsafe', { blocking: unsafeBlocks })
373
- const leaf = fs.lstatSync(note, { throwIfNoEntry: false })
497
+ const unreadable = (error) => { if (!UNREADABLE_PATH.has(error?.code)) throw error; return outcome('path-unsafe', { blocking: unsafeBlocks, errorCode: error.code }) }
498
+ let leaf
499
+ try {
500
+ if (!ensureParents(store.vaultRoot, unit.path)) return outcome('path-unsafe', { blocking: unsafeBlocks })
501
+ leaf = fs.lstatSync(note, { throwIfNoEntry: false })
502
+ } catch (error) { return unreadable(error) }
374
503
  if (leaf && !leaf.isFile()) return outcome('path-unsafe', { blocking: unsafeBlocks })
375
504
 
376
505
  let plan = unit
@@ -378,7 +507,8 @@ async function publishUnit(unit, context) {
378
507
  plan = planSettings(unit, context)
379
508
  if (plan.outcome) return plan
380
509
  }
381
- const current = readNote(note)
510
+ let current
511
+ try { current = readNote(note) } catch (error) { return unreadable(error) }
382
512
  const currentDigest = current === null ? null : sha256Digest(current)
383
513
 
384
514
  if (plan.op === 'keep') {
@@ -403,6 +533,14 @@ async function publishUnit(unit, context) {
403
533
  return outcome('already-current')
404
534
  }
405
535
 
536
+ if (unit.path === PLUGIN_DATA_PATH && plan.op !== 'remove') {
537
+ const privacy = ensurePrivateVaultRoot(store)
538
+ if (!privacy.private) {
539
+ if (plan.stagedPath) retire(plan, context)
540
+ return outcome('vault-not-private', { reason: privacy.reason })
541
+ }
542
+ }
543
+
406
544
  const operationId = `${journalId}:${unit.unit}`
407
545
  const displaced = plan.op === 'create' ? null : store.displacedPath(journalId, unit.unit)
408
546
  // The journal state never goes backwards: once a note has been published the transition is `updating`.
@@ -552,18 +690,27 @@ function stageLate(plan, { store, journal, journalId, crash, updating }) {
552
690
  return unit.stagedPath
553
691
  }
554
692
 
555
- // Policy settings are owned per key. The candidate is the file on disk now
556
- // with only the owned keys set; the base is that same file. A file that is
557
- // not a JSON object or array is the person's to repair and is left alone.
693
+ // Policy settings are owned per key or entry. The candidate is the file on
694
+ // disk now with only what Atelier owns set; the base is that same file. A file
695
+ // whose shape the policy does not know is the person's to repair and is left
696
+ // alone.
558
697
  function planSettings(unit, context) {
559
698
  const note = path.join(context.store.vaultRoot, unit.path)
560
- const existing = readNote(note)
561
- let prepared
562
- try { prepared = prepareSettings({ existing }) } catch (error) {
699
+ let existing
700
+ try { existing = readNote(note) } catch (error) {
701
+ if (!UNREADABLE_PATH.has(error?.code)) throw error
702
+ return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'path-unsafe', blocking: false, errorCode: error.code }
703
+ }
704
+ // Prepared from other bytes than these: the person changed the file since, and whatever Atelier decided from the old
705
+ // bytes is not written over the change. The view is tried again, and the next preparation reads the file as it is.
706
+ if (Object.hasOwn(unit, 'expectedDigest') && (existing === null ? null : sha256Digest(existing)) !== unit.expectedDigest) {
707
+ return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'settings-changed', blocking: true }
708
+ }
709
+ let file
710
+ try { file = preparePolicySettingsFile(unit.path, existing) } catch (error) {
563
711
  if (error instanceof ObsidianContractRefusal) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'settings-invalid', blocking: false }
564
712
  throw error
565
713
  }
566
- const [file] = prepared.files
567
714
  if (existing !== null && file.bytes.equals(existing)) return { path: unit.path, kind: 'settings', op: 'settings', outcome: 'policy-satisfied', blocking: false }
568
715
  const plan = { ...unit, op: existing === null ? 'create' : 'replace', baseDigest: existing === null ? null : sha256Digest(existing), candidateDigest: file.digest, bytes: file.bytes,
569
716
  mode: existing === null ? 0o644 : fs.lstatSync(note).mode & 0o777 }