@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,320 @@
1
+ import BaseManager, { type BackupFolder } from './BaseManager.js';
2
+ import type { WikiEngine } from '../types/WikiEngine.js';
3
+ import { type ActorContext } from '../context/ActorContext.js';
4
+ import { type RecoveryWordsExport } from '../utils/recoveryWordsFile.js';
5
+ /** The folder every signed-in user may open (decision; the shipped `shared-folder-default` policy). */
6
+ export declare const DEFAULT_SHARED_FOLDER = "default";
7
+ /** The Executive shared folder (#2095); who opens it is the shipped policies' (`shared-folder-executive-read`, `shared-folder-executive-file`, `shared-folder-admin`). */
8
+ export declare const EXECUTIVE_SHARED_FOLDER = "executive";
9
+ /** The folders every site has, made once the recovery words are confirmed (#2125, #2095). */
10
+ export declare const SHIPPED_SHARED_FOLDERS: readonly string[];
11
+ /** Who may read and who may file in a folder, as the Shared folders screen sets it (#2126). */
12
+ export interface SharedFolderAccess {
13
+ /** Roles granted `shared-folder-read` on `shared/<name>` by the screen's policy. */
14
+ read: string[];
15
+ /** Roles granted `shared-folder-file` on `shared/<name>` by the screen's policy. */
16
+ file: string[];
17
+ }
18
+ /** A policy in force that reaches a folder and that the screen did not write: shipped, an add-on's, or the site's own. Shown, never changed here. */
19
+ export interface SharedFolderOtherPolicy {
20
+ id: string;
21
+ name: string;
22
+ effect: string;
23
+ /** Its subjects as written (`role:editor`, `job:nightly`); empty: everyone, anonymous visitors included. */
24
+ subjects: string[];
25
+ /** Which of `shared-folder-read` and `shared-folder-file` it decides, as the PDP matches actions (an empty list or `*` is both). */
26
+ actions: string[];
27
+ /** It has an id the screen writes for this folder: saving the folder is refused until it is renamed or removed. */
28
+ reserved: boolean;
29
+ }
30
+ /** The words waiting for this admin, as the confirm step needs them: never the words. */
31
+ export interface WaitingWords {
32
+ token: string;
33
+ /** Whether they replace a set already saved (a new set, #2144). */
34
+ replacing: boolean;
35
+ }
36
+ /** What Admin → Backup shows about the organization's recovery words: never the words. */
37
+ export interface SharedFoldersWordsInfo {
38
+ /** Which set (`recoveryWordsId`): what the admin's confirmation is bound to. */
39
+ wordsId: string;
40
+ madeAt: string;
41
+ shownTo: string;
42
+ /** Whether any folder key is sealed for this set: such a set can never be discarded. */
43
+ protectsFolders: boolean;
44
+ }
45
+ /** The door said no: not allowed, or the request does not fit the state. Nothing was changed. */
46
+ export declare class SharedFolderRefusedError extends Error {
47
+ readonly name = "SharedFolderRefusedError";
48
+ }
49
+ /** The words typed back are not the ones shown (#2144). Nothing was saved; the words still wait, to be typed again. */
50
+ export declare class SharedFolderWordsMismatchError extends SharedFolderRefusedError {
51
+ constructor();
52
+ }
53
+ /** The keys cannot be used right now (no server key, or not the one they were made under); the message says why. */
54
+ export declare class SharedFoldersUnavailableError extends Error {
55
+ readonly name = "SharedFoldersUnavailableError";
56
+ }
57
+ declare class SharedFolderManager extends BaseManager {
58
+ readonly description = "The organization's shared folders: each folder's key, the organization key and the recovery words (#1775, #2124)";
59
+ private storageDir;
60
+ /** The 32-byte wrap key derived from `NGDPBASE_SHARED_FOLDERS_KEY`, read once at start; null without one. */
61
+ private wrapKey;
62
+ private keyFingerprint;
63
+ /** Why the keys cannot be used, or null when they can. */
64
+ private unavailable;
65
+ private file;
66
+ private readonly pending;
67
+ private readonly serial;
68
+ constructor(engine: WikiEngine);
69
+ initialize(config?: Record<string, unknown>): Promise<void>;
70
+ shutdown(): Promise<void>;
71
+ private configuredStorageDir;
72
+ /** The wrapped keys are in the backup; a backup from before #2124 has none, so it is optional. */
73
+ backupFolders(): BackupFolder[];
74
+ /** The organization's recovery words as made (never the words), or null: none made yet. */
75
+ recoveryWordsInfo(): SharedFoldersWordsInfo | null;
76
+ /**
77
+ * Whether the admin shown the current words confirmed saving them, at the
78
+ * wording's current version: their consent in `shared-folders-recovery-words`
79
+ * for this set exists (ConsentManager's fact, asked each time). False with
80
+ * no words, no ConsentManager, or no wording page (then nobody could have
81
+ * confirmed what it says).
82
+ */
83
+ recoveryWordsConfirmed(): Promise<boolean>;
84
+ /** Why the keys cannot be used now, or null when they can. */
85
+ unavailableReason(): string | null;
86
+ /**
87
+ * Make the organization's 12 recovery words, to show to the admin `ctx`
88
+ * once (decision 4). Nothing is saved: the words wait, in memory only, for
89
+ * that admin's `saveRecoveryWords` with the returned token, for
90
+ * `ngdpbase.shared-folders.words-pending-minutes`. A second call replaces
91
+ * the first set unsaved. Refused while a set is saved: an unconfirmed set is
92
+ * discarded first (`discardUnconfirmedWords`), and a saved set is replaced
93
+ * with `prepareNewRecoveryWords`.
94
+ */
95
+ prepareRecoveryWords(ctx: ActorContext): Promise<{
96
+ token: string;
97
+ words: string[];
98
+ }>;
99
+ /**
100
+ * Make a new set of recovery words to replace the saved set (#2144): the
101
+ * admin `ctx`, with a fresh sign-in (`admin-system` and the step-up
102
+ * permission). Shown and confirmed exactly as the first set; nothing changes
103
+ * until `saveRecoveryWords` with all 12 typed back, which seals every folder
104
+ * key for the new words and retires the old ones. Abandoned or discarded
105
+ * (`discardRecoveryWords`), the old set stays.
106
+ */
107
+ prepareNewRecoveryWords(ctx: ActorContext): Promise<{
108
+ token: string;
109
+ words: string[];
110
+ }>;
111
+ /** Make 12 words and hold them, for `ctx` only, until saved, discarded or expired. Replaces any set waiting for them. */
112
+ private holdNewWords;
113
+ /** The words waiting for `ctx` to type them back, or null: none, or they expired. Never the words. */
114
+ waitingWords(ctx: ActorContext): WaitingWords | null;
115
+ /**
116
+ * The words waiting for `ctx`, to download as a file or print (#2144),
117
+ * while they still wait: once saved they are gone and cannot be made again.
118
+ * Recorded first as `recovery-words-export` (who, which set, how; never the
119
+ * words; on-failure refuse). Refused for a token that is not the one shown
120
+ * to them, or expired; the words are not discarded by a refused export.
121
+ */
122
+ pendingRecoveryWords(ctx: ActorContext, token: string, how: RecoveryWordsExport): Promise<{
123
+ words: string[];
124
+ madeAt: Date;
125
+ wordsId: string;
126
+ }>;
127
+ /** Forget the words shown to `ctx` and not saved: they are never shown again. A set they were to replace stays. */
128
+ discardRecoveryWords(ctx: ActorContext): void;
129
+ /** The words waiting for `ctx`, or undefined; expired words are forgotten. */
130
+ private livePending;
131
+ /**
132
+ * Discard the saved recovery words while the admin shown them has not
133
+ * confirmed saving them (operator, 2026-10-29): any holder of
134
+ * `admin-system` may, so a reminder that nobody can clear (that admin's
135
+ * account deleted, say) can always be cleared by making a new set and
136
+ * confirming it themselves. Refused once the set is confirmed, and while
137
+ * any folder key is sealed for it (`createFolderKey` needs a confirmed set,
138
+ * so such a set was confirmed once). Recorded as
139
+ * `shared-folder-words-discard` before the words are removed (on-failure
140
+ * refuse).
141
+ */
142
+ discardUnconfirmedWords(ctx: ActorContext): Promise<void>;
143
+ /**
144
+ * Save the recovery words shown to `ctx` (`prepareRecoveryWords` or
145
+ * `prepareNewRecoveryWords`) once they proved they kept them: `typed` is
146
+ * all 12 typed back (#2144). The caller asks for the tick first, and records
147
+ * the confirmation with ConsentManager after (`wordsId`).
148
+ *
149
+ * - A token that is not the one shown to them, or expired: refused, and the
150
+ * words are discarded.
151
+ * - Words that do not match: `SharedFolderWordsMismatchError`; nothing is
152
+ * saved and the words still wait, to be typed again.
153
+ * - The first set: recorded as `shared-folder-words-create`, then saved.
154
+ * Refused when a set was saved meanwhile.
155
+ * - A new set (#2144): asks the fresh sign-in again, seals every folder key
156
+ * again for the new words from its server-key wrap, records
157
+ * `shared-folder-words-replace`, then writes the new set and the sealed
158
+ * keys as one file renamed into place: the old words stop opening the
159
+ * folders, and on any failure before that the old set stays as it was.
160
+ * Refused when the saved set changed meanwhile.
161
+ *
162
+ * Each record is on-failure refuse, written before the save.
163
+ *
164
+ * @returns Which set of words was saved (`recoveryWordsId`)
165
+ */
166
+ saveRecoveryWords(ctx: ActorContext, token: string, typed: string): Promise<string>;
167
+ /**
168
+ * Every folder key in `folders`, its recovery copy sealed again for the
169
+ * words of `recovery` (#2144), opened with the server key. Nothing is
170
+ * written; a key that cannot be opened throws, and the caller keeps the
171
+ * old set.
172
+ */
173
+ private sealedForWords;
174
+ /**
175
+ * Make folder `folderId`'s own key (decision 2): a random key, wrapped
176
+ * under the server key and sealed for the recovery words, so it needs the
177
+ * words to exist and to be confirmed saved by the admin shown them
178
+ * (`recoveryWordsConfirmed`; operator, 2026-10-29): no folder key is ever
179
+ * protected only by words nobody saved. Recorded as `shared-folder-key-create` before it is
180
+ * saved (on-failure refuse). The key itself is not returned; open it with
181
+ * `openFolderKey`.
182
+ */
183
+ createFolderKey(ctx: ActorContext, folderId: string): Promise<void>;
184
+ /**
185
+ * Folder `folderId`'s key, unwrapped with the server key, for a caller the
186
+ * PDP permits `shared-folder-read` on the folder (#2125). The caller holds
187
+ * it for the moment it needs it and zeroes it. The page and file doors do
188
+ * not call this: they reach the key through the store I/O
189
+ * (`sharedFolderKeyFor`), only for a context permitted on the folder.
190
+ */
191
+ openFolderKey(ctx: ActorContext, folderId: string): Promise<Buffer>;
192
+ /** Folder `folderId`'s key with the server key, or undefined: no such folder, or the keys cannot be used. Never asks who; its callers have. */
193
+ private folderKey;
194
+ /** The folders there are, by name: each has its key. */
195
+ folderNames(): string[];
196
+ /**
197
+ * Make shared folder `name` (#2125): its key (`createFolderKey`, which
198
+ * needs the recovery words confirmed) and its place for pages, sealed with
199
+ * that key. Admins only (decision 8) until the Shared folders screen
200
+ * (slice 3). A new folder is opened by admins (`shared-folder-admin`) and
201
+ * read by executives (`shared-folder-executive-read`), and by nobody else
202
+ * until a policy names `shared/<name>`; `default` is shipped open to every
203
+ * signed-in user.
204
+ * Recorded as `shared-folder-create` before anything is made (on-failure
205
+ * refuse). Refused when it exists.
206
+ */
207
+ createFolder(ctx: ActorContext, name: string): Promise<void>;
208
+ /**
209
+ * Make the shipped folders (`SHIPPED_SHARED_FOLDERS`: `/shared/default` and
210
+ * `/shared/executive`) that are not there yet, once they can be made: the
211
+ * recovery words are confirmed. Finishing the words does this, and so does
212
+ * every start (`ensureShippedFoldersAtBoot`), so a site that confirmed its
213
+ * words before a shipped folder existed gets it on its next start.
214
+ * Idempotent. Returns the folders made now.
215
+ */
216
+ ensureShippedFolders(ctx: ActorContext): Promise<string[]>;
217
+ /**
218
+ * At start, make the shipped folders a site with confirmed words does not
219
+ * have yet (#2095), under the system context. Best-effort: a failure is
220
+ * logged and never blocks start-up; nothing is tried while the keys cannot
221
+ * be used.
222
+ */
223
+ ensureShippedFoldersAtBoot(): Promise<void>;
224
+ /**
225
+ * May `ctx` do `action` (a page or file action, or `shared-folder-read` /
226
+ * `shared-folder-file`) in folder `name`? The PDP's answer
227
+ * (`permitsInSharedFolder`); false for a folder that does not exist.
228
+ */
229
+ mayInFolder(ctx: ActorContext, action: string, name: string): Promise<boolean>;
230
+ /**
231
+ * The folders `ctx` may open (`shared-folder-read`), by name. Asking opens
232
+ * each one's key to this context (`sharedFolderKeyFor`), so the page and
233
+ * file doors can read what this reader may. The session middleware asks it
234
+ * on every request, so a grant removed ends access on the next request
235
+ * (decision 5).
236
+ */
237
+ foldersReadableBy(ctx: ActorContext): Promise<string[]>;
238
+ /**
239
+ * Record that `ctx` opened `what` (a page, its history, or a file) in
240
+ * folder `name` (`shared-folder-read`, on by default: every read of a
241
+ * shared folder is recorded, #1775). Called by what serves it, after the
242
+ * door allowed it. The writes are recorded by the page and file doors.
243
+ */
244
+ recordRead(ctx: ActorContext, name: string, what: {
245
+ page?: string;
246
+ attachmentId?: string;
247
+ action?: string;
248
+ }): Promise<void>;
249
+ /**
250
+ * Who may read and who may file in folder `name`, as the Shared folders
251
+ * screen set it: the roles its own policies (`shared-folder-access-<name>-read`
252
+ * / `-file`, in the site's custom configuration, in the shape the screen
253
+ * writes) name, and every other policy in force that reaches the folder,
254
+ * shown as it is, matched as PolicyEvaluator matches it. A policy with one of
255
+ * the screen's ids that the screen did not write is shown, marked
256
+ * `reserved`. `admin-system`, as the screen is. Display only: the PDP
257
+ * decides every access.
258
+ */
259
+ folderAccess(ctx: ActorContext, name: string): Promise<SharedFolderAccess & {
260
+ others: SharedFolderOtherPolicy[];
261
+ }>;
262
+ /** The roles a folder's access may name: every role there is but `anonymous`, by name. */
263
+ grantableRoles(): string[];
264
+ /**
265
+ * Set who may read and who may file in folder `name` (#2126, decision 8):
266
+ * writes the site's policies `shared-folder-access-<name>-read` (granting
267
+ * `shared-folder-read` on `shared/<name>`) and `-file` (`shared-folder-file`)
268
+ * to the roles given, through ConfigurationManager, and removes one whose
269
+ * list is empty. `admin-system`, with a fresh sign-in (the step-up
270
+ * permission, as every other configuration write asks). Refused for a folder
271
+ * that does not exist, a role that does not exist, `anonymous` (a folder is
272
+ * never public), and a folder one of whose ids holds a policy the screen did
273
+ * not write (it is never overwritten). Recorded as
274
+ * `shared-folder-access-change` (who, the folder, the roles before and
275
+ * after) once the write has succeeded; a failed write is recorded as a
276
+ * failure, and a record that cannot be written puts the policies back
277
+ * (on-failure refuse). ConfigurationManager records the configuration
278
+ * change as well. Nothing is written or recorded when nothing changes.
279
+ * Returns whether it changed.
280
+ *
281
+ * Only the screen's own policies change: a shipped or add-on policy, or one
282
+ * the site wrote elsewhere, still applies (`folderAccess` shows them).
283
+ */
284
+ setFolderAccess(ctx: ActorContext, name: string, access: SharedFolderAccess): Promise<boolean>;
285
+ /**
286
+ * Folder `name`'s access as the screen sees it: the screen's own policies
287
+ * (the site's entry under each id, in the shape `accessPolicy` writes), the
288
+ * ids holding a policy the screen did not write (the site's in another
289
+ * shape, a deny say, or one in force from a shipped or add-on layer), and
290
+ * the policies in force.
291
+ */
292
+ private accessState;
293
+ /** The site's own entries of the access policies (the custom configuration layer), as they are: never the shipped or an add-on's. */
294
+ private siteAccessPolicies;
295
+ /** The pages directory and the configured vault layout, where folders are kept. */
296
+ private pagesPlace;
297
+ /**
298
+ * Folder `folderId`'s key, opened with the recovery words instead of the
299
+ * server key: what recovery after a lost server key will use (a later
300
+ * slice: a new server key, every folder key rewrapped). Throws when the
301
+ * words are wrong. Works with no server key at all.
302
+ */
303
+ openFolderKeyWithWords(ctx: ActorContext, folderId: string, words: string): Promise<Buffer>;
304
+ private requireAdmin;
305
+ /**
306
+ * The fresh sign-in a new set of words (#2144) and an access change (#2126)
307
+ * need: the PDP applies step-up to `FRESH_SIGN_IN_PERMISSION`, and never
308
+ * grants it to a delegated credential (an agent token).
309
+ */
310
+ private requireFreshSignIn;
311
+ /** The wrap key, or throws why there is none to use. */
312
+ private requireUsable;
313
+ private pendingMinutes;
314
+ private fileOrNew;
315
+ private keysPath;
316
+ private readKeysFile;
317
+ /** Owner-only, written whole and renamed into place, so a crash never leaves half a file. */
318
+ private writeKeysFile;
319
+ }
320
+ export default SharedFolderManager;
@@ -407,8 +407,11 @@ declare class ValidationManager extends BaseManager {
407
407
  /**
408
408
  * Check for conflicts with existing pages (duplicate title, slug, or UUID mismatch).
409
409
  *
410
- * Used when saving a new page or comparing required-pages sources against live pages.
411
- * Checks slug first (most precise), then title.
410
+ * Used when saving a page and when comparing shipped pages (required pages,
411
+ * add-on pages) against live pages. Checks slug first (most precise), then
412
+ * title. A name clashes only when another page holds exactly that title or
413
+ * slug (`PageManager.uuidHoldingName`), never by the plural or fuzzy
414
+ * matching a link uses (#2140).
412
415
  *
413
416
  * @async
414
417
  * @param {string} uuid - The UUID of the page being checked
@@ -0,0 +1,99 @@
1
+ /**
2
+ * App-wide CSRF middleware (#663).
3
+ *
4
+ * Issues a per-session token at session start and validates it on every
5
+ * state-changing request. Token lives at `req.session.csrfToken` and is
6
+ * surfaced to templates as `csrfToken` via `getCommonTemplateData()`.
7
+ *
8
+ * Submission channels:
9
+ * - HTML forms: hidden `<input name="_csrf" value="<%= csrfToken %>">`
10
+ * - JS-driven fetches: `X-CSRF-Token` request header
11
+ *
12
+ * Safe methods (GET/HEAD/OPTIONS) pass through unchecked but still trigger
13
+ * lazy token issuance, so the very first response can already render the
14
+ * token in any form. Mismatches return HTTP 403 with a plain-text body and
15
+ * a logger.warn that captures method, path, ip, and which side was missing.
16
+ * When the cause is a secure session cookie on a plain-http request, the
17
+ * body says so in plain words (#2071).
18
+ *
19
+ * Token comparison uses `crypto.timingSafeEqual` after a length-equal guard
20
+ * so neither presence nor length leaks via response timing.
21
+ */
22
+ import { randomBytes, timingSafeEqual } from 'crypto';
23
+ import logger from '../utils/logger.js';
24
+ import { sessionCookieWithheld, SESSION_COOKIE_WITHHELD_MESSAGE } from '../utils/sessionSecurity.js';
25
+ const TOKEN_BYTES = 32;
26
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
27
+ const HEADER_NAME = 'x-csrf-token';
28
+ const BODY_FIELD = '_csrf';
29
+ export function generateCsrfToken() {
30
+ return randomBytes(TOKEN_BYTES).toString('hex');
31
+ }
32
+ function constantTimeEquals(a, b) {
33
+ if (typeof a !== 'string' || typeof b !== 'string')
34
+ return false;
35
+ if (a.length !== b.length)
36
+ return false;
37
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
38
+ }
39
+ function readSubmittedToken(req) {
40
+ const header = req.get(HEADER_NAME);
41
+ if (typeof header === 'string' && header.length > 0)
42
+ return header;
43
+ const body = req.body?.[BODY_FIELD];
44
+ return typeof body === 'string' && body.length > 0 ? body : undefined;
45
+ }
46
+ export function csrfMiddleware(req, res, next) {
47
+ if (req.session && !req.session.csrfToken) {
48
+ req.session.csrfToken = generateCsrfToken();
49
+ // Force a sync save before the response goes out. Otherwise the file-
50
+ // store write can lag behind the Set-Cookie header reaching the client,
51
+ // and a fast follow-up POST (typical in parallel E2E browsers) hits
52
+ // before the session file lands on disk — server creates a fresh
53
+ // session with a different token, CSRF check fails. Pay the latency
54
+ // cost once on first request per session; cheap on subsequent ones
55
+ // because the if-guard skips this branch.
56
+ if (typeof req.session.save === 'function') {
57
+ req.session.save(() => continueAfterSave(req, res, next));
58
+ return;
59
+ }
60
+ }
61
+ continueAfterSave(req, res, next);
62
+ }
63
+ function continueAfterSave(req, res, next) {
64
+ if (SAFE_METHODS.has(req.method)) {
65
+ next();
66
+ return;
67
+ }
68
+ // #818 — token-authenticated API requests are exempt. The request-credential
69
+ // middleware sets `req.bearerAuth` after AuthManager verifies the token
70
+ // (a Bearer header, or Dawarich's agent token in x-api-key, #1759); such requests
71
+ // carry no session cookie, so CSRF (a cookie-confused-deputy defense) does
72
+ // not apply. Cookie/session-authenticated POSTs still require the token.
73
+ if (req.bearerAuth === true) {
74
+ next();
75
+ return;
76
+ }
77
+ const expected = req.session?.csrfToken;
78
+ const submitted = readSubmittedToken(req);
79
+ if (typeof expected !== 'string' ||
80
+ typeof submitted !== 'string' ||
81
+ !constantTimeEquals(expected, submitted)) {
82
+ const expectedState = expected ? 'present' : 'missing';
83
+ const submittedState = submitted ? 'present' : 'missing';
84
+ // #2071: a secure cookie on a plain-http request never reached the
85
+ // browser, so no token could match. Say so, rather than only "invalid".
86
+ const withheld = sessionCookieWithheld(req);
87
+ logger.warn(`[csrf] rejected ${req.method} ${req.path} from ${req.ip ?? 'unknown'} — ` +
88
+ `expected:${expectedState} submitted:${submittedState}` +
89
+ (withheld ? ' (the session cookie is secure and this request is plain http, #2071)' : ''));
90
+ res
91
+ .status(403)
92
+ .type('text/plain')
93
+ .send(withheld
94
+ ? `Forbidden — invalid CSRF token. ${SESSION_COOKIE_WITHHELD_MESSAGE}`
95
+ : 'Forbidden — invalid CSRF token');
96
+ return;
97
+ }
98
+ next();
99
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * AgentTokenAuthProvider — verify a user-delegated agent API token presented
3
+ * on the `Authorization: Bearer <token>` header (#946).
4
+ *
5
+ * Counterpart to AuthentikBearerAuthProvider, but with no external IdP: the
6
+ * credential is minted in-app by the user it belongs to. Both providers can be
7
+ * enabled at once — the bearer middleware tries each registered bearer-capable
8
+ * provider in turn.
9
+ *
10
+ * The provider only answers "which user, and with which scopes". Permissions
11
+ * are resolved live from that user's record by the middleware, so a token
12
+ * never carries a snapshot of its owner's authority.
13
+ *
14
+ * Wire-up: request-time middleware in `src/app.ts` (header → verify →
15
+ * set req.userContext). Stateless — no session is created for API calls. It
16
+ * does NOT participate in the /login flow.
17
+ */
18
+ import logger from '../utils/logger.js';
19
+ export class AgentTokenAuthProvider {
20
+ id = 'agent-token';
21
+ displayName = 'Agent API Token';
22
+ /** Marks this provider as usable by the bearer middleware. */
23
+ acceptsBearer = true;
24
+ engine;
25
+ constructor(engine) {
26
+ this.engine = engine;
27
+ }
28
+ async verify(credentials) {
29
+ const token = credentials?.token;
30
+ if (typeof token !== 'string' || token.length === 0)
31
+ return null;
32
+ const manager = this.engine.getManager('AgentTokenManager');
33
+ if (!manager) {
34
+ logger.warn('[agent-token] AgentTokenManager not available — cannot verify');
35
+ return null;
36
+ }
37
+ const record = await manager.verify(token);
38
+ if (!record)
39
+ return null;
40
+ return {
41
+ username: record.owner,
42
+ viaToken: { id: record.id, name: record.name, scopes: record.scopes }
43
+ };
44
+ }
45
+ }
46
+ export default AgentTokenAuthProvider;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * AuthentikBearerAuthProvider — verify an Authentik-issued OAuth/OIDC bearer
3
+ * JWT presented on the `Authorization: Bearer <token>` header (#818).
4
+ *
5
+ * Unlike the interactive Google-OIDC code-exchange flow, this provider trusts
6
+ * a token an automated client (e.g. an AI agent) already holds — minted in
7
+ * Authentik via the `client_credentials` grant against a per-person service
8
+ * account. It verifies the JWT signature against Authentik's JWKS, checks
9
+ * issuer + audience, then coordinates the identity to a local User record.
10
+ *
11
+ * Design mirrors CloudflareAccessAuthProvider (#649):
12
+ * - User coordination by **email** (`email` claim).
13
+ * - Unknown user → **JIT-provision** with default-role (overridable per
14
+ * Authentik group via `group-map`).
15
+ * - `groups[]` claim → ngdpbase roles via config-map.
16
+ *
17
+ * Author attribution (epic decision A): the service account's email is set to
18
+ * match the human's ngdpbase account, so `findUserByEmail` resolves to that
19
+ * user and pages are authored under their existing username (the UI renders
20
+ * their displayName, e.g. "Jim Willeke").
21
+ *
22
+ * Wire-up: invoked by the request-time middleware in `src/app.ts` (header →
23
+ * verify → resolve-or-provision → set req.userContext, stateless — no session
24
+ * is created for API calls). It does NOT participate in the /login flow.
25
+ *
26
+ * Token verification uses `jose.createRemoteJWKSet` against the configured
27
+ * JWKS URL — keys are fetched on demand and cached + rotated by jose.
28
+ */
29
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
30
+ import { jobContextFromRequestWithReason } from '../context/JobContext.js';
31
+ import { systemPrincipalOf } from '../context/bootActions.js';
32
+ import logger from '../utils/logger.js';
33
+ import { emailDomain } from '../utils/emailForLog.js';
34
+ export class AuthentikBearerAuthProvider {
35
+ id = 'authentik-bearer';
36
+ displayName = 'Authentik (Bearer)';
37
+ /** Marks this provider as usable by the bearer middleware. */
38
+ acceptsBearer = true;
39
+ engine;
40
+ config;
41
+ jwks;
42
+ /**
43
+ * @param jwksOverride optional pre-built JWKS getter for tests; production
44
+ * omits this and uses `createRemoteJWKSet` against the configured JWKS URL.
45
+ */
46
+ constructor(engine, config, jwksOverride) {
47
+ this.engine = engine;
48
+ this.config = config;
49
+ this.jwks = jwksOverride ?? createRemoteJWKSet(new URL(config.jwksUrl));
50
+ }
51
+ /**
52
+ * Verify the bearer JWT and resolve (or provision) the local user.
53
+ * `credentials.token` is the raw JWT lifted from the `Authorization: Bearer`
54
+ * header by the request-time middleware.
55
+ */
56
+ async verify(credentials) {
57
+ const token = credentials.token;
58
+ if (!token) {
59
+ return null;
60
+ }
61
+ // The bearer middleware offers every bearer token to every bearer
62
+ // provider in turn, so this one routinely sees credentials that belong to
63
+ // another provider (an `ngdp_at_` agent token, an opaque OIDC token). A
64
+ // token that is not a compact JWS (three dot-separated segments) cannot be
65
+ // an Authentik JWT: decline it quietly. The result is the same `null`
66
+ // jwtVerify would give — nothing is accepted that was refused before —
67
+ // only the warn line, which fired on every agent-token request, goes.
68
+ // JWT-shaped tokens that fail verification still warn below.
69
+ if (token.split('.').length !== 3) {
70
+ logger.debug('[AuthentikBearerAuthProvider] bearer token is not a JWT — not an Authentik credential');
71
+ return null;
72
+ }
73
+ let verified;
74
+ try {
75
+ verified = await jwtVerify(token, this.jwks, {
76
+ issuer: this.config.issuer,
77
+ audience: this.config.audience
78
+ });
79
+ }
80
+ catch (err) {
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ // Surface the underlying cause (e.g. JWKS fetch network errors like
83
+ // ECONNREFUSED / EHOSTUNREACH / ENOTFOUND) — "fetch failed" alone is opaque.
84
+ const cause = (err && typeof err === 'object' && 'cause' in err)
85
+ ? err.cause
86
+ : undefined;
87
+ const causeStr = cause
88
+ ? ` (cause: ${cause.code ?? ''} ${cause.message ?? ''})`.trimEnd()
89
+ : '';
90
+ logger.warn(`[AuthentikBearerAuthProvider] JWT verification failed: ${message}${causeStr}`);
91
+ return null;
92
+ }
93
+ const payload = verified.payload;
94
+ const email = payload.email?.toLowerCase();
95
+ if (!email) {
96
+ logger.warn('[AuthentikBearerAuthProvider] JWT had no email claim');
97
+ return null;
98
+ }
99
+ return this.resolveOrProvision(email, payload);
100
+ }
101
+ async resolveOrProvision(email, payload) {
102
+ const userManager = this.engine.getManager('UserManager');
103
+ if (!userManager) {
104
+ logger.error('[AuthentikBearerAuthProvider] UserManager unavailable');
105
+ return null;
106
+ }
107
+ // Email-keyed lookup. Resolving to the human's existing account is the
108
+ // mechanism behind decision A (author reads as the human).
109
+ // #2073: an email several accounts share is refused, never guessed between
110
+ // and never given a new account: a gateway's header cannot ask which.
111
+ const holders = await userManager.getUsersByEmail(email);
112
+ if (holders.length > 1) {
113
+ logger.warn(`[AuthentikBearerAuthProvider] ${holders.length} accounts share an address at ${emailDomain(email)}; refused, since a gateway sign-in cannot ask which (#2073)`);
114
+ return null;
115
+ }
116
+ const existing = holders[0];
117
+ if (existing) {
118
+ logger.info(`[AuthentikBearerAuthProvider] Existing user matched: ${existing.username}`);
119
+ return { username: existing.username };
120
+ }
121
+ // JIT-provision. Username derived from a name claim or email local-part.
122
+ const desiredUsername = payload.preferred_username || email.split('@')[0];
123
+ const username = await this.deriveUniqueUsername(userManager, desiredUsername);
124
+ const displayName = payload.name || payload.preferred_username || username;
125
+ const roles = this.mapGroupsToRoles(payload.groups ?? []);
126
+ try {
127
+ const created = await userManager.createUser({
128
+ username,
129
+ email,
130
+ displayName,
131
+ // Federated users have no local password — mirrors GoogleOIDC / CF.
132
+ password: '',
133
+ roles,
134
+ isExternal: true,
135
+ isActive: true
136
+ }, jobContextFromRequestWithReason({ username: systemPrincipalOf(this.engine) }, 'provisioned by authentik-bearer')); // #1204: provisioned, not registered; #1179: the system principal acts, the reason names the provider
137
+ logger.info(`[AuthentikBearerAuthProvider] JIT-provisioned user: ${username} (an address at ${emailDomain(email)}) with roles=[${roles.join(', ')}]`);
138
+ return { username: created.username }; // #1882: the account as stored
139
+ }
140
+ catch (err) {
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ logger.error(`[AuthentikBearerAuthProvider] JIT provision failed for ${username}:`, message);
143
+ return null;
144
+ }
145
+ }
146
+ /**
147
+ * Map Authentik groups[] to ngdpbase role names. Groups without an entry in
148
+ * config.groupMap are dropped silently. Result always contains
149
+ * config.defaultRole — even with no group hits, user gets the default.
150
+ */
151
+ mapGroupsToRoles(groups) {
152
+ const out = new Set([this.config.defaultRole]);
153
+ for (const g of groups) {
154
+ const mapped = this.config.groupMap[g];
155
+ if (mapped)
156
+ out.add(mapped);
157
+ }
158
+ return Array.from(out);
159
+ }
160
+ async deriveUniqueUsername(userManager, desired) {
161
+ const getUser = userManager.getUser;
162
+ if (typeof getUser !== 'function')
163
+ return desired;
164
+ let candidate = desired;
165
+ let suffix = 1;
166
+ // Cap at a sane number — defensive only; collisions are rare in practice.
167
+ while (suffix < 100) {
168
+ const existing = await getUser.call(userManager, candidate);
169
+ if (!existing)
170
+ return candidate;
171
+ candidate = `${desired}${suffix}`;
172
+ suffix++;
173
+ }
174
+ return `${desired}-${Date.now()}`;
175
+ }
176
+ }