@jwilleke/ngdpbase-types 5.39.0 → 5.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/lib/packages/ngdpbase-types/index.d.ts +1 -1
  2. package/lib/src/context/ApiContext.d.ts +3 -2
  3. package/lib/src/context/ApiContext.js +4 -2
  4. package/lib/src/ice/fileHandling.d.ts +6 -1
  5. package/lib/src/ice/givenName.d.ts +19 -0
  6. package/lib/src/ice/runExport.d.ts +41 -0
  7. package/lib/src/ice/runImport.d.ts +37 -8
  8. package/lib/src/ice/shims/csv.d.ts +24 -0
  9. package/lib/src/ice/shims/export.d.ts +23 -0
  10. package/lib/src/ice/shims/index.d.ts +3 -1
  11. package/lib/src/ice/shims/svg.d.ts +27 -0
  12. package/lib/src/ice/shims/takeout.d.ts +83 -0
  13. package/lib/src/ice/types.d.ts +301 -5
  14. package/lib/src/ice/vet.d.ts +19 -0
  15. package/lib/src/managers/AttachmentManager.d.ts +100 -36
  16. package/lib/src/managers/AuthManager.d.ts +152 -12
  17. package/lib/src/managers/AuthManager.js +3830 -0
  18. package/lib/src/managers/BackgroundJobManager.d.ts +25 -0
  19. package/lib/src/managers/BackupManager.d.ts +24 -0
  20. package/lib/src/managers/BackupManager.js +48 -1
  21. package/lib/src/managers/ExportManager.d.ts +16 -0
  22. package/lib/src/managers/ExportManager.js +25 -0
  23. package/lib/src/managers/FileManager.d.ts +182 -0
  24. package/lib/src/managers/ImportManager.d.ts +279 -16
  25. package/lib/src/managers/MediaManager.d.ts +2 -0
  26. package/lib/src/managers/PageManager.d.ts +53 -0
  27. package/lib/src/managers/SearchManager.d.ts +4 -0
  28. package/lib/src/managers/SharedFolderManager.d.ts +320 -0
  29. package/lib/src/managers/ValidationManager.d.ts +5 -2
  30. package/lib/src/middleware/csrf.js +99 -0
  31. package/lib/src/providers/AgentTokenAuthProvider.js +46 -0
  32. package/lib/src/providers/AuthentikBearerAuthProvider.js +176 -0
  33. package/lib/src/providers/BaseCredentialsProvider.js +21 -0
  34. package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
  35. package/lib/src/providers/BasePageProvider.d.ts +8 -0
  36. package/lib/src/providers/CloudflareAccessAuthProvider.js +160 -0
  37. package/lib/src/providers/FileCredentialsProvider.js +188 -0
  38. package/lib/src/providers/FileSystemMediaProvider.d.ts +6 -0
  39. package/lib/src/providers/MagicLinkAuthProvider.js +333 -0
  40. package/lib/src/providers/OidcAuthProvider.js +350 -0
  41. package/lib/src/providers/OidcBearerAuthProvider.js +27 -0
  42. package/lib/src/providers/PasskeyAuthProvider.js +147 -0
  43. package/lib/src/providers/PasswordAuthProvider.js +44 -0
  44. package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
  45. package/lib/src/security/pageActions.js +43 -0
  46. package/lib/src/security/permissions.generated.d.ts +7 -3
  47. package/lib/src/types/File.d.ts +90 -0
  48. package/lib/src/types/Provider.d.ts +15 -0
  49. package/lib/src/types/express.d.ts +7 -1
  50. package/lib/src/types/guards.js +366 -0
  51. package/lib/src/types/linkedom.d.ts +2 -0
  52. package/lib/src/utils/atomicWrite.js +129 -0
  53. package/lib/src/utils/auditEventNames.generated.d.ts +12 -0
  54. package/lib/src/utils/auditEventNames.generated.js +12 -0
  55. package/lib/src/utils/auditEvents.d.ts +15 -0
  56. package/lib/src/utils/auditEvents.js +17 -1
  57. package/lib/src/utils/bip39English.js +2 -0
  58. package/lib/src/utils/browserDescription.js +28 -0
  59. package/lib/src/utils/delegation.js +79 -0
  60. package/lib/src/utils/deriveUsername.js +38 -0
  61. package/lib/src/utils/emailForLog.js +28 -0
  62. package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
  63. package/lib/src/utils/instanceEnvBackup.d.ts +6 -0
  64. package/lib/src/utils/instanceEnvBackup.js +13 -0
  65. package/lib/src/utils/ipLocation.js +106 -0
  66. package/lib/src/utils/magicLinkDeviceState.js +97 -0
  67. package/lib/src/utils/pageUrl.d.ts +1 -0
  68. package/lib/src/utils/passwordHash.js +150 -0
  69. package/lib/src/utils/permissionCatalog.js +102 -0
  70. package/lib/src/utils/privateStoreAccess.d.ts +25 -0
  71. package/lib/src/utils/privateStoreAccess.js +22 -1
  72. package/lib/src/utils/privateStoreCatalogs.js +57 -0
  73. package/lib/src/utils/privateStoreCrypto.js +266 -0
  74. package/lib/src/utils/privateStoreDoor.d.ts +10 -0
  75. package/lib/src/utils/privateStoreDoor.js +298 -0
  76. package/lib/src/utils/privateStoreImport.d.ts +0 -10
  77. package/lib/src/utils/privateStoreMeta.js +123 -0
  78. package/lib/src/utils/privateStorePath.d.ts +37 -1
  79. package/lib/src/utils/privateStorePath.js +60 -3
  80. package/lib/src/utils/privateStoreTakeout.js +149 -0
  81. package/lib/src/utils/privateStoreUnlock.d.ts +3 -0
  82. package/lib/src/utils/privateStoreUnlock.js +499 -0
  83. package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
  84. package/lib/src/utils/safeRedirect.js +55 -0
  85. package/lib/src/utils/sessionAccount.js +55 -0
  86. package/lib/src/utils/sessionEnd.d.ts +1 -0
  87. package/lib/src/utils/sessionEnd.js +42 -0
  88. package/lib/src/utils/sessionGeneration.js +29 -0
  89. package/lib/src/utils/sessionIdle.d.ts +4 -0
  90. package/lib/src/utils/sessionIdle.js +72 -0
  91. package/lib/src/utils/sessionLimits.js +72 -0
  92. package/lib/src/utils/sessionSecurity.js +122 -0
  93. package/lib/src/utils/shareLockbox.js +101 -0
  94. package/lib/src/utils/sharedFolderAccess.d.ts +57 -0
  95. package/lib/src/utils/sharedFolderAccess.js +80 -0
  96. package/lib/src/utils/userCreateError.js +82 -0
  97. package/lib/src/utils/username.d.ts +2 -0
  98. package/lib/src/utils/username.js +105 -0
  99. package/package.json +2 -1
@@ -0,0 +1,298 @@
1
+ /**
2
+ * The store door (#1414, epic #1382).
3
+ *
4
+ * A user's copy of a store kind is created when they first walk through its
5
+ * door — never at login and never mid-save. Decisions in
6
+ * docs/private-stores.md: "Recovery words: at first deliberate entry
7
+ * into the store", "The words are confirmed before anything is committed",
8
+ * "Core owns the door", "The door asks for the password when the user has no
9
+ * key", "`store.json` shape".
10
+ *
11
+ * This module holds what the door does, not how it is shown: which kinds
12
+ * exist, the words that are pending confirmation, and the commit. The route
13
+ * renders; nothing here touches HTTP.
14
+ *
15
+ * What exists only while the words screen is open lives in a process Map keyed
16
+ * by the session's private-store handle — never in express-session JSON, never
17
+ * logged, never in a record. Abandon the flow and nothing was written.
18
+ */
19
+ import fs from 'fs-extra';
20
+ import path from 'path';
21
+ import { createEncryptedStore, isSealedBytes, newRecoveryWords, sameMnemonic, unwrapDek } from './privateStoreCrypto.js';
22
+ import { isSharedFolderOwner, isValidStoreId, privateStoreRoot, privateUserKeysPath, systemCategoryVaults, storeMetaPath } from './privateStorePath.js';
23
+ import { readVaultSealState } from './privateStoreMeta.js';
24
+ import { SYSTEM_CATEGORY_KEY } from './systemCategoryRules.js';
25
+ import { listPrivateOwners } from './privateStoreTakeout.js';
26
+ import { writeFileAtomic } from './atomicWrite.js';
27
+ import { SECRET_FILE_MODE } from './secretFileMode.js';
28
+ /** The site itself, as the owner of its own vault kinds; no add-on may claim it. */
29
+ export const ADMIN_STORE_OWNER = 'admin';
30
+ /**
31
+ * The kind named `id`, or `null` when no system-category declares that vault.
32
+ * `encrypt` is read as the boolean it is and nothing else (a string "true" is
33
+ * not a switch).
34
+ */
35
+ export function storeKindFromConfig(getProperty, id) {
36
+ if (!isValidStoreId(id))
37
+ return null;
38
+ const categories = getProperty(SYSTEM_CATEGORY_KEY, null);
39
+ const vault = systemCategoryVaults(categories).find((v) => v.vaultId === id);
40
+ if (!vault)
41
+ return null;
42
+ const entry = categories[vault.key];
43
+ const owner = typeof entry?.owner === 'string' && entry.owner ? entry.owner : ADMIN_STORE_OWNER;
44
+ return { id, owner, encrypt: vault.encrypt };
45
+ }
46
+ export function storeDoorState(kind, ownerState) {
47
+ if (kind.owner === ADMIN_STORE_OWNER || ownerState === 'loaded')
48
+ return { open: true };
49
+ switch (ownerState) {
50
+ case 'failed':
51
+ return { open: false, reason: 'unavailable', message: 'This store is temporarily unavailable. Its data is untouched; try again later.' };
52
+ case 'disabled':
53
+ return { open: false, reason: 'disabled', message: 'The add-on that owns this store is turned off on this site. Its data is untouched.' };
54
+ default:
55
+ return { open: false, reason: 'not-installed', message: 'The add-on that owns this store is not installed on this site. Its data is untouched.' };
56
+ }
57
+ }
58
+ /** Every vault kind, by id: the vault each system-category declares (#1505). */
59
+ export function storeKindIds(allProperties) {
60
+ return [...new Set(systemCategoryVaults(allProperties[SYSTEM_CATEGORY_KEY]).map((v) => v.vaultId))].sort();
61
+ }
62
+ /**
63
+ * How many users hold a copy of a kind: whose `store.json` for it exists.
64
+ * The copy is what the door creates, so this is "who has walked through".
65
+ */
66
+ export async function countStoreCopies(pagesDirectory, kindId, layout) {
67
+ let copies = 0;
68
+ for (const owner of await listPrivateOwners(pagesDirectory, layout)) {
69
+ // #2125: the shared folders' container is the organization's, not a user holding a copy.
70
+ if (isSharedFolderOwner(owner))
71
+ continue;
72
+ if (await fs.pathExists(storeMetaPath(pagesDirectory, owner, kindId, layout)))
73
+ copies++;
74
+ }
75
+ return copies;
76
+ }
77
+ /**
78
+ * Every vault of a sealed kind that holds files in the clear (#1834): a folder
79
+ * with something in it but no `store.json` (written before its door was
80
+ * opened), or a `store.json` that is not sealed. Read only — nothing is moved,
81
+ * sealed or deleted; what to do with the files is an administrator's call.
82
+ */
83
+ export async function findUnsealedVaults(pagesDirectory, layout) {
84
+ const found = [];
85
+ if (layout.sealedVaults.length === 0)
86
+ return found;
87
+ for (const owner of await listPrivateOwners(pagesDirectory, layout)) {
88
+ for (const vault of layout.sealedVaults) {
89
+ const root = privateStoreRoot(pagesDirectory, owner, vault, layout);
90
+ if (!await fs.pathExists(root))
91
+ continue;
92
+ const { state } = await readVaultSealState(pagesDirectory, owner, vault, layout);
93
+ if (state === 'mismatch')
94
+ found.push({ owner, vault, state });
95
+ else if (state === 'unopened' && (await fs.readdir(root)).length > 0)
96
+ found.push({ owner, vault, state });
97
+ }
98
+ }
99
+ return found;
100
+ }
101
+ /** The first bytes of a file — enough to see the sealed-file magic without reading the rest. */
102
+ async function headOf(file) {
103
+ const handle = await fs.promises.open(file, 'r');
104
+ try {
105
+ const buf = Buffer.alloc(64);
106
+ const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
107
+ return buf.subarray(0, bytesRead);
108
+ }
109
+ finally {
110
+ await handle.close();
111
+ }
112
+ }
113
+ /**
114
+ * Every sealed vault (its `store.json` says `encrypt: true`) that holds files
115
+ * in the clear (#1836): a page's history moved in with `fs.move`, an
116
+ * attachment adopted without its I/O, a backup restored from before the vault
117
+ * was sealed. A sealed file starts with the `sealBytes` magic, so each file's
118
+ * first bytes tell them apart without the key. `store.json` is the one file
119
+ * that is never sealed; an empty file holds nothing and is not counted.
120
+ *
121
+ * Read only — nothing is moved, sealed or deleted; what to do with the files
122
+ * is an administrator's call. Vaults that are not sealed at all are
123
+ * {@link findUnsealedVaults}'s.
124
+ */
125
+ export async function findPlaintextInSealedVaults(pagesDirectory, layout) {
126
+ const found = [];
127
+ for (const owner of await listPrivateOwners(pagesDirectory, layout)) {
128
+ const userDir = path.join(pagesDirectory, layout.privateRoot, owner);
129
+ for (const ent of await fs.readdir(userDir, { withFileTypes: true })) {
130
+ if (!ent.isDirectory() || !isValidStoreId(ent.name))
131
+ continue;
132
+ const vault = ent.name;
133
+ let state;
134
+ try {
135
+ ({ state } = await readVaultSealState(pagesDirectory, owner, vault, layout));
136
+ }
137
+ catch {
138
+ continue; // an unreadable store.json is not a sealed one; not this check's to report
139
+ }
140
+ if (state !== 'sealed')
141
+ continue;
142
+ const root = privateStoreRoot(pagesDirectory, owner, vault, layout);
143
+ let files = 0;
144
+ const areas = new Set();
145
+ for (const f of await fs.readdir(root, { recursive: true, withFileTypes: true })) {
146
+ if (!f.isFile())
147
+ continue;
148
+ const full = path.join(f.parentPath, f.name);
149
+ const rel = path.relative(root, full).split(path.sep);
150
+ if (rel.length === 1 && rel[0] === layout.files.storemeta)
151
+ continue;
152
+ const head = await headOf(full);
153
+ if (head.length === 0 || isSealedBytes(head))
154
+ continue;
155
+ files++;
156
+ areas.add(rel.length === 1 ? '.' : rel[0]);
157
+ }
158
+ if (files > 0)
159
+ found.push({ owner, vault, files, areas: [...areas].sort() });
160
+ }
161
+ }
162
+ return found;
163
+ }
164
+ /** Attempts at confirming the words: one, plus `ngdpbase.stores.recovery.confirmretries`. */
165
+ export function confirmAttempts(getProperty) {
166
+ const retries = getProperty('ngdpbase.stores.recovery.confirmretries', 1);
167
+ return 1 + (typeof retries === 'number' && Number.isInteger(retries) && retries > 0 ? retries : 0);
168
+ }
169
+ /** Words not confirmed within this time are forgotten, as an abandoned tab is. */
170
+ const PENDING_TTL_MS = 30 * 60 * 1000;
171
+ const pending = new Map();
172
+ function forget(handle) {
173
+ const entry = pending.get(handle);
174
+ if (entry)
175
+ entry.kek.fill(0);
176
+ pending.delete(handle);
177
+ }
178
+ function live(handle, username, store) {
179
+ const entry = pending.get(handle);
180
+ if (!entry)
181
+ return undefined;
182
+ if (Date.now() - entry.startedAt > PENDING_TTL_MS || entry.username !== username || entry.store !== store) {
183
+ forget(handle);
184
+ return undefined;
185
+ }
186
+ return entry;
187
+ }
188
+ /**
189
+ * Hold a new user KEK and its words while the user writes them down. Replaces
190
+ * anything this session had pending: starting over is always allowed.
191
+ */
192
+ export function holdWordsForConfirmation(handle, args) {
193
+ forget(handle);
194
+ pending.set(handle, {
195
+ username: args.username,
196
+ store: args.store,
197
+ kek: Buffer.from(args.kek),
198
+ envelope: args.envelope,
199
+ mnemonic: args.mnemonic,
200
+ attemptsLeft: Math.max(1, args.attempts),
201
+ startedAt: Date.now()
202
+ });
203
+ }
204
+ /** Whether this session is part-way through the words screen for `store`. */
205
+ export function hasPendingWords(handle, username, store) {
206
+ return live(handle, username, store) !== undefined;
207
+ }
208
+ /**
209
+ * The words this session is part-way through confirming for `store`, to
210
+ * download or print (#2144), or undefined: none, or they expired. Once
211
+ * confirmed or abandoned they are gone.
212
+ */
213
+ export function pendingWordsOf(handle, username, store) {
214
+ const entry = live(handle, username, store);
215
+ return entry ? { words: entry.mnemonic.split(' '), madeAt: new Date(entry.startedAt), envelope: entry.envelope } : undefined;
216
+ }
217
+ /**
218
+ * Check the words the user typed back. A match hands over the KEK and envelope
219
+ * to commit and forgets the words. A miss discards the words it showed — they
220
+ * are never shown again — and, while attempts remain, makes a new set for the
221
+ * same uncommitted KEK. No attempts left: everything is forgotten and nothing
222
+ * was ever written.
223
+ */
224
+ export function confirmWords(handle, username, store, entered) {
225
+ const entry = live(handle, username, store);
226
+ if (!entry)
227
+ return { status: 'none' };
228
+ if (sameMnemonic(entered, entry.mnemonic)) {
229
+ const kek = Buffer.from(entry.kek);
230
+ const envelope = entry.envelope;
231
+ forget(handle);
232
+ return { status: 'confirmed', kek, envelope };
233
+ }
234
+ entry.attemptsLeft -= 1;
235
+ if (entry.attemptsLeft <= 0) {
236
+ forget(handle);
237
+ return { status: 'exhausted' };
238
+ }
239
+ const fresh = newRecoveryWords(entry.kek);
240
+ entry.mnemonic = fresh.mnemonic;
241
+ entry.envelope = { ...entry.envelope, recoveryWrap: fresh.recoveryWrap };
242
+ return { status: 'retry', mnemonic: fresh.mnemonic, attemptsLeft: entry.attemptsLeft };
243
+ }
244
+ /** Logout, or a new login in this session: nothing pending survives it. */
245
+ export function dropPendingWords(handle) {
246
+ forget(handle);
247
+ }
248
+ /** Test teardown only. */
249
+ export function clearPendingWords() {
250
+ for (const handle of [...pending.keys()])
251
+ forget(handle);
252
+ }
253
+ async function writeExclusive(file, value) {
254
+ // An existing file is a refusal, never an overwrite: replacing a store.json
255
+ // loses the wrapped DEK — and with it every byte of the store. Atomic, owner
256
+ // only and flushed (#1625): a crash mid-write leaves no truncated key file.
257
+ await writeFileAtomic(file, JSON.stringify(value), 'utf8', { exclusive: true, mode: SECRET_FILE_MODE, fsync: true });
258
+ }
259
+ /** Whether `username` already has a copy of `store` — the door is then just a way in. */
260
+ export async function storeCopyExists(args) {
261
+ return fs.pathExists(storeMetaPath(args.pagesDirectory, args.username, args.store, args.layout));
262
+ }
263
+ /** Whether `username` has a user KEK on disk. */
264
+ export async function userKeysExist(args) {
265
+ return fs.pathExists(privateUserKeysPath(args.pagesDirectory, args.username, args.layout));
266
+ }
267
+ /**
268
+ * Write the user's copy of a store kind: `user-keys.json` first when this
269
+ * commit creates the user's KEK, then `store.json` — each written only if it
270
+ * does not exist yet. Returns the store DEK for a sealed copy, so the caller
271
+ * can put it in the session that walked through the door.
272
+ */
273
+ export async function commitStoreCopy(args) {
274
+ const created = (args.now ?? new Date()).toISOString();
275
+ if (args.kind.encrypt && !args.kek)
276
+ throw new Error('encrypted store is locked: missing KEK');
277
+ const keysFile = privateUserKeysPath(args.pagesDirectory, args.username, args.layout);
278
+ if (args.newEnvelope)
279
+ await writeExclusive(keysFile, args.newEnvelope);
280
+ try {
281
+ const metaFile = storeMetaPath(args.pagesDirectory, args.username, args.kind.id, args.layout);
282
+ if (!args.kind.encrypt) {
283
+ const record = { kind: args.kind.id, encrypt: false, created };
284
+ await writeExclusive(metaFile, record);
285
+ return {};
286
+ }
287
+ const sealed = createEncryptedStore(args.kek);
288
+ const record = { kind: args.kind.id, encrypt: true, created, dekWrap: sealed.dekWrap };
289
+ await writeExclusive(metaFile, record);
290
+ return { dek: unwrapDek(args.kek, sealed) };
291
+ }
292
+ catch (err) {
293
+ // A key must never outlive the store it was made for: no orphan key.
294
+ if (args.newEnvelope)
295
+ await fs.remove(keysFile);
296
+ throw err;
297
+ }
298
+ }
@@ -46,13 +46,3 @@ export type ParsedTakeout = {
46
46
  };
47
47
  /** What a takeout's entries hold. */
48
48
  export declare function readTakeout(entries: readonly ZipEntry[]): ParsedTakeout;
49
- /** `body` with every `/attachments/{old}` link pointed at its new id. */
50
- export declare function rewriteAttachmentLinks(body: string, ids: ReadonlyMap<string, string>): string;
51
- /** Whether `body` links to the file `id`. */
52
- export declare function linksToAttachment(body: string, id: string): boolean;
53
- /**
54
- * The first free title of `title`, `title (imported)`, `title (imported 2)` …
55
- * (operator, 2026-09-25): a DIFFERENT page already holding the title keeps it,
56
- * and the import lands beside it.
57
- */
58
- export declare function freeImportTitle(title: string, isTaken: (candidate: string) => Promise<boolean>): Promise<string>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Per-store encrypt flag on disk (#1384).
3
+ *
4
+ * Lives on `pages/vaults/{user}/{store}/store.json`, not on PageManager.
5
+ * Missing file = encrypt off (`default/` stays plaintext unless enabled).
6
+ */
7
+ import fs from 'fs-extra';
8
+ import path from 'path';
9
+ import logger from './logger.js';
10
+ import { STORE_META_FILENAME, resolvePrivateStoreLayout, storeMetaPath, privateStoreRoot } from './privateStorePath.js';
11
+ function looksLikeWrap(value) {
12
+ if (!value || typeof value !== 'object')
13
+ return false;
14
+ const blob = value;
15
+ return typeof blob.iv === 'string' && typeof blob.tag === 'string' && typeof blob.ct === 'string';
16
+ }
17
+ export async function readStoreMeta(pagesDirectory, creator, store, layout) {
18
+ const file = storeMetaPath(pagesDirectory, creator, store, layout);
19
+ if (!await fs.pathExists(file))
20
+ return { encrypt: false };
21
+ const raw = await fs.readJson(file);
22
+ if (!raw || typeof raw !== 'object')
23
+ return { encrypt: false };
24
+ const rec = raw;
25
+ if (rec.encrypt === true) {
26
+ return {
27
+ encrypt: true,
28
+ dekWrap: looksLikeWrap(rec.dekWrap) ? rec.dekWrap : { iv: '', tag: '', ct: '' }
29
+ };
30
+ }
31
+ return { encrypt: false };
32
+ }
33
+ export function kindIsSealed(layout, store) {
34
+ return resolvePrivateStoreLayout(layout).sealedVaults.includes(store);
35
+ }
36
+ function sealStateOf(kindSealed, metaExists, encrypt) {
37
+ if (encrypt)
38
+ return 'sealed';
39
+ if (!kindSealed)
40
+ return 'plain';
41
+ return metaExists ? 'mismatch' : 'unopened';
42
+ }
43
+ /** {@link VaultSealState} of `owner`'s `store`, with the record that decided it. */
44
+ export async function readVaultSealState(pagesDirectory, owner, store, layout) {
45
+ const kindSealed = kindIsSealed(layout, store);
46
+ const metaExists = await fs.pathExists(storeMetaPath(pagesDirectory, owner, store, layout));
47
+ let meta;
48
+ try {
49
+ meta = await readStoreMeta(pagesDirectory, owner, store, layout);
50
+ }
51
+ catch (err) {
52
+ // An unreadable store.json under a sealed kind is a vault that cannot be
53
+ // shown to be sealed: refused like any other mismatch, never plain.
54
+ if (!kindSealed)
55
+ throw err;
56
+ meta = { encrypt: false };
57
+ }
58
+ return { state: sealStateOf(kindSealed, metaExists, meta.encrypt), meta };
59
+ }
60
+ /** The synchronous form of {@link readVaultSealState}, for synchronous page lookups. */
61
+ export function readVaultSealStateSync(pagesDirectory, owner, store, layout) {
62
+ const metaFile = storeMetaPath(pagesDirectory, owner, store, layout);
63
+ const metaExists = fs.existsSync(metaFile);
64
+ let encrypt = false;
65
+ if (metaExists) {
66
+ const meta = fs.readJsonSync(metaFile, { throws: false });
67
+ encrypt = meta?.encrypt === true;
68
+ }
69
+ return sealStateOf(kindIsSealed(layout, store), metaExists, encrypt);
70
+ }
71
+ /**
72
+ * A read or write refused because the vault's kind is sealed and the vault
73
+ * is not (#1834). Never answered with plain I/O: the bytes would be clear on
74
+ * disk under a kind that says they are sealed.
75
+ */
76
+ export class SealedVaultRefusedError extends Error {
77
+ owner;
78
+ store;
79
+ reason;
80
+ code = 'sealed-vault-refused';
81
+ constructor(owner, store, reason) {
82
+ super(reason === 'unopened'
83
+ ? `Vault '${store}' is encrypted and has not been opened yet: open it at /stores/${store} first.`
84
+ : `Vault '${store}' should be encrypted, but its stored copy is not, so it stays closed until an administrator resolves it.`);
85
+ this.owner = owner;
86
+ this.store = store;
87
+ this.reason = reason;
88
+ this.name = 'SealedVaultRefusedError';
89
+ }
90
+ }
91
+ /**
92
+ * Throw {@link SealedVaultRefusedError} for a state that may not be read or
93
+ * written, logging it: an unopened vault is the owner's next step (warn), a
94
+ * mismatch is the site's configuration and its disk disagreeing (error).
95
+ * Names the owner and vault, never a page or its content.
96
+ */
97
+ export function refuseUnsealedVault(owner, store, state) {
98
+ if (state === 'unopened') {
99
+ logger.warn(`[private-store] refused: ${owner}'s vault '${store}' is sealed by its kind but has not been opened (no store.json) — nothing written in the clear (#1834)`);
100
+ throw new SealedVaultRefusedError(owner, store, 'unopened');
101
+ }
102
+ if (state === 'mismatch') {
103
+ logger.error(`[private-store] refused: ${owner}'s vault '${store}' is sealed by its kind but its store.json is not sealed — not read or written (#1834)`);
104
+ throw new SealedVaultRefusedError(owner, store, 'mismatch');
105
+ }
106
+ }
107
+ /** True when `dir` is a store directory with encrypt on (`store.json`). */
108
+ export async function storeDirectoryIsEncrypted(dir, metaFilename = STORE_META_FILENAME) {
109
+ const file = path.join(dir, metaFilename);
110
+ if (!await fs.pathExists(file))
111
+ return false;
112
+ try {
113
+ const raw = await fs.readJson(file);
114
+ return raw?.encrypt === true;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ export async function writeStoreMeta(pagesDirectory, creator, store, record, layout) {
121
+ await fs.ensureDir(privateStoreRoot(pagesDirectory, creator, store, layout));
122
+ await fs.writeJson(storeMetaPath(pagesDirectory, creator, store, layout), record);
123
+ }
@@ -141,9 +141,45 @@ export interface PrivatePageName {
141
141
  store: string;
142
142
  title: string;
143
143
  }
144
+ /**
145
+ * The organization's shared folders (#1775, #2125) are kept as one more
146
+ * container under the vaults' parent folder, owned by this reserved name
147
+ * rather than a user: `pages/vaults/shared-folders/{folder}/`, laid out as a
148
+ * vault is. So the page and file doors, the per-folder page, file and search
149
+ * indexes, history, trash, backup and every scanner that keeps vault content
150
+ * out of the shared indexes treat a folder exactly as they treat a vault.
151
+ * What differs is who may act (a permission, `shared-folder-read` /
152
+ * `shared-folder-file` on `shared/{folder}`, never the owner rule:
153
+ * `utils/sharedFolderAccess.ts`), where the key comes from (the server, by
154
+ * SharedFolderManager), and the name: `shared/{folder}/{title}`, which is
155
+ * also its URL. No account can be called this (`RESERVED_USERNAMES`).
156
+ */
157
+ export declare const SHARED_FOLDER_OWNER = "shared-folders";
158
+ /** A shared folder page's name, and its URL without the leading slash: `shared/{folder}/{title}`. */
159
+ export declare const SHARED_PAGE_NAME_PREFIX = "shared/";
160
+ /** The first path segment of a shared folder's URL: `/shared/{folder}/{title}`. */
161
+ export declare const SHARED_URL_SEGMENT: string;
162
+ /** True when `owner` is the shared folders' container, not a user's. */
163
+ export declare function isSharedFolderOwner(owner: unknown): boolean;
144
164
  export declare function formatPrivatePageName(owner: string, store: string, title: string): string;
145
- /** The parts of a private page name, or null when `name` is not one. */
165
+ /**
166
+ * The parts of a private page name, or null when `name` is not one. A shared
167
+ * folder's page, `shared/{folder}/{title}`, reads as the container
168
+ * {@link SHARED_FOLDER_OWNER}'s; `vaults/shared-folders/…` is not a name.
169
+ */
146
170
  export declare function parsePrivatePageName(name: unknown): PrivatePageName | null;
171
+ /**
172
+ * A page's name for a log line (#1461, #2118, #2178): a private page's title
173
+ * stays out of logs, so it goes by its uuid, from its `metadata` — with its
174
+ * owner and store when its name holds them, and as "uuid unknown" before it
175
+ * has one. A private page saved under its bare title (#1520) is known only by
176
+ * its `metadata`. A public page goes by its name, and its uuid when known.
177
+ * The audit record is not a log line: it keeps the page's name.
178
+ */
179
+ export declare function nameForLog(pageName: string, metadata?: {
180
+ private?: unknown;
181
+ uuid?: unknown;
182
+ } | null): string;
147
183
  /**
148
184
  * The name a page an identifier opened is known by: its title — the
149
185
  * identifier may be a uuid or slug, and every downstream index is keyed by
@@ -198,21 +198,78 @@ export const LEGACY_PRIVATE_ROOT = 'private';
198
198
  * from `private/` to `vaults/` in #1506.
199
199
  */
200
200
  export const PRIVATE_SLUG_PREFIX = 'vaults--';
201
+ /**
202
+ * The organization's shared folders (#1775, #2125) are kept as one more
203
+ * container under the vaults' parent folder, owned by this reserved name
204
+ * rather than a user: `pages/vaults/shared-folders/{folder}/`, laid out as a
205
+ * vault is. So the page and file doors, the per-folder page, file and search
206
+ * indexes, history, trash, backup and every scanner that keeps vault content
207
+ * out of the shared indexes treat a folder exactly as they treat a vault.
208
+ * What differs is who may act (a permission, `shared-folder-read` /
209
+ * `shared-folder-file` on `shared/{folder}`, never the owner rule:
210
+ * `utils/sharedFolderAccess.ts`), where the key comes from (the server, by
211
+ * SharedFolderManager), and the name: `shared/{folder}/{title}`, which is
212
+ * also its URL. No account can be called this (`RESERVED_USERNAMES`).
213
+ */
214
+ export const SHARED_FOLDER_OWNER = 'shared-folders';
215
+ /** A shared folder page's name, and its URL without the leading slash: `shared/{folder}/{title}`. */
216
+ export const SHARED_PAGE_NAME_PREFIX = 'shared/';
217
+ /** The first path segment of a shared folder's URL: `/shared/{folder}/{title}`. */
218
+ export const SHARED_URL_SEGMENT = SHARED_PAGE_NAME_PREFIX.slice(0, -1);
219
+ /** True when `owner` is the shared folders' container, not a user's. */
220
+ export function isSharedFolderOwner(owner) {
221
+ return owner === SHARED_FOLDER_OWNER;
222
+ }
201
223
  export function formatPrivatePageName(owner, store, title) {
224
+ if (isSharedFolderOwner(owner))
225
+ return `${SHARED_PAGE_NAME_PREFIX}${store}/${title}`;
202
226
  return `${PRIVATE_PAGE_NAME_PREFIX}${owner}/${store}/${title}`;
203
227
  }
204
- /** The parts of a private page name, or null when `name` is not one. */
228
+ /**
229
+ * The parts of a private page name, or null when `name` is not one. A shared
230
+ * folder's page, `shared/{folder}/{title}`, reads as the container
231
+ * {@link SHARED_FOLDER_OWNER}'s; `vaults/shared-folders/…` is not a name.
232
+ */
205
233
  export function parsePrivatePageName(name) {
206
- if (typeof name !== 'string' || !name.startsWith(PRIVATE_PAGE_NAME_PREFIX))
234
+ if (typeof name !== 'string')
235
+ return null;
236
+ if (name.startsWith(SHARED_PAGE_NAME_PREFIX)) {
237
+ const parts = name.slice(SHARED_PAGE_NAME_PREFIX.length).split('/');
238
+ if (parts.length !== 2)
239
+ return null;
240
+ const [store, title] = parts;
241
+ if (!isValidStoreId(store) || !title.trim())
242
+ return null;
243
+ return { owner: SHARED_FOLDER_OWNER, store, title };
244
+ }
245
+ if (!name.startsWith(PRIVATE_PAGE_NAME_PREFIX))
207
246
  return null;
208
247
  const parts = name.slice(PRIVATE_PAGE_NAME_PREFIX.length).split('/');
209
248
  if (parts.length !== 3)
210
249
  return null;
211
250
  const [owner, store, title] = parts;
212
- if (!owner || !isSafePathSegment(owner) || !isValidStoreId(store) || !title.trim())
251
+ if (!owner || !isSafePathSegment(owner) || isSharedFolderOwner(owner) || !isValidStoreId(store) || !title.trim())
213
252
  return null;
214
253
  return { owner, store, title };
215
254
  }
255
+ /**
256
+ * A page's name for a log line (#1461, #2118, #2178): a private page's title
257
+ * stays out of logs, so it goes by its uuid, from its `metadata` — with its
258
+ * owner and store when its name holds them, and as "uuid unknown" before it
259
+ * has one. A private page saved under its bare title (#1520) is known only by
260
+ * its `metadata`. A public page goes by its name, and its uuid when known.
261
+ * The audit record is not a log line: it keeps the page's name.
262
+ */
263
+ export function nameForLog(pageName, metadata) {
264
+ const uuid = typeof metadata?.uuid === 'string' && metadata.uuid ? metadata.uuid : null;
265
+ const byUuid = `(uuid ${uuid ?? 'unknown'})`;
266
+ const vault = parsePrivatePageName(pageName);
267
+ if (vault)
268
+ return `a page in ${vault.owner}'s store '${vault.store}' ${byUuid}`;
269
+ if (metadata?.private === true)
270
+ return `a private page ${byUuid}`;
271
+ return uuid ? `'${pageName}' ${byUuid}` : `'${pageName}'`;
272
+ }
216
273
  /**
217
274
  * The name a page an identifier opened is known by: its title — the
218
275
  * identifier may be a uuid or slug, and every downstream index is keyed by