@jwilleke/ngdpbase-types 5.40.0 → 5.41.1

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 (47) hide show
  1. package/lib/packages/media-date/index.d.ts +34 -0
  2. package/lib/packages/ngdpbase-types/index.d.ts +1 -1
  3. package/lib/src/ice/fileHandling.d.ts +6 -1
  4. package/lib/src/ice/runExport.d.ts +41 -0
  5. package/lib/src/ice/runImport.d.ts +16 -7
  6. package/lib/src/ice/shims/csv.d.ts +24 -0
  7. package/lib/src/ice/shims/export.d.ts +23 -0
  8. package/lib/src/ice/shims/index.d.ts +3 -1
  9. package/lib/src/ice/shims/svg.d.ts +27 -0
  10. package/lib/src/ice/shims/takeout.d.ts +83 -0
  11. package/lib/src/ice/types.d.ts +251 -5
  12. package/lib/src/ice/vet.d.ts +19 -0
  13. package/lib/src/managers/AttachmentManager.d.ts +86 -36
  14. package/lib/src/managers/AuthManager.d.ts +117 -7
  15. package/lib/src/managers/AuthManager.js +341 -37
  16. package/lib/src/managers/ConfigurationManager.d.ts +6 -0
  17. package/lib/src/managers/ExportManager.d.ts +16 -0
  18. package/lib/src/managers/ExportManager.js +25 -0
  19. package/lib/src/managers/FileManager.d.ts +182 -0
  20. package/lib/src/managers/ImportManager.d.ts +264 -16
  21. package/lib/src/managers/MediaManager.d.ts +11 -0
  22. package/lib/src/managers/SharedFolderManager.d.ts +320 -0
  23. package/lib/src/providers/BaseMediaProvider.d.ts +4 -0
  24. package/lib/src/providers/FileSystemMediaProvider.d.ts +25 -0
  25. package/lib/src/security/PolicyDecisionPoint.d.ts +3 -2
  26. package/lib/src/security/permissions.generated.d.ts +3 -3
  27. package/lib/src/types/File.d.ts +90 -0
  28. package/lib/src/types/express.d.ts +3 -1
  29. package/lib/src/types/linkedom.d.ts +2 -0
  30. package/lib/src/utils/auditEventNames.generated.d.ts +2 -0
  31. package/lib/src/utils/auditEventNames.generated.js +2 -0
  32. package/lib/src/utils/auditEvents.d.ts +3 -0
  33. package/lib/src/utils/auditEvents.js +3 -0
  34. package/lib/src/utils/fileMigrationPlan.d.ts +81 -0
  35. package/lib/src/utils/mediaFolderOverlap.d.ts +50 -0
  36. package/lib/src/utils/privateStoreAccess.d.ts +21 -0
  37. package/lib/src/utils/privateStoreAccess.js +11 -0
  38. package/lib/src/utils/privateStoreImport.d.ts +0 -10
  39. package/lib/src/utils/privateStorePath.d.ts +12 -0
  40. package/lib/src/utils/privateStorePath.js +18 -0
  41. package/lib/src/utils/recoveryWordsFile.d.ts +54 -0
  42. package/lib/src/utils/sessionEnd.d.ts +1 -0
  43. package/lib/src/utils/sessionEnd.js +4 -0
  44. package/lib/src/utils/sessionIdle.d.ts +4 -0
  45. package/lib/src/utils/sessionIdle.js +6 -0
  46. package/package.json +1 -1
  47. package/lib/src/providers/mediaFilenameDate.d.ts +0 -22
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The files migration dry run (#1859, epic #1851): what copying today's
3
+ * attachments into files WOULD do, worked out from the door's inventory
4
+ * (`FileManager.inventory`). Pure: it reads nothing and writes nothing.
5
+ *
6
+ * The rules are the decision log's
7
+ * (https://github.com/jwilleke/ngdpbase/issues/1851#issuecomment-6236003946):
8
+ * - two different files with the same name in one place get a numeric
9
+ * suffix, `report.pdf` and `report-001.pdf`;
10
+ * - each page that shows a renamed file is rewritten to its new name, so it
11
+ * keeps showing its own file;
12
+ * - an unknown or invalid uploader gets an owner from configuration.
13
+ */
14
+ import type { FileInventory, FileRecord, VaultFileSummary } from '../types/File.js';
15
+ /** The configured values the plan applies (`ngdpbase.files.migration.*`). */
16
+ export interface FileMigrationOptions {
17
+ /** Recorded uploader → username, applied first (`owner-map`). */
18
+ ownerMap: Record<string, string>;
19
+ /** Owner for a file whose record names no user (`default-owner`). */
20
+ defaultOwner: string;
21
+ /** Width of the clash suffix: 3 gives `-001` (`clash-suffix-digits`). */
22
+ suffixDigits: number;
23
+ }
24
+ /** A file the migration would give a new name, and the pages it would rewrite to it. */
25
+ export interface PlannedRename {
26
+ id: string;
27
+ from: string;
28
+ to: string;
29
+ pages: string[];
30
+ }
31
+ /** Who a group of public files would belong to after the migration. */
32
+ export interface PlannedOwner {
33
+ /** The uploader the record names, or `null` when it names none. */
34
+ recorded: string | null;
35
+ owner: string;
36
+ /** `kept`: the record names a user; `mapped`: `owner-map`; `default`: `default-owner`. */
37
+ how: 'kept' | 'mapped' | 'default';
38
+ files: number;
39
+ }
40
+ export interface FileMigrationReport {
41
+ publicFiles: {
42
+ count: number;
43
+ bytes: number;
44
+ byType: Array<{
45
+ type: string;
46
+ files: number;
47
+ }>;
48
+ };
49
+ owners: PlannedOwner[];
50
+ /** Public files whose record names no user (before the owner mapping). */
51
+ noOwner: number;
52
+ /** Each clashing name, with the renames it would get. */
53
+ clashes: Array<{
54
+ name: string;
55
+ files: number;
56
+ renames: PlannedRename[];
57
+ }>;
58
+ /** Each page that would be rewritten, and to which names. */
59
+ pagesToRewrite: Array<{
60
+ page: string;
61
+ changes: Array<{
62
+ from: string;
63
+ to: string;
64
+ }>;
65
+ }>;
66
+ vaults: VaultFileSummary[];
67
+ }
68
+ /** The `-###` name a clash gets: before the extension, `route.png` → `route-001.png`. */
69
+ export declare function suffixedName(name: string, n: number, digits: number): string;
70
+ /**
71
+ * The new name of every file in one place that shares its name with another
72
+ * (id → new name). The oldest keeps the name (then the lowest id, so the
73
+ * answer is the same on every run); the rest are numbered from 1 in that
74
+ * order, skipping any name already used in the place. Names match exactly,
75
+ * as a link finds a file today.
76
+ */
77
+ export declare function planClashRenames(files: ReadonlyArray<Pick<FileRecord, 'id' | 'filename' | 'dateCreated'>>, digits: number): Map<string, string>;
78
+ /** What the migration would do with this inventory. */
79
+ export declare function planFileMigration(inventory: FileInventory, opts: FileMigrationOptions): FileMigrationReport;
80
+ /** The report as the operator reads it. */
81
+ export declare function formatFileMigrationReport(r: FileMigrationReport, header?: string[]): string;
@@ -0,0 +1,50 @@
1
+ /** One place private files are kept, and what to call it in a message. */
2
+ export interface PrivateStorageRoot {
3
+ label: string;
4
+ path: string;
5
+ }
6
+ /** How a media folder overlaps a root: it is the root, holds it, or is inside it. */
7
+ export type OverlapRelation = 'equals' | 'contains' | 'is inside';
8
+ export interface MediaFolderOverlap {
9
+ folder: string;
10
+ /** 1-based position of the folder in `ngdpbase.media.folders`. */
11
+ position: number;
12
+ root: PrivateStorageRoot;
13
+ relation: OverlapRelation;
14
+ }
15
+ /** Minimal configuration surface, so tests need no live engine. */
16
+ export interface StorageRootConfig {
17
+ getProperty(key: string, defaultValue: unknown): unknown;
18
+ getResolvedDataPath(key: string, defaultValue: string): string;
19
+ }
20
+ /**
21
+ * Where private files live: the vaults (shared folders' pages and files are
22
+ * the `shared-folders` container's vault, so they are in here), the legacy
23
+ * `private/` root a site may not have moved yet, the shared folders' key
24
+ * storage, and two places in attachment storage: the old private attachments
25
+ * the migration leaves behind and reads as a fallback, and the thumbnail cache,
26
+ * which holds thumbnails of vault images. Attachment paths are the ones
27
+ * BasicAttachmentProvider resolves.
28
+ */
29
+ export declare function privateStorageRoots(config: StorageRootConfig): PrivateStorageRoot[];
30
+ /**
31
+ * The real path of `p`, or of its nearest existing ancestor with the rest
32
+ * appended. `p` is not normalized first: `link/..` is the parent of where
33
+ * `link` points, not the directory holding `link` (#2195).
34
+ */
35
+ export declare function realPathOf(p: string): string;
36
+ /** Whether `child` is `parent` or under it; both already real paths. */
37
+ export declare function isSameOrUnder(child: string, parent: string): boolean;
38
+ /** How `folder` overlaps `root`, or null for no overlap. */
39
+ export declare function overlapRelation(folder: string, root: string): OverlapRelation | null;
40
+ /** The first private storage root `folder` overlaps, or null. */
41
+ export declare function findMediaFolderOverlap(folder: string, roots: PrivateStorageRoot[], position?: number): MediaFolderOverlap | null;
42
+ /** Each of `folders` that overlaps a root, with its position in the list. */
43
+ export declare function findMediaFolderOverlaps(folders: string[], roots: PrivateStorageRoot[]): MediaFolderOverlap[];
44
+ /**
45
+ * The message for a refused folder. It names the folder by its position and
46
+ * the kind of storage. It never echoes the folder: one inside a vault would
47
+ * name the vault's owner and id, and the message reaches admin notices and
48
+ * their email.
49
+ */
50
+ export declare function describeMediaFolderOverlap(o: MediaFolderOverlap): string;
@@ -38,6 +38,16 @@ export interface VaultDecider {
38
38
  permit: boolean;
39
39
  }>;
40
40
  }
41
+ /** The PDP surface {@link holdsInVault} needs. */
42
+ export interface VaultHolder {
43
+ holds(subject: unknown, action: string, on: {
44
+ resource: {
45
+ type: string;
46
+ id: string;
47
+ };
48
+ attributes: Record<string, unknown>;
49
+ }): Promise<boolean>;
50
+ }
41
51
  export declare function mayActInPrivateContainer(ctx: ActorContext, owner: string, opts?: {
42
52
  vault?: string;
43
53
  }): boolean;
@@ -59,3 +69,14 @@ export declare function permitsInVault(pdp: VaultDecider | null | undefined, ctx
59
69
  vault: string;
60
70
  resource: string;
61
71
  }): Promise<boolean>;
72
+ /**
73
+ * Does `ctx` HOLD `action` inside `owner`'s vault (#2120)? {@link permitsInVault}
74
+ * without the step-up rule, for an affordance, and to tell a refusal for a
75
+ * stale sign-in (answered with the step-up prompt) from a plain one. Shared
76
+ * folders are not asked: `permitsInVault` decides them by permission alone.
77
+ */
78
+ export declare function holdsInVault(pdp: VaultHolder | null | undefined, ctx: ActorContext, action: string, where: {
79
+ owner: string;
80
+ vault: string;
81
+ resource: string;
82
+ }): Promise<boolean>;
@@ -71,3 +71,14 @@ export async function permitsInVault(pdp, ctx, action, where) {
71
71
  return false;
72
72
  return (await pdp.decide(ctx, { action, resource: { type: 'page', id: where.resource }, attributes: { vault: where.vault } })).permit;
73
73
  }
74
+ /**
75
+ * Does `ctx` HOLD `action` inside `owner`'s vault (#2120)? {@link permitsInVault}
76
+ * without the step-up rule, for an affordance, and to tell a refusal for a
77
+ * stale sign-in (answered with the step-up prompt) from a plain one. Shared
78
+ * folders are not asked: `permitsInVault` decides them by permission alone.
79
+ */
80
+ export async function holdsInVault(pdp, ctx, action, where) {
81
+ if (isSharedFolderOwner(where.owner) || typeof pdp?.holds !== 'function' || !mayActInPrivateContainer(ctx, where.owner, { vault: where.vault }))
82
+ return false;
83
+ return pdp.holds(ctx, action, { resource: { type: 'page', id: where.resource }, attributes: { vault: where.vault } });
84
+ }
@@ -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>;
@@ -168,6 +168,18 @@ export declare function formatPrivatePageName(owner: string, store: string, titl
168
168
  * {@link SHARED_FOLDER_OWNER}'s; `vaults/shared-folders/…` is not a name.
169
169
  */
170
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;
171
183
  /**
172
184
  * The name a page an identifier opened is known by: its title — the
173
185
  * identifier may be a uuid or slug, and every downstream index is keyed by
@@ -252,6 +252,24 @@ export function parsePrivatePageName(name) {
252
252
  return null;
253
253
  return { owner, store, title };
254
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
+ }
255
273
  /**
256
274
  * The name a page an identifier opened is known by: its title — the
257
275
  * identifier may be a uuid or slug, and every downstream index is keyed by
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Recovery words to keep (#2144): the sheet an admin or a user downloads as a
3
+ * file or prints while their new words still wait to be confirmed, and the
4
+ * audit record of handing them out.
5
+ *
6
+ * One sheet for both sets of words: the shared folders' (SharedFolderManager)
7
+ * and a user's vault key (the store door, `privateStoreDoor.ts`). The words
8
+ * come only from the set still held for confirmation; once confirmed they are
9
+ * gone from the server and cannot be made again, so nothing here stores,
10
+ * caches or logs them. No key material is on the sheet.
11
+ */
12
+ import { type ActorContext } from '../context/ActorContext.js';
13
+ import { type AuditEventSink } from './auditEvents.js';
14
+ /** Whose words: the organization's shared folders', or a user's vault key. */
15
+ export type RecoveryWordsSet = 'shared-folders' | 'store';
16
+ /** How they left the page: a plain text file, or the print view. */
17
+ export type RecoveryWordsExport = 'download' | 'print';
18
+ /** Everything the sheet shows, for the text file and the print view alike. */
19
+ export interface RecoveryWordsSheet {
20
+ title: string;
21
+ siteName: string;
22
+ siteUrl: string;
23
+ /** When the words were made, in the server's local time with its offset. */
24
+ made: string;
25
+ words: readonly string[];
26
+ purpose: string;
27
+ notStored: string;
28
+ }
29
+ /** `date` in the server's local time: `2026-10-30 05:28 (UTC-04:00)`. */
30
+ export declare function serverLocalTime(date: Date): string;
31
+ /** The sheet for `words` of `set`, made at `madeAt`, on the site named `siteName` at `siteUrl`. */
32
+ export declare function recoveryWordsSheet(args: {
33
+ set: RecoveryWordsSet;
34
+ siteName: string;
35
+ siteUrl: string;
36
+ words: readonly string[];
37
+ madeAt: Date;
38
+ }): RecoveryWordsSheet;
39
+ /** The sheet as a plain text file: the site, when made, the numbered words, what they are for. */
40
+ export declare function recoveryWordsText(sheet: RecoveryWordsSheet): string;
41
+ /** The file name: the site's name and whose words, safe for any file system. */
42
+ export declare function recoveryWordsFilename(siteName: string, set: RecoveryWordsSet): string;
43
+ /**
44
+ * Record that `ctx` downloaded or opened to print the set `wordsId` of `set`
45
+ * (`recovery-words-export`, on-failure refuse): called before the words are
46
+ * sent, so a record that cannot be written sends nothing. Who, which set and
47
+ * how; never the words.
48
+ */
49
+ export declare function recordRecoveryWordsExport(sink: AuditEventSink | null | undefined, ctx: ActorContext, what: {
50
+ set: RecoveryWordsSet;
51
+ wordsId: string;
52
+ how: RecoveryWordsExport;
53
+ store?: string;
54
+ }): Promise<void>;
@@ -2,6 +2,7 @@ export type SessionEndReason = 'logout' | 'idle-timeout' | 'max-age' | 'password
2
2
  export declare function endSession(engine: {
3
3
  getManager(name: string): unknown;
4
4
  }, session: {
5
+ id?: unknown;
5
6
  username?: unknown;
6
7
  privateStoreHandle?: unknown;
7
8
  pendingConnection?: unknown;
@@ -10,6 +10,7 @@
10
10
  * - the end is recorded as `authentication-logout`, with who, from where and
11
11
  * why (#1670). Until this, only the idle timeout was recorded.
12
12
  * - a choice to act for someone (`actingFor`, #1762) ends with it.
13
+ * - so do the form POSTs it kept for a fresh sign-in (#2176), by its `id`.
13
14
  *
14
15
  * An account deleted or turned off ends its sessions here too (#1937), with
15
16
  * the reason `account-deleted` / `account-disabled`: on the session's next
@@ -30,6 +31,9 @@ export function endSession(engine, session, reason, from) {
30
31
  // #1762 slice 4: nor does a choice to act for someone.
31
32
  if (session)
32
33
  delete session.actingFor;
34
+ // #2176: nor do the form POSTs it kept for a fresh sign-in.
35
+ if (typeof session?.id === 'string')
36
+ engine.getManager('AuthManager')?.dropStepUpReplays?.(session.id);
33
37
  const username = typeof session?.username === 'string' && session.username ? session.username : null;
34
38
  if (!username)
35
39
  return; // nobody was signed in: nothing ended
@@ -39,3 +39,7 @@ export declare function warnBeforeMs(timeoutMs: number): number;
39
39
  export declare function idleRemainingMs(lastActivity: unknown, now: number, timeoutMs: number): number | null;
40
40
  /** The status poll does not count as activity, or the warning would keep the session alive by itself. */
41
41
  export declare const IDLE_STATUS_PATH = "/api/session/idle-status";
42
+ /** #2193: nor does a page's poll for its step-up prompt, or a page left waiting would keep its session alive. */
43
+ export declare const STEP_UP_STATUS_PATH = "/auth/reauth/status";
44
+ /** Whether a request to `path` counts as the person's activity: every one but the polls above. */
45
+ export declare function countsAsActivity(path: string): boolean;
@@ -64,3 +64,9 @@ export function idleRemainingMs(lastActivity, now, timeoutMs) {
64
64
  }
65
65
  /** The status poll does not count as activity, or the warning would keep the session alive by itself. */
66
66
  export const IDLE_STATUS_PATH = '/api/session/idle-status';
67
+ /** #2193: nor does a page's poll for its step-up prompt, or a page left waiting would keep its session alive. */
68
+ export const STEP_UP_STATUS_PATH = '/auth/reauth/status';
69
+ /** Whether a request to `path` counts as the person's activity: every one but the polls above. */
70
+ export function countsAsActivity(path) {
71
+ return path !== IDLE_STATUS_PATH && path !== STEP_UP_STATUS_PATH;
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.40.0",
3
+ "version": "5.41.1",
4
4
  "description": "TypeScript types for ngdpbase add-ons, and a test kit of core's own values. A dev dependency, versioned with ngdpbase.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jim Willeke",
@@ -1,22 +0,0 @@
1
- /**
2
- * Conservative filename → capture-date parser (#809, slice C of #807).
3
- *
4
- * Last-resort fallback used by media providers when a file carries no usable
5
- * EXIF/QuickTime capture date. Many devices encode the capture time in the
6
- * filename (e.g. `PXL_20260113_123414708.jpg`, `2026-04-03-IMG_4654.jpg`,
7
- * `Feb_20_2026_19_25_04.jpg`); without this, those files index with
8
- * `dateTimeOriginal: null` and sort only by file `mtime`.
9
- *
10
- * Design constraints (from the issue):
11
- * - **Conservative** — only a small set of high-confidence, anchored patterns
12
- * are accepted. Anything ambiguous returns `null`, and the caller then
13
- * falls back to `mtime` and surfaces the file to admins.
14
- * - **Distinguishable provenance** — the caller records that the resulting
15
- * date came from the filename (`captureDateSource: 'filename'`), so a
16
- * parsed date never masquerades as authoritative EXIF metadata.
17
- *
18
- * Returns a `"YYYY-MM-DD HH:MM:SS"` string (the exact shape produced by the
19
- * EXIF extractor, so it flows through sort/filter identically), or `null`.
20
- * A date-only match yields a `00:00:00` time.
21
- */
22
- export declare function parseDateFromFilename(filename: string): string | null;