@jwilleke/ngdpbase-types 5.28.0 → 5.29.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.
@@ -40,21 +40,34 @@ export declare class ApiError extends Error {
40
40
  * offer the way forward, as core routes do (`{ error, reauth }`).
41
41
  */
42
42
  readonly reauth?: string | undefined;
43
+ /**
44
+ * For a refusal because the user has documents to accept (#2027): this
45
+ * site's consent prompt, returning to the page they were on. Sent with
46
+ * the 403 as `consent`; a page is redirected there.
47
+ */
48
+ readonly consent?: string | undefined;
43
49
  constructor(status: number, message: string,
44
50
  /**
45
51
  * For a step-up refusal (#1745): where the person re-authenticates,
46
52
  * returning to the page they were on. Send it with the 403 so the page can
47
53
  * offer the way forward, as core routes do (`{ error, reauth }`).
48
54
  */
49
- reauth?: string | undefined);
55
+ reauth?: string | undefined,
56
+ /**
57
+ * For a refusal because the user has documents to accept (#2027): this
58
+ * site's consent prompt, returning to the page they were on. Sent with
59
+ * the 403 as `consent`; a page is redirected there.
60
+ */
61
+ consent?: string | undefined);
50
62
  }
51
63
  /**
52
64
  * Send a refusal — the one way it reaches the client, from core routes and
53
65
  * every add-on route alike (#1749). JSON is `{ success: false, error, reauth? }`;
54
66
  * text is the message, then where to sign in again for a step-up refusal.
55
- * `page` is for a link the person followed in the browser: a step-up refusal
56
- * redirects to the re-authenticate page, a 401 to sign-in (returning here),
57
- * anything else is the message as text. A route never writes its own refusal:
67
+ * `page` is for a link the user followed or a form they sent in the browser:
68
+ * a step-up refusal redirects to the re-authenticate page, a consent refusal
69
+ * to the consent prompt (#2027), a 401 to sign-in (returning here), anything
70
+ * else is the message as text. JSON carries the consent link as `consent`. A route never writes its own refusal:
58
71
  * the step-up `reauth` is easy to drop.
59
72
  */
60
73
  export declare function sendApiError(res: Response, err: ApiError, mode?: ReauthMode): void;
@@ -4,6 +4,7 @@
4
4
  import '../types/express.js';
5
5
  import { BaseContext } from './BaseContext.js';
6
6
  import { STEP_UP_REASON } from '../types/Policy.js';
7
+ import { CONSENT_PATH } from '../types/Consent.js';
7
8
  // ── ApiError ────────────────────────────────────────────────────────────────
8
9
  /**
9
10
  * Thrown by ApiContext guard methods (requirePermission, actingUsername).
@@ -12,16 +13,24 @@ import { STEP_UP_REASON } from '../types/Policy.js';
12
13
  export class ApiError extends Error {
13
14
  status;
14
15
  reauth;
16
+ consent;
15
17
  constructor(status, message,
16
18
  /**
17
19
  * For a step-up refusal (#1745): where the person re-authenticates,
18
20
  * returning to the page they were on. Send it with the 403 so the page can
19
21
  * offer the way forward, as core routes do (`{ error, reauth }`).
20
22
  */
21
- reauth) {
23
+ reauth,
24
+ /**
25
+ * For a refusal because the user has documents to accept (#2027): this
26
+ * site's consent prompt, returning to the page they were on. Sent with
27
+ * the 403 as `consent`; a page is redirected there.
28
+ */
29
+ consent) {
22
30
  super(message);
23
31
  this.status = status;
24
32
  this.reauth = reauth;
33
+ this.consent = consent;
25
34
  this.name = 'ApiError';
26
35
  }
27
36
  }
@@ -29,19 +38,26 @@ export class ApiError extends Error {
29
38
  * Send a refusal — the one way it reaches the client, from core routes and
30
39
  * every add-on route alike (#1749). JSON is `{ success: false, error, reauth? }`;
31
40
  * text is the message, then where to sign in again for a step-up refusal.
32
- * `page` is for a link the person followed in the browser: a step-up refusal
33
- * redirects to the re-authenticate page, a 401 to sign-in (returning here),
34
- * anything else is the message as text. A route never writes its own refusal:
41
+ * `page` is for a link the user followed or a form they sent in the browser:
42
+ * a step-up refusal redirects to the re-authenticate page, a consent refusal
43
+ * to the consent prompt (#2027), a 401 to sign-in (returning here), anything
44
+ * else is the message as text. JSON carries the consent link as `consent`. A route never writes its own refusal:
35
45
  * the step-up `reauth` is easy to drop.
36
46
  */
37
47
  export function sendApiError(res, err, mode = 'json') {
38
48
  // Only this site's re-authenticate page: an add-on's ApiError cannot send the person elsewhere.
39
49
  const reauth = typeof err.reauth === 'string' && err.reauth.startsWith('/auth/reauth?') ? err.reauth : undefined;
50
+ // Likewise only this site's consent prompt.
51
+ const consent = typeof err.consent === 'string' && (err.consent === CONSENT_PATH || err.consent.startsWith(`${CONSENT_PATH}?`)) ? err.consent : undefined;
40
52
  if (mode === 'page') {
41
53
  if (reauth) {
42
54
  res.redirect(reauth);
43
55
  return;
44
56
  }
57
+ if (consent) {
58
+ res.redirect(consent);
59
+ return;
60
+ }
45
61
  if (err.status === 401) {
46
62
  res.redirect('/login?redirect=' + encodeURIComponent(res.req?.originalUrl || '/'));
47
63
  return;
@@ -50,10 +66,10 @@ export function sendApiError(res, err, mode = 'json') {
50
66
  return;
51
67
  }
52
68
  if (mode === 'text') {
53
- res.status(err.status).send(reauth ? `${err.message}: ${reauth}` : err.message);
69
+ res.status(err.status).send(reauth ? `${err.message}: ${reauth}` : consent ? `${err.message} Read and accept them at ${consent}` : err.message);
54
70
  return;
55
71
  }
56
- res.status(err.status).json({ success: false, error: err.message, ...(reauth ? { reauth } : {}) });
72
+ res.status(err.status).json({ success: false, error: err.message, ...(reauth ? { reauth } : {}), ...(consent ? { consent } : {}) });
57
73
  }
58
74
  /**
59
75
  * The subject a request carries, refused when there is none (#1399).
@@ -43,6 +43,32 @@ export interface ChainSpec {
43
43
  * warnings first. Partial notes are collected into `partial`.
44
44
  */
45
45
  export declare function convertChain(shim: RegisteredShim, input: IceItem, chain: string[], spec: ChainSpec, partial: string[]): Promise<ChainLeaf[]>;
46
+ /** Does this run keep its originals during the upload (#2034)? */
47
+ export declare function keepsAtUpload(receiver: RegisteredReceiver | undefined, original: OriginalChoice): boolean;
48
+ /** What keeping at upload did: how many it kept, or the first file it could not keep. */
49
+ export interface UploadKeepResult {
50
+ kept: number;
51
+ refused?: {
52
+ name: string;
53
+ message: string;
54
+ };
55
+ }
56
+ /**
57
+ * Keep the originals of a run whose receiver keeps into an encrypted store,
58
+ * now, during the upload (#2034). `ctx` is the REQUEST's context: it holds
59
+ * the user's unlocked key, so the receiver's `keepOriginal` seals the bytes
60
+ * and the store's sealed index entry while the key is live. Each kept
61
+ * original is recorded in the ledger by fingerprint and door (#2011), so the
62
+ * queued run reuses it and never keeps again.
63
+ *
64
+ * The files are decided exactly as the run decides them ({@link planFile}):
65
+ * noise, a file too large, one nothing reads and one already imported are
66
+ * not kept. An original already kept through this door is not kept again.
67
+ * Stops at the first refusal (or failure) and says which file: the caller
68
+ * then queues nothing. Originals kept before it stay kept, and recorded, so
69
+ * the next try reuses them.
70
+ */
71
+ export declare function keepOriginalsAtUpload(deps: IceRunDeps, request: IceRunRequest, ctx: ActorContext, scope: string): Promise<UploadKeepResult>;
46
72
  /**
47
73
  * Run one import. Fills and returns `report`, which the caller may be
48
74
  * showing while it runs. Never throws for one file's failure; that file's
@@ -182,6 +182,13 @@ export interface IceReceiver {
182
182
  * store). Without this, `keep` hands the original to the files receiver.
183
183
  */
184
184
  keepOriginal?(original: IceItem, ctx: ActorContext): Promise<ReceiveOutcome>;
185
+ /**
186
+ * `keepOriginal` writes into an encrypted store (phr's vault), which only a
187
+ * request holding the user's unlocked key can write (#2034). The engine then
188
+ * keeps each original during the upload, with the request's own context,
189
+ * before the run is queued; the run reuses what was kept and needs no key.
190
+ */
191
+ keepsEncrypted?: boolean;
185
192
  /** The original-file choices this receiver offers (D11). Without it: all three. */
186
193
  originals?: IceOriginalChoices;
187
194
  }
@@ -31,7 +31,7 @@ import BaseManager, { BackupData } from './BaseManager.js';
31
31
  import type { WikiEngine } from '../types/WikiEngine.js';
32
32
  import { IContentConverter } from '../converters/IContentConverter.js';
33
33
  import { type ZipReadLimits } from '../utils/zipArchive.js';
34
- import type { JobContext } from '../context/JobContext.js';
34
+ import { type JobContext } from '../context/JobContext.js';
35
35
  import type { IceLedger, IceOriginalChoices, IceReceiver, IceRunReport, IceRunRequest, IceShim, RegisteredShim } from '../ice/types.js';
36
36
  /**
37
37
  * Options for import operations
@@ -207,6 +207,16 @@ export declare class TakeoutImportRefused extends Error {
207
207
  readonly reason: 'not-owner' | 'no-such-store' | 'locked' | 'unreadable';
208
208
  constructor(reason: 'not-owner' | 'no-such-store' | 'locked' | 'unreadable', message: string);
209
209
  }
210
+ /**
211
+ * An import refused at upload, before anything was queued (#2034): keeping
212
+ * the original of `file` in the receiver's encrypted store was refused or
213
+ * failed. `message` is written for the user who uploaded.
214
+ */
215
+ export declare class ImportUploadRefused extends Error {
216
+ readonly file: string;
217
+ readonly reason: string;
218
+ constructor(file: string, reason: string);
219
+ }
210
220
  /** One page's outcome in a takeout import (#1472). */
211
221
  export type TakeoutPageOutcome = {
212
222
  title: string;
@@ -344,11 +354,27 @@ declare class ImportManager extends BaseManager {
344
354
  runImport(request: IceRunRequest, ctx: ActorContext): Promise<IceRunReport>;
345
355
  /**
346
356
  * Queue an import as background work and return its run id at once. The
347
- * run carries `ctx` — the person who asked (`jobContextFromRequest`), or a
357
+ * run carries `ctx` — the user who asked (`jobContextFromRequest`), or a
348
358
  * job's context with its reason — and each receiver checks its own
349
- * permission with it when the run reaches it (D5).
359
+ * permission with it when the run reaches it (D5). A user's upload, from
360
+ * a request, goes through {@link enqueueUpload} instead.
350
361
  */
351
362
  enqueueImport(request: IceRunRequest, ctx: JobContext): Promise<string>;
363
+ /**
364
+ * Queue a user's upload, from the request it arrived in (#2034). `ctx` is
365
+ * the REQUEST's context; the run carries the job context derived from it
366
+ * (`jobContextFromRequest`), as {@link enqueueImport} does.
367
+ *
368
+ * When the run keeps originals through a receiver that keeps into an
369
+ * encrypted store (`keepsEncrypted`), they are kept first, here, with
370
+ * `ctx`, while the user's key is in the request: the bytes are sealed and
371
+ * the store's sealed index entry written. Each is recorded in the ledger
372
+ * as the keep-once record (#2011), so the run reuses it and needs no key.
373
+ *
374
+ * @throws ImportUploadRefused when an original could not be kept; nothing is queued
375
+ */
376
+ enqueueUpload(request: IceRunRequest, ctx: ActorContext): Promise<string>;
377
+ private enqueueResolved;
352
378
  /** A run's report, to the person it runs as; null to anyone else. */
353
379
  getImportRun(runId: string, ctx: ActorContext): IceRunReport | null;
354
380
  /** The runs this process holds for the user `ctx` names, newest first (#2021): their own, never anyone else's. */
@@ -357,6 +383,8 @@ declare class ImportManager extends BaseManager {
357
383
  setImportLedger(ledger: IceLedger): void;
358
384
  private newRunReport;
359
385
  private executeRun;
386
+ /** What a run, or keeping at upload, works with. */
387
+ private iceDeps;
360
388
  private enabledShims;
361
389
  /**
362
390
  * Hand the queue to the background job. One job run drains every queued
@@ -37,8 +37,8 @@ export type LinkOfferOutcome = 'sent' | 'no-match' | 'several' | 'unavailable' |
37
37
  * person keeps its `@id` and takes the account; the person made for the new
38
38
  * account is folded into it and removed, so there is never a second person.
39
39
  *
40
- * `getByUserIdentifier(ctx, username)` remains as a lazy-migration entry
41
- * point for legacy users that pre-date the sync wiring.
40
+ * `getByUserIdentifier(ctx, username)` returns a user's identity record and
41
+ * makes it when the user has none (#2037): every user has exactly one.
42
42
  */
43
43
  declare class PersonManager extends BaseManager {
44
44
  readonly description = "Canonical Person records (#617)";
@@ -51,6 +51,8 @@ declare class PersonManager extends BaseManager {
51
51
  private metricsRef;
52
52
  /** Sign-up link offers by token (#1930). In memory: a restart drops them, and an administrator links by hand. */
53
53
  private linkOffers;
54
+ /** #2037: making a user's missing identity record, one at a time per username. */
55
+ private readonly accountRecords;
54
56
  constructor(engine: WikiEngine);
55
57
  initialize(config?: Record<string, unknown>): Promise<void>;
56
58
  /** `ngdpbase.application.persons.storagedir`, resolved: where the records are. */
@@ -155,13 +157,20 @@ declare class PersonManager extends BaseManager {
155
157
  */
156
158
  invalidateCache(): void;
157
159
  /**
158
- * Lazy-migration entry point.
160
+ * The identity record of the user `username`, made when the user has none
161
+ * (#2037). Every user has exactly one identity record; one made outside
162
+ * `UserManager.createUser` (an account written straight into the user
163
+ * store) gets it here, from the account's display name and email.
159
164
  *
160
- * Returns the Person paired to `username`. If none exists and a matching
161
- * User does, a Person is created from the User's email/displayName and
162
- * persisted. Subsequent calls return the persisted record. Decided as
163
- * {@link getByIdentifier} (#2018): a signed-in user's own under
164
- * `profile-manage` ("your receipts"), anyone else's under `contact-read`.
165
+ * The read is decided as {@link getByIdentifier} (#2018): a signed-in user's
166
+ * own under `profile-manage`, anyone else's under `contact-read`, core's own
167
+ * work under the system context. Making the missing record is core's
168
+ * bookkeeping, not the caller's contact management, so it goes through
169
+ * {@link create} under the system context, naming the caller, and is
170
+ * recorded as `contact-manage`. One at a time per username, so two lookups
171
+ * never make two records. Null when there is no account by that name.
172
+ * Never a second record: a contact with the same email is left alone
173
+ * (an administrator links it, {@link linkAccount}).
165
174
  */
166
175
  getByUserIdentifier(ctx: ActorContext, username: string): Promise<Person | null>;
167
176
  private requireProvider;
@@ -67,8 +67,9 @@ declare class RoleManager extends BaseManager {
67
67
  * The pseudo-roles `'Authenticated'` and `'All'` are NOT added here — the
68
68
  * caller adds those when constructing `userContext.roles`.
69
69
  *
70
- * Returns `[]` when PersonManager is unavailable, the user has no Person
71
- * record, no record lists them, or the lookup throws.
70
+ * Returns `[]` when PersonManager is unavailable, there is no account by
71
+ * that username, no record lists them, or the lookup throws. An account
72
+ * missing its identity record gets it here (#2037).
72
73
  *
73
74
  * `ctx` is who is asking (#2018), decided by PersonManager's door: a user
74
75
  * about themselves under `profile-manage`, about anyone else under
@@ -340,6 +340,17 @@ declare class UserManager extends BaseManager {
340
340
  private withAccountRoles;
341
341
  /** `ngdpbase.user.account-roles`: the roles every account holds (#1539). */
342
342
  private accountRoles;
343
+ /**
344
+ * Every account has exactly one identity record, made by `createUser`
345
+ * (#2037). One that has none (written straight into the user store, or
346
+ * whose record was lost) gets it at boot, once, through PersonManager's
347
+ * door under the system context, each creation recorded as
348
+ * `contact-manage`. The identity records are listed once and only the
349
+ * missing ones are made, so a large user store costs one read. A failure
350
+ * is logged and the boot goes on: the role paths make the record when the
351
+ * account is next used (`getByUserIdentifier`).
352
+ */
353
+ private giveIdentityRecordsToAccountsWithout;
343
354
  /**
344
355
  * Accounts made before a role joined `ngdpbase.user.account-roles` get it
345
356
  * once, at boot (#1539) — RoleManager decides "once" and records each grant.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Consent to a site's documents (#1603): the shapes ConsentManager and its
3
+ * callers share, and what an add-on registers.
4
+ */
5
+ /** Where a person is asked to accept (#1603): `?scope=` (core when absent) and `?returnTo=`. */
6
+ export const CONSENT_PATH = '/consent';
7
+ /** The scope of core's own documents, `ngdpbase.consent.documents`. */
8
+ export const CORE_CONSENT_SCOPE = 'core';
9
+ /**
10
+ * Thrown when a per-person add-on is turned on before its documents are
11
+ * accepted (#1603). `documents` names what is still to accept.
12
+ */
13
+ export class ConsentRequiredError extends Error {
14
+ scope;
15
+ documents;
16
+ name = 'ConsentRequiredError';
17
+ constructor(scope, documents) {
18
+ super(`Turning ${scope} on needs its ${documents.join(' and ')} accepted first`);
19
+ this.scope = scope;
20
+ this.documents = documents;
21
+ }
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.28.0",
3
+ "version": "5.29.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",