@astram/identity 0.14.0 → 0.15.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.
Files changed (38) hide show
  1. package/dist/features/identity-feature.module.d.ts +7 -1
  2. package/dist/features/identity-feature.module.d.ts.map +1 -1
  3. package/dist/features/identity-feature.module.js +33 -1
  4. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts +19 -0
  5. package/dist/features/org-bootstrap/run-org-bootstrap.d.ts.map +1 -1
  6. package/dist/features/org-bootstrap/run-org-bootstrap.js +96 -10
  7. package/dist/features/org-bootstrap/set-password-links.d.ts +14 -2
  8. package/dist/features/org-bootstrap/set-password-links.d.ts.map +1 -1
  9. package/dist/features/org-bootstrap/set-password-links.js +25 -4
  10. package/dist/features/user/invite-message.d.ts +8 -0
  11. package/dist/features/user/invite-message.d.ts.map +1 -0
  12. package/dist/features/user/invite-message.js +23 -0
  13. package/dist/features/user/invite.config.d.ts +15 -0
  14. package/dist/features/user/invite.config.d.ts.map +1 -0
  15. package/dist/features/user/invite.config.js +55 -0
  16. package/dist/features/user/invite.service.d.ts +62 -0
  17. package/dist/features/user/invite.service.d.ts.map +1 -0
  18. package/dist/features/user/invite.service.js +254 -0
  19. package/dist/features/user/invite.tokens.d.ts +11 -0
  20. package/dist/features/user/invite.tokens.d.ts.map +1 -0
  21. package/dist/features/user/invite.tokens.js +13 -0
  22. package/dist/features/user/user.controller.d.ts +12 -3
  23. package/dist/features/user/user.controller.d.ts.map +1 -1
  24. package/dist/features/user/user.controller.js +28 -2
  25. package/dist/features/user/user.service.d.ts +25 -7
  26. package/dist/features/user/user.service.d.ts.map +1 -1
  27. package/dist/features/user/user.service.js +53 -17
  28. package/dist/index.d.ts +7 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/user.object.d.ts.map +1 -1
  31. package/dist/user.object.js +3 -0
  32. package/package.json +4 -4
  33. package/dist/features/org-bootstrap/org-bootstrap-runner.harness.d.ts +0 -259
  34. package/dist/features/org-bootstrap/org-bootstrap-runner.harness.d.ts.map +0 -1
  35. package/dist/features/org-bootstrap/org-bootstrap-runner.harness.js +0 -604
  36. package/dist/features/org-bootstrap/org-bootstrap-runner.mocks.d.ts +0 -24
  37. package/dist/features/org-bootstrap/org-bootstrap-runner.mocks.d.ts.map +0 -1
  38. 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;AA+GxB;;;;;;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;;;;;;;;;;;;;;;;;;;OAmBG;IACH,MAAM,CAAC,OAAO,CAAC,EACb,OAAO,EACP,YAAiB,EACjB,OAAO,EAAE,YAAY,GACtB,GAAE;QACD,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;QAC5B,YAAY,CAAC,EAAE,SAAS,eAAe,EAAE,CAAC;QAC1C,OAAO,CAAC,EAAE,gBAAgB,CAAC;KACvB,GAAG,aAAa;CAqkBvB"}
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;AA4BpE,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,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,gEAAgE;IAChE,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAwFD,wBAAsB,eAAe,CACnC,GAAG,EAAE,uBAAuB,EAC5B,IAAI,EAAE,sBAAsB,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CA8mB7B"}
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 { applied: false, plan, written: {}, links: 0, rootIds };
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
- if (opts.isTTY === undefined ? process.stdout.isTTY !== true : !opts.isTTY) {
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 consider = async (username, companyPath) => {
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
- targets.push({ username, ...fresh, invalidateFirst: false });
582
+ into.push({ username, ...fresh, invalidateFirst: false, ...emailOf(username) });
547
583
  }
548
584
  else if (opts.reissueLinks === true && lockedInState(username)) {
549
- targets.push({
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
- for (const r of file.roots)
558
- await consider(r.username, r.homeCompany);
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
- links = await (0, set_password_links_js_1.issueSetPasswordLinks)({
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
- audit: (target, expiresAtMs) => auditOn((sql, params) => pool.query(sql, params), target.tenantId, 'credential.setup_link_issued', STEP.users, { userUuid: target.principalId, expiresAtMs }),
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 { applied: true, plan, written, links, rootIds };
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
- /** Issue one token per target and print its link once. Returns how many were issued. */
29
- export declare function issueSetPasswordLinks(input: IssueLinksInput): Promise<number>;
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":"AAMA,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AAG9E,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;CAC1B;AAED,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;CACnE;AAED,wFAAwF;AACxF,wBAAsB,qBAAqB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAkBnF"}
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. A token or link never reaches the audit row, the report or an error.
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 and print its link once. Returns how many were issued. */
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, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&#39;');
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"}