@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,21 @@
1
+ /**
2
+ * The credentials store (#1524): an account's ways in beyond its password.
3
+ *
4
+ * Passwords stay on the user record and are never copied here (decided
5
+ * 2026-10-02). This store holds passkeys, verified email / phone addresses
6
+ * and known devices (TOTP seeds once #421 is built) — one row each, many per
7
+ * account.
8
+ *
9
+ * Every row is signed with `NGDPBASE_CREDENTIALS_KEY` (the instance `.env`).
10
+ * A row that does not verify is never returned: a planted row is ignored, and
11
+ * reported through `onRejected` so the owner of the store raises the alert.
12
+ *
13
+ * AuthManager is the only door; nothing else reads or writes a provider.
14
+ */
15
+ import BaseProvider from './BaseProvider.js';
16
+ /** The environment variable holding the signing and encryption key. */
17
+ export const CREDENTIALS_KEY_ENV = 'NGDPBASE_CREDENTIALS_KEY';
18
+ export const CREDENTIAL_KINDS = ['passkey', 'totp', 'email', 'sms', 'device', 'identity', 'proxy', 'invitation'];
19
+ class BaseCredentialsProvider extends BaseProvider {
20
+ }
21
+ export default BaseCredentialsProvider;
@@ -68,8 +68,12 @@ export interface ScanResult {
68
68
  skippedTruncated?: boolean;
69
69
  /** Number of indexed items with no usable capture date (EXIF/QuickTime) — they sort by file mtime (#807) */
70
70
  noCaptureDate?: number;
71
+ /** Paths of the first items counted in `noCaptureDate`, capped; the count stays exact (#2191) */
72
+ noCaptureDateFiles?: string[];
71
73
  /** Number of indexed items with a partial (year-only / year+month) EXIF date, defaulted to Jan 1 (#808) */
72
74
  partialCaptureDate?: number;
75
+ /** Paths of the first items counted in `partialCaptureDate`, capped; the count stays exact (#2191) */
76
+ partialCaptureDateFiles?: string[];
73
77
  /** Number of stale index entries pruned because their file vanished from a scanned folder (#867) */
74
78
  removed?: number;
75
79
  /** Total elapsed time in milliseconds */
@@ -225,6 +225,14 @@ declare abstract class BasePageProvider extends BaseProvider {
225
225
  * @returns {string|null} Canonical page title or null
226
226
  */
227
227
  abstract findPage(identifier: string): string | null;
228
+ /**
229
+ * The uuid of the page that holds `name` as its title, or as its slug (#2140)
230
+ * @param field - Which name: 'title' or 'slug'
231
+ * @param name - The title or slug
232
+ * @param ctx - Who is asking: a vault page's title is matched in its vault
233
+ * @returns The holder's uuid, or null when no page holds it
234
+ */
235
+ abstract uuidHoldingName(field: 'title' | 'slug', name: string, ctx: ActorContext): string | null;
228
236
  /**
229
237
  * Get a page by its UUID
230
238
  * @param {string} uuid - Page UUID
@@ -0,0 +1,160 @@
1
+ /**
2
+ * CloudflareAccessAuthProvider — JWT-based trust of Cloudflare Access (#649).
3
+ *
4
+ * Cloudflare Access is a zero-trust gateway: it authenticates users at the
5
+ * edge (against your IdP) and forwards a signed JWT to the origin in the
6
+ * `Cf-Access-Jwt-Assertion` header. This provider verifies that JWT and
7
+ * coordinates the identity to a local User record.
8
+ *
9
+ * Design decisions (per #649 operator-confirmed 2026-05-25):
10
+ * - Q1 — User coordination by **email** (`identity.email` from the JWT).
11
+ * - Q2 — Unknown user → **JIT-provision** with default-role (overridable
12
+ * per CF group via `group-map`).
13
+ * - Q3 — CF `identity.groups[]` → ngdpbase roles via **config-map**.
14
+ *
15
+ * Cloudflare-Access-specific — not a generalised federated-IdP abstraction.
16
+ *
17
+ * Wire-up: this provider is invoked by the request-time middleware in
18
+ * `src/app.ts` (header → verify → JIT-or-lookup → `req.session.username`).
19
+ * It does NOT participate in the explicit /login flow; routes call AuthManager
20
+ * with `providerId='cloudflare-access'` only from the middleware path.
21
+ *
22
+ * Token verification uses `jose.createRemoteJWKSet` against
23
+ * `https://<team-domain>/cdn-cgi/access/certs` — keys are fetched on demand
24
+ * and cached + rotated by jose internally.
25
+ */
26
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
27
+ import { jobContextFromRequestWithReason } from '../context/JobContext.js';
28
+ import { systemPrincipalOf } from '../context/bootActions.js';
29
+ import logger from '../utils/logger.js';
30
+ import { emailDomain } from '../utils/emailForLog.js';
31
+ export class CloudflareAccessAuthProvider {
32
+ id = 'cloudflare-access';
33
+ displayName = 'Cloudflare Access';
34
+ /** An identity provider, as Google: AAL1 with no `amr` of its own (#1523). */
35
+ factor = { amr: [], aal: 1, primary: true };
36
+ engine;
37
+ config;
38
+ jwks;
39
+ issuer;
40
+ /**
41
+ * @param jwksOverride optional pre-built JWKS getter for tests; production
42
+ * omits this and uses `createRemoteJWKSet` against the team-domain certs
43
+ * endpoint.
44
+ */
45
+ constructor(engine, config, jwksOverride) {
46
+ this.engine = engine;
47
+ this.config = config;
48
+ this.issuer = `https://${config.teamDomain}`;
49
+ this.jwks = jwksOverride
50
+ ?? createRemoteJWKSet(new URL(`${this.issuer}/cdn-cgi/access/certs`));
51
+ }
52
+ /**
53
+ * Verify the Cloudflare Access JWT and resolve (or provision) the local user.
54
+ * `credentials.token` is the raw JWT lifted from the `Cf-Access-Jwt-Assertion`
55
+ * header by the request-time middleware.
56
+ */
57
+ async verify(credentials) {
58
+ const token = credentials.token;
59
+ if (!token) {
60
+ return null;
61
+ }
62
+ let verified;
63
+ try {
64
+ verified = await jwtVerify(token, this.jwks, {
65
+ issuer: this.issuer,
66
+ audience: this.config.applicationAud
67
+ });
68
+ }
69
+ catch (err) {
70
+ const message = err instanceof Error ? err.message : String(err);
71
+ logger.warn(`[CloudflareAccessAuthProvider] JWT verification failed: ${message}`);
72
+ return null;
73
+ }
74
+ const payload = verified.payload;
75
+ const email = payload.email?.toLowerCase();
76
+ if (!email) {
77
+ logger.warn('[CloudflareAccessAuthProvider] JWT had no email claim');
78
+ return null;
79
+ }
80
+ return this.resolveOrProvision(email, payload);
81
+ }
82
+ async resolveOrProvision(email, payload) {
83
+ const userManager = this.engine.getManager('UserManager');
84
+ if (!userManager) {
85
+ logger.error('[CloudflareAccessAuthProvider] UserManager unavailable');
86
+ return null;
87
+ }
88
+ // Email-keyed lookup (Q1=a).
89
+ // #2073: an email several accounts share is refused, never guessed between
90
+ // and never given a new account: a gateway's header cannot ask which.
91
+ const holders = await userManager.getUsersByEmail(email);
92
+ if (holders.length > 1) {
93
+ logger.warn(`[CloudflareAccessAuthProvider] ${holders.length} accounts share an address at ${emailDomain(email)}; refused, since a gateway sign-in cannot ask which (#2073)`);
94
+ return null;
95
+ }
96
+ const existing = holders[0];
97
+ if (existing) {
98
+ logger.info(`[CloudflareAccessAuthProvider] Existing user matched: ${existing.username}`);
99
+ return { username: existing.username };
100
+ }
101
+ // JIT-provision (Q2=i). Username derived from local-part of email; if
102
+ // already taken (e.g. existing local-only user with same handle but
103
+ // different email), append a numeric suffix.
104
+ const desiredUsername = email.split('@')[0];
105
+ const username = await this.deriveUniqueUsername(userManager, desiredUsername);
106
+ const displayName = payload.identity?.name || username;
107
+ const roles = this.mapGroupsToRoles(payload.identity?.groups ?? []);
108
+ try {
109
+ const created = await userManager.createUser({
110
+ username,
111
+ email,
112
+ displayName,
113
+ // Federated users have no local password — mirrors GoogleOIDC's pattern
114
+ // (src/providers/GoogleOIDCProvider.ts:215). isExternal=true blocks
115
+ // local password reset; isActive=true allows immediate login.
116
+ password: '',
117
+ roles,
118
+ isExternal: true,
119
+ isActive: true
120
+ }, jobContextFromRequestWithReason({ username: systemPrincipalOf(this.engine) }, 'provisioned by cloudflare-access')); // #1204: provisioned, not registered; #1179: the system principal acts, the reason names the provider
121
+ logger.info(`[CloudflareAccessAuthProvider] JIT-provisioned user: ${username} (an address at ${emailDomain(email)}) with roles=[${roles.join(', ')}]`);
122
+ return { username: created.username }; // #1882: the account as stored
123
+ }
124
+ catch (err) {
125
+ const message = err instanceof Error ? err.message : String(err);
126
+ logger.error(`[CloudflareAccessAuthProvider] JIT provision failed for ${username}:`, message);
127
+ return null;
128
+ }
129
+ }
130
+ /**
131
+ * Map CF identity.groups[] to ngdpbase role names. Groups without an
132
+ * entry in config.groupMap are dropped silently. Result always contains
133
+ * config.defaultRole — even with no group hits, user gets the default.
134
+ */
135
+ mapGroupsToRoles(groups) {
136
+ const out = new Set([this.config.defaultRole]);
137
+ for (const g of groups) {
138
+ const mapped = this.config.groupMap[g];
139
+ if (mapped)
140
+ out.add(mapped);
141
+ }
142
+ return Array.from(out);
143
+ }
144
+ async deriveUniqueUsername(userManager, desired) {
145
+ const getUser = userManager.getUser;
146
+ if (typeof getUser !== 'function')
147
+ return desired;
148
+ let candidate = desired;
149
+ let suffix = 1;
150
+ // Cap at a sane number — defensive only; collisions are rare in practice.
151
+ while (suffix < 100) {
152
+ const existing = await getUser.call(userManager, candidate);
153
+ if (!existing)
154
+ return candidate;
155
+ candidate = `${desired}${suffix}`;
156
+ suffix++;
157
+ }
158
+ return `${desired}-${Date.now()}`;
159
+ }
160
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The credentials store as one JSON file beside the user store (#1524),
3
+ * `${FAST_STORAGE}/users/credentials.json` by default.
4
+ *
5
+ * - Written owner-only (`0600`) through the atomic save, so the file is never
6
+ * readable by other local accounts, not even mid-write (#1560).
7
+ * - Every row carries `sig`: HMAC-SHA-256 over its fields in a fixed order,
8
+ * keyed by `NGDPBASE_CREDENTIALS_KEY`. On load a row whose signature is
9
+ * missing or wrong is reported and can never sign anyone in; it is kept in
10
+ * quarantine and written back unchanged (#1633), so a lost key costs no
11
+ * data. `scripts/retrust-credentials.ts` re-signs them on purpose.
12
+ * - Nothing here is a password hash. Public keys, (when TOTP is built, #421, encrypted seeds) and token
13
+ * hashes are low value to a reader; a writer is what signing stops.
14
+ */
15
+ import { createHmac, timingSafeEqual } from 'node:crypto';
16
+ import fs from 'fs-extra';
17
+ import path from 'node:path';
18
+ import BaseCredentialsProvider, { CREDENTIAL_KINDS } from './BaseCredentialsProvider.js';
19
+ import { writeFileAtomic } from '../utils/atomicWrite.js';
20
+ import { secureExistingSecretFile } from '../utils/secretFileMode.js';
21
+ import logger from '../utils/logger.js';
22
+ /** The signed fields, in a fixed order, so a signature never depends on key order in the file. */
23
+ function canonical(r) {
24
+ return JSON.stringify([r.id, r.username, r.kind, r.subject, r.secret, r.label, r.createdAt, r.lastUsedAt ?? null]);
25
+ }
26
+ function isRecord(r) {
27
+ const o = r;
28
+ return typeof o === 'object' && o !== null &&
29
+ ['id', 'username', 'subject', 'secret', 'label', 'createdAt'].every(k => typeof o[k] === 'string') &&
30
+ CREDENTIAL_KINDS.includes(o.kind) &&
31
+ (o.lastUsedAt === undefined || typeof o.lastUsedAt === 'string');
32
+ }
33
+ class FileCredentialsProvider extends BaseCredentialsProvider {
34
+ file;
35
+ key;
36
+ providerName = 'FileCredentialsProvider';
37
+ providerDescription = 'Signed JSON credentials store beside the user store';
38
+ rows = new Map();
39
+ /** #1633: rows that failed verification, exactly as read, written back unchanged. */
40
+ quarantine = [];
41
+ /**
42
+ * @param file the store; created on first write
43
+ * @param key `NGDPBASE_CREDENTIALS_KEY`; refused when empty
44
+ */
45
+ constructor(file, key) {
46
+ super();
47
+ this.file = file;
48
+ this.key = key;
49
+ if (!key)
50
+ throw new Error('The credentials store needs NGDPBASE_CREDENTIALS_KEY (#1524)');
51
+ }
52
+ sign(r) {
53
+ return createHmac('sha256', this.key).update(canonical(r)).digest('base64');
54
+ }
55
+ verifies(r, sig) {
56
+ const expected = Buffer.from(this.sign(r));
57
+ const given = Buffer.from(sig);
58
+ return expected.length === given.length && timingSafeEqual(expected, given);
59
+ }
60
+ async initialize(onRejected) {
61
+ this.rows.clear();
62
+ this.quarantine = [];
63
+ if (!(await fs.pathExists(this.file)))
64
+ return;
65
+ const tightened = secureExistingSecretFile(this.file); // #1560
66
+ if (tightened)
67
+ logger.warn(tightened);
68
+ const parsed = JSON.parse(await fs.readFile(this.file, 'utf8'));
69
+ const rejected = [];
70
+ for (const raw of Array.isArray(parsed.rows) ? parsed.rows : []) {
71
+ const { sig, ...fields } = raw;
72
+ let reason = null;
73
+ if (!isRecord(fields))
74
+ reason = 'malformed';
75
+ else if (typeof sig !== 'string' || sig === '')
76
+ reason = 'unsigned';
77
+ else if (!this.verifies(fields, sig))
78
+ reason = 'bad-signature';
79
+ if (reason) {
80
+ const entry = { row: fields, reason };
81
+ rejected.push(entry);
82
+ this.quarantine.push({ raw: raw, rejected: entry });
83
+ }
84
+ else {
85
+ this.rows.set(fields.id, fields);
86
+ }
87
+ }
88
+ if (rejected.length > 0)
89
+ onRejected(rejected);
90
+ }
91
+ list(username) {
92
+ return [...this.rows.values()]
93
+ .filter(r => r.username === username)
94
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
95
+ }
96
+ listKind(kind) {
97
+ return [...this.rows.values()]
98
+ .filter(r => r.kind === kind)
99
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
100
+ }
101
+ get(id) {
102
+ return this.rows.get(id) ?? null;
103
+ }
104
+ async add(record) {
105
+ if (!isRecord(record))
106
+ throw new Error('Not a credential record (#1524)');
107
+ if (this.rows.has(record.id))
108
+ throw new Error(`Credential ${record.id} already exists`);
109
+ for (const r of this.rows.values()) {
110
+ if (r.kind === record.kind && r.subject === record.subject) {
111
+ throw new Error(`A ${record.kind} credential with that subject is already enrolled`);
112
+ }
113
+ }
114
+ this.rows.set(record.id, { ...record });
115
+ await this.save();
116
+ }
117
+ async remove(id) {
118
+ if (!this.rows.delete(id))
119
+ return false;
120
+ await this.save();
121
+ return true;
122
+ }
123
+ findBySubject(kind, subject) {
124
+ for (const r of this.rows.values())
125
+ if (r.kind === kind && r.subject === subject)
126
+ return r;
127
+ return null;
128
+ }
129
+ async touch(id, at, secret) {
130
+ const r = this.rows.get(id);
131
+ if (!r)
132
+ return;
133
+ r.lastUsedAt = at;
134
+ if (secret !== undefined)
135
+ r.secret = secret;
136
+ await this.save();
137
+ }
138
+ async relabel(id, label) {
139
+ const r = this.rows.get(id);
140
+ if (!r)
141
+ return false;
142
+ r.label = label;
143
+ await this.save();
144
+ return true;
145
+ }
146
+ location() {
147
+ return this.file;
148
+ }
149
+ quarantined() {
150
+ return this.quarantine.map(q => q.rejected);
151
+ }
152
+ async retrustQuarantined() {
153
+ const trusted = [];
154
+ const kept = [];
155
+ for (const q of this.quarantine) {
156
+ const { sig: _sig, ...fields } = q.raw;
157
+ const clash = isRecord(fields) && (this.rows.has(fields.id) ||
158
+ [...this.rows.values()].some(r => r.kind === fields.kind && r.subject === fields.subject));
159
+ if (!isRecord(fields) || clash) {
160
+ kept.push(q);
161
+ continue;
162
+ }
163
+ this.rows.set(fields.id, { ...fields });
164
+ trusted.push({ ...fields });
165
+ }
166
+ this.quarantine = kept;
167
+ if (trusted.length > 0)
168
+ await this.save();
169
+ return trusted;
170
+ }
171
+ getDurability() {
172
+ return { bufferedForMs: 0, bufferedRecords: 0, fsync: true };
173
+ }
174
+ async save() {
175
+ const out = {
176
+ version: 1,
177
+ rows: [
178
+ ...[...this.rows.values()].map(r => ({ ...r, sig: this.sign(r) })),
179
+ // #1633: quarantined rows go back exactly as they were read — never
180
+ // re-signed here, never dropped — until an operator re-trusts them.
181
+ ...this.quarantine.map(q => q.raw)
182
+ ]
183
+ };
184
+ await fs.ensureDir(path.dirname(this.file), { mode: 0o700 });
185
+ await writeFileAtomic(this.file, JSON.stringify(out, null, 2), 'utf8', { fsync: true, mode: 0o600 });
186
+ }
187
+ }
188
+ export default FileCredentialsProvider;
@@ -221,6 +221,12 @@ declare class FileSystemMediaProvider extends BaseMediaProvider {
221
221
  * Patterns ending with `/` are directory-only; all others match both files and dirs.
222
222
  */
223
223
  private matchesIgnorePattern;
224
+ /**
225
+ * Add a file to a capture-date list until CAPTURE_DATE_LIST_CAP (#2191).
226
+ * The path is relative to the media folder it was found under, so the
227
+ * notice and its email do not disclose the server's storage layout.
228
+ */
229
+ private listCaptureDateFile;
224
230
  /**
225
231
  * Process a single media file: stat, EXIF read, index update.
226
232
  */