@jwilleke/ngdpbase-types 5.30.1 → 5.32.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.
- package/lib/packages/ngdpbase-types/index.d.ts +4 -2
- package/lib/src/managers/AuthManager.d.ts +96 -1
- package/lib/src/managers/BackgroundJobManager.d.ts +9 -0
- package/lib/src/managers/IdentityManager.d.ts +109 -4
- package/lib/src/managers/OrganizationManager.d.ts +9 -5
- package/lib/src/managers/ReviewManager.d.ts +108 -0
- package/lib/src/managers/UserManager.d.ts +39 -15
- package/lib/src/providers/AuthentikBearerAuthProvider.d.ts +0 -1
- package/lib/src/providers/BaseAuthProvider.d.ts +7 -0
- package/lib/src/providers/CloudflareAccessAuthProvider.d.ts +0 -1
- package/lib/src/providers/MagicLinkAuthProvider.d.ts +12 -0
- package/lib/src/providers/databaseMigrations.js +30 -0
- package/lib/src/types/Config.d.ts +10 -0
- package/lib/src/types/Organization.d.ts +3 -0
- package/lib/src/types/Person.d.ts +84 -6
- package/lib/src/types/Review.d.ts +67 -0
- package/lib/src/types/express.d.ts +3 -1
- package/lib/src/utils/addonHost.d.ts +19 -0
- package/lib/src/utils/addonHost.js +31 -1
- package/lib/src/utils/auditEventNames.generated.d.ts +12 -0
- package/lib/src/utils/auditEventNames.generated.js +12 -0
- package/lib/src/utils/languages.d.ts +9 -0
- package/lib/src/utils/phoneNumbers.d.ts +38 -0
- package/lib/src/utils/roleGrants.d.ts +18 -3
- package/lib/src/utils/sessionSecurity.d.ts +116 -0
- package/lib/src/utils/userCreateError.d.ts +4 -2
- package/package.json +1 -1
|
@@ -87,6 +87,36 @@ const DATABASE_MIGRATIONS = [
|
|
|
87
87
|
created_at TEXT NOT NULL
|
|
88
88
|
)`);
|
|
89
89
|
}
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
// #2072: requests for review. `details` is the request's JSON, which can
|
|
93
|
+
// be personal: ReviewManager writes rows only while this database is
|
|
94
|
+
// encrypted. A failed handler leaves the row `open`, with `failure` and
|
|
95
|
+
// `failed_at` saying why and when.
|
|
96
|
+
id: '20261025120000',
|
|
97
|
+
description: 'reviews: review_requests',
|
|
98
|
+
up: (db) => {
|
|
99
|
+
db.exec(`CREATE TABLE review_requests (
|
|
100
|
+
id TEXT PRIMARY KEY,
|
|
101
|
+
kind TEXT NOT NULL,
|
|
102
|
+
handler_id TEXT NOT NULL,
|
|
103
|
+
decide_permission TEXT NOT NULL,
|
|
104
|
+
subject TEXT NOT NULL,
|
|
105
|
+
summary TEXT NOT NULL,
|
|
106
|
+
details TEXT NOT NULL,
|
|
107
|
+
status TEXT NOT NULL,
|
|
108
|
+
filed_at TEXT NOT NULL,
|
|
109
|
+
filed_by TEXT NOT NULL,
|
|
110
|
+
decided_at TEXT,
|
|
111
|
+
decided_by TEXT,
|
|
112
|
+
note TEXT,
|
|
113
|
+
outcome TEXT,
|
|
114
|
+
failed_at TEXT,
|
|
115
|
+
failure TEXT
|
|
116
|
+
)`);
|
|
117
|
+
db.exec('CREATE INDEX review_requests_by_status ON review_requests (status, filed_at)');
|
|
118
|
+
db.exec('CREATE INDEX review_requests_by_decided ON review_requests (decided_at)');
|
|
119
|
+
}
|
|
90
120
|
}
|
|
91
121
|
];
|
|
92
122
|
export default DATABASE_MIGRATIONS;
|
|
@@ -316,6 +316,16 @@ export interface ApplicationOrganizationConfig {
|
|
|
316
316
|
'ngdpbase.application.persons.search.limit': number;
|
|
317
317
|
/** #1930: minutes the emailed "Is this you?" sign-up link stays usable */
|
|
318
318
|
'ngdpbase.application.persons.link-offer.ttl-minutes': number;
|
|
319
|
+
/** #2073: the country (ISO 3166-1 alpha-2) a phone or address on /profile is read with until the user picks another */
|
|
320
|
+
'ngdpbase.application.persons.default-country': string;
|
|
321
|
+
/** #2073: minutes the link confirming a user's new email stays usable */
|
|
322
|
+
'ngdpbase.auth.email-change.ttl-minutes': number;
|
|
323
|
+
/** #2073: the least time between two email-change links for one account, in seconds */
|
|
324
|
+
'ngdpbase.auth.email-change.cooldown-seconds': number;
|
|
325
|
+
/** #2073: the most email-change links one account is sent per window */
|
|
326
|
+
'ngdpbase.auth.email-change.max-per-window': number;
|
|
327
|
+
/** #2073: that window, in minutes */
|
|
328
|
+
'ngdpbase.auth.email-change.window-minutes': number;
|
|
319
329
|
/** #1930: the most results `OrganizationManager.search` returns when the caller gives no limit */
|
|
320
330
|
'ngdpbase.application.organization.search.limit': number;
|
|
321
331
|
/** Storage directory for OrganizationRole JSON files (one per (org, namedPosition) pair) */
|
|
@@ -39,7 +39,10 @@ export interface ContactPoint {
|
|
|
39
39
|
/** Who or what this contact point reaches, e.g. an agent's or an emergency contact's name (schema.org Thing.name). */
|
|
40
40
|
name?: string;
|
|
41
41
|
email?: string;
|
|
42
|
+
/** E.164 (`+14155552671`) when written from /profile (#2073); add-ons may store other forms. */
|
|
42
43
|
telephone?: string;
|
|
44
|
+
/** A phone's extension, digits, kept apart from `telephone` (#2073). */
|
|
45
|
+
extension?: string;
|
|
43
46
|
url?: string;
|
|
44
47
|
availableLanguage?: string[];
|
|
45
48
|
}
|
|
@@ -17,15 +17,40 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { PostalAddress, ContactPointEntry } from './Organization.js';
|
|
19
19
|
/**
|
|
20
|
-
* One
|
|
21
|
-
* `
|
|
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
|
-
|
|
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
|
-
/**
|
|
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,65 @@ 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 =
|
|
111
|
+
export type PersonInput = PersonChange & {
|
|
79
112
|
identifier?: string;
|
|
80
113
|
};
|
|
114
|
+
/** A phone as a user typed it (#2073): the number, the country to read it with, and an extension. */
|
|
115
|
+
export interface PhoneInput {
|
|
116
|
+
number?: string;
|
|
117
|
+
/** ISO 3166-1 alpha-2; the configured default country when left out. Ignored for a number typed with a leading `+`. */
|
|
118
|
+
country?: string;
|
|
119
|
+
extension?: string;
|
|
120
|
+
}
|
|
121
|
+
/** An address as a user typed it (#2073). Every field empty removes the address. */
|
|
122
|
+
export interface AddressInput {
|
|
123
|
+
streetAddress?: string;
|
|
124
|
+
addressLocality?: string;
|
|
125
|
+
addressRegion?: string;
|
|
126
|
+
postalCode?: string;
|
|
127
|
+
/** ISO 3166-1 alpha-2; the configured default country when left out. */
|
|
128
|
+
addressCountry?: string;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* What `IdentityManager.updateContactDetails` takes (#2073): the parts of an
|
|
132
|
+
* identity record a user keeps on /profile. A part left out is not touched;
|
|
133
|
+
* a text given empty is removed.
|
|
134
|
+
*/
|
|
135
|
+
export interface ContactDetailsInput {
|
|
136
|
+
givenName?: string;
|
|
137
|
+
familyName?: string;
|
|
138
|
+
/** Phones by `contactType` (`home`, `mobile`, `business`, `other`, or any other kind). */
|
|
139
|
+
phones?: Array<PhoneInput & {
|
|
140
|
+
contactType: string;
|
|
141
|
+
}>;
|
|
142
|
+
/** The `emergency` ContactPoint: who to call, their phone and email. */
|
|
143
|
+
emergency?: {
|
|
144
|
+
name?: string;
|
|
145
|
+
email?: string;
|
|
146
|
+
phone?: PhoneInput;
|
|
147
|
+
};
|
|
148
|
+
/** The home address (`address`). */
|
|
149
|
+
address?: AddressInput;
|
|
150
|
+
/** The `mailing` PostalAddress in `contactPoint`. */
|
|
151
|
+
mailing?: AddressInput;
|
|
152
|
+
/** Languages, each a name ("Spanish") or a tag ("es"); stored as tags. */
|
|
153
|
+
knowsLanguage?: string[];
|
|
154
|
+
/** A photo: a web address. */
|
|
155
|
+
image?: string;
|
|
156
|
+
/** Links: web addresses. */
|
|
157
|
+
sameAs?: string[];
|
|
158
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Requests for review (#2072): what an add-on files, what a reviewer sees,
|
|
3
|
+
* and the handler that carries out an approved request. ReviewManager is the
|
|
4
|
+
* door; these are its shapes, declared once.
|
|
5
|
+
*/
|
|
6
|
+
import type { ActorContext } from '../context/ActorContext.js';
|
|
7
|
+
/** Where a request is: waiting for a decision, or decided. A handler failure leaves it `open`. */
|
|
8
|
+
export type ReviewStatus = 'open' | 'approved' | 'rejected';
|
|
9
|
+
/** The two answers a reviewer gives. */
|
|
10
|
+
export type ReviewDecision = 'approve' | 'reject';
|
|
11
|
+
/** What an add-on files: `fileRequest(ctx, input)`. */
|
|
12
|
+
export interface ReviewRequestInput {
|
|
13
|
+
/** What kind of request this is, `<slug>.<kind>` (`accommodation.new-owner`). Starts with the filing add-on's slug. */
|
|
14
|
+
kind: string;
|
|
15
|
+
/** What the request is about, in a few words a reviewer recognises (`Unit 12B`). */
|
|
16
|
+
subject: string;
|
|
17
|
+
/** One line saying what approving it does (`Add Pat Lee as an owner of unit 12B`). */
|
|
18
|
+
summary: string;
|
|
19
|
+
/** What the reviewer needs to decide, shown as label and value pairs; may be personal, so it is kept only in the encrypted database. */
|
|
20
|
+
details?: Record<string, string | number | boolean | null>;
|
|
21
|
+
/** The permission whose holders may decide it. Declared in `ngdpbase.permissions.definitions` and grantable; never a role name. */
|
|
22
|
+
decidePermission: string;
|
|
23
|
+
/** The handler that carries it out once approved, as registered with `host.registerReviewHandler` (`<slug>.<name>`). */
|
|
24
|
+
handlerId: string;
|
|
25
|
+
}
|
|
26
|
+
/** A request as the door hands it out. */
|
|
27
|
+
export interface ReviewRequest {
|
|
28
|
+
id: string;
|
|
29
|
+
kind: string;
|
|
30
|
+
subject: string;
|
|
31
|
+
summary: string;
|
|
32
|
+
details: Record<string, string | number | boolean | null>;
|
|
33
|
+
decidePermission: string;
|
|
34
|
+
handlerId: string;
|
|
35
|
+
status: ReviewStatus;
|
|
36
|
+
/** ISO 8601, UTC. A screen shows it in the viewer's local time. */
|
|
37
|
+
filedAt: string;
|
|
38
|
+
/** The username of whoever filed it (`anonymous` for a public form). */
|
|
39
|
+
filedBy: string;
|
|
40
|
+
decidedAt: string | null;
|
|
41
|
+
decidedBy: string | null;
|
|
42
|
+
/** The reviewer's note, if they wrote one. */
|
|
43
|
+
note: string | null;
|
|
44
|
+
/** On approval: what the handler said it did. */
|
|
45
|
+
outcome: string | null;
|
|
46
|
+
/** The last time the handler failed, and why, while the request stays open. */
|
|
47
|
+
failedAt: string | null;
|
|
48
|
+
failure: string | null;
|
|
49
|
+
}
|
|
50
|
+
/** What a handler answers when it has carried out the request. */
|
|
51
|
+
export interface ReviewHandlerResult {
|
|
52
|
+
/** What was done, in plain words, shown to reviewers (`Account created for Pat Lee and invitation sent`). */
|
|
53
|
+
summary: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Carries out an approved request, with the decider's context. Throw to say it
|
|
57
|
+
* was not done: the request stays open and the error's message is shown to
|
|
58
|
+
* reviewers as the reason, so write it for them.
|
|
59
|
+
*/
|
|
60
|
+
export type ReviewHandler = (request: ReviewRequest, ctx: ActorContext) => Promise<ReviewHandlerResult>;
|
|
61
|
+
/** The door as `host.reviews` hands it to an add-on: the same calls, with `fileRequest` held to the add-on's own prefix. */
|
|
62
|
+
export interface AddonReviews {
|
|
63
|
+
fileRequest(ctx: ActorContext, input: ReviewRequestInput): Promise<ReviewRequest>;
|
|
64
|
+
listOpen(ctx: ActorContext): Promise<ReviewRequest[]>;
|
|
65
|
+
get(ctx: ActorContext, id: string): Promise<ReviewRequest | null>;
|
|
66
|
+
decide(ctx: ActorContext, id: string, decision: ReviewDecision, note?: string): Promise<ReviewRequest>;
|
|
67
|
+
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { ShareGrant } from './Share.js';
|
|
19
19
|
import type { ProxyClaim } from '../managers/UserManager.js';
|
|
20
|
-
import type { SignInRecord, PendingConnection } from '../managers/AuthManager.js';
|
|
20
|
+
import type { SignInRecord, PendingConnection, OutsideChoice } from '../managers/AuthManager.js';
|
|
21
21
|
declare module 'express-session' {
|
|
22
22
|
interface SessionData {
|
|
23
23
|
csrfToken?: string;
|
|
@@ -45,6 +45,8 @@ declare module 'express-session' {
|
|
|
45
45
|
pendingRedirect?: string;
|
|
46
46
|
/** A verified outside identity no account has connected, waiting for the person to sign in another way (#1743). */
|
|
47
47
|
pendingConnection?: PendingConnection;
|
|
48
|
+
/** #2073: an outside sign-in whose email several accounts share, waiting for the person to choose one. */
|
|
49
|
+
outsideChoice?: OutsideChoice;
|
|
48
50
|
/**
|
|
49
51
|
* Set at sign-in when the person has core terms to accept at their current
|
|
50
52
|
* version (#1603); the consent gate holds the session to the prompt until
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* unchanged, so an add-on never imports them from `dist/` (#1813);
|
|
24
24
|
* - `keyedSerial()` returns a new `KeyedSerial`, core's own per-key queue, so
|
|
25
25
|
* an add-on's manager changes each of its records one at a time (#2065);
|
|
26
|
+
* - `reviews` is ReviewManager's door (#2072), with `fileRequest` held to this
|
|
27
|
+
* add-on's own `<slug>.` kinds and handlers, and `registerReviewHandler`
|
|
28
|
+
* registers only `<slug>.<name>` handlers, so one add-on can never file for,
|
|
29
|
+
* or carry out, another's requests;
|
|
26
30
|
* - the object is `Object.freeze`d, so no add-on can replace a value.
|
|
27
31
|
*
|
|
28
32
|
* Additive only: nothing here is removed or renamed without a major release.
|
|
@@ -43,6 +47,7 @@ import { KeyedSerial } from './keyedSerial.js';
|
|
|
43
47
|
import { recordAuditEvent } from './auditEvents.js';
|
|
44
48
|
import { addonAuditEventName } from './auditEventNames.js';
|
|
45
49
|
import type { IceReceiver, IceShim } from '../ice/types.js';
|
|
50
|
+
import type { AddonReviews, ReviewHandler } from '../types/Review.js';
|
|
46
51
|
import type { AddonManifest } from '../managers/AddonsManager.js';
|
|
47
52
|
/** The values core offers an add-on, under the names they were imported as. */
|
|
48
53
|
export interface AddonHost {
|
|
@@ -98,6 +103,20 @@ export interface AddonHost {
|
|
|
98
103
|
* the read included. Not re-entrant: `fn` must not `run` the same key again.
|
|
99
104
|
*/
|
|
100
105
|
readonly keyedSerial: () => KeyedSerial;
|
|
106
|
+
/**
|
|
107
|
+
* #2072: core's one way to review requests. `fileRequest(ctx, { kind,
|
|
108
|
+
* subject, summary, details, decidePermission, handlerId })` files one for
|
|
109
|
+
* the holders of `decidePermission` to decide; `kind` and `handlerId` must
|
|
110
|
+
* start with this add-on's slug. `listOpen`, `get` and `decide` are the
|
|
111
|
+
* reviewer's calls, each asked of the PDP with the caller's context.
|
|
112
|
+
*/
|
|
113
|
+
readonly reviews: AddonReviews;
|
|
114
|
+
/**
|
|
115
|
+
* #2072: register the handler that carries out an approved request, as
|
|
116
|
+
* `<slug>.<name>`; any other prefix throws. It is called with the decider's
|
|
117
|
+
* context; throw to leave the request open with your message as the reason.
|
|
118
|
+
*/
|
|
119
|
+
readonly registerReviewHandler: (handlerId: string, handler: ReviewHandler) => void;
|
|
101
120
|
/**
|
|
102
121
|
* #1979 (ICE, D6): register an import shim, as `<slug>.<shim.name>`. Call it
|
|
103
122
|
* in `register`. Refused when the manifest's `shims` does not name it.
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
* unchanged, so an add-on never imports them from `dist/` (#1813);
|
|
24
24
|
* - `keyedSerial()` returns a new `KeyedSerial`, core's own per-key queue, so
|
|
25
25
|
* an add-on's manager changes each of its records one at a time (#2065);
|
|
26
|
+
* - `reviews` is ReviewManager's door (#2072), with `fileRequest` held to this
|
|
27
|
+
* add-on's own `<slug>.` kinds and handlers, and `registerReviewHandler`
|
|
28
|
+
* registers only `<slug>.<name>` handlers, so one add-on can never file for,
|
|
29
|
+
* or carry out, another's requests;
|
|
26
30
|
* - the object is `Object.freeze`d, so no add-on can replace a value.
|
|
27
31
|
*
|
|
28
32
|
* Additive only: nothing here is removed or renamed without a major release.
|
|
@@ -93,6 +97,30 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
|
|
|
93
97
|
}
|
|
94
98
|
return registry;
|
|
95
99
|
};
|
|
100
|
+
const reviewDoor = () => {
|
|
101
|
+
const door = engine?.getManager('ReviewManager');
|
|
102
|
+
if (!door)
|
|
103
|
+
throw new Error('reviews are not available: this host was built without a site\'s ReviewManager');
|
|
104
|
+
return door;
|
|
105
|
+
};
|
|
106
|
+
const ownPrefix = (what, value) => {
|
|
107
|
+
if (typeof value !== 'string' || !value.startsWith(`${slug}.`)) {
|
|
108
|
+
const message = `Add-on '${slug}' may file only its own review requests: a ${what} starts '${slug}.'; '${String(value)}' does not`;
|
|
109
|
+
logger.error(`[addons] ${message}`);
|
|
110
|
+
throw new Error(message);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const reviews = {
|
|
114
|
+
fileRequest: async (ctx, input) => {
|
|
115
|
+
ownPrefix('kind', input?.kind);
|
|
116
|
+
ownPrefix('handler', input?.handlerId);
|
|
117
|
+
return reviewDoor().fileRequest(ctx, input);
|
|
118
|
+
},
|
|
119
|
+
listOpen: async (ctx) => reviewDoor().listOpen(ctx),
|
|
120
|
+
get: async (ctx, id) => reviewDoor().get(ctx, id),
|
|
121
|
+
decide: async (ctx, id, decision, note) => reviewDoor().decide(ctx, id, decision, note)
|
|
122
|
+
};
|
|
123
|
+
Object.freeze(reviews);
|
|
96
124
|
const hostAddonAuditEventName = (name) => {
|
|
97
125
|
const checked = addonAuditEventName(name);
|
|
98
126
|
refuseUndeclared(slug, name, declared);
|
|
@@ -123,6 +151,8 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
|
|
|
123
151
|
refusedNodeMessage,
|
|
124
152
|
isAtLeast,
|
|
125
153
|
zonedParts,
|
|
126
|
-
keyedSerial: () => new KeyedSerial()
|
|
154
|
+
keyedSerial: () => new KeyedSerial(),
|
|
155
|
+
reviews,
|
|
156
|
+
registerReviewHandler: (handlerId, handler) => reviewDoor().registerHandler(slug, handlerId, handler)
|
|
127
157
|
});
|
|
128
158
|
}
|
|
@@ -54,6 +54,10 @@ export declare const AUDIT_EVENT: {
|
|
|
54
54
|
readonly CONSENT_GRANT: "consent-grant";
|
|
55
55
|
/** A user's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
|
|
56
56
|
readonly CONSENT_WITHDRAW: "consent-withdraw";
|
|
57
|
+
/** A user's new email was confirmed by its emailed link and saved (#2073). Names only, never an address */
|
|
58
|
+
readonly EMAILCHANGE_CONFIRM: "emailchange-confirm";
|
|
59
|
+
/** A user asked to change their own email: a confirmation link was sent to the new address (#2073). Names only, never an address */
|
|
60
|
+
readonly EMAILCHANGE_REQUEST: "emailchange-request";
|
|
57
61
|
/** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
|
|
58
62
|
readonly FOOTNOTE_EDIT: "footnote-edit";
|
|
59
63
|
/** Identity record or organization created, changed, deleted or linked to an account (recorded as contact-manage before #2038); metadata names the kind (person or organization), the operation and the fields changed, never their values */
|
|
@@ -132,6 +136,14 @@ export declare const AUDIT_EVENT: {
|
|
|
132
136
|
readonly REAUTH_PROMPT: "reauth-prompt";
|
|
133
137
|
/** Step-up: the user re-authenticated, with the factor used */
|
|
134
138
|
readonly REAUTH_SUCCESS: "reauth-success";
|
|
139
|
+
/** A request was approved and the add-on's handler carried it out (#2072): who decided, its id, kind and handler; never the note, summary or details */
|
|
140
|
+
readonly REVIEW_APPROVED: "review-approved";
|
|
141
|
+
/** A request was approved but the add-on's handler did not carry it out, so it stays open (#2072): who approved it, its id, kind and handler */
|
|
142
|
+
readonly REVIEW_FAILED: "review-failed";
|
|
143
|
+
/** A request was filed for review (#2072): who filed it, its id, kind, the add-on handler and the permission whose holders decide; never its summary or details */
|
|
144
|
+
readonly REVIEW_FILED: "review-filed";
|
|
145
|
+
/** A request was rejected (#2072): who decided, its id, kind and handler; never the note, summary or details */
|
|
146
|
+
readonly REVIEW_REJECTED: "review-rejected";
|
|
135
147
|
/** Page search; not recorded, read volume */
|
|
136
148
|
readonly SEARCH_PAGE: "search-page";
|
|
137
149
|
/** Users searched for; enumerating users is disclosive in a way searching pages is not. Off by default as read volume */
|
|
@@ -61,6 +61,10 @@ export const AUDIT_EVENT = {
|
|
|
61
61
|
CONSENT_GRANT: 'consent-grant',
|
|
62
62
|
/** A user's acceptances ended (#1603): today only when their account is deleted, which removes their consent records; names the documents and versions they had accepted. Recorded before the records are removed */
|
|
63
63
|
CONSENT_WITHDRAW: 'consent-withdraw',
|
|
64
|
+
/** A user's new email was confirmed by its emailed link and saved (#2073). Names only, never an address */
|
|
65
|
+
EMAILCHANGE_CONFIRM: 'emailchange-confirm',
|
|
66
|
+
/** A user asked to change their own email: a confirmation link was sent to the new address (#2073). Names only, never an address */
|
|
67
|
+
EMAILCHANGE_REQUEST: 'emailchange-request',
|
|
64
68
|
/** A page's footnote list changed — action add, import, transfer, update or delete; page content written on someone's behalf (#1233) */
|
|
65
69
|
FOOTNOTE_EDIT: 'footnote-edit',
|
|
66
70
|
/** Identity record or organization created, changed, deleted or linked to an account (recorded as contact-manage before #2038); metadata names the kind (person or organization), the operation and the fields changed, never their values */
|
|
@@ -139,6 +143,14 @@ export const AUDIT_EVENT = {
|
|
|
139
143
|
REAUTH_PROMPT: 'reauth-prompt',
|
|
140
144
|
/** Step-up: the user re-authenticated, with the factor used */
|
|
141
145
|
REAUTH_SUCCESS: 'reauth-success',
|
|
146
|
+
/** A request was approved and the add-on's handler carried it out (#2072): who decided, its id, kind and handler; never the note, summary or details */
|
|
147
|
+
REVIEW_APPROVED: 'review-approved',
|
|
148
|
+
/** A request was approved but the add-on's handler did not carry it out, so it stays open (#2072): who approved it, its id, kind and handler */
|
|
149
|
+
REVIEW_FAILED: 'review-failed',
|
|
150
|
+
/** A request was filed for review (#2072): who filed it, its id, kind, the add-on handler and the permission whose holders decide; never its summary or details */
|
|
151
|
+
REVIEW_FILED: 'review-filed',
|
|
152
|
+
/** A request was rejected (#2072): who decided, its id, kind and handler; never the note, summary or details */
|
|
153
|
+
REVIEW_REJECTED: 'review-rejected',
|
|
142
154
|
/** Page search; not recorded, read volume */
|
|
143
155
|
SEARCH_PAGE: 'search-page',
|
|
144
156
|
/** Users searched for; enumerating users is disclosive in a way searching pages is not. Off by default as read volume */
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language names and tags for an identity record's `knowsLanguage` (#2073).
|
|
3
|
+
* The names are the runtime's own (`Intl.DisplayNames`), so no list is kept
|
|
4
|
+
* here: every two-letter ISO 639-1 code the runtime can name.
|
|
5
|
+
*/
|
|
6
|
+
/** The tag for a language given by name ("Spanish") or tag ("es"), ignoring case; null when unknown. */
|
|
7
|
+
export declare function languageTag(given: string): string | null;
|
|
8
|
+
/** A stored tag as a name ("es" → "Spanish"); the tag itself when the runtime has no name for it. */
|
|
9
|
+
export declare function languageName(tag: string): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phone numbers and countries for identity records (#2073).
|
|
3
|
+
*
|
|
4
|
+
* Phones are stored in E.164 (`+14155552671`), the extension apart. What a
|
|
5
|
+
* user types is read with the country chosen beside it; a number typed with
|
|
6
|
+
* a leading `+` keeps its own country code. Parsing and the per-country
|
|
7
|
+
* rules are libphonenumber-js's (Google's libphonenumber metadata, kept
|
|
8
|
+
* current by its maintainers); country names are the runtime's own
|
|
9
|
+
* (`Intl.DisplayNames`), so no list is kept here.
|
|
10
|
+
*/
|
|
11
|
+
/** A country the dropdowns offer: its ISO 3166-1 alpha-2 code, its name, and its calling code. */
|
|
12
|
+
export interface CountryOption {
|
|
13
|
+
code: string;
|
|
14
|
+
name: string;
|
|
15
|
+
callingCode: string;
|
|
16
|
+
}
|
|
17
|
+
/** Every country libphonenumber knows, by English name. Built once. */
|
|
18
|
+
export declare function countryOptions(): CountryOption[];
|
|
19
|
+
/** `code` (any case) when it is a country the dropdowns offer, else null. */
|
|
20
|
+
export declare function knownCountry(code: unknown): string | null;
|
|
21
|
+
/**
|
|
22
|
+
* What the user typed, as E.164 with any extension typed after it
|
|
23
|
+
* (`x12`, `ext. 12`), read with `country` unless it starts with `+`.
|
|
24
|
+
* Null when it is not a valid number.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toE164(typed: string, country: string): {
|
|
27
|
+
telephone: string;
|
|
28
|
+
extension: string | null;
|
|
29
|
+
} | null;
|
|
30
|
+
/**
|
|
31
|
+
* A stored number shown back: its national form and its country, for the
|
|
32
|
+
* dropdown. A value that is not E.164 (written before #2073, or by an
|
|
33
|
+
* add-on) is shown as stored, with `fallback` as its country.
|
|
34
|
+
*/
|
|
35
|
+
export declare function localForm(stored: string, fallback: string): {
|
|
36
|
+
number: string;
|
|
37
|
+
country: string;
|
|
38
|
+
};
|
|
@@ -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.
|
|
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;
|
|
@@ -32,8 +32,10 @@ export type UserCreateFailureReason =
|
|
|
32
32
|
'username-taken'
|
|
33
33
|
/** The display name collides with something that already exists. */
|
|
34
34
|
| 'display-name-conflict'
|
|
35
|
-
/**
|
|
36
|
-
| 'email-
|
|
35
|
+
/** An account holding the admin role would share its email (#2073). */
|
|
36
|
+
| 'admin-email-shared'
|
|
37
|
+
/** The account would set its own email without the emailed link (#2073). */
|
|
38
|
+
| 'email-unconfirmed';
|
|
37
39
|
/**
|
|
38
40
|
* A user-creation failure with a reason a caller can branch on.
|
|
39
41
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jwilleke/ngdpbase-types",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.32.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",
|