@astram/identity 0.14.0 → 0.16.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/dist/features/identity-feature.module.d.ts +7 -1
- package/dist/features/identity-feature.module.d.ts.map +1 -1
- package/dist/features/identity-feature.module.js +33 -1
- package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +19 -0
- package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -1
- package/dist/features/org-bootstrap/run-org-bootstrap.js +96 -10
- package/dist/features/org-bootstrap/set-password-links.d.ts +14 -2
- package/dist/features/org-bootstrap/set-password-links.d.ts.map +1 -1
- package/dist/features/org-bootstrap/set-password-links.js +25 -4
- package/dist/features/user/invite-message.d.ts +8 -0
- package/dist/features/user/invite-message.d.ts.map +1 -0
- package/dist/features/user/invite-message.js +23 -0
- package/dist/features/user/invite.config.d.ts +15 -0
- package/dist/features/user/invite.config.d.ts.map +1 -0
- package/dist/features/user/invite.config.js +55 -0
- package/dist/features/user/invite.service.d.ts +62 -0
- package/dist/features/user/invite.service.d.ts.map +1 -0
- package/dist/features/user/invite.service.js +254 -0
- package/dist/features/user/invite.tokens.d.ts +11 -0
- package/dist/features/user/invite.tokens.d.ts.map +1 -0
- package/dist/features/user/invite.tokens.js +13 -0
- package/dist/features/user/user.controller.d.ts +12 -3
- package/dist/features/user/user.controller.d.ts.map +1 -1
- package/dist/features/user/user.controller.js +28 -2
- package/dist/features/user/user.service.d.ts +25 -7
- package/dist/features/user/user.service.d.ts.map +1 -1
- package/dist/features/user/user.service.js +53 -17
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/user.object.d.ts.map +1 -1
- package/dist/user.object.js +3 -0
- package/migrations/1710000000102-CreateAuditChain.cjs +71 -0
- package/package.json +9 -4
- package/dist/features/org-bootstrap/org-bootstrap-runner.harness.d.ts +0 -259
- package/dist/features/org-bootstrap/org-bootstrap-runner.harness.d.ts.map +0 -1
- package/dist/features/org-bootstrap/org-bootstrap-runner.harness.js +0 -604
- package/dist/features/org-bootstrap/org-bootstrap-runner.mocks.d.ts +0 -24
- package/dist/features/org-bootstrap/org-bootstrap-runner.mocks.d.ts.map +0 -1
- package/dist/features/org-bootstrap/org-bootstrap-runner.mocks.js +0 -39
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type DynamicModule } from '@nestjs/common';
|
|
2
2
|
import { type FrameworkSchemas } from '@astram/core';
|
|
3
3
|
import { type EngineDbConfig, type ObjectTypeInput } from '@astram/engine';
|
|
4
|
+
import { type InviteOptions } from './user/invite.config.js';
|
|
4
5
|
/**
|
|
5
6
|
* The boot check behind {@link IDENTITY_DECISION_AUTHORITY_CHECK}: with this module wired, the
|
|
6
7
|
* request path must decide with the authority guard built from this module's inputs. A missing
|
|
@@ -67,11 +68,16 @@ export declare class IdentityFeatureModule {
|
|
|
67
68
|
* throws). The one set is handed to the engine (registrations + its audit sink), the seven
|
|
68
69
|
* audit sinks, the audit reader and every identity store; a boot check refuses names that
|
|
69
70
|
* differ from guard's `FRAMEWORK_SCHEMAS`. Never read per request.
|
|
71
|
+
* @param invite administrator invite (G-010 R11, `POST /identity/users/:uuid/invite`).
|
|
72
|
+
* `returnLinkToAdmin: true` also returns the link once in the response (default false: the
|
|
73
|
+
* link is only emailed). `INVITE_LINK_TTL_HOURS` and `INVITE_RATE_PER_ADMIN` are read here,
|
|
74
|
+
* once; a bad value refuses boot.
|
|
70
75
|
*/
|
|
71
|
-
static forRoot({ rootIds, extraObjects, schemas: givenSchemas, }?: {
|
|
76
|
+
static forRoot({ rootIds, extraObjects, schemas: givenSchemas, invite, }?: {
|
|
72
77
|
rootIds?: readonly string[];
|
|
73
78
|
extraObjects?: readonly ObjectTypeInput[];
|
|
74
79
|
schemas?: FrameworkSchemas;
|
|
80
|
+
invite?: InviteOptions;
|
|
75
81
|
}): DynamicModule;
|
|
76
82
|
}
|
|
77
83
|
//# sourceMappingURL=identity-feature.module.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAYL,KAAK,cAAc,EAGnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"identity-feature.module.d.ts","sourceRoot":"","sources":["../../src/features/identity-feature.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEpE,OAAO,EAGL,KAAK,gBAAgB,EAEtB,MAAM,cAAc,CAAC;AACtB,OAAO,EAYL,KAAK,cAAc,EAGnB,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAiDxB,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,yBAAyB,CAAC;AA0E/E;;;;;;GAMG;AACH,wBAAgB,0CAA0C,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAUnF;AAKD;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CACzC,QAAQ,EAAE,gBAAgB,EAC1B,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GACzC,IAAI,CAwBN;AAKD;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAC3C,gBAAgB,EAAE,MAAM,EACxB,WAAW,EAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,GAC/E,IAAI,CAQN;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,gBAAgB,EACzB,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,cAAc,CAqBhB;AAED;;;;;;;;;;GAUG;AACH,qBACa,qBAAqB;IAChC;;;;;;;;;;;;;;;;;;;;;;;OAuBG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,EACjB,OAAO,EAAE,YAAY,EACrB,MAAM,GACP,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;QAC3B,MAAM,CAAC,EAAE,aAAa,CAAC;KACnB,GAAG,aAAa;CA+lBvB"}
|
|
@@ -31,6 +31,9 @@ const credential_provisioner_token_js_1 = require("./user/credential-provisioner
|
|
|
31
31
|
const framework_schemas_token_js_1 = require("./framework-schemas.token.js");
|
|
32
32
|
const org_bootstrap_tokens_js_1 = require("./org-bootstrap/org-bootstrap.tokens.js");
|
|
33
33
|
const user_controller_js_1 = require("./user/user.controller.js");
|
|
34
|
+
const invite_service_js_1 = require("./user/invite.service.js");
|
|
35
|
+
const invite_config_js_1 = require("./user/invite.config.js");
|
|
36
|
+
const invite_tokens_js_1 = require("./user/invite.tokens.js");
|
|
34
37
|
const membership_controller_js_1 = require("./membership/membership.controller.js");
|
|
35
38
|
const membership_service_js_1 = require("./membership/membership.service.js");
|
|
36
39
|
const posting_service_js_1 = require("./membership/posting.service.js");
|
|
@@ -204,10 +207,16 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
204
207
|
* throws). The one set is handed to the engine (registrations + its audit sink), the seven
|
|
205
208
|
* audit sinks, the audit reader and every identity store; a boot check refuses names that
|
|
206
209
|
* differ from guard's `FRAMEWORK_SCHEMAS`. Never read per request.
|
|
210
|
+
* @param invite administrator invite (G-010 R11, `POST /identity/users/:uuid/invite`).
|
|
211
|
+
* `returnLinkToAdmin: true` also returns the link once in the response (default false: the
|
|
212
|
+
* link is only emailed). `INVITE_LINK_TTL_HOURS` and `INVITE_RATE_PER_ADMIN` are read here,
|
|
213
|
+
* once; a bad value refuses boot.
|
|
207
214
|
*/
|
|
208
|
-
static forRoot({ rootIds, extraObjects = [], schemas: givenSchemas, } = {}) {
|
|
215
|
+
static forRoot({ rootIds, extraObjects = [], schemas: givenSchemas, invite, } = {}) {
|
|
209
216
|
// G-009 N4: a reserved operation principal is never a root, whatever variable `rootIds` came from.
|
|
210
217
|
(0, guard_1.assertNoReservedRootId)(rootIds);
|
|
218
|
+
// G-010 T9: invite settings, read ONCE (boot); a bad value refuses boot naming the variable.
|
|
219
|
+
const inviteConfig = (0, invite_config_js_1.loadInviteConfig)(invite, process.env);
|
|
211
220
|
// The framework database names, resolved ONCE (boot) for every SQL path built below.
|
|
212
221
|
// Caller-supplied names are checked before any SQL is built (fail closed, Shield #1).
|
|
213
222
|
const schemas = givenSchemas !== undefined
|
|
@@ -704,6 +713,29 @@ let IdentityFeatureModule = IdentityFeatureModule_1 = class IdentityFeatureModul
|
|
|
704
713
|
catalog_bootstrap_provider_js_1.CatalogBootstrapProvider,
|
|
705
714
|
// Stored files (file-storage Phase 5): the request-principal RecordsService + the guard's
|
|
706
715
|
// object store (`null`/absent ⇒ every method answers 501) + the file_access audit bridge.
|
|
716
|
+
// G-010 T9 (P-5): the invite, its settings, its audit writer and its two Redis limiters
|
|
717
|
+
// (built by guard's SHIELD_ATTEMPT_LIMITER_FACTORY over guard's one Redis client; `null`
|
|
718
|
+
// without auth, and InviteService then refuses invites with 503: fail closed).
|
|
719
|
+
invite_service_js_1.InviteService,
|
|
720
|
+
{ provide: invite_tokens_js_1.IDENTITY_INVITE_CONFIG, useValue: inviteConfig },
|
|
721
|
+
// `user.invited` / `user.invite_throttled`: the same audit-schema writer over the shared
|
|
722
|
+
// authz pool as `file_access` (one sink, no new one; best-effort in InviteService).
|
|
723
|
+
{ provide: invite_tokens_js_1.IDENTITY_INVITE_AUDIT, useValue: filesAudit },
|
|
724
|
+
{
|
|
725
|
+
provide: invite_tokens_js_1.IDENTITY_INVITE_LIMITERS,
|
|
726
|
+
// Guard's limiter factory over its own Redis (G-010 P-5); absent (auth off) ⇒ null, and
|
|
727
|
+
// the invite fails closed (503) rather than running unthrottled.
|
|
728
|
+
useFactory: (makeLimiter) => makeLimiter
|
|
729
|
+
? {
|
|
730
|
+
user: makeLimiter({ maxAttempts: invite_service_js_1.INVITES_PER_USER, windowMs: invite_service_js_1.INVITE_WINDOW_MS }),
|
|
731
|
+
admin: makeLimiter({
|
|
732
|
+
maxAttempts: inviteConfig.perAdminPerHour,
|
|
733
|
+
windowMs: invite_service_js_1.INVITE_WINDOW_MS,
|
|
734
|
+
}),
|
|
735
|
+
}
|
|
736
|
+
: null,
|
|
737
|
+
inject: [{ token: internal_1.SHIELD_ATTEMPT_LIMITER_FACTORY, optional: true }],
|
|
738
|
+
},
|
|
707
739
|
{
|
|
708
740
|
provide: files_service_js_1.FilesService,
|
|
709
741
|
useFactory: (records, store) => new files_service_js_1.FilesService(records, store ?? null, filesAudit),
|
|
@@ -19,6 +19,14 @@ export interface RunOrgBootstrapOptions {
|
|
|
19
19
|
linkTtlHours?: number;
|
|
20
20
|
/** Print links although stdout is not a terminal. */
|
|
21
21
|
linksToStdout?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* G-010 R12: email each link through the message dispatcher (purpose `invite`) instead of printing
|
|
24
|
+
* it. Needs a bound dispatcher with mail on (`ShieldModule.forRoot({ emailSender })`), else the run
|
|
25
|
+
* is refused before any write. A link that cannot be sent is killed and not printed. ROOT links
|
|
26
|
+
* are never emailed (a root link is a platform takeover): they print under the G-009 rule (a
|
|
27
|
+
* terminal, or `linksToStdout`), else they are not issued and are reported as not delivered.
|
|
28
|
+
*/
|
|
29
|
+
emailLinks?: boolean;
|
|
22
30
|
isTTY?: boolean;
|
|
23
31
|
/** Seconds to wait for the run lock (default 30). */
|
|
24
32
|
lockTimeoutSeconds?: number;
|
|
@@ -35,6 +43,17 @@ export interface OrgBootstrapReport {
|
|
|
35
43
|
written: OrgBootstrapWritten;
|
|
36
44
|
/** Set-password links issued. */
|
|
37
45
|
links: number;
|
|
46
|
+
/**
|
|
47
|
+
* With `emailLinks`: links emailed, and links not delivered (a failed send, killed; or a root
|
|
48
|
+
* link with no terminal to print it on, never issued). Nothing undelivered is printed.
|
|
49
|
+
*/
|
|
50
|
+
linksEmailed: number;
|
|
51
|
+
linksNotEmailed: number;
|
|
52
|
+
/**
|
|
53
|
+
* With `emailLinks`: root links printed to the terminal instead. A root link is a platform
|
|
54
|
+
* takeover, so it is never emailed; it follows the G-009 print rule.
|
|
55
|
+
*/
|
|
56
|
+
linksPrintedRoots: number;
|
|
38
57
|
/** Uuid of every root in the file that exists after the run. */
|
|
39
58
|
rootIds: string[];
|
|
40
59
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-org-bootstrap.d.ts","sourceRoot":"","sources":["../../../src/features/org-bootstrap/run-org-bootstrap.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,uBAAuB,EAAQ,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"run-org-bootstrap.d.ts","sourceRoot":"","sources":["../../../src/features/org-bootstrap/run-org-bootstrap.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,uBAAuB,EAAQ,MAAM,gBAAgB,CAAC;AA+BpE,OAAO,EAAqB,KAAK,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AACvF,OAAO,EAEL,KAAK,OAAO,EAIb,MAAM,yBAAyB,CAAC;AAiBjC,OAAO,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAC7D,YAAY,EAAE,qBAAqB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAE3F,MAAM,WAAW,sBAAsB;IACrC,yDAAyD;IACzD,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IACtB,qFAAqF;IACrF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,wGAAwG;IACxG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,uFAAuF;IACvF,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,iFAAiF;IACjF,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,2DAA2D;IAC3D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,qDAAqD;IACrD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,sEAAsE;IACtE,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,8FAA8F;IAC9F,kBAAkB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CAC5D;AAED,oDAAoD;AACpD,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,kDAAkD;IAClD,OAAO,EAAE,mBAAmB,CAAC;IAC7B,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gEAAgE;IAChE,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAwFD,wBAAsB,eAAe,CACnC,GAAG,EAAE,uBAAuB,EAC5B,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CAqtB7B"}
|
|
@@ -31,6 +31,7 @@ const role_owner_transition_js_1 = require("../role/role-owner-transition.js");
|
|
|
31
31
|
const role_service_js_1 = require("../role/role.service.js");
|
|
32
32
|
const credential_provisioner_token_js_1 = require("../user/credential-provisioner.token.js");
|
|
33
33
|
const user_service_js_1 = require("../user/user.service.js");
|
|
34
|
+
const invite_message_js_1 = require("../user/invite-message.js");
|
|
34
35
|
const org_bootstrap_file_js_1 = require("./org-bootstrap-file.js");
|
|
35
36
|
const org_bootstrap_error_js_1 = require("./org-bootstrap-error.js");
|
|
36
37
|
const org_bootstrap_plan_js_1 = require("./org-bootstrap-plan.js");
|
|
@@ -105,6 +106,7 @@ async function runOrgBootstrap(app, opts) {
|
|
|
105
106
|
let provisioner;
|
|
106
107
|
let grantAdmin;
|
|
107
108
|
let tokenStore;
|
|
109
|
+
let dispatcher;
|
|
108
110
|
try {
|
|
109
111
|
pool = app.get(org_bootstrap_tokens_js_1.IDENTITY_AUTHZ_POOL, { strict: false });
|
|
110
112
|
schemas = app.get(framework_schemas_token_js_1.IDENTITY_FRAMEWORK_SCHEMAS, { strict: false });
|
|
@@ -118,6 +120,18 @@ async function runOrgBootstrap(app, opts) {
|
|
|
118
120
|
catch {
|
|
119
121
|
throw new org_bootstrap_error_js_1.OrgBootstrapError('not_wired', 'IdentityFeatureModule is not available in this application: wire IdentityFeatureModule.forRoot(...) and ShieldModule (auth enabled) into the AppModule this script creates.');
|
|
120
122
|
}
|
|
123
|
+
// G-010 R12: --email-links needs a dispatcher that can deliver; refused before any read or write.
|
|
124
|
+
if (opts.emailLinks === true) {
|
|
125
|
+
try {
|
|
126
|
+
dispatcher = app.get(guard_1.SHIELD_MESSAGE_DISPATCHER, { strict: false });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
dispatcher = undefined;
|
|
130
|
+
}
|
|
131
|
+
if (dispatcher?.emailEnabled !== true) {
|
|
132
|
+
throw new org_bootstrap_error_js_1.OrgBootstrapError('not_wired', "--email-links needs emailSender configured: pass ShieldModule.forRoot({ emailSender: 'env' }) (or your own sender) with auth enabled, or run without --email-links to print the links.");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
121
135
|
const catalog = await grantAdmin.listCatalog();
|
|
122
136
|
const file = (0, org_bootstrap_file_js_1.parseOrgBootstrapFile)(loadFile(opts.file), {
|
|
123
137
|
grantableKeys: new Set([...catalog.catalog.map((r) => r.key), ...catalog.wildcards]),
|
|
@@ -141,7 +155,16 @@ async function runOrgBootstrap(app, opts) {
|
|
|
141
155
|
if (id !== undefined)
|
|
142
156
|
rootIds.push(id);
|
|
143
157
|
}
|
|
144
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
applied: false,
|
|
160
|
+
plan,
|
|
161
|
+
written: {},
|
|
162
|
+
links: 0,
|
|
163
|
+
linksEmailed: 0,
|
|
164
|
+
linksNotEmailed: 0,
|
|
165
|
+
linksPrintedRoots: 0,
|
|
166
|
+
rootIds,
|
|
167
|
+
};
|
|
145
168
|
}
|
|
146
169
|
// ---- checks that need no state ----
|
|
147
170
|
const operator = (opts.operator ?? '').trim() || (env.ASTRAM_BOOTSTRAP_OPERATOR ?? '').trim();
|
|
@@ -232,7 +255,9 @@ async function runOrgBootstrap(app, opts) {
|
|
|
232
255
|
let linkBase = '';
|
|
233
256
|
if (mayIssueLinks) {
|
|
234
257
|
linkBase = (0, set_password_links_js_1.resolveSetPasswordBase)(env);
|
|
235
|
-
|
|
258
|
+
// Emailed links are never printed, so they need no terminal.
|
|
259
|
+
if (dispatcher === undefined &&
|
|
260
|
+
(opts.isTTY === undefined ? process.stdout.isTTY !== true : !opts.isTTY)) {
|
|
236
261
|
if (opts.linksToStdout !== true) {
|
|
237
262
|
throw new org_bootstrap_error_js_1.OrgBootstrapError('links_need_terminal', 'Set-password links are printed only to a terminal; pass --links-to-stdout to print them anyway.');
|
|
238
263
|
}
|
|
@@ -537,35 +562,87 @@ async function runOrgBootstrap(app, opts) {
|
|
|
537
562
|
});
|
|
538
563
|
// 7. set-password links, after every other write
|
|
539
564
|
let links = 0;
|
|
565
|
+
let linksEmailed = 0;
|
|
566
|
+
let linksNotEmailed = 0;
|
|
567
|
+
let linksPrintedRoots = 0;
|
|
540
568
|
if (mayIssueLinks) {
|
|
541
569
|
await step(STEP.users, async () => {
|
|
542
570
|
const targets = [];
|
|
543
|
-
const
|
|
571
|
+
const rootTargets = [];
|
|
572
|
+
// The stored (canonical) address the link is mailed to, never the file's spelling of it.
|
|
573
|
+
const emailOf = (username) => {
|
|
574
|
+
if (dispatcher === undefined)
|
|
575
|
+
return {};
|
|
576
|
+
const fileEmail = fileEmailOf(username);
|
|
577
|
+
return fileEmail === undefined ? {} : { email: hintFor(username, fileEmail) };
|
|
578
|
+
};
|
|
579
|
+
const consider = async (username, companyPath, into = targets) => {
|
|
544
580
|
const fresh = lockedNow.get((0, org_names_js_1.nameKey)(username));
|
|
545
581
|
if (fresh !== undefined) {
|
|
546
|
-
|
|
582
|
+
into.push({ username, ...fresh, invalidateFirst: false, ...emailOf(username) });
|
|
547
583
|
}
|
|
548
584
|
else if (opts.reissueLinks === true && lockedInState(username)) {
|
|
549
|
-
|
|
585
|
+
into.push({
|
|
550
586
|
username,
|
|
551
587
|
principalId: await ids.user(username),
|
|
552
588
|
tenantId: await ids.company(companyPath),
|
|
553
589
|
invalidateFirst: true,
|
|
590
|
+
...emailOf(username),
|
|
554
591
|
});
|
|
555
592
|
}
|
|
556
593
|
};
|
|
557
|
-
|
|
558
|
-
|
|
594
|
+
// A root link is a platform takeover: with --email-links it is never emailed. It follows
|
|
595
|
+
// the G-009 print rule (a terminal, or --links-to-stdout); otherwise it is not issued.
|
|
596
|
+
for (const r of file.roots) {
|
|
597
|
+
await consider(r.username, r.homeCompany, dispatcher === undefined ? targets : rootTargets);
|
|
598
|
+
}
|
|
559
599
|
for (const u of flat.users)
|
|
560
600
|
await consider(u.username, u.company);
|
|
561
|
-
|
|
601
|
+
const mail = dispatcher;
|
|
602
|
+
const audit = (target, expiresAtMs) => auditOn((sql, params) => pool.query(sql, params), target.tenantId, 'credential.setup_link_issued', STEP.users, { userUuid: target.principalId, expiresAtMs });
|
|
603
|
+
if (rootTargets.length > 0) {
|
|
604
|
+
const printable = opts.linksToStdout === true ||
|
|
605
|
+
(opts.isTTY === undefined ? process.stdout.isTTY === true : opts.isTTY);
|
|
606
|
+
if (printable) {
|
|
607
|
+
const printed = await (0, set_password_links_js_1.issueSetPasswordLinks)({
|
|
608
|
+
store: tokenStore,
|
|
609
|
+
targets: rootTargets,
|
|
610
|
+
base: linkBase,
|
|
611
|
+
ttlHours,
|
|
612
|
+
out,
|
|
613
|
+
audit,
|
|
614
|
+
});
|
|
615
|
+
links += printed.issued;
|
|
616
|
+
linksPrintedRoots = printed.issued;
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
for (const target of rootTargets) {
|
|
620
|
+
linksNotEmailed += 1;
|
|
621
|
+
out(`${target.username} root set-password link not printed: root links are never emailed ` +
|
|
622
|
+
'and print only to a terminal. Re-run on a terminal with --reissue-links ' +
|
|
623
|
+
'(or with --reissue-links --links-to-stdout, without --email-links).');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
const result = await (0, set_password_links_js_1.issueSetPasswordLinks)({
|
|
562
628
|
store: tokenStore,
|
|
563
629
|
targets,
|
|
564
630
|
base: linkBase,
|
|
565
631
|
ttlHours,
|
|
566
632
|
out,
|
|
567
|
-
|
|
633
|
+
...(mail !== undefined
|
|
634
|
+
? {
|
|
635
|
+
deliver: async (target, link) => {
|
|
636
|
+
const outcome = await mail.sendEmail('invite', (0, invite_message_js_1.inviteMessage)(target.email, link, ttlHours), { actorId: org_bootstrap_manifest_js_1.ORG_BOOTSTRAP_PRINCIPAL_ID, tenantId: target.tenantId });
|
|
637
|
+
return outcome.status === 'sent';
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
: {}),
|
|
641
|
+
audit,
|
|
568
642
|
});
|
|
643
|
+
links += result.issued;
|
|
644
|
+
linksEmailed = result.emailed;
|
|
645
|
+
linksNotEmailed += result.notEmailed;
|
|
569
646
|
});
|
|
570
647
|
}
|
|
571
648
|
// 8. root wiring (R9): print each root's uuid and the line to set
|
|
@@ -579,7 +656,16 @@ async function runOrgBootstrap(app, opts) {
|
|
|
579
656
|
}
|
|
580
657
|
if (rootIds.length > 0)
|
|
581
658
|
out(`SHIELD_ROOT_IDS=${rootIds.join(',')}`);
|
|
582
|
-
return {
|
|
659
|
+
return {
|
|
660
|
+
applied: true,
|
|
661
|
+
plan,
|
|
662
|
+
written,
|
|
663
|
+
links,
|
|
664
|
+
linksEmailed,
|
|
665
|
+
linksNotEmailed,
|
|
666
|
+
linksPrintedRoots,
|
|
667
|
+
rootIds,
|
|
668
|
+
};
|
|
583
669
|
// ----- raw writes allowed by the spec (R5, R13), both under the run lock and audited -----
|
|
584
670
|
async function inTransaction(work) {
|
|
585
671
|
const conn = await pool.getConnection();
|
|
@@ -15,7 +15,11 @@ export interface LinkTarget {
|
|
|
15
15
|
tenantId: string;
|
|
16
16
|
/** Existing user whose earlier unused tokens must die first. */
|
|
17
17
|
invalidateFirst: boolean;
|
|
18
|
+
/** The stored (canonical) address; required when the links are emailed. */
|
|
19
|
+
email?: string;
|
|
18
20
|
}
|
|
21
|
+
/** Emails one link (G-010 `--email-links`); `true` when the provider accepted it. Never throws. */
|
|
22
|
+
export type LinkDelivery = (target: LinkTarget, link: string) => Promise<boolean>;
|
|
19
23
|
export interface IssueLinksInput {
|
|
20
24
|
store: ResetTokenStore;
|
|
21
25
|
targets: readonly LinkTarget[];
|
|
@@ -24,7 +28,15 @@ export interface IssueLinksInput {
|
|
|
24
28
|
out: (line: string) => void;
|
|
25
29
|
/** Writes the `credential.setup_link_issued` audit row (user and expiry only). */
|
|
26
30
|
audit: (target: LinkTarget, expiresAtMs: number) => Promise<void>;
|
|
31
|
+
/** Email the links instead of printing them (G-010 R12). */
|
|
32
|
+
deliver?: LinkDelivery;
|
|
27
33
|
}
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
export interface IssuedLinks {
|
|
35
|
+
issued: number;
|
|
36
|
+
/** With `deliver`: links the provider accepted, and links that could not be sent (killed). */
|
|
37
|
+
emailed: number;
|
|
38
|
+
notEmailed: number;
|
|
39
|
+
}
|
|
40
|
+
/** Issue one token per target; print its link once, or email it with `deliver`. */
|
|
41
|
+
export declare function issueSetPasswordLinks(input: IssueLinksInput): Promise<IssuedLinks>;
|
|
30
42
|
//# sourceMappingURL=set-password-links.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"set-password-links.d.ts","sourceRoot":"","sources":["../../../src/features/org-bootstrap/set-password-links.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"set-password-links.d.ts","sourceRoot":"","sources":["../../../src/features/org-bootstrap/set-password-links.ts"],"names":[],"mappings":"AAQA,OAAO,EAAsC,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAGzF,eAAO,MAAM,sBAAsB,KAAK,CAAC;AAGzC,6BAA6B;AAC7B,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQ1D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,MAAM,CAarE;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,yCAAyC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,eAAe,EAAE,OAAO,CAAC;IACzB,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,mGAAmG;AACnG,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAElF,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,eAAe,CAAC;IACvB,OAAO,EAAE,SAAS,UAAU,EAAE,CAAC;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,kFAAkF;IAClF,KAAK,EAAE,CAAC,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,4DAA4D;IAC5D,OAAO,CAAC,EAAE,YAAY,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,8FAA8F;IAC9F,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,mFAAmF;AACnF,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,WAAW,CAAC,CAoCxF"}
|
|
@@ -7,7 +7,9 @@ exports.issueSetPasswordLinks = issueSetPasswordLinks;
|
|
|
7
7
|
/**
|
|
8
8
|
* One-time set-password links (G-009 R8): the same token mechanism as forgot-password (32 random bytes,
|
|
9
9
|
* stored only as sha256 in the reset token store, per-row expiry), printed once to the operator's
|
|
10
|
-
* terminal
|
|
10
|
+
* terminal, or (G-010 R12, `--email-links`) emailed through the message dispatcher instead and never
|
|
11
|
+
* printed. Links carry `&purpose=invite` (G-010 R11: wording on the reset page only; redemption
|
|
12
|
+
* ignores it). A token or link never reaches the audit row, the report or an error.
|
|
11
13
|
*/
|
|
12
14
|
const node_crypto_1 = require("node:crypto");
|
|
13
15
|
const guard_1 = require("@astram/guard");
|
|
@@ -38,9 +40,11 @@ function resolveSetPasswordBase(env) {
|
|
|
38
40
|
throw new org_bootstrap_error_js_1.OrgBootstrapError('link_base_url', 'RESET_LINK_BASE_URL must be set to a public http(s) URL (not localhost) outside development/test.');
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
|
-
/** Issue one token per target
|
|
43
|
+
/** Issue one token per target; print its link once, or email it with `deliver`. */
|
|
42
44
|
async function issueSetPasswordLinks(input) {
|
|
43
45
|
let issued = 0;
|
|
46
|
+
let emailed = 0;
|
|
47
|
+
let notEmailed = 0;
|
|
44
48
|
for (const target of input.targets) {
|
|
45
49
|
if (target.invalidateFirst)
|
|
46
50
|
await input.store.invalidateAllForPrincipal(target.principalId);
|
|
@@ -48,8 +52,25 @@ async function issueSetPasswordLinks(input) {
|
|
|
48
52
|
const expiresAtMs = Date.now() + input.ttlHours * HOUR_MS;
|
|
49
53
|
await input.store.issue(target.principalId, (0, node_crypto_1.createHash)('sha256').update(raw).digest('hex'), expiresAtMs);
|
|
50
54
|
await input.audit(target, expiresAtMs);
|
|
51
|
-
input.out(`${target.username} ${input.base}?token=${raw} (expires ${new Date(expiresAtMs).toISOString()})`);
|
|
52
55
|
issued += 1;
|
|
56
|
+
const link = `${input.base}?token=${raw}&purpose=invite`;
|
|
57
|
+
const expires = new Date(expiresAtMs).toISOString();
|
|
58
|
+
if (input.deliver === undefined) {
|
|
59
|
+
input.out(`${target.username} ${link} (expires ${expires})`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const masked = (0, guard_1.maskEmail)(target.email ?? '');
|
|
63
|
+
if (target.email !== undefined && target.email !== '' && (await input.deliver(target, link))) {
|
|
64
|
+
emailed += 1;
|
|
65
|
+
input.out(`${target.username} set-password link emailed to ${masked} (expires ${expires})`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
// Nobody holds this link: kill it; the operator re-runs with --reissue-links.
|
|
69
|
+
await input.store.invalidateAllForPrincipal(target.principalId);
|
|
70
|
+
notEmailed += 1;
|
|
71
|
+
input.out(`${target.username} set-password link could not be emailed to ${masked}; nothing printed. ` +
|
|
72
|
+
'Re-run with --reissue-links to send a fresh one.');
|
|
73
|
+
}
|
|
53
74
|
}
|
|
54
|
-
return issued;
|
|
75
|
+
return { issued, emailed, notEmailed };
|
|
55
76
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-010 T9 (R9, R11): the invite email. The link is HTML-escaped in the HTML part (guard's
|
|
3
|
+
* escaper is internal to guard, so a local one with the same rule plus `'`); the text part carries
|
|
4
|
+
* the plain link. Only the link and its lifetime appear: no other user data.
|
|
5
|
+
*/
|
|
6
|
+
import type { EmailMessage } from '@astram/core';
|
|
7
|
+
export declare function inviteMessage(to: string, link: string, ttlHours: number): EmailMessage;
|
|
8
|
+
//# sourceMappingURL=invite-message.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invite-message.d.ts","sourceRoot":"","sources":["../../../src/features/user/invite-message.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAWjD,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,YAAY,CAWtF"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.inviteMessage = inviteMessage;
|
|
4
|
+
function escapeHtml(value) {
|
|
5
|
+
return value
|
|
6
|
+
.replace(/&/g, '&')
|
|
7
|
+
.replace(/</g, '<')
|
|
8
|
+
.replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"')
|
|
10
|
+
.replace(/'/g, ''');
|
|
11
|
+
}
|
|
12
|
+
function inviteMessage(to, link, ttlHours) {
|
|
13
|
+
const lifetime = `${ttlHours} ${ttlHours === 1 ? 'hour' : 'hours'}`;
|
|
14
|
+
const intro = 'You have been invited. Set your password to sign in:';
|
|
15
|
+
const expiry = `This link works once and expires in ${lifetime}.`;
|
|
16
|
+
const safe = escapeHtml(link);
|
|
17
|
+
return {
|
|
18
|
+
to,
|
|
19
|
+
subject: 'Set your password',
|
|
20
|
+
text: `${intro} ${link}\n\n${expiry}`,
|
|
21
|
+
html: `<p>${escapeHtml(intro)} <a href="${safe}">${safe}</a></p><p>${escapeHtml(expiry)}</p>`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface InviteOptions {
|
|
2
|
+
/** Return the link in the invite response (once, never readable again). Default false. */
|
|
3
|
+
returnLinkToAdmin?: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface InviteConfig {
|
|
6
|
+
readonly returnLinkToAdmin: boolean;
|
|
7
|
+
readonly ttlHours: number;
|
|
8
|
+
readonly perAdminPerHour: number;
|
|
9
|
+
/** The reset page base, or `null` when it cannot be used (the invite answers 501). */
|
|
10
|
+
readonly linkBase: string | null;
|
|
11
|
+
}
|
|
12
|
+
export declare const DEFAULT_INVITE_LINK_TTL_HOURS = 72;
|
|
13
|
+
export declare const DEFAULT_INVITE_RATE_PER_ADMIN = 30;
|
|
14
|
+
export declare function loadInviteConfig(options?: InviteOptions, env?: NodeJS.ProcessEnv): InviteConfig;
|
|
15
|
+
//# sourceMappingURL=invite.config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invite.config.d.ts","sourceRoot":"","sources":["../../../src/features/user/invite.config.ts"],"names":[],"mappings":"AAiBA,MAAM,WAAW,aAAa;IAC5B,0FAA0F;IAC1F,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAChD,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAWhD,wBAAgB,gBAAgB,CAC9B,OAAO,GAAE,aAAkB,EAC3B,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,YAAY,CAoCd"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_INVITE_RATE_PER_ADMIN = exports.DEFAULT_INVITE_LINK_TTL_HOURS = void 0;
|
|
4
|
+
exports.loadInviteConfig = loadInviteConfig;
|
|
5
|
+
/**
|
|
6
|
+
* G-010 T9 (R11, P-4, P-5): invite configuration, read ONCE by `IdentityFeatureModule.forRoot`.
|
|
7
|
+
*
|
|
8
|
+
* - `INVITE_LINK_TTL_HOURS`: link lifetime, whole hours 1 to 168 (default 72; the G-009 rule).
|
|
9
|
+
* - `INVITE_RATE_PER_ADMIN`: invites one administrator may send per 60 minutes, 1 to 1000
|
|
10
|
+
* (default 30).
|
|
11
|
+
* - The link base follows forgot-password's `RESET_LINK_BASE_URL` rule (G-009
|
|
12
|
+
* `resolveSetPasswordBase`). When it cannot be resolved the invite answers 501, unless the app
|
|
13
|
+
* opted into `returnLinkToAdmin`, which then refuses boot (it promised a usable link).
|
|
14
|
+
*
|
|
15
|
+
* A bad value refuses boot with an error naming the variable, never echoing the value.
|
|
16
|
+
*/
|
|
17
|
+
const set_password_links_js_1 = require("../org-bootstrap/set-password-links.js");
|
|
18
|
+
exports.DEFAULT_INVITE_LINK_TTL_HOURS = 72;
|
|
19
|
+
exports.DEFAULT_INVITE_RATE_PER_ADMIN = 30;
|
|
20
|
+
const MAX_INVITE_RATE_PER_ADMIN = 1000;
|
|
21
|
+
function wholeNumber(raw, name, fallback) {
|
|
22
|
+
if (raw === undefined)
|
|
23
|
+
return fallback;
|
|
24
|
+
if (!/^\d+$/.test(raw)) {
|
|
25
|
+
throw new Error(`${name} must be a plain positive whole number.`);
|
|
26
|
+
}
|
|
27
|
+
return Number(raw);
|
|
28
|
+
}
|
|
29
|
+
function loadInviteConfig(options = {}, env = process.env) {
|
|
30
|
+
const ttlRaw = wholeNumber(env.INVITE_LINK_TTL_HOURS, 'INVITE_LINK_TTL_HOURS', exports.DEFAULT_INVITE_LINK_TTL_HOURS);
|
|
31
|
+
let ttlHours;
|
|
32
|
+
try {
|
|
33
|
+
ttlHours = (0, set_password_links_js_1.validateLinkTtlHours)(ttlRaw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error('INVITE_LINK_TTL_HOURS must be 1 to 168 whole hours.');
|
|
37
|
+
}
|
|
38
|
+
const perAdminPerHour = wholeNumber(env.INVITE_RATE_PER_ADMIN, 'INVITE_RATE_PER_ADMIN', exports.DEFAULT_INVITE_RATE_PER_ADMIN);
|
|
39
|
+
if (perAdminPerHour < 1 || perAdminPerHour > MAX_INVITE_RATE_PER_ADMIN) {
|
|
40
|
+
throw new Error(`INVITE_RATE_PER_ADMIN must be 1 to ${MAX_INVITE_RATE_PER_ADMIN}.`);
|
|
41
|
+
}
|
|
42
|
+
const returnLinkToAdmin = options.returnLinkToAdmin === true;
|
|
43
|
+
let linkBase;
|
|
44
|
+
try {
|
|
45
|
+
linkBase = (0, set_password_links_js_1.resolveSetPasswordBase)(env);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (returnLinkToAdmin) {
|
|
49
|
+
throw new Error('IdentityFeatureModule.forRoot({ invite: { returnLinkToAdmin: true } }): ' +
|
|
50
|
+
(err instanceof Error ? err.message : 'RESET_LINK_BASE_URL is not usable.'));
|
|
51
|
+
}
|
|
52
|
+
linkBase = null;
|
|
53
|
+
}
|
|
54
|
+
return { returnLinkToAdmin, ttlHours, perAdminPerHour, linkBase };
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { RecordsService } from '@astram/engine';
|
|
2
|
+
import type { AuditEvent, Principal } from '@astram/core';
|
|
3
|
+
import { type CredentialProvisioner, type MessageDispatcher, type ResetTokenStore } from '@astram/guard';
|
|
4
|
+
import { GrantCeiling } from '../authz/grant-ceiling.js';
|
|
5
|
+
import type { InviteConfig } from './invite.config.js';
|
|
6
|
+
/**
|
|
7
|
+
* The slice of guard's `AttemptLimiter` the invite uses: `consume` is one atomic Lua step
|
|
8
|
+
* (`ATTEMPT_CONSUME_SCRIPT`: INCR, window TTL armed on the first hit and re-armed when the key has
|
|
9
|
+
* none) that answers whether this reservation is within the budget. It REJECTS when Redis cannot
|
|
10
|
+
* answer (`AttemptLimiterUnavailableError`); the invite then proceeds (fail open, P-5).
|
|
11
|
+
*/
|
|
12
|
+
export interface InviteLimiter {
|
|
13
|
+
consume(key: string): Promise<{
|
|
14
|
+
readonly allowed: boolean;
|
|
15
|
+
readonly count: number;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export interface InviteLimiters {
|
|
19
|
+
readonly user: InviteLimiter;
|
|
20
|
+
readonly admin: InviteLimiter;
|
|
21
|
+
}
|
|
22
|
+
export interface InviteAuditWriter {
|
|
23
|
+
record(event: AuditEvent): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** The route's response. `link` only when the app opted into `returnLinkToAdmin`. */
|
|
26
|
+
export interface InviteResult {
|
|
27
|
+
emailed: boolean;
|
|
28
|
+
link?: string;
|
|
29
|
+
}
|
|
30
|
+
/** Invites per invited user per window (matches forgot-password's per-account limit). */
|
|
31
|
+
export declare const INVITES_PER_USER = 3;
|
|
32
|
+
export declare const INVITE_WINDOW_MS = 3600000;
|
|
33
|
+
export declare class InviteService {
|
|
34
|
+
private readonly records;
|
|
35
|
+
private readonly ceiling;
|
|
36
|
+
private readonly config;
|
|
37
|
+
private readonly limiters?;
|
|
38
|
+
private readonly audit?;
|
|
39
|
+
private readonly resetTokens?;
|
|
40
|
+
private readonly provisioner?;
|
|
41
|
+
private readonly dispatcher?;
|
|
42
|
+
private readonly logger;
|
|
43
|
+
constructor(records: RecordsService, ceiling: GrantCeiling, config: InviteConfig, limiters?: (InviteLimiters | null) | undefined, audit?: (InviteAuditWriter | null) | undefined, resetTokens?: (ResetTokenStore | null) | undefined, provisioner?: (CredentialProvisioner | null) | undefined, dispatcher?: (MessageDispatcher | null) | undefined);
|
|
44
|
+
invite(actor: Principal, uuid: string): Promise<InviteResult>;
|
|
45
|
+
/** Only a locked credential with an email can be invited; otherwise 409 with the reason. */
|
|
46
|
+
private assertInvitable;
|
|
47
|
+
/** Reserve one slot; `false` = over the budget. A limiter that cannot answer fails open (P-5). */
|
|
48
|
+
private reserve;
|
|
49
|
+
/**
|
|
50
|
+
* Burn this invite's own token (the store's single-use consume of exactly that hash). A token a
|
|
51
|
+
* newer invite already invalidated is simply not found. A store error is logged, not thrown: the
|
|
52
|
+
* caller still answers 503 and nobody holds the link.
|
|
53
|
+
*/
|
|
54
|
+
private burn;
|
|
55
|
+
private throttled;
|
|
56
|
+
/**
|
|
57
|
+
* Writes one audit row; never throws. Returns whether the row was written (`false` when no
|
|
58
|
+
* writer is wired or the sink failed), so the link-return path can fail closed on it.
|
|
59
|
+
*/
|
|
60
|
+
private record;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=invite.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invite.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/invite.service.ts"],"names":[],"mappings":"AAuCA,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAIL,KAAK,qBAAqB,EAC1B,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,YAAY,EAAE,MAAM,2BAA2B,CAAC;AAOzD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQvD;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;QAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACtF;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1C;AAED,qFAAqF;AACrF,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,yFAAyF;AACzF,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,gBAAgB,UAAY,CAAC;AAW1C,qBACa,aAAa;IAItB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO;IACQ,OAAO,CAAC,QAAQ,CAAC,MAAM;IAGvD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IACiB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;IAGlE,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAG7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAG7B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAC;IAlB9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkC;gBAGtC,OAAO,EAAE,cAAc,EACvB,OAAO,EAAE,YAAY,EACW,MAAM,EAAE,YAAY,EAGpD,QAAQ,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EACW,KAAK,CAAC,GAAE,iBAAiB,GAAG,IAAI,aAAA,EAG3E,WAAW,CAAC,GAAE,eAAe,GAAG,IAAI,aAAA,EAGpC,WAAW,CAAC,GAAE,qBAAqB,GAAG,IAAI,aAAA,EAG1C,UAAU,CAAC,GAAE,iBAAiB,GAAG,IAAI,aAAA;IAGlD,MAAM,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAqGnE,4FAA4F;IAC5F,OAAO,CAAC,eAAe;IAKvB,kGAAkG;YACpF,OAAO;IASrB;;;;OAIG;YACW,IAAI;YAQJ,SAAS;IAKvB;;;OAGG;YACW,MAAM;CAuBrB"}
|