@jwilleke/ngdpbase-types 5.35.0 → 5.37.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.
@@ -48,7 +48,7 @@ export function openAddonTestDatabase(owner, options, declaredBy) {
48
48
  checkAddonDatabaseRequest(owner, options);
49
49
  if (declaredBy) {
50
50
  const { slug, defaults } = declaredBy;
51
- const layer = addonDatabaseLayer([{ slug, dir: '', source: 'directory', enabled: true, defaults }], {});
51
+ const layer = addonDatabaseLayer([{ slug, dir: '', enabled: true, defaults }], {});
52
52
  const refused = layer.refused.get(slug);
53
53
  if (refused)
54
54
  throw new Error(`Add-on '${slug}' is not loaded: ${refused}`);
@@ -1,5 +1,13 @@
1
1
  import type { IceLedger, LedgerFileEntry } from './types.js';
2
+ /**
3
+ * The ledger file form (#2013). A scope is bound to its receiver and user
4
+ * (`boundScope`); a file without this form was written before that binding,
5
+ * under a scope any caller could name, so it belongs to nobody: it is read
6
+ * as empty, never as anyone's history, and its files are imported once more.
7
+ */
8
+ export declare const LEDGER_FORM = 2;
2
9
  interface ScopeFile {
10
+ form?: number;
3
11
  scope: string;
4
12
  files: LedgerFileEntry[];
5
13
  items: string[];
@@ -21,8 +21,24 @@ export interface IceRunDeps {
21
21
  }
22
22
  /** The id of the receiver that keeps an original when the chosen receiver does not keep its own (D11). */
23
23
  export declare const FILES_RECEIVER = "core.files";
24
- /** The scope a run's fingerprints are checked in, unless the caller names one. */
25
- export declare function defaultScope(receiverId: string, ctx: ActorContext): string;
24
+ /**
25
+ * The engine's key for an item of a file it stored, so a retry of that file
26
+ * skips it (#2010): the file's fingerprint, the chain, and a hash of the
27
+ * item's content (kind, text, data, bytes). Never its position: a shim that
28
+ * changes or orders its output differently cannot make a retry skip the
29
+ * wrong item. `seen` is how many identical items came before it in this file.
30
+ */
31
+ export declare function itemKeyOf(sha256: string, chain: string[], item: IceItem, seen: number): string;
32
+ /** The form of a bound scope (#2013). Scopes written before it never match one. */
33
+ export declare const SCOPE_FORM = "ice2";
34
+ /**
35
+ * The scope a run's fingerprints, per-file status and item keys live in
36
+ * (#2013): bound to the receiver and to the user the run is for, by their
37
+ * identity `@id` (else their username, #2041). The engine works it out from
38
+ * the caller's context; a request cannot name another user's. A caller's own
39
+ * `sub` (phr's source id) narrows it within that user's scope, never outside.
40
+ */
41
+ export declare function boundScope(receiverId: string, owner: string, sub?: string): string;
26
42
  export interface ChainLeaf {
27
43
  item: IceItem;
28
44
  chain: string[];
@@ -201,7 +201,13 @@ export interface IceReceiver {
201
201
  label?: string;
202
202
  /** The kinds it takes, e.g. `['ncm']`, `['file']`, `['phr.health-record']`. */
203
203
  accepts: IceKind[];
204
- /** May this context use this receiver? Its existing permission — the import screen shows only these (D5). */
204
+ /**
205
+ * May this context use this receiver? Its existing permission (D5). The
206
+ * import screen shows only these, and the engine asks it before any work
207
+ * of a run (#2012): a no refuses every file before anything is unpacked,
208
+ * read by a shim, kept or fetched. Answer no whenever `receive` would
209
+ * refuse this context outright; `receive` still checks each item.
210
+ */
205
211
  mayUse(ctx: ActorContext): Promise<boolean>;
206
212
  /** Hand one item to the receiver's own door. */
207
213
  receive(item: IceItem, ctx: ActorContext, info: ReceiveInfo): Promise<ReceiveOutcome>;
@@ -252,10 +258,13 @@ export interface IceRunRequest {
252
258
  /** D11. Default `ngdpbase.import.engine.default-original`. */
253
259
  original?: OriginalChoice;
254
260
  /**
255
- * Whose fingerprints and item keys this run is checked against. Default
256
- * `<receiver>/<username>`: the same file imported again by the same person
257
- * into the same receiver is skipped. A caller with its own notion of a
258
- * source (phr's source id) passes it.
261
+ * A narrower ledger scope within the caller's own (#2013). The engine binds
262
+ * every run's scope to the receiver and to the user it runs as (their
263
+ * identity `@id`, else their username): the same file imported again by
264
+ * the same user into the same receiver is skipped. A caller with its own
265
+ * notion of a source (phr's source id) passes it to keep those apart; it
266
+ * can never reach another user's or another receiver's scope. Never pass a
267
+ * value a client sent.
259
268
  */
260
269
  scope?: string;
261
270
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { type ActorContext } from '../context/ActorContext.js';
14
14
  import type { BackupFolder } from './BaseManager.js';
15
+ import { type UpdateCheck, type UpdateOutcome } from '../utils/addonInstaller.js';
15
16
  import BaseManager from './BaseManager.js';
16
17
  import type { BackupData } from './BaseManager.js';
17
18
  import type { WikiEngine } from '../types/WikiEngine.js';
@@ -302,6 +303,9 @@ export interface AddonDashboardCard {
302
303
  /** URL of the add-on's admin page */
303
304
  adminUrl: string;
304
305
  }
306
+ /** #2100: the PDP refused an administrator's add-on Update or Check. */
307
+ export declare class AddonUpdateForbiddenError extends Error {
308
+ }
305
309
  /**
306
310
  * Status information returned by getStatus()
307
311
  */
@@ -338,12 +342,14 @@ declare class AddonsManager extends BaseManager {
338
342
  private addonsPaths;
339
343
  /** Resolved absolute paths to addons directories */
340
344
  private resolvedAddonsPaths;
341
- /** #673: npm package globs (from `node_modules:` addons-path entries) to discover from node_modules. */
342
- private npmAddonPatterns;
343
345
  /** #1830: `<data>/addons`, where installed add-ons live; null when there is no data folder. */
344
346
  private installedDir;
345
347
  /** #1830: the installed add-ons verified this boot — the only ones loaded from `installedDir`. */
346
348
  private verifiedInstalled;
349
+ /** #2100: installed add-ons this start switched to an Update's release; settled after the load pass. */
350
+ private updatedThisStart;
351
+ /** #2100: Updates run one at a time, so two never write the lock file at once. */
352
+ private updateQueue;
347
353
  /** Stylesheets registered by add-ons via registerStylesheet() */
348
354
  private registeredStylesheets;
349
355
  /** Dashboard cards registered by add-ons via registerDashboardCard() */
@@ -374,6 +380,14 @@ declare class AddonsManager extends BaseManager {
374
380
  * this boot, not the next.
375
381
  */
376
382
  private installConfiguredAddons;
383
+ /**
384
+ * The installer's options for this site (#1830, #2100): the registry and the
385
+ * limits from configuration, and every request — registry, attestations, the
386
+ * Sigstore TUF repository — through the guarded client and the egress policy
387
+ * (#1139). `timeoutKey` is the `…install.<key>` that bounds each request: the
388
+ * boot install's, or the shorter one an admin page waits on.
389
+ */
390
+ private installerOptions;
377
391
  /**
378
392
  * `addon-install` (#1830): written after every check passed and before
379
393
  * anything is written to disk. on-failure: refuse — code does not join the
@@ -381,6 +395,47 @@ declare class AddonsManager extends BaseManager {
381
395
  * (recordSystemAction throws, and the installer writes nothing).
382
396
  */
383
397
  private recordAddonInstall;
398
+ /**
399
+ * #2100: after the load pass, settle each add-on this start switched to an
400
+ * Update's release. Loaded (or switched off, so there was nothing to try):
401
+ * the previous release's folder and tarball go. Not loaded: the previous
402
+ * release is restored on disk and in the lock, so the next start runs it.
403
+ * Core cannot unload an add-on that failed half-way (its modules, routes and
404
+ * jobs stay), so the restored release is not loaded in this process.
405
+ */
406
+ private settleUpdatedAddons;
407
+ /**
408
+ * `addon-update` (#2100) for a fall-back: core moved an installed add-on back
409
+ * from `from` to `to` because `from` did not load. on-failure: continue for
410
+ * this record — the fall-back has already happened on disk, and a lost
411
+ * record must not undo it.
412
+ */
413
+ private recordFallback;
414
+ /** #2100: the door for the admin's add-on questions. Throws when the PDP refuses. */
415
+ private requireAddonPermission;
416
+ /**
417
+ * #2100: for each add-on in `ngdpbase.addons.install`, the pinned release,
418
+ * the configured range, and the newest release in it that is newer — asked
419
+ * of the registry now. Called when an administrator opens the add-ons page
420
+ * or presses Check; never at start. Registry unreachable is an answer
421
+ * (`registryUnreachable`), not an error.
422
+ */
423
+ checkInstalledUpdates(ctx: ActorContext): Promise<UpdateCheck[]>;
424
+ /**
425
+ * #2100: an administrator's Update. Downloads `version` of the installed
426
+ * add-on `slug`, checks it exactly as a first install is checked (sha512,
427
+ * provenance, contents, `ngdpbase.minVersion`) and stages it: it runs from
428
+ * the next start. Asks the PDP for `config-manage`. Recorded as
429
+ * `addon-update` (who, the add-on, from and to) before anything is written;
430
+ * a record that cannot be written stops the Update.
431
+ */
432
+ updateInstalledAddon(ctx: ActorContext, slug: string, version: string): Promise<UpdateOutcome>;
433
+ /**
434
+ * `addon-update` (#2100): an administrator's Update, written after every
435
+ * check passed and before anything is written to disk. on-failure: refuse —
436
+ * a record that cannot be written stops the Update.
437
+ */
438
+ private recordAddonUpdate;
384
439
  /**
385
440
  * `addon-verify-failed` (#1830): an integrity, provenance, tarball, manifest,
386
441
  * lock or folder check refused (or, for a repaired folder, found it changed).
@@ -404,25 +459,10 @@ declare class AddonsManager extends BaseManager {
404
459
  * Scan a single addons directory and register any add-ons found.
405
460
  */
406
461
  private scanAddonsDirectory;
407
- /**
408
- * #673 (packaged model): discover addons published as npm packages, matching a
409
- * `node_modules:<glob>` addons-path entry (e.g. `node_modules:@jwilleke/*-addon`).
410
- * Each matching package is loaded through the same slug/module/register()
411
- * contract as bundled and drop-in addons — only discovery differs.
412
- */
413
- private scanNpmAddons;
414
- /** Locate the node_modules directory (app root / cwd). */
415
- private findNodeModules;
416
- /** Expand a `@scope/glob` (or bare `glob`) pattern to package directories. */
417
- private resolveNpmAddonDirs;
418
462
  /**
419
463
  * Load a single addon from its directory (index.js/ts + optional package.json
420
- * `ngdpbase` manifest) and register it. Shared by directory scans and npm
421
- * (#673) discovery; `sourceLabel` is for logging only. `source` selects the
422
- * no-slug identity fallback (#927): a directory addon's folder name is its
423
- * identity verbatim, while an npm package strips the conventional `-addon`
424
- * suffix. Duplicate ids are skipped — since npm discovery runs last, a
425
- * bundled/drop-in addon wins.
464
+ * `ngdpbase` manifest) and register it. `sourceLabel` is for logging only.
465
+ * Duplicate ids are skipped, so the first directory scanned wins.
426
466
  */
427
467
  private registerAddonFromDir;
428
468
  /**
@@ -141,16 +141,15 @@ declare class ConfigurationManager extends BaseManager {
141
141
  /**
142
142
  * #672: refuse to start if any `ngdpbase.addons.<id>.enabled = true` key
143
143
  * references an `<id>` that has no matching addon in any configured
144
- * `addons-path` entry — either a directory (bundled/drop-in) or, per
145
- * #673/#924, an npm package matched by a `node_modules:<glob>` entry.
144
+ * `addons-path` directory (bundled/drop-in) and is not named in
145
+ * `ngdpbase.addons.install`. A retired `node_modules:` entry is ignored (#2102).
146
146
  * The discovery logic mirrors what `AddonsManager` does at runtime —
147
147
  * directory/package present with `index.js` or `index.ts` — but without
148
148
  * importing the modules (boot-time speed; `ConfigurationManager.initialize()`
149
- * runs before any managers). The directory-vs-npm-pattern split, npm glob
150
- * matching, AND the canonical-identity resolution are all shared with
149
+ * runs before any managers). The addons-path parsing AND the
150
+ * canonical-identity resolution are shared with
151
151
  * `AddonsManager` via `utils/addonsPathResolver.ts` so the two can no
152
- * longer drift out of sync (#924: they had, and packaged addons could
153
- * never boot as a result).
152
+ * longer drift out of sync (#924).
154
153
  *
155
154
  * Catches the failure mode that caused the 2026-05-10 geohazardwatch.com
156
155
  * outage: the deploy configmap had `ngdpbase.addons.ve-geology.enabled =
@@ -382,6 +382,18 @@ declare class ImportManager extends BaseManager {
382
382
  importRunsFor(ctx: ActorContext): IceRunReport[];
383
383
  /** Use this ledger instead of the file one: a test's, or a database table's. */
384
384
  setImportLedger(ledger: IceLedger): void;
385
+ /**
386
+ * The ledger scope a run uses (#2013): bound to the receiver and to the user
387
+ * `ctx` names, by their identity `@id`; their username only when the
388
+ * identity store answers that there is no record for them (#2041). Never a
389
+ * scope the caller names: a request's `scope` only narrows within this
390
+ * user's own.
391
+ *
392
+ * @throws IdentityStoreUnavailableError when the identity store cannot
393
+ * answer: the run does not start, and nothing is recorded, rather than
394
+ * land in a scope the user's other runs do not use
395
+ */
396
+ private importScope;
385
397
  private newRunReport;
386
398
  private executeRun;
387
399
  /** What a run, or keeping at upload, works with. */
@@ -18,8 +18,8 @@
18
18
  * shipped entry. The operator's file still wins over everything.
19
19
  *
20
20
  * Discovery follows `ngdpbase.managers.addons-manager.addons-path` exactly as
21
- * AddonsManager does — directories and `node_modules:` patterns — so bundled
22
- * and external addons are treated alike. Only an addon whose
21
+ * AddonsManager does — directory entries — so bundled
22
+ * and drop-in addons are treated alike. Only an addon whose
23
23
  * `ngdpbase.addons.<slug>.enabled` is true in the shipped+custom view
24
24
  * contributes; a disabled addon's opinions do not reach the catalog.
25
25
  *
@@ -63,7 +63,6 @@ export declare const ADDONS_MANAGER_ENABLED_KEY = "ngdpbase.managers.addons-mana
63
63
  export interface AddonDefaults {
64
64
  slug: string;
65
65
  dir: string;
66
- source: 'directory' | 'npm';
67
66
  /**
68
67
  * Whether the addon is switched on. A turned-off addon's file is read for its
69
68
  * system-categories alone (#1559); none of its other keys reach the merge.
@@ -83,7 +82,6 @@ export declare function isAddonEnabled(base: Record<string, unknown>, slug: stri
83
82
  */
84
83
  export declare function discoverAddonDirs(base: Record<string, unknown>, cwd?: string, dataFolder?: string): Array<{
85
84
  dir: string;
86
- source: 'directory' | 'npm';
87
85
  }>;
88
86
  /**
89
87
  * The defaults of every installed addon, enabled or not, in discovery order,
@@ -18,8 +18,8 @@
18
18
  * shipped entry. The operator's file still wins over everything.
19
19
  *
20
20
  * Discovery follows `ngdpbase.managers.addons-manager.addons-path` exactly as
21
- * AddonsManager does — directories and `node_modules:` patterns — so bundled
22
- * and external addons are treated alike. Only an addon whose
21
+ * AddonsManager does — directory entries — so bundled
22
+ * and drop-in addons are treated alike. Only an addon whose
23
23
  * `ngdpbase.addons.<slug>.enabled` is true in the shipped+custom view
24
24
  * contributes; a disabled addon's opinions do not reach the catalog.
25
25
  *
@@ -61,7 +61,7 @@ import fs from 'fs';
61
61
  import path from 'path';
62
62
  import { isDeepStrictEqual } from 'util';
63
63
  import { configFilePaths, deepMergeConfigs, deepMergeObjects, instanceDataFolder, isPlainObject, readConfigFilesSync } from './configFiles.js';
64
- import { configuredInstallSlugs, findNodeModulesDir, installedAddonsDir, matchNpmPackageDirs, resolveAddonSlug, splitAddonsPath } from './addonsPathResolver.js';
64
+ import { configuredInstallSlugs, installedAddonsDir, resolveAddonSlug, splitAddonsPath } from './addonsPathResolver.js';
65
65
  import { lockedAddonDirsSync } from './addonLock.js';
66
66
  import { LEGACY_SECRET_KEYS_KEY, SENSITIVE_VALUES_KEY } from './sensitiveValues.js';
67
67
  import { parsePrivatestore } from './privateStorePath.js';
@@ -81,7 +81,7 @@ export function isAddonEnabled(base, slug) {
81
81
  export function discoverAddonDirs(base, cwd = process.cwd(), dataFolder = instanceDataFolder()) {
82
82
  if (base[ADDONS_MANAGER_ENABLED_KEY] === false)
83
83
  return [];
84
- const { directories, npmPatterns } = splitAddonsPath(base[ADDONS_PATH_KEY] ?? './addons');
84
+ const { directories } = splitAddonsPath(base[ADDONS_PATH_KEY] ?? './addons');
85
85
  const found = [];
86
86
  for (const raw of directories) {
87
87
  const root = path.resolve(cwd, raw);
@@ -95,20 +95,13 @@ export function discoverAddonDirs(base, cwd = process.cwd(), dataFolder = instan
95
95
  for (const entry of entries) {
96
96
  if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'shared')
97
97
  continue;
98
- found.push({ dir: path.join(root, entry.name), source: 'directory' });
98
+ found.push({ dir: path.join(root, entry.name) });
99
99
  }
100
100
  }
101
101
  const installed = installedAddonsDir(dataFolder, cwd);
102
102
  if (!found.some((f) => path.dirname(f.dir) === installed)) {
103
103
  for (const dir of lockedAddonDirsSync(installed, configuredInstallSlugs(base)))
104
- found.push({ dir, source: 'directory' });
105
- }
106
- const nmRoot = npmPatterns.length > 0 ? findNodeModulesDir(cwd) : null;
107
- if (nmRoot) {
108
- for (const pattern of npmPatterns) {
109
- for (const dir of matchNpmPackageDirs(nmRoot, pattern))
110
- found.push({ dir, source: 'npm' });
111
- }
104
+ found.push({ dir });
112
105
  }
113
106
  return found;
114
107
  }
@@ -122,8 +115,8 @@ export function discoverAddonDirs(base, cwd = process.cwd(), dataFolder = instan
122
115
  */
123
116
  export function discoverAddonDefaults(base, cwd = process.cwd(), dataFolder = instanceDataFolder()) {
124
117
  const out = [];
125
- for (const { dir, source } of discoverAddonDirs(base, cwd, dataFolder)) {
126
- const slug = resolveAddonSlug(dir, source);
118
+ for (const { dir } of discoverAddonDirs(base, cwd, dataFolder)) {
119
+ const slug = resolveAddonSlug(dir);
127
120
  const enabled = isAddonEnabled(base, slug);
128
121
  const file = path.join(dir, 'config', 'default-config.json');
129
122
  if (!fs.existsSync(file))
@@ -136,10 +129,10 @@ export function discoverAddonDefaults(base, cwd = process.cwd(), dataFolder = in
136
129
  for (const [key, value] of Object.entries(raw))
137
130
  if (!key.startsWith('_'))
138
131
  defaults[key] = value;
139
- out.push({ slug, dir, source, enabled, defaults });
132
+ out.push({ slug, dir, enabled, defaults });
140
133
  }
141
134
  catch (err) {
142
- out.push({ slug, dir, source, enabled, defaults: {}, error: err instanceof Error ? err.message : String(err) });
135
+ out.push({ slug, dir, enabled, defaults: {}, error: err instanceof Error ? err.message : String(err) });
143
136
  }
144
137
  }
145
138
  return out;
@@ -85,6 +85,12 @@ export type InstallStatus =
85
85
  | 'verified'
86
86
  /** Already installed; the folder differed from its verified tarball and was rewritten. */
87
87
  | 'repaired'
88
+ /**
89
+ * #2100: switched to the release an administrator's Update staged; the
90
+ * previous release is kept until AddonsManager confirms this one loaded
91
+ * ({@link confirmUpdate}) or falls back to it ({@link rollBackUpdate}).
92
+ */
93
+ | 'updated'
88
94
  /** Not installed (and not loaded); `reason` says why. */
89
95
  | 'skipped';
90
96
  /** Which verification refused, for `addon-verify-failed`. */
@@ -99,6 +105,20 @@ export interface InstallOutcome {
99
105
  registryUnreachable?: boolean;
100
106
  /** Set when a verification refused, or (for `repaired`) found the folder changed. */
101
107
  failedCheck?: FailedCheck;
108
+ /** For `updated`: the release it replaced. */
109
+ previousVersion?: string;
110
+ /**
111
+ * #2100: a switch an earlier start made and never confirmed (the new release
112
+ * did not load, or the process stopped before it could) was undone at this
113
+ * start, before anything loaded: `from` is the release given up, `to` the
114
+ * one restored.
115
+ */
116
+ rolledBack?: {
117
+ from: string;
118
+ to: string;
119
+ };
120
+ /** #2100: something admins should hear about a staged Update, such as one discarded. */
121
+ notice?: string;
102
122
  }
103
123
  /** `sha512-<base64>` of `data`. */
104
124
  export declare function sha512Integrity(data: Buffer): string;
@@ -107,3 +127,73 @@ export declare function sha512Integrity(data: Buffer): string;
107
127
  * Never throws for one add-on's problem: each outcome says what happened.
108
128
  */
109
129
  export declare function installConfiguredAddons(specs: Record<string, unknown>, opts: InstallerOptions): Promise<InstallOutcome[]>;
130
+ /**
131
+ * The switched-to release loaded (or the add-on is off, so there is nothing to
132
+ * try): the previous release's folder and tarball go. No-op when nothing waits.
133
+ */
134
+ export declare function confirmUpdate(addonsDir: string, slug: string): Promise<void>;
135
+ /**
136
+ * The switched-to release did not load: restore the previous release on disk
137
+ * and in the lock, so the next start runs it. The new folder is set aside
138
+ * (`.rejected-…`) for inspection. Undefined when there is nothing to undo.
139
+ */
140
+ export declare function rollBackUpdate(addonsDir: string, slug: string, now?: Date): Promise<{
141
+ from: string;
142
+ to: string;
143
+ } | undefined>;
144
+ /** Why an Update is refused while the last one is not settled (`previous` still in the lock). */
145
+ export declare const FINISH_LAST_UPDATE = "finish the last update first: restart the site";
146
+ /** What the admin page shows for one add-on in `ngdpbase.addons.install`. */
147
+ export interface UpdateCheck {
148
+ slug: string;
149
+ package?: string;
150
+ /** The configured range (`version` in the entry). */
151
+ range?: string;
152
+ /** The release the lock pins: what runs. Absent when not installed. */
153
+ pinned?: string;
154
+ /** A release an Update downloaded, used from the next start. */
155
+ pending?: string;
156
+ /** The newest release in the range, whether or not this core can run it. */
157
+ newest?: string;
158
+ /** The newest release in the range, newer than what is pinned or staged, that this core can run: what Update installs. */
159
+ available?: string;
160
+ /** The newest release in the range, newer than what is pinned or staged, when it needs a newer ngdpbase. */
161
+ needsCore?: {
162
+ version: string;
163
+ minVersion: string;
164
+ };
165
+ registryUnreachable?: boolean;
166
+ /** Why there is nothing to compare: not installed, or a configuration problem. */
167
+ problem?: string;
168
+ /** The last update is not settled (the lock still names the release it replaced): no Update until a restart. */
169
+ blocked?: string;
170
+ }
171
+ /**
172
+ * Ask the registry, for every installed add-on, which releases in its range
173
+ * are newer than the one pinned. Reads the packument only; writes nothing.
174
+ * Called only when an administrator opens the add-ons page or presses Check,
175
+ * never at start.
176
+ */
177
+ export declare function checkForUpdates(specs: Record<string, unknown>, opts: InstallerOptions): Promise<UpdateCheck[]>;
178
+ /** What an Update did. */
179
+ export interface UpdateOutcome {
180
+ slug: string;
181
+ /** `staged`: verified and downloaded; it runs from the next start. `refused`: nothing changed; `reason` says why. */
182
+ status: 'staged' | 'refused';
183
+ package?: string;
184
+ version?: string;
185
+ /** The release that runs until the next start. */
186
+ previousVersion?: string;
187
+ reason?: string;
188
+ registryUnreachable?: boolean;
189
+ failedCheck?: FailedCheck;
190
+ }
191
+ /**
192
+ * Download `version` of an installed add-on and stage it for the next start
193
+ * (#2100). It must be a release in the configured range, newer than the
194
+ * pinned one, that this core can run, and it passes the same checks as a
195
+ * first install ({@link fetchVerified}): sha512, provenance, contents,
196
+ * package.json. `beforeInstall` (the audit record) runs after the checks and
197
+ * before anything is written. The running add-on's folder is not touched.
198
+ */
199
+ export declare function stageUpdate(slug: string, rawSpec: unknown, version: string, opts: InstallerOptions): Promise<UpdateOutcome>;
@@ -7,9 +7,8 @@
7
7
  * folders in `<data>/addons` are installed add-ons at all: a folder the lock
8
8
  * does not name is never an add-on, whatever it contains.
9
9
  */
10
- /** What the lock records for one installed add-on. */
11
- export interface LockEntry {
12
- package: string;
10
+ /** One verified release of an add-on: what it is and what it was accepted on. */
11
+ export interface PinnedVersion {
13
12
  version: string;
14
13
  integrity: string;
15
14
  installedAt: string;
@@ -19,6 +18,23 @@ export interface LockEntry {
19
18
  verifiedAt: string;
20
19
  };
21
20
  }
21
+ /** What the lock records for one installed add-on. */
22
+ export interface LockEntry extends PinnedVersion {
23
+ package: string;
24
+ /**
25
+ * #2100: a newer release an administrator's Update downloaded and verified.
26
+ * Its tarball waits in `.packages/`; the next start switches to it. Nothing
27
+ * else reads it, so until then the site runs `version`.
28
+ */
29
+ pending?: PinnedVersion;
30
+ /**
31
+ * #2100: the release this one replaced at the start that switched to it,
32
+ * kept (its folder as `.previous-<slug>`, its tarball in `.packages/`) until
33
+ * this one has loaded. Present at a later start only when that start never
34
+ * confirmed the switch, so the installer falls back to it.
35
+ */
36
+ previous?: PinnedVersion;
37
+ }
22
38
  export interface LockFile {
23
39
  lockfileVersion: 1;
24
40
  addons: Record<string, LockEntry>;
@@ -26,6 +42,8 @@ export interface LockFile {
26
42
  export declare const LOCK_FILE_NAME = "addons-lock.json";
27
43
  /** Verified tarballs, kept so a restart needs no registry. Dot-named: discovery skips it. */
28
44
  export declare const PACKAGES_DIR_NAME = ".packages";
45
+ /** #2100: the folder a switched-from release waits in until the new one has loaded. Dot-named: discovery skips it. */
46
+ export declare const PREVIOUS_DIR_PREFIX = ".previous-";
29
47
  export declare function lockFilePath(addonsDir: string): string;
30
48
  /** The lock file, or an empty one when there is none. Throws when it exists and is not JSON. */
31
49
  export declare function readLockSync(addonsDir: string): LockFile;
@@ -12,6 +12,8 @@ import path from 'path';
12
12
  export const LOCK_FILE_NAME = 'addons-lock.json';
13
13
  /** Verified tarballs, kept so a restart needs no registry. Dot-named: discovery skips it. */
14
14
  export const PACKAGES_DIR_NAME = '.packages';
15
+ /** #2100: the folder a switched-from release waits in until the new one has loaded. Dot-named: discovery skips it. */
16
+ export const PREVIOUS_DIR_PREFIX = '.previous-';
15
17
  export function lockFilePath(addonsDir) {
16
18
  return path.join(addonsDir, LOCK_FILE_NAME);
17
19
  }
@@ -1,30 +1,26 @@
1
1
  /**
2
2
  * Shared parsing/discovery for the `ngdpbase.managers.addons-manager.addons-path`
3
- * config value.
3
+ * config value, used by `AddonsManager` (runtime discovery), `ConfigurationManager`
4
+ * (the #672 boot-time "does this enabled addon exist" check) and the add-on
5
+ * config layer, so the three cannot drift (#924).
4
6
  *
5
- * #924: `AddonsManager` (real runtime discovery) and `ConfigurationManager`
6
- * (the #672 boot-time "does this enabled addon exist" safety check) used to
7
- * each carry their own copy of this logic. They drifted: `AddonsManager` was
8
- * updated for #673's `node_modules:<glob>` packaged-addon syntax, but the
9
- * validator's copy was not, so it resolved a `node_modules:` entry as a
10
- * literal (non-existent) directory and treated every packaged addon as
11
- * "unknown" — crashing boot the moment one was enabled. Consolidating the
12
- * parsing/matching into one module removes the class of bug, not just this
13
- * instance of it.
7
+ * #2102: the packaged model (`node_modules:<glob>` entries) is gone. A site
8
+ * config may still hold such an entry; it is ignored, never treated as a
9
+ * directory, and never stops the boot.
14
10
  */
15
- /** Prefix marking an addons-path entry as an npm-package glob rather than a directory. */
16
- export declare const NPM_ADDON_PREFIX = "node_modules:";
17
11
  export interface SplitAddonsPath {
18
12
  /** Plain directory entries (bundled/drop-in), not yet resolved to absolute. */
19
13
  directories: string[];
20
- /** npm package name globs (the part after `node_modules:`), e.g. `@jwilleke/*-addon`. */
21
- npmPatterns: string[];
14
+ /** Retired `node_modules:` entries (#2102): ignored; AddonsManager warns about them once at start. */
15
+ retired: string[];
22
16
  }
23
17
  /**
24
18
  * Coerce the raw config value (string or string[]) and split it into
25
- * filesystem directory entries vs. `node_modules:<glob>` npm patterns.
19
+ * filesystem directory entries and retired `node_modules:` entries.
26
20
  */
27
21
  export declare function splitAddonsPath(raw: unknown): SplitAddonsPath;
22
+ /** The warning for one retired `node_modules:` entry (#2102). */
23
+ export declare function retiredAddonsPathWarning(entry: string): string;
28
24
  /** The config key naming the add-ons to install (#1830). */
29
25
  export declare const ADDONS_INSTALL_KEY = "ngdpbase.addons.install";
30
26
  /**
@@ -35,42 +31,15 @@ export declare const ADDONS_INSTALL_KEY = "ngdpbase.addons.install";
35
31
  export declare function installedAddonsDir(dataFolder: string, cwd?: string): string;
36
32
  /** The slugs `ngdpbase.addons.install` names, from a merged or shipped+custom view. */
37
33
  export declare function configuredInstallSlugs(config: Record<string, unknown>): string[];
38
- /** Locate the `node_modules` directory to search for packaged addons (resolved from cwd). */
39
- export declare function findNodeModulesDir(cwd?: string): string | null;
40
- /**
41
- * Expand a `@scope/glob` (or bare `glob`) npm pattern to matching package
42
- * directories under `nmRoot`.
43
- */
44
- export declare function matchNpmPackageDirs(nmRoot: string, pattern: string): string[];
45
- /**
46
- * Best-effort addon identity for a packaged (npm) addon directory, derived
47
- * from its package-directory name alone — no module import, matching the
48
- * same boot-time-speed, no-module-loading design already accepted for the
49
- * directory-scan case (see `ConfigurationManager.assertConfiguredAddonsExist`).
50
- *
51
- * Follows the documented packaged-addon naming convention
52
- * (`@scope/<slug>-addon`, see docs/platform/deployment/addon-packaged.md):
53
- * strips a trailing `-addon` suffix if present, else returns the folder name
54
- * as-is.
55
- *
56
- * Caveat (same class as the existing directory-name heuristic): the addon's
57
- * *real* identity is whatever its module exports as `name` at runtime. A
58
- * package that doesn't follow the `-addon` naming convention, or whose
59
- * module exports a different `name`, won't be matched precisely here — this
60
- * is a validation-time approximation, not the source of truth.
61
- */
62
- export declare function deriveAddonSlugFromPackageDirName(dirName: string): string;
63
34
  /**
64
35
  * Canonical identity for an addon directory (#927), resolved the SAME
65
36
  * import-free way in every layer (discovery, `isEnabled`, the boot-time
66
- * validator) so the four historical identities (folder name, module `name`,
67
- * config key, manifest slug) can no longer drift:
37
+ * validator):
68
38
  *
69
- * canonicalId = package.json `ngdpbase.slug` ?? deriveAddonSlugFromPackageDirName(folder)
39
+ * canonicalId = package.json `ngdpbase.slug` ?? the folder name verbatim
70
40
  *
71
- * Reads `package.json` statically — never imports the module — which is what
72
- * lets the boot validator compute the exact id the runtime will register
73
- * under, instead of guessing. The module's exported `name` is a display
74
- * label validated against this at load time, not a source of identity.
41
+ * Reads `package.json` statically — never imports the module. The module's
42
+ * exported `name` is a display label validated against this at load time,
43
+ * not a source of identity.
75
44
  */
76
- export declare function resolveAddonSlug(addonDir: string, source: 'directory' | 'npm'): string;
45
+ export declare function resolveAddonSlug(addonDir: string): string;
@@ -1,36 +1,32 @@
1
1
  /**
2
2
  * Shared parsing/discovery for the `ngdpbase.managers.addons-manager.addons-path`
3
- * config value.
3
+ * config value, used by `AddonsManager` (runtime discovery), `ConfigurationManager`
4
+ * (the #672 boot-time "does this enabled addon exist" check) and the add-on
5
+ * config layer, so the three cannot drift (#924).
4
6
  *
5
- * #924: `AddonsManager` (real runtime discovery) and `ConfigurationManager`
6
- * (the #672 boot-time "does this enabled addon exist" safety check) used to
7
- * each carry their own copy of this logic. They drifted: `AddonsManager` was
8
- * updated for #673's `node_modules:<glob>` packaged-addon syntax, but the
9
- * validator's copy was not, so it resolved a `node_modules:` entry as a
10
- * literal (non-existent) directory and treated every packaged addon as
11
- * "unknown" — crashing boot the moment one was enabled. Consolidating the
12
- * parsing/matching into one module removes the class of bug, not just this
13
- * instance of it.
7
+ * #2102: the packaged model (`node_modules:<glob>` entries) is gone. A site
8
+ * config may still hold such an entry; it is ignored, never treated as a
9
+ * directory, and never stops the boot.
14
10
  */
15
11
  import * as fs from 'fs';
16
12
  import * as path from 'path';
17
- import { minimatch } from 'minimatch';
18
- /** Prefix marking an addons-path entry as an npm-package glob rather than a directory. */
19
- export const NPM_ADDON_PREFIX = 'node_modules:';
13
+ /** The retired packaged-model prefix, recognised only so the entry can be ignored with a warning. */
14
+ const RETIRED_PACKAGED_PREFIX = 'node_modules:';
20
15
  /**
21
16
  * Coerce the raw config value (string or string[]) and split it into
22
- * filesystem directory entries vs. `node_modules:<glob>` npm patterns.
17
+ * filesystem directory entries and retired `node_modules:` entries.
23
18
  */
24
19
  export function splitAddonsPath(raw) {
25
20
  const entries = Array.isArray(raw) ? raw.map(String) : [String(raw)];
26
21
  return {
27
- directories: entries.filter(p => !p.startsWith(NPM_ADDON_PREFIX)),
28
- npmPatterns: entries
29
- .filter(p => p.startsWith(NPM_ADDON_PREFIX))
30
- .map(p => p.slice(NPM_ADDON_PREFIX.length).trim())
31
- .filter(Boolean)
22
+ directories: entries.filter(p => !p.trim().startsWith(RETIRED_PACKAGED_PREFIX)),
23
+ retired: entries.filter(p => p.trim().startsWith(RETIRED_PACKAGED_PREFIX))
32
24
  };
33
25
  }
26
+ /** The warning for one retired `node_modules:` entry (#2102). */
27
+ export function retiredAddonsPathWarning(entry) {
28
+ return `addons-path entry '${entry}' is no longer supported and is ignored; install add-ons with ngdpbase.addons.install`;
29
+ }
34
30
  /** The config key naming the add-ons to install (#1830). */
35
31
  export const ADDONS_INSTALL_KEY = 'ngdpbase.addons.install';
36
32
  /**
@@ -46,69 +42,18 @@ export function configuredInstallSlugs(config) {
46
42
  const map = config[ADDONS_INSTALL_KEY];
47
43
  return map && typeof map === 'object' && !Array.isArray(map) ? Object.keys(map) : [];
48
44
  }
49
- /** Locate the `node_modules` directory to search for packaged addons (resolved from cwd). */
50
- export function findNodeModulesDir(cwd = process.cwd()) {
51
- const nm = path.resolve(cwd, 'node_modules');
52
- return fs.existsSync(nm) && fs.statSync(nm).isDirectory() ? nm : null;
53
- }
54
- /**
55
- * Expand a `@scope/glob` (or bare `glob`) npm pattern to matching package
56
- * directories under `nmRoot`.
57
- */
58
- export function matchNpmPackageDirs(nmRoot, pattern) {
59
- let baseDir = nmRoot;
60
- let nameGlob = pattern;
61
- if (pattern.startsWith('@')) {
62
- const slash = pattern.indexOf('/');
63
- const scope = slash > 0 ? pattern.slice(0, slash) : pattern;
64
- nameGlob = slash > 0 ? pattern.slice(slash + 1) : '*';
65
- baseDir = path.join(nmRoot, scope);
66
- }
67
- if (!fs.existsSync(baseDir))
68
- return [];
69
- const out = [];
70
- for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) {
71
- if (entry.isDirectory() && !entry.name.startsWith('.') && minimatch(entry.name, nameGlob)) {
72
- out.push(path.join(baseDir, entry.name));
73
- }
74
- }
75
- return out;
76
- }
77
- /**
78
- * Best-effort addon identity for a packaged (npm) addon directory, derived
79
- * from its package-directory name alone — no module import, matching the
80
- * same boot-time-speed, no-module-loading design already accepted for the
81
- * directory-scan case (see `ConfigurationManager.assertConfiguredAddonsExist`).
82
- *
83
- * Follows the documented packaged-addon naming convention
84
- * (`@scope/<slug>-addon`, see docs/platform/deployment/addon-packaged.md):
85
- * strips a trailing `-addon` suffix if present, else returns the folder name
86
- * as-is.
87
- *
88
- * Caveat (same class as the existing directory-name heuristic): the addon's
89
- * *real* identity is whatever its module exports as `name` at runtime. A
90
- * package that doesn't follow the `-addon` naming convention, or whose
91
- * module exports a different `name`, won't be matched precisely here — this
92
- * is a validation-time approximation, not the source of truth.
93
- */
94
- export function deriveAddonSlugFromPackageDirName(dirName) {
95
- const suffix = '-addon';
96
- return dirName.endsWith(suffix) ? dirName.slice(0, -suffix.length) : dirName;
97
- }
98
45
  /**
99
46
  * Canonical identity for an addon directory (#927), resolved the SAME
100
47
  * import-free way in every layer (discovery, `isEnabled`, the boot-time
101
- * validator) so the four historical identities (folder name, module `name`,
102
- * config key, manifest slug) can no longer drift:
48
+ * validator):
103
49
  *
104
- * canonicalId = package.json `ngdpbase.slug` ?? deriveAddonSlugFromPackageDirName(folder)
50
+ * canonicalId = package.json `ngdpbase.slug` ?? the folder name verbatim
105
51
  *
106
- * Reads `package.json` statically — never imports the module — which is what
107
- * lets the boot validator compute the exact id the runtime will register
108
- * under, instead of guessing. The module's exported `name` is a display
109
- * label validated against this at load time, not a source of identity.
52
+ * Reads `package.json` statically — never imports the module. The module's
53
+ * exported `name` is a display label validated against this at load time,
54
+ * not a source of identity.
110
55
  */
111
- export function resolveAddonSlug(addonDir, source) {
56
+ export function resolveAddonSlug(addonDir) {
112
57
  const dirName = path.basename(addonDir);
113
58
  const pkgPath = path.join(addonDir, 'package.json');
114
59
  try {
@@ -118,12 +63,7 @@ export function resolveAddonSlug(addonDir, source) {
118
63
  return slug.trim();
119
64
  }
120
65
  catch {
121
- /* no/invalid package.json — fall back to the folder-derived id */
66
+ /* no/invalid package.json — fall back to the folder name */
122
67
  }
123
- // Fallback when no slug is declared. npm packages follow the
124
- // `@scope/<slug>-addon` publishing convention, so strip the conventional
125
- // trailing `-addon`. A plain directory (bundled/drop-in) addon's folder
126
- // name IS its identity verbatim — an addon may legitimately be named
127
- // `something-addon` on disk — so never strip there.
128
- return source === 'npm' ? deriveAddonSlugFromPackageDirName(dirName) : dirName;
68
+ return dirName;
129
69
  }
@@ -4,7 +4,7 @@ export declare const AUDIT_EVENT_NAME_PATTERN: RegExp;
4
4
  export declare const AUDIT_EVENT: {
5
5
  /** An add-on named in ngdpbase.addons.install installed into the data folder: package, version, sha512 and the repository its provenance names; recorded before it is written, which cannot proceed without it (#1830) */
6
6
  readonly ADDON_INSTALL: "addon-install";
7
- /** An installed add-on moved to another version by an administrator's Update: who, and from which version to which (#1830; the Update action is not built yet) */
7
+ /** The release an installed add-on runs changed: an administrator's Update (who, the add-on, from and to version), recorded before the release is written, which cannot proceed without it; or core falling back to the previous release because the new one did not load (#2100) */
8
8
  readonly ADDON_UPDATE: "addon-update";
9
9
  /** An add-on refused or set aside by a check: its sha512, provenance, tarball contents, package.json, the lock file, or a changed installed folder (#1830) */
10
10
  readonly ADDON_VERIFY_FAILED: "addon-verify-failed";
@@ -11,7 +11,7 @@ export const AUDIT_EVENT_NAME_PATTERN = /^[a-z]+(-[a-z]+)+$/;
11
11
  export const AUDIT_EVENT = {
12
12
  /** An add-on named in ngdpbase.addons.install installed into the data folder: package, version, sha512 and the repository its provenance names; recorded before it is written, which cannot proceed without it (#1830) */
13
13
  ADDON_INSTALL: 'addon-install',
14
- /** An installed add-on moved to another version by an administrator's Update: who, and from which version to which (#1830; the Update action is not built yet) */
14
+ /** The release an installed add-on runs changed: an administrator's Update (who, the add-on, from and to version), recorded before the release is written, which cannot proceed without it; or core falling back to the previous release because the new one did not load (#2100) */
15
15
  ADDON_UPDATE: 'addon-update',
16
16
  /** An add-on refused or set aside by a check: its sha512, provenance, tarball contents, package.json, the lock file, or a changed installed folder (#1830) */
17
17
  ADDON_VERIFY_FAILED: 'addon-verify-failed',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.35.0",
3
+ "version": "5.37.0",
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",
@@ -41,15 +41,14 @@
41
41
  "@types/multer": "^2.3.0",
42
42
  "@types/node": "^24.19.2",
43
43
  "better-sqlite3-multiple-ciphers": "^13.0.3",
44
- "dotenv": "^18.0.8",
44
+ "dotenv": "^18.0.9",
45
45
  "express": "^5.3.0",
46
46
  "express-session": "^1.19.0",
47
47
  "fs-extra": "^11.4.1",
48
48
  "ipaddr.js": "^2.5.0",
49
- "jose": "^6.2.12",
50
- "linkedom": "^0.18.13",
49
+ "jose": "^6.2.13",
50
+ "linkedom": "^0.18.14",
51
51
  "markdown-it": "^14.3.3",
52
- "minimatch": "^10.2.6",
53
52
  "minisearch": "7.2.0",
54
53
  "multer": "^2.4.0",
55
54
  "rrule-temporal": "2.2.8",