@jwilleke/ngdpbase-types 5.30.1 → 5.31.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.
@@ -41,12 +41,12 @@ export type { IceShim, IceReceiver, IceItem, IceKind, IceWarning, ShimDirection,
41
41
  export type { default as UserManager, UserUpdateInput, UserUpdateFn } from '../../src/managers/UserManager.js';
42
42
  export type { User, UserPreferences } from '../../src/types/User.js';
43
43
  export type { default as AuthManager, InviteOutcome, InvitationView } from '../../src/managers/AuthManager.js';
44
- export type { default as IdentityManager, LinkOfferOutcome } from '../../src/managers/IdentityManager.js';
44
+ export type { default as IdentityManager, LinkOfferOutcome, AffiliationQuery, IdentitySearch, IdentityMatch } from '../../src/managers/IdentityManager.js';
45
45
  export type {
46
46
  /** @deprecated Renamed `IdentityManager` (#2038); this alias goes after one release. */
47
47
  default as PersonManager } from '../../src/managers/IdentityManager.js';
48
48
  export type { default as OrganizationManager } from '../../src/managers/OrganizationManager.js';
49
- export type { Person, PersonAffiliation, PersonInput, PersonUpdate } from '../../src/types/Person.js';
49
+ export type { Person, PersonAffiliation, LegacyPersonAffiliation, PersonChange, PersonInput, PersonUpdate } from '../../src/types/Person.js';
50
50
  export type { Organization, OrganizationInput, OrganizationUpdate, ContactPoint, ContactPointEntry, PostalAddress } from '../../src/types/Organization.js';
51
51
  export type { ContactRefusedError } from '../../src/utils/contactAccess.js';
52
52
  export type { default as RoleManager } from '../../src/managers/RoleManager.js';
@@ -327,6 +327,15 @@ declare class BackgroundJobManager extends BaseManager {
327
327
  private startDrain;
328
328
  /** Every scheduled job, its state and its recent history. */
329
329
  listScheduledJobs(): Promise<ScheduledJobView[]>;
330
+ /**
331
+ * Every scheduled job's id and display name, as `listScheduledJobs` names
332
+ * them, without reading their state — for the admin roles page's Jobs part
333
+ * (#2067).
334
+ */
335
+ scheduledJobNames(): Array<{
336
+ id: string;
337
+ displayName: string;
338
+ }>;
330
339
  /**
331
340
  * One scheduled job as `listScheduledJobs` shows it, without its run
332
341
  * history, or null when no job of that id is scheduled. For a manager that
@@ -1,8 +1,31 @@
1
1
  import BaseManager, { type BackupFolder, type ManagerStats } from './BaseManager.js';
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
- import type { Person, PersonInput, PersonUpdate } from '../types/Person.js';
3
+ import type { Person, PersonAffiliation, PersonChange, PersonInput } from '../types/Person.js';
4
4
  import type { PersonProvider } from '../types/PersonProvider.js';
5
5
  import { type ActorContext } from '../context/ActorContext.js';
6
+ /** What `search(ctx, { affiliation })` looks for (#2039). */
7
+ export interface AffiliationQuery {
8
+ /** The organization, by its `@id` (or `{ '@id' }`). */
9
+ memberOf: string | {
10
+ '@id': string;
11
+ };
12
+ /** The title, compared without case or surrounding spaces. Left out: any title, or none. */
13
+ title?: string;
14
+ /** True (the default): only affiliations held today, the first and last day included. False: any, past or future too. */
15
+ current?: boolean;
16
+ }
17
+ /** A search by affiliation (#2039). `limit` as for a text search; 0 is no limit. */
18
+ export interface IdentitySearch {
19
+ affiliation: AffiliationQuery;
20
+ limit?: number;
21
+ }
22
+ /** One identity a search by affiliation found: a user or a contact, and the affiliations that matched. */
23
+ export interface IdentityMatch {
24
+ identity: Person;
25
+ /** True for a user (an identity with credentials), false for a contact. */
26
+ hasCredentials: boolean;
27
+ affiliations: PersonAffiliation[];
28
+ }
6
29
  /** What the confirmation page shows: the person the link would join. */
7
30
  export interface LinkOfferDetails {
8
31
  personName: string | null;
@@ -52,6 +75,7 @@ declare class IdentityManager extends BaseManager {
52
75
  private storageDir;
53
76
  private byIdentifierCache;
54
77
  private byIdCache;
78
+ private allCache;
55
79
  private metricsRef;
56
80
  /** Sign-up link offers by token (#1930). In memory: a restart drops them, and an administrator links by hand. */
57
81
  private linkOffers;
@@ -59,6 +83,18 @@ declare class IdentityManager extends BaseManager {
59
83
  private readonly accountRecords;
60
84
  constructor(engine: WikiEngine);
61
85
  initialize(config?: Record<string, unknown>): Promise<void>;
86
+ /**
87
+ * Once, at start-up (#2039): every identity record still holding an
88
+ * affiliation in the old `{ organization, role? }` shape is rewritten as
89
+ * schema.org `OrganizationRole` (organization → `memberOf`, role →
90
+ * `roleName`), each rewrite recorded as `identity-manage` (`person-update`,
91
+ * field `affiliation`) under the system context. Nothing is dropped: any
92
+ * other field an old entry carries is kept on it. A record with no old entry
93
+ * is not touched, so a second start finds nothing to do. A record that
94
+ * cannot be rewritten is logged and left as it is; reads convert it anyway.
95
+ * Runs before any request, so nothing else writes these records meanwhile.
96
+ */
97
+ private convertLegacyAffiliations;
62
98
  /** `ngdpbase.application.persons.storagedir`, resolved: where the records are. */
63
99
  private configuredStorageDir;
64
100
  /**
@@ -79,6 +115,8 @@ declare class IdentityManager extends BaseManager {
79
115
  */
80
116
  getByIdentifier(ctx: ActorContext, identifier: string): Promise<Person | null>;
81
117
  private cachedById;
118
+ /** Every record, affiliations in the current shape, read from the provider once per write (#2039). */
119
+ private allRecords;
82
120
  private cachedByIdentifier;
83
121
  /** #1006: how many people this instance holds. Count only, never records. */
84
122
  getManagerStats(): Promise<ManagerStats>;
@@ -93,11 +131,20 @@ declare class IdentityManager extends BaseManager {
93
131
  * `query`, ignoring case; an empty query matches everyone. `accountless`
94
132
  * narrows to persons with no account. At most `limit` results
95
133
  * (`ngdpbase.application.persons.search.limit` by default).
134
+ *
135
+ * Or by affiliation (#2039): `search(ctx, { affiliation: { memberOf, title?,
136
+ * current? } })` finds every identity, users and contacts alike, with an
137
+ * affiliation to that organization: with that title when one is given, and
138
+ * held today (the first and last day included) unless `current` is false.
139
+ * Each match says whether it has credentials and which affiliations matched.
140
+ * Checked like any read of others' records: `identity-read`.
96
141
  */
97
142
  search(ctx: ActorContext, query: string, options?: {
98
143
  limit?: number;
99
144
  accountless?: boolean;
100
145
  }): Promise<Person[]>;
146
+ search(ctx: ActorContext, query: IdentitySearch): Promise<IdentityMatch[]>;
147
+ private searchByAffiliation;
101
148
  /**
102
149
  * Create a person. With no `identifier` the person has no account and gets
103
150
  * `person:<uuid>`. An `identifier` must be an existing account that has no
@@ -108,8 +155,13 @@ declare class IdentityManager extends BaseManager {
108
155
  * Change a person's fields, affiliations included. `@id` and `identifier`
109
156
  * never change here. The caller's own identity record under
110
157
  * `profile-manage`, anyone else's under `identity-manage` (#2018).
158
+ *
159
+ * Affiliations (#2039): one with the site's own organization is a fact, so
160
+ * adding, changing or removing one also asks `identity-manage`, even on the
161
+ * caller's own record; one with any other organization is a claim, which a
162
+ * user may set on their own record under `profile-manage`.
111
163
  */
112
- update(ctx: ActorContext, id: string, patch: PersonUpdate): Promise<Person | null>;
164
+ update(ctx: ActorContext, id: string, patch: PersonChange): Promise<Person | null>;
113
165
  delete(ctx: ActorContext, id: string): Promise<boolean>;
114
166
  /**
115
167
  * An administrator joins a person who has no account to an account (#1930),
@@ -150,10 +202,18 @@ declare class IdentityManager extends BaseManager {
150
202
  */
151
203
  private join;
152
204
  /**
153
- * Each affiliation names an organization that exists, looked up with the
154
- * caller's context; `role` is trimmed text. Throws on any other shape.
205
+ * Each affiliation is a schema.org `OrganizationRole` (#2039): `memberOf` an
206
+ * organization that exists, an optional `roleName` (the title, trimmed free
207
+ * text) and optional `startDate` / `endDate` (`YYYY-MM-DD`, the start not
208
+ * after the end; a null end is an ongoing one, stored without it). An entry
209
+ * in the old `{ organization, role? }` shape is converted (one release, for
210
+ * add-ons not yet switched). Throws on any other shape. The organization is
211
+ * looked up as core's own check, under the system context, so a user may
212
+ * name one on their own record without `identity-read`.
155
213
  */
156
214
  private checkedAffiliations;
215
+ /** The site's own organization's `@id` (`OrganizationManager.getInstallOrg`): an affiliation with it is a fact (#2039). */
216
+ private siteOrganizationId;
157
217
  private configNumber;
158
218
  /**
159
219
  * Clear all caches. Called on every write — the simplest correct
@@ -2,7 +2,7 @@ import BaseManager, { type BackupFolder, type ManagerStats } from './BaseManager
2
2
  import type { WikiEngine } from '../types/WikiEngine.js';
3
3
  import type { Organization, OrganizationInput, OrganizationUpdate } from '../types/Organization.js';
4
4
  import type { OrganizationProvider } from '../types/OrganizationProvider.js';
5
- import type { Person } from '../types/Person.js';
5
+ import type { Person, PersonAffiliation } from '../types/Person.js';
6
6
  import type { ActorContext } from '../context/ActorContext.js';
7
7
  /**
8
8
  * Subset of install-form data this manager needs to seed the anchor org.
@@ -107,13 +107,17 @@ declare class OrganizationManager extends BaseManager {
107
107
  limit?: number;
108
108
  }): Promise<Organization[]>;
109
109
  /**
110
- * The people affiliated with an organization, read from each person's
111
- * `affiliation` (#1930): the link is stored once, on the person. Each comes
112
- * with the role they hold there, when one is given.
110
+ * The identities affiliated with an organization, users and contacts alike,
111
+ * read from each identity record's `affiliation` (#1930): the link is stored
112
+ * once, on the identity record. One entry per affiliation, each with its
113
+ * schema.org `OrganizationRole` (title as `roleName`, dates) (#2039), past
114
+ * and future ones included; `IdentityManager.search(ctx, { affiliation })`
115
+ * answers who holds one today. Searching identities is IdentityManager's,
116
+ * so this asks it.
113
117
  */
114
118
  people(ctx: ActorContext, organizationId: string): Promise<Array<{
115
119
  person: Person;
116
- role?: string;
120
+ affiliation: PersonAffiliation;
117
121
  }>>;
118
122
  /**
119
123
  * Create an organization. `name` is required; `@id` defaults to the `url`,
@@ -222,6 +222,8 @@ declare class UserManager extends BaseManager {
222
222
  private passwordSalt?;
223
223
  /** One read-merge-write at a time per account, keyed by the normalized username (#2025). */
224
224
  private readonly accountWrites;
225
+ /** Whether this process has already reported the withheld session cookie (#2071). */
226
+ private sessionCookieWithheldReported;
225
227
  /**
226
228
  * Creates a new UserManager instance
227
229
  *
@@ -255,6 +257,15 @@ declare class UserManager extends BaseManager {
255
257
  * its safe message and other routes their own.
256
258
  */
257
259
  private assertEmailFree;
260
+ /**
261
+ * The session cookie is `secure` and a request came over plain http, so
262
+ * express-session sent no cookie and nobody can sign in (#2071). Sessions
263
+ * are this manager's, so the report is too: an error in the log, the
264
+ * dashboard's "needs your attention", and a notification for admins, once
265
+ * per process. Every later call is one boolean check, because app.ts calls
266
+ * this on each such request.
267
+ */
268
+ reportSessionCookieWithheld(): void;
258
269
  /**
259
270
  * Accounts that share an email, saved before #1748 refused it. They are not
260
271
  * changed — an admin decides — but they are never silent: the manager is
@@ -17,15 +17,40 @@
17
17
  */
18
18
  import type { PostalAddress, ContactPointEntry } from './Organization.js';
19
19
  /**
20
- * One organization a person is affiliated with (#1930): schema.org
21
- * `affiliation`, with the person's role or title there when one is known.
20
+ * One affiliation of a digital identity (#2039): schema.org `affiliation`
21
+ * with an `OrganizationRole`, as the identity model says
22
+ * (`docs/planning/identity-model.md`, "Affiliation shape"). It puts the
23
+ * identity in a group, the organization plus the title, and never grants a
24
+ * permission.
25
+ *
22
26
  * The organization is named by its `@id`; OrganizationManager lists an
23
27
  * organization's people by reading these, so the link is stored once, here.
28
+ * With the site's own organization it is a fact, set only under
29
+ * `identity-manage`; with any other it is a claim, which a user may also set
30
+ * on their own identity record. IdentityManager's door decides.
24
31
  */
25
32
  export interface PersonAffiliation {
26
- /** The organization's `@id`. */
33
+ '@type': 'OrganizationRole';
34
+ /** The organization, by its `@id`. */
35
+ memberOf: {
36
+ '@id': string;
37
+ };
38
+ /** The affiliation's title there (Owner, Treasurer, Vendor): free text, never a Role. Left out when none is known. */
39
+ roleName?: string;
40
+ /** ISO date (`YYYY-MM-DD`) the affiliation began; left out when not known. */
41
+ startDate?: string;
42
+ /** ISO date (`YYYY-MM-DD`) it ended or ends, the day included; left out while it is ongoing. */
43
+ endDate?: string;
44
+ }
45
+ /**
46
+ * An affiliation in its shape before #2039, `{ organization, role? }`.
47
+ * Converted (organization → `memberOf`, role → `roleName`) when read, once on
48
+ * disk at start-up, and when an add-on still writes it (for one release).
49
+ *
50
+ * @deprecated Write {@link PersonAffiliation}.
51
+ */
52
+ export interface LegacyPersonAffiliation {
27
53
  organization: string;
28
- /** The person's role or title there (schema.org `roleName`), when known. */
29
54
  role?: string;
30
55
  }
31
56
  /**
@@ -59,7 +84,7 @@ export interface Person {
59
84
  image?: string;
60
85
  /** Other URLs that identify this person (LinkedIn, GitHub, etc.) */
61
86
  sameAs?: string[];
62
- /** Organizations this person is affiliated with, each with an optional role (#1930). */
87
+ /** Affiliations: each a schema.org OrganizationRole, an organization with a title and dates (#2039). */
63
88
  affiliation?: PersonAffiliation[];
64
89
  /** Allow extension fields (additionalProperty, etc.) */
65
90
  [key: string]: unknown;
@@ -69,12 +94,20 @@ export interface Person {
69
94
  * `@context`/`@type` are immutable; `@id` and `identifier` cannot be changed via update.
70
95
  */
71
96
  export type PersonUpdate = Partial<Omit<Person, '@context' | '@type' | '@id' | 'identifier'>>;
97
+ /**
98
+ * What `IdentityManager.update` takes: a {@link PersonUpdate} whose
99
+ * `affiliation` (the whole list) may still hold entries in the old
100
+ * `{ organization, role? }` shape, converted at the door, for one release (#2039).
101
+ */
102
+ export type PersonChange = Omit<PersonUpdate, 'affiliation'> & {
103
+ affiliation?: Array<PersonAffiliation | LegacyPersonAffiliation>;
104
+ };
72
105
  /**
73
106
  * What a caller gives `IdentityManager.create` (#1930): the person's fields.
74
107
  * `identifier` is left out for a person with no account (one is generated);
75
108
  * UserManager passes the account's username when it creates the person for a
76
109
  * new account.
77
110
  */
78
- export type PersonInput = PersonUpdate & {
111
+ export type PersonInput = PersonChange & {
79
112
  identifier?: string;
80
113
  };
@@ -24,12 +24,27 @@ export interface RoleGrants {
24
24
  /** Actions a deny policy names for this role. */
25
25
  denies: string[];
26
26
  }
27
+ /** A grant to one subject, with the policies that give it (#2067). */
28
+ export interface SubjectGrant extends RoleGrant {
29
+ /** The ids of the allow policies that give it, lowest priority first. */
30
+ by: string[];
31
+ }
32
+ export interface SubjectGrants {
33
+ allows: SubjectGrant[];
34
+ /** Actions a deny policy names for this subject. */
35
+ denies: string[];
36
+ }
27
37
  /**
28
38
  * Every role a policy names, with what its policies allow and deny — the one
29
39
  * reading of "what a role grants" (#1539): the user forms, the profile, the
30
40
  * admin roles page and `PolicyDecisionPoint.rolePermissions` all come from
31
- * here. Policies are applied lowest priority first, so a deny takes away what
32
- * a lower-priority allow gave and an allow above it gives it back — the order
33
- * the evaluator's first-match-by-priority decides in.
41
+ * here.
34
42
  */
35
43
  export declare function roleGrants(policies: readonly Policy[]): Record<string, RoleGrants>;
44
+ /**
45
+ * Every scheduled job a policy names (`{ "type": "job", "value": "<job id>" }`,
46
+ * #1999), with what its policies allow and deny and which policy gives each
47
+ * allow — the admin roles page's Jobs part (#2067). A job a policy names acts
48
+ * as itself and holds only these. Display only, like {@link roleGrants}.
49
+ */
50
+ export declare function jobGrants(policies: readonly Policy[]): Record<string, SubjectGrants>;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * resolveSessionSecurity — keep `session.secure` and `trust proxy` consistent (#1046).
3
+ *
4
+ * #1043 made the session cookie `secure` actually turn on in production. On
5
+ * every HTTPS deployment that terminates TLS upstream — Cloudflare, an ingress,
6
+ * a tunnel — that silently broke login:
7
+ *
8
+ * 1. `secure` resolves true, so express-session will only emit `Set-Cookie`
9
+ * on a connection it believes is https.
10
+ * 2. `trust proxy` defaults to false, so Express reads the raw socket and
11
+ * believes the connection is plain http. It is not wrong — the hop from
12
+ * the proxy really is http; it simply has not been told to read
13
+ * X-Forwarded-Proto.
14
+ * 3. No session cookie ever reaches the browser. Every following request
15
+ * builds a fresh session, so `req.session.csrfToken` is missing and the
16
+ * CSRF middleware answers `403 Forbidden — invalid CSRF token` — on
17
+ * `POST /login` included. Nobody can sign in.
18
+ *
19
+ * The two settings are not independent, so they are resolved together here.
20
+ * Turning on `secure` without `trust proxy` is not a working configuration;
21
+ * `secure` is the operator's stated intent to be behind TLS, and behind
22
+ * terminated TLS `trust proxy` is what makes Express able to see it.
23
+ *
24
+ * The operator's explicit choice still wins for both keys. The shipped
25
+ * app-default-config.json pins `ngdpbase.session.secure` and
26
+ * `ngdpbase.server.trust-proxy` to false, so a plain getProperty() read cannot
27
+ * tell "operator asked for false" from "nobody has said anything" — hence
28
+ * reading getCustomProperties() rather than the merged view.
29
+ *
30
+ * ## #1160 — native TLS breaks the assumption
31
+ *
32
+ * All of the above assumed `secure: true` meant TLS terminated UPSTREAM, so a
33
+ * proxy existed whose `X-Forwarded-Proto` Express had to be told to read. #1153
34
+ * made the server able to terminate TLS itself, and then there is no proxy —
35
+ * and trusting a forwarded header that nothing sets is worse than not trusting
36
+ * it. With `trust proxy` on and nothing in front, a caller can assert their own
37
+ * address and scheme, which the login throttle counts by and the audit log
38
+ * records.
39
+ *
40
+ * So the derivation now asks what is actually true rather than inferring from
41
+ * `secure` alone. The same applies to the misconfiguration warning: `secure`
42
+ * with `trust-proxy` explicitly false is the CORRECT configuration under native
43
+ * TLS, and warning about it would train an operator to ignore a warning that is
44
+ * load-bearing in the upstream case.
45
+ */
46
+ /** Express's accepted `trust proxy` values: off, hop count, or a subnet list. */
47
+ export type TrustProxyValue = boolean | number | string;
48
+ /** What the resolution needs to know about the transport (#1160). */
49
+ export interface SessionSecurityContext {
50
+ /**
51
+ * True when this server terminates TLS itself (#1153).
52
+ *
53
+ * Defaults to false so every existing caller keeps its pre-#1160 behaviour
54
+ * rather than silently changing what it resolves.
55
+ */
56
+ nativeTls?: boolean;
57
+ }
58
+ export interface SessionSecurity {
59
+ /** Value for the session cookie's `secure` flag. */
60
+ secure: boolean;
61
+ /** Value for `app.set('trust proxy', …)`. `false` means do not set it. */
62
+ trustProxy: TrustProxyValue;
63
+ /** True when `trustProxy` was derived from `secure` rather than configured. */
64
+ trustProxyDerived: boolean;
65
+ /**
66
+ * True when the resolved pair cannot issue a session cookie behind
67
+ * terminated TLS — `secure` on with `trust proxy` explicitly off. Only
68
+ * reachable when the operator configured that combination by hand; the
69
+ * caller is expected to log it loudly rather than silently "fix" a stated
70
+ * choice.
71
+ */
72
+ misconfigured: boolean;
73
+ }
74
+ /**
75
+ * Resolve the session cookie's `secure` flag and the matching `trust proxy`.
76
+ *
77
+ * `secure`: the operator's explicit boolean, else on when NODE_ENV is
78
+ * production. Inferring it from the request would get the terminated-TLS case
79
+ * exactly backwards, which is the case that matters.
80
+ *
81
+ * `trustProxy`: the operator's explicit value, else `true` whenever `secure`
82
+ * ends up on. `true` — rather than a hop count — because req.ip must come out
83
+ * as the end client. Under `1` behind Cloudflare, req.ip would resolve to the
84
+ * edge address shared by every visitor, and the login throttle's `ip:` bucket
85
+ * (#1044) would lock out all users at once on ten failures from anyone. An
86
+ * operator whose instance is also reachable directly should pin a hop count or
87
+ * a subnet list, since X-Forwarded-For is client-spoofable on that path.
88
+ *
89
+ * @param customProperties operator overrides only — NOT the merged config
90
+ * @param nodeEnv typically process.env.NODE_ENV
91
+ */
92
+ export declare function resolveSessionSecurity(customProperties: Record<string, unknown> | null | undefined, nodeEnv: string | undefined, context?: SessionSecurityContext): SessionSecurity;
93
+ /**
94
+ * #2071 — what an administrator and a visitor are told when the session
95
+ * cookie cannot reach the browser, spelled once: the server log, the admin
96
+ * dashboard, the CSRF refusal and the login page all say this.
97
+ */
98
+ export declare const SESSION_COOKIE_WITHHELD_MESSAGE: string;
99
+ /** The two request fields the check reads; express's Request has both. */
100
+ export interface SessionCookieRequest {
101
+ secure?: boolean;
102
+ session?: {
103
+ cookie?: {
104
+ secure?: unknown;
105
+ };
106
+ } | null;
107
+ }
108
+ /**
109
+ * True when express-session will NOT send the session cookie on this request
110
+ * (#2071): the cookie is `secure` and the request is not https as Express
111
+ * sees it, after `trust proxy`. express-session makes exactly this check and
112
+ * then silently sends nothing, so the browser has no session, every POST
113
+ * meets a fresh one, and the CSRF middleware refuses it. A boolean read of
114
+ * two fields, cheap enough for every request.
115
+ */
116
+ export declare function sessionCookieWithheld(req: SessionCookieRequest): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.30.1",
3
+ "version": "5.31.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",