@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.
- 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/package.json +4 -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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
var InviteService_1;
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.InviteService = exports.INVITE_WINDOW_MS = exports.INVITES_PER_USER = void 0;
|
|
17
|
+
/**
|
|
18
|
+
* G-010 T9 (SPEC R11, P-4, P-5; AC-8, AC-9, AC-15): administrator invite.
|
|
19
|
+
*
|
|
20
|
+
* `invite(actor, uuid)` sends a one-time set-password link to a user whose credential is still
|
|
21
|
+
* LOCKED (created by an admin, never signed in). Order:
|
|
22
|
+
* a. no delivery channel (mail off and `returnLinkToAdmin` off, or no usable link base) → 501,
|
|
23
|
+
* before anything else; the auth surface or the limiter missing → 503 (fail closed);
|
|
24
|
+
* b. record-level `authorize('user', uuid, 'invite')` (ADR-0038) — missing ≡ foreign, same 403;
|
|
25
|
+
* c. `GrantCeiling.assertDominates` — an admin cannot invite a user who outranks them;
|
|
26
|
+
* d. the user record is read (`records.get`): invite ALSO requires `identity:user:read`, and a
|
|
27
|
+
* caller who cannot read the user gets the same 403 before any budget is spent;
|
|
28
|
+
* e. rate limits: 3 per user and `INVITE_RATE_PER_ADMIN` per admin, per 60 minutes, counted
|
|
29
|
+
* only after b–d pass (no oracle, no burning another admin's budget). The slot is RESERVED
|
|
30
|
+
* first with guard's atomic `consume` (one Lua step: INCR, and the window TTL armed on the
|
|
31
|
+
* first hit or re-armed when missing, so a counter can never stay without an expiry), so
|
|
32
|
+
* concurrent invites cannot all pass a check; over → 429 and `user.invite_throttled`. A
|
|
33
|
+
* Redis error (consume rejects) lets the invite proceed (fail open, P-5);
|
|
34
|
+
* f. under the user row lock (`transaction` + `lockForUpdate`): the credential state (usable or
|
|
35
|
+
* none, or no email → 409 with the reason), earlier links killed, one token (sha256 stored,
|
|
36
|
+
* per-row expiry), `&purpose=invite`. The lock serialises invites of one user, so two can
|
|
37
|
+
* never leave two live tokens. After the commit the state is read again: a user who finished
|
|
38
|
+
* a reset in between gets their tokens killed and a 409;
|
|
39
|
+
* g. emailed through guard's dispatcher (purpose `invite`); a failed send with no link return
|
|
40
|
+
* burns THIS invite's token (single-use consume of its own hash, never every token of the
|
|
41
|
+
* user: a newer invite issued after our lock was released stays live) and answers 503;
|
|
42
|
+
* h. `user.invited` audited (emailed / linkReturned), never the link or token. Best-effort when
|
|
43
|
+
* only emailed; MANDATORY when the link goes back to the admin (no unaudited link: a failed
|
|
44
|
+
* write burns this invite's token, as in g, and answers 503).
|
|
45
|
+
*/
|
|
46
|
+
const node_crypto_1 = require("node:crypto");
|
|
47
|
+
const common_1 = require("@nestjs/common");
|
|
48
|
+
const engine_1 = require("@astram/engine");
|
|
49
|
+
const guard_1 = require("@astram/guard");
|
|
50
|
+
const grant_ceiling_js_1 = require("../authz/grant-ceiling.js");
|
|
51
|
+
const identity_errors_js_1 = require("../shared/identity-errors.js");
|
|
52
|
+
const credential_provisioner_token_js_1 = require("./credential-provisioner.token.js");
|
|
53
|
+
const invite_message_js_1 = require("./invite-message.js");
|
|
54
|
+
const invite_tokens_js_1 = require("./invite.tokens.js");
|
|
55
|
+
/** Invites per invited user per window (matches forgot-password's per-account limit). */
|
|
56
|
+
exports.INVITES_PER_USER = 3;
|
|
57
|
+
exports.INVITE_WINDOW_MS = 3_600_000;
|
|
58
|
+
const HOUR_MS = 3_600_000;
|
|
59
|
+
const MSG_NO_CHANNEL = 'No delivery channel is configured for invites.';
|
|
60
|
+
const MSG_UNAVAILABLE = 'Invites are temporarily unavailable. Try again.';
|
|
61
|
+
const MSG_THROTTLED = 'Too many invites. Try again later.';
|
|
62
|
+
const MSG_USABLE = 'This user has already set a password. Use forgot-password instead.';
|
|
63
|
+
const MSG_NO_CREDENTIAL = 'This user has no sign-in credential yet (needs an email and a company).';
|
|
64
|
+
const MSG_SEND_FAILED = 'The invite email could not be sent. Try again.';
|
|
65
|
+
const MSG_DENIED = 'You do not have access to perform this action.';
|
|
66
|
+
let InviteService = InviteService_1 = class InviteService {
|
|
67
|
+
records;
|
|
68
|
+
ceiling;
|
|
69
|
+
config;
|
|
70
|
+
limiters;
|
|
71
|
+
audit;
|
|
72
|
+
resetTokens;
|
|
73
|
+
provisioner;
|
|
74
|
+
dispatcher;
|
|
75
|
+
logger = new common_1.Logger(InviteService_1.name);
|
|
76
|
+
constructor(records, ceiling, config, limiters, audit, resetTokens, provisioner, dispatcher) {
|
|
77
|
+
this.records = records;
|
|
78
|
+
this.ceiling = ceiling;
|
|
79
|
+
this.config = config;
|
|
80
|
+
this.limiters = limiters;
|
|
81
|
+
this.audit = audit;
|
|
82
|
+
this.resetTokens = resetTokens;
|
|
83
|
+
this.provisioner = provisioner;
|
|
84
|
+
this.dispatcher = dispatcher;
|
|
85
|
+
}
|
|
86
|
+
async invite(actor, uuid) {
|
|
87
|
+
const { returnLinkToAdmin, linkBase, ttlHours } = this.config;
|
|
88
|
+
const emailEnabled = this.dispatcher?.emailEnabled === true;
|
|
89
|
+
const store = this.resetTokens;
|
|
90
|
+
const provisioner = this.provisioner;
|
|
91
|
+
// a. P-4: nothing could carry the link (or no auth surface to redeem it) → 501, before any check.
|
|
92
|
+
if ((!emailEnabled && !returnLinkToAdmin) || linkBase === null || !store || !provisioner) {
|
|
93
|
+
throw new common_1.NotImplementedException(MSG_NO_CHANNEL);
|
|
94
|
+
}
|
|
95
|
+
const limiters = this.limiters;
|
|
96
|
+
if (!limiters) {
|
|
97
|
+
// Fail closed: an unthrottled invite route is never served.
|
|
98
|
+
this.logger.warn('invite refused: no rate limiter is wired (guard Redis not visible)');
|
|
99
|
+
throw new identity_errors_js_1.IdentityUnavailableError(MSG_UNAVAILABLE);
|
|
100
|
+
}
|
|
101
|
+
// b. record-level decision for the declared action; c. dominance (same as update).
|
|
102
|
+
await this.records.authorize('user', uuid, 'invite');
|
|
103
|
+
await this.ceiling.assertDominates(actor, uuid);
|
|
104
|
+
// d. read the user BEFORE any count: invite also requires identity:user:read (the engine's
|
|
105
|
+
// read gate), so a caller who cannot read the user gets 403 without burning anyone's budget.
|
|
106
|
+
if ((await this.records.get('user', uuid)) === null) {
|
|
107
|
+
throw new identity_errors_js_1.IdentityForbiddenError(MSG_DENIED);
|
|
108
|
+
}
|
|
109
|
+
// e. rate limits: reserve the slot first (atomic consume), then decide on its verdict.
|
|
110
|
+
const userKey = `invite:user:${uuid.toLowerCase()}`;
|
|
111
|
+
const adminKey = `invite:admin:${actor.id}`;
|
|
112
|
+
if (!(await this.reserve(limiters.user, userKey))) {
|
|
113
|
+
await this.throttled(actor, uuid, 'user');
|
|
114
|
+
}
|
|
115
|
+
if (!(await this.reserve(limiters.admin, adminKey))) {
|
|
116
|
+
await this.throttled(actor, uuid, 'admin');
|
|
117
|
+
}
|
|
118
|
+
// f. under the user row lock: re-check the state, kill earlier links, issue one token. The
|
|
119
|
+
// reset-token store writes on its own pool (no FK to users, so no self-block); holding the
|
|
120
|
+
// row lock across those writes serialises every invite of this user.
|
|
121
|
+
const raw = (0, node_crypto_1.randomBytes)(32).toString('hex');
|
|
122
|
+
// THIS invite's token hash: a compensation burns exactly it (never every token of the user).
|
|
123
|
+
const tokenHash = (0, node_crypto_1.createHash)('sha256').update(raw).digest('hex');
|
|
124
|
+
const email = await this.records.transaction(async (tx) => {
|
|
125
|
+
await tx.lockForUpdate('user', uuid);
|
|
126
|
+
const user = await tx.get('user', uuid);
|
|
127
|
+
if (user === null)
|
|
128
|
+
throw new identity_errors_js_1.IdentityForbiddenError(MSG_DENIED);
|
|
129
|
+
const address = typeof user.fields.email === 'string' ? user.fields.email.trim() : '';
|
|
130
|
+
this.assertInvitable(await provisioner.stateOf(uuid), address);
|
|
131
|
+
await store.invalidateAllForPrincipal(uuid);
|
|
132
|
+
await store.issue(uuid, tokenHash, Date.now() + ttlHours * HOUR_MS);
|
|
133
|
+
return address;
|
|
134
|
+
});
|
|
135
|
+
// A reset finished between the check and the issue (the credential is not under the row
|
|
136
|
+
// lock): kill the token we just issued and answer as if it had been usable at the check.
|
|
137
|
+
const after = await provisioner.stateOf(uuid);
|
|
138
|
+
if (after !== 'locked') {
|
|
139
|
+
await store.invalidateAllForPrincipal(uuid);
|
|
140
|
+
this.assertInvitable(after, email);
|
|
141
|
+
}
|
|
142
|
+
const link = `${linkBase}?token=${raw}&purpose=invite`;
|
|
143
|
+
// g. deliver.
|
|
144
|
+
let emailed = false;
|
|
145
|
+
let delivery = {};
|
|
146
|
+
if (emailEnabled) {
|
|
147
|
+
const outcome = await this.dispatcher.sendEmail('invite', (0, invite_message_js_1.inviteMessage)(email, link, ttlHours), { actorId: actor.id, tenantId: actor.tenantId });
|
|
148
|
+
emailed = outcome.status === 'sent';
|
|
149
|
+
// AC-9: what the delivery row says, beside the invite (masked recipient, never the link).
|
|
150
|
+
delivery = { channel: 'email', purpose: 'invite', recipient: (0, guard_1.maskEmail)(email) };
|
|
151
|
+
if (outcome.status === 'sent') {
|
|
152
|
+
delivery.provider = outcome.provider;
|
|
153
|
+
if (outcome.messageId)
|
|
154
|
+
delivery.messageId = outcome.messageId;
|
|
155
|
+
}
|
|
156
|
+
else if (outcome.status === 'failed') {
|
|
157
|
+
delivery.provider = outcome.provider;
|
|
158
|
+
delivery.failureClass = outcome.failureClass;
|
|
159
|
+
}
|
|
160
|
+
if (!emailed && !returnLinkToAdmin) {
|
|
161
|
+
// Nobody holds the link: burn it. Only OUR token: a concurrent invite may have issued a
|
|
162
|
+
// newer one after our transaction released the row lock.
|
|
163
|
+
await this.burn(store, tokenHash);
|
|
164
|
+
throw new identity_errors_js_1.IdentityUnavailableError(MSG_SEND_FAILED);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// h. audit; never the link or the token. A link handed back to the admin is never unaudited.
|
|
168
|
+
const audited = await this.record(actor, uuid, 'user.invited', {
|
|
169
|
+
emailed,
|
|
170
|
+
linkReturned: returnLinkToAdmin,
|
|
171
|
+
...delivery,
|
|
172
|
+
});
|
|
173
|
+
if (returnLinkToAdmin && !audited) {
|
|
174
|
+
await this.burn(store, tokenHash);
|
|
175
|
+
throw new identity_errors_js_1.IdentityUnavailableError(MSG_UNAVAILABLE);
|
|
176
|
+
}
|
|
177
|
+
return returnLinkToAdmin ? { emailed, link } : { emailed };
|
|
178
|
+
}
|
|
179
|
+
/** Only a locked credential with an email can be invited; otherwise 409 with the reason. */
|
|
180
|
+
assertInvitable(state, email) {
|
|
181
|
+
if (state === 'usable')
|
|
182
|
+
throw new identity_errors_js_1.IdentityConflictError(MSG_USABLE);
|
|
183
|
+
if (state !== 'locked' || email === '')
|
|
184
|
+
throw new identity_errors_js_1.IdentityConflictError(MSG_NO_CREDENTIAL);
|
|
185
|
+
}
|
|
186
|
+
/** Reserve one slot; `false` = over the budget. A limiter that cannot answer fails open (P-5). */
|
|
187
|
+
async reserve(limiter, key) {
|
|
188
|
+
try {
|
|
189
|
+
return (await limiter.consume(key)).allowed;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
this.logger.warn('invite rate limiter unavailable; the invite proceeds unthrottled (P-5)');
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Burn this invite's own token (the store's single-use consume of exactly that hash). A token a
|
|
198
|
+
* newer invite already invalidated is simply not found. A store error is logged, not thrown: the
|
|
199
|
+
* caller still answers 503 and nobody holds the link.
|
|
200
|
+
*/
|
|
201
|
+
async burn(store, tokenHash) {
|
|
202
|
+
try {
|
|
203
|
+
await store.consume(tokenHash);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
this.logger.warn('invite token could not be burned after a failed delivery or audit');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async throttled(actor, uuid, limit) {
|
|
210
|
+
await this.record(actor, uuid, 'user.invite_throttled', { limit });
|
|
211
|
+
throw new common_1.HttpException(MSG_THROTTLED, common_1.HttpStatus.TOO_MANY_REQUESTS);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Writes one audit row; never throws. Returns whether the row was written (`false` when no
|
|
215
|
+
* writer is wired or the sink failed), so the link-return path can fail closed on it.
|
|
216
|
+
*/
|
|
217
|
+
async record(actor, uuid, key, attributes) {
|
|
218
|
+
if (!this.audit)
|
|
219
|
+
return false;
|
|
220
|
+
try {
|
|
221
|
+
await this.audit.record({
|
|
222
|
+
type: 'admin_action',
|
|
223
|
+
occurredAtMs: Date.now(),
|
|
224
|
+
actorId: actor.id,
|
|
225
|
+
tenantId: actor.tenantId,
|
|
226
|
+
action: { key },
|
|
227
|
+
resource: { level: 'record', typeKey: 'user', tenantId: actor.tenantId, recordId: uuid },
|
|
228
|
+
attributes: { userUuid: uuid, ...attributes },
|
|
229
|
+
});
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
this.logger.warn(`${key} audit row could not be written`);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
exports.InviteService = InviteService;
|
|
239
|
+
exports.InviteService = InviteService = InviteService_1 = __decorate([
|
|
240
|
+
(0, common_1.Injectable)(),
|
|
241
|
+
__param(2, (0, common_1.Inject)(invite_tokens_js_1.IDENTITY_INVITE_CONFIG)),
|
|
242
|
+
__param(3, (0, common_1.Optional)()),
|
|
243
|
+
__param(3, (0, common_1.Inject)(invite_tokens_js_1.IDENTITY_INVITE_LIMITERS)),
|
|
244
|
+
__param(4, (0, common_1.Optional)()),
|
|
245
|
+
__param(4, (0, common_1.Inject)(invite_tokens_js_1.IDENTITY_INVITE_AUDIT)),
|
|
246
|
+
__param(5, (0, common_1.Optional)()),
|
|
247
|
+
__param(5, (0, common_1.Inject)(guard_1.SHIELD_RESET_TOKEN_STORE)),
|
|
248
|
+
__param(6, (0, common_1.Optional)()),
|
|
249
|
+
__param(6, (0, common_1.Inject)(credential_provisioner_token_js_1.IDENTITY_CREDENTIAL_PROVISIONER)),
|
|
250
|
+
__param(7, (0, common_1.Optional)()),
|
|
251
|
+
__param(7, (0, common_1.Inject)(guard_1.SHIELD_MESSAGE_DISPATCHER)),
|
|
252
|
+
__metadata("design:paramtypes", [engine_1.RecordsService,
|
|
253
|
+
grant_ceiling_js_1.GrantCeiling, Object, Object, Object, Object, Object, Object])
|
|
254
|
+
], InviteService);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G-010 T9: module-internal DI tokens for the invite (built in `IdentityFeatureModule.forRoot`).
|
|
3
|
+
* Not exported from the package.
|
|
4
|
+
*/
|
|
5
|
+
/** The {@link InviteConfig} read once at boot. */
|
|
6
|
+
export declare const IDENTITY_INVITE_CONFIG: unique symbol;
|
|
7
|
+
/** The per-user and per-admin limiters over guard's Redis (`null` when Redis is not visible). */
|
|
8
|
+
export declare const IDENTITY_INVITE_LIMITERS: unique symbol;
|
|
9
|
+
/** The audit writer for `user.invited` / `user.invite_throttled` (shared authz pool). */
|
|
10
|
+
export declare const IDENTITY_INVITE_AUDIT: unique symbol;
|
|
11
|
+
//# sourceMappingURL=invite.tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invite.tokens.d.ts","sourceRoot":"","sources":["../../../src/features/user/invite.tokens.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,kDAAkD;AAClD,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AACvE,iGAAiG;AACjG,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAC3E,yFAAyF;AACzF,eAAO,MAAM,qBAAqB,eAAkC,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IDENTITY_INVITE_AUDIT = exports.IDENTITY_INVITE_LIMITERS = exports.IDENTITY_INVITE_CONFIG = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* G-010 T9: module-internal DI tokens for the invite (built in `IdentityFeatureModule.forRoot`).
|
|
6
|
+
* Not exported from the package.
|
|
7
|
+
*/
|
|
8
|
+
/** The {@link InviteConfig} read once at boot. */
|
|
9
|
+
exports.IDENTITY_INVITE_CONFIG = Symbol('IDENTITY_INVITE_CONFIG');
|
|
10
|
+
/** The per-user and per-admin limiters over guard's Redis (`null` when Redis is not visible). */
|
|
11
|
+
exports.IDENTITY_INVITE_LIMITERS = Symbol('IDENTITY_INVITE_LIMITERS');
|
|
12
|
+
/** The audit writer for `user.invited` / `user.invite_throttled` (shared authz pool). */
|
|
13
|
+
exports.IDENTITY_INVITE_AUDIT = Symbol('IDENTITY_INVITE_AUDIT');
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { UserService } from './user.service.js';
|
|
2
|
+
import { InviteService, type InviteResult } from './invite.service.js';
|
|
2
3
|
import { type ListQueryDto } from '../shared/list-query.js';
|
|
3
4
|
import type { AuthedRequest } from '../shared/http.js';
|
|
4
5
|
export declare class UserController {
|
|
5
6
|
private readonly users;
|
|
6
|
-
|
|
7
|
+
private readonly invites?;
|
|
8
|
+
constructor(users: UserService, invites?: InviteService | undefined);
|
|
7
9
|
/**
|
|
8
10
|
* List users — gated on `identity:user:read` (see class doc). Server-side DataTable:
|
|
9
11
|
* `?page&limit&search&sortBy&sortOrder`. Returns `{ total, rows, page, limit }` —
|
|
@@ -11,7 +13,7 @@ export declare class UserController {
|
|
|
11
13
|
*/
|
|
12
14
|
listUsers(query: ListQueryDto): Promise<{
|
|
13
15
|
total: number;
|
|
14
|
-
rows: readonly import("
|
|
16
|
+
rows: readonly import("./user.service.js").UserRecord[];
|
|
15
17
|
page: number;
|
|
16
18
|
limit: number;
|
|
17
19
|
}>;
|
|
@@ -26,7 +28,7 @@ export declare class UserController {
|
|
|
26
28
|
}): Promise<import("./user.service.js").CreateUserResult>;
|
|
27
29
|
/** Read one user — gated on `identity:user:read` (CONTROL-plane; root, the identity
|
|
28
30
|
* administrator, may read users). */
|
|
29
|
-
getUser(uuid: string): Promise<import("
|
|
31
|
+
getUser(uuid: string): Promise<import("./user.service.js").UserRecord | null>;
|
|
30
32
|
/** Update a user — gated on `identity:user:update`. Only `username`, `email`, `status` apply. */
|
|
31
33
|
updateUser(req: AuthedRequest, uuid: string, body: {
|
|
32
34
|
username?: string;
|
|
@@ -35,6 +37,13 @@ export declare class UserController {
|
|
|
35
37
|
}): Promise<{
|
|
36
38
|
success: boolean;
|
|
37
39
|
}>;
|
|
40
|
+
/**
|
|
41
|
+
* G-010 R11: invite a user whose credential is still locked — gated on the custom action
|
|
42
|
+
* `identity:user:invite` (ADR-0038; record-level check, dominance, rate limits in the service).
|
|
43
|
+
* No body. 200 `{ emailed, link? }`; `link` only with `forRoot({ invite: { returnLinkToAdmin } })`.
|
|
44
|
+
* 409 not locked, 429 throttled, 501 no delivery channel, 503 send failed.
|
|
45
|
+
*/
|
|
46
|
+
inviteUser(req: AuthedRequest, uuid: string): Promise<InviteResult>;
|
|
38
47
|
/** Delete a user — gated on `identity:user:delete`; the caller must dominate the user. */
|
|
39
48
|
deleteUser(req: AuthedRequest, uuid: string): Promise<{
|
|
40
49
|
success: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.controller.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.controller.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"user.controller.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.controller.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,KAAK,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACvE,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAOvD,qBAEa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACV,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;gBADpB,KAAK,EAAE,WAAW,EACN,OAAO,CAAC,EAAE,aAAa,YAAA;IAGtD;;;;OAIG;IAGG,SAAS,CAAU,KAAK,EAAE,YAAY;;;;;;IAM5C;;;OAGG;IAGG,UAAU,CACP,GAAG,EAAE,aAAa,EACjB,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE;IAY3E;0CACsC;IAGtC,OAAO,CAA0B,IAAI,EAAE,MAAM;IAI7C,iGAAiG;IAG3F,UAAU,CACP,GAAG,EAAE,aAAa,EACA,IAAI,EAAE,MAAM,EAC7B,IAAI,EAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;;;IAUtE;;;;;OAKG;IAIH,UAAU,CACD,GAAG,EAAE,aAAa,EACA,IAAI,EAAE,MAAM,GACpC,OAAO,CAAC,YAAY,CAAC;IAKxB,0FAA0F;IAGpF,UAAU,CAAQ,GAAG,EAAE,aAAa,EAA2B,IAAI,EAAE,MAAM;;;CAIlF"}
|
|
@@ -16,6 +16,7 @@ exports.UserController = void 0;
|
|
|
16
16
|
const common_1 = require("@nestjs/common");
|
|
17
17
|
const guard_1 = require("@astram/guard");
|
|
18
18
|
const user_service_js_1 = require("./user.service.js");
|
|
19
|
+
const invite_service_js_1 = require("./invite.service.js");
|
|
19
20
|
const list_query_js_1 = require("../shared/list-query.js");
|
|
20
21
|
const identity_error_interceptor_js_1 = require("../shared/identity-error.interceptor.js");
|
|
21
22
|
// User management is gated on `identity:user:<action>` grants. `user` is CONTROL-plane
|
|
@@ -24,8 +25,10 @@ const identity_error_interceptor_js_1 = require("../shared/identity-error.interc
|
|
|
24
25
|
// email or status, or deleting them, also needs the caller to dominate that user (D2).
|
|
25
26
|
let UserController = class UserController {
|
|
26
27
|
users;
|
|
27
|
-
|
|
28
|
+
invites;
|
|
29
|
+
constructor(users, invites) {
|
|
28
30
|
this.users = users;
|
|
31
|
+
this.invites = invites;
|
|
29
32
|
}
|
|
30
33
|
/**
|
|
31
34
|
* List users — gated on `identity:user:read` (see class doc). Server-side DataTable:
|
|
@@ -65,6 +68,17 @@ let UserController = class UserController {
|
|
|
65
68
|
});
|
|
66
69
|
return { success: true };
|
|
67
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* G-010 R11: invite a user whose credential is still locked — gated on the custom action
|
|
73
|
+
* `identity:user:invite` (ADR-0038; record-level check, dominance, rate limits in the service).
|
|
74
|
+
* No body. 200 `{ emailed, link? }`; `link` only with `forRoot({ invite: { returnLinkToAdmin } })`.
|
|
75
|
+
* 409 not locked, 429 throttled, 501 no delivery channel, 503 send failed.
|
|
76
|
+
*/
|
|
77
|
+
inviteUser(req, uuid) {
|
|
78
|
+
if (!this.invites)
|
|
79
|
+
throw new Error('UserController: InviteService is not wired');
|
|
80
|
+
return this.invites.invite(req.principal, uuid);
|
|
81
|
+
}
|
|
68
82
|
/** Delete a user — gated on `identity:user:delete`; the caller must dominate the user. */
|
|
69
83
|
async deleteUser(req, uuid) {
|
|
70
84
|
await this.users.deleteUser(req.principal, uuid);
|
|
@@ -107,6 +121,16 @@ __decorate([
|
|
|
107
121
|
__metadata("design:paramtypes", [Object, String, Object]),
|
|
108
122
|
__metadata("design:returntype", Promise)
|
|
109
123
|
], UserController.prototype, "updateUser", null);
|
|
124
|
+
__decorate([
|
|
125
|
+
(0, common_1.Post)('users/:uuid/invite'),
|
|
126
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
127
|
+
(0, guard_1.Classified)({ typeKey: 'user', action: 'invite' }),
|
|
128
|
+
__param(0, (0, common_1.Req)()),
|
|
129
|
+
__param(1, (0, common_1.Param)('uuid', guard_1.UuidPipe)),
|
|
130
|
+
__metadata("design:type", Function),
|
|
131
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
132
|
+
__metadata("design:returntype", Promise)
|
|
133
|
+
], UserController.prototype, "inviteUser", null);
|
|
110
134
|
__decorate([
|
|
111
135
|
(0, common_1.Delete)('users/:uuid'),
|
|
112
136
|
(0, guard_1.Classified)({ typeKey: 'user', action: 'delete' }),
|
|
@@ -119,5 +143,7 @@ __decorate([
|
|
|
119
143
|
exports.UserController = UserController = __decorate([
|
|
120
144
|
(0, common_1.Controller)('identity'),
|
|
121
145
|
(0, common_1.UseInterceptors)(identity_error_interceptor_js_1.IdentityErrorInterceptor),
|
|
122
|
-
|
|
146
|
+
__param(1, (0, common_1.Optional)()),
|
|
147
|
+
__metadata("design:paramtypes", [user_service_js_1.UserService,
|
|
148
|
+
invite_service_js_1.InviteService])
|
|
123
149
|
], UserController);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { RecordsService, type RecordId } from '@astram/engine';
|
|
1
|
+
import { RecordsService, type RecordId, type RecordView } from '@astram/engine';
|
|
2
2
|
import type { FrameworkSchemas, Principal } from '@astram/core';
|
|
3
|
-
import { type EmailAddressChanger, type CredentialProvisioner, type EmailSender, type ResetTokenStore, type SessionSweeper } from '@astram/guard';
|
|
3
|
+
import { type EmailAddressChanger, type CredentialProvisioner, type EmailSender, type MessageDispatcher, type ResetTokenStore, type SessionSweeper } from '@astram/guard';
|
|
4
4
|
import { type PostingVersionBump } from '../membership/posting-version-bump.js';
|
|
5
5
|
import { type ListParams } from '../shared/list-query.js';
|
|
6
6
|
import { GrantCeiling } from '../authz/grant-ceiling.js';
|
|
@@ -13,6 +13,15 @@ export type CreateUserResult = {
|
|
|
13
13
|
credential: 'none';
|
|
14
14
|
reason: 'no_email' | 'no_company' | 'no_provisioner';
|
|
15
15
|
};
|
|
16
|
+
/**
|
|
17
|
+
* G-010 R13 (P-6): the computed, read-only sign-in state on user reads, from the credential:
|
|
18
|
+
* locked → `pending` (invite-able), usable → `active`, none → `none`. Never any credential data.
|
|
19
|
+
*/
|
|
20
|
+
export type SignInStatus = 'pending' | 'active' | 'none';
|
|
21
|
+
/** A user record as `GET /identity/users[/:uuid]` returns it: the engine row plus `signInStatus`. */
|
|
22
|
+
export type UserRecord = RecordView & {
|
|
23
|
+
readonly signInStatus?: SignInStatus;
|
|
24
|
+
};
|
|
16
25
|
/** The patch keys a generic user update accepts (security hardening #3). */
|
|
17
26
|
export interface UserPatch {
|
|
18
27
|
username?: unknown;
|
|
@@ -47,6 +56,7 @@ export declare class UserService {
|
|
|
47
56
|
private readonly versionBump?;
|
|
48
57
|
private readonly emailAddresses?;
|
|
49
58
|
private readonly provisioner?;
|
|
59
|
+
private readonly dispatcher?;
|
|
50
60
|
private readonly logger;
|
|
51
61
|
/**
|
|
52
62
|
* The engine's audit sink, used on the email helper's transaction (no pool of its own), on the
|
|
@@ -54,7 +64,7 @@ export declare class UserService {
|
|
|
54
64
|
* service) ⇒ the sink resolves `SHIELD_DB_NAMESPACE` itself.
|
|
55
65
|
*/
|
|
56
66
|
private readonly emailAudit;
|
|
57
|
-
constructor(records: RecordsService, ceiling: GrantCeiling, resetTokens?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, sessions?: (SessionSweeper | null) | undefined, versionBump?: (PostingVersionBump | null) | undefined, emailAddresses?: (EmailAddressChanger | null) | undefined, schemas?: FrameworkSchemas | null, provisioner?: (CredentialProvisioner | null) | undefined);
|
|
67
|
+
constructor(records: RecordsService, ceiling: GrantCeiling, resetTokens?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, sessions?: (SessionSweeper | null) | undefined, versionBump?: (PostingVersionBump | null) | undefined, emailAddresses?: (EmailAddressChanger | null) | undefined, schemas?: FrameworkSchemas | null, provisioner?: (CredentialProvisioner | null) | undefined, dispatcher?: (MessageDispatcher | null) | undefined);
|
|
58
68
|
/**
|
|
59
69
|
* Create a user; returns its id. Delegates to {@link createUserWithCredential} (same behaviour;
|
|
60
70
|
* the detailed credential outcome is dropped to keep this public signature stable).
|
|
@@ -84,10 +94,18 @@ export declare class UserService {
|
|
|
84
94
|
email?: string | undefined;
|
|
85
95
|
companyUuid?: string | undefined;
|
|
86
96
|
}): Promise<CreateUserResult>;
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
/**
|
|
98
|
+
* List users (tenant-scoped by the pipe), with paging/search/sort. Each row gains a top-level
|
|
99
|
+
* `signInStatus` (T10), from ONE batched credential read for the page.
|
|
100
|
+
*/
|
|
101
|
+
listUsers(params?: ListParams): Promise<{
|
|
102
|
+
rows: readonly UserRecord[];
|
|
103
|
+
total: number;
|
|
104
|
+
}>;
|
|
105
|
+
/** Read a single user, with its top-level `signInStatus` (T10). */
|
|
106
|
+
getUser(uuid: string): Promise<UserRecord | null>;
|
|
107
|
+
/** One `statesOf` call for the given rows; no provisioner (hand-built service) ⇒ rows unchanged. */
|
|
108
|
+
private withSignInStatus;
|
|
91
109
|
/** Update a user (whitelisted patch; username/email/status changes need dominance — class doc). */
|
|
92
110
|
updateUser(actor: Principal, uuid: string, patch: UserPatch): Promise<void>;
|
|
93
111
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.service.ts"],"names":[],"mappings":"AACA,OAAO,EAAoB,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"user.service.d.ts","sourceRoot":"","sources":["../../../src/features/user/user.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -53,6 +53,11 @@ async function mappingDuplicates(write) {
|
|
|
53
53
|
throw err;
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
const SIGN_IN_STATUS = {
|
|
57
|
+
locked: 'pending',
|
|
58
|
+
usable: 'active',
|
|
59
|
+
none: 'none',
|
|
60
|
+
};
|
|
56
61
|
const USER_SEARCH_FIELDS = ['username', 'email'];
|
|
57
62
|
const USER_SORT_FIELDS = ['username', 'email', 'status', 'createdAt'];
|
|
58
63
|
/**
|
|
@@ -83,6 +88,7 @@ let UserService = UserService_1 = class UserService {
|
|
|
83
88
|
versionBump;
|
|
84
89
|
emailAddresses;
|
|
85
90
|
provisioner;
|
|
91
|
+
dispatcher;
|
|
86
92
|
logger = new common_1.Logger(UserService_1.name);
|
|
87
93
|
/**
|
|
88
94
|
* The engine's audit sink, used on the email helper's transaction (no pool of its own), on the
|
|
@@ -90,7 +96,7 @@ let UserService = UserService_1 = class UserService {
|
|
|
90
96
|
* service) ⇒ the sink resolves `SHIELD_DB_NAMESPACE` itself.
|
|
91
97
|
*/
|
|
92
98
|
emailAudit;
|
|
93
|
-
constructor(records, ceiling, resetTokens, emailSender, sessions, versionBump, emailAddresses, schemas, provisioner) {
|
|
99
|
+
constructor(records, ceiling, resetTokens, emailSender, sessions, versionBump, emailAddresses, schemas, provisioner, dispatcher) {
|
|
94
100
|
this.records = records;
|
|
95
101
|
this.ceiling = ceiling;
|
|
96
102
|
this.resetTokens = resetTokens;
|
|
@@ -99,6 +105,7 @@ let UserService = UserService_1 = class UserService {
|
|
|
99
105
|
this.versionBump = versionBump;
|
|
100
106
|
this.emailAddresses = emailAddresses;
|
|
101
107
|
this.provisioner = provisioner;
|
|
108
|
+
this.dispatcher = dispatcher;
|
|
102
109
|
this.emailAudit = new engine_1.MariaDbAuditSink(schemas ? { schema: schemas.audit } : {});
|
|
103
110
|
}
|
|
104
111
|
/**
|
|
@@ -156,13 +163,31 @@ let UserService = UserService_1 = class UserService {
|
|
|
156
163
|
reason: email === '' ? 'no_email' : 'no_provisioner',
|
|
157
164
|
};
|
|
158
165
|
}
|
|
159
|
-
/**
|
|
160
|
-
|
|
161
|
-
|
|
166
|
+
/**
|
|
167
|
+
* List users (tenant-scoped by the pipe), with paging/search/sort. Each row gains a top-level
|
|
168
|
+
* `signInStatus` (T10), from ONE batched credential read for the page.
|
|
169
|
+
*/
|
|
170
|
+
async listUsers(params) {
|
|
171
|
+
const page = await this.records.list('user', (0, list_query_js_1.buildListQuery)(params, USER_SEARCH_FIELDS, USER_SORT_FIELDS));
|
|
172
|
+
return { ...page, rows: await this.withSignInStatus(page.rows) };
|
|
173
|
+
}
|
|
174
|
+
/** Read a single user, with its top-level `signInStatus` (T10). */
|
|
175
|
+
async getUser(uuid) {
|
|
176
|
+
const record = await this.records.get('user', uuid);
|
|
177
|
+
if (record === null)
|
|
178
|
+
return null;
|
|
179
|
+
return (await this.withSignInStatus([record]))[0];
|
|
162
180
|
}
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
181
|
+
/** One `statesOf` call for the given rows; no provisioner (hand-built service) ⇒ rows unchanged. */
|
|
182
|
+
async withSignInStatus(rows) {
|
|
183
|
+
const provisioner = this.provisioner;
|
|
184
|
+
if (rows.length === 0 || provisioner == null)
|
|
185
|
+
return [...rows];
|
|
186
|
+
const states = await provisioner.statesOf(rows.map((r) => String(r.id)));
|
|
187
|
+
return rows.map((r) => ({
|
|
188
|
+
...r,
|
|
189
|
+
signInStatus: SIGN_IN_STATUS[states.get(String(r.id)) ?? 'none'],
|
|
190
|
+
}));
|
|
166
191
|
}
|
|
167
192
|
/** Update a user (whitelisted patch; username/email/status changes need dominance — class doc). */
|
|
168
193
|
async updateUser(actor, uuid, patch) {
|
|
@@ -201,7 +226,7 @@ let UserService = UserService_1 = class UserService {
|
|
|
201
226
|
if (statusChanged || wantsInactive)
|
|
202
227
|
await this.applyStatusChange(uuid, String(patch.status));
|
|
203
228
|
if (emailChanged && oldEmail)
|
|
204
|
-
await this.notifyEmailChanged(oldEmail);
|
|
229
|
+
await this.notifyEmailChanged(actor, oldEmail);
|
|
205
230
|
}
|
|
206
231
|
/**
|
|
207
232
|
* Phase 12 (KI #14): write a dominated user's new (canonical) email through guard's
|
|
@@ -292,19 +317,28 @@ let UserService = UserService_1 = class UserService {
|
|
|
292
317
|
await this.records.delete('user', uuid);
|
|
293
318
|
}
|
|
294
319
|
/** Tell the OLD address its account email changed (best-effort; never names the new one). */
|
|
295
|
-
async notifyEmailChanged(oldEmail) {
|
|
296
|
-
if (!this.emailSender)
|
|
320
|
+
async notifyEmailChanged(actor, oldEmail) {
|
|
321
|
+
if (!this.dispatcher && !this.emailSender)
|
|
297
322
|
return;
|
|
298
323
|
const text = 'The email address on your account was changed by an administrator. ' +
|
|
299
324
|
'Password-reset links sent before this change no longer work. ' +
|
|
300
325
|
'If you did not expect this, contact your administrator.';
|
|
326
|
+
const message = {
|
|
327
|
+
to: oldEmail,
|
|
328
|
+
subject: 'Your account email address was changed',
|
|
329
|
+
text,
|
|
330
|
+
html: `<p>${text}</p>`,
|
|
331
|
+
};
|
|
301
332
|
try {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
333
|
+
if (this.dispatcher) {
|
|
334
|
+
// G-010: bounded and audited (`message.sent` / `message.failed`); never throws.
|
|
335
|
+
await this.dispatcher.sendEmail('security_notice', message, {
|
|
336
|
+
actorId: actor.id,
|
|
337
|
+
tenantId: actor.tenantId,
|
|
338
|
+
});
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await this.emailSender.send(message);
|
|
308
342
|
}
|
|
309
343
|
catch {
|
|
310
344
|
// Best-effort: the change is committed; a delivery failure must not fail it.
|
|
@@ -329,6 +363,8 @@ exports.UserService = UserService = UserService_1 = __decorate([
|
|
|
329
363
|
__param(7, (0, common_1.Inject)(framework_schemas_token_js_1.IDENTITY_FRAMEWORK_SCHEMAS)),
|
|
330
364
|
__param(8, (0, common_1.Optional)()),
|
|
331
365
|
__param(8, (0, common_1.Inject)(credential_provisioner_token_js_1.IDENTITY_CREDENTIAL_PROVISIONER)),
|
|
366
|
+
__param(9, (0, common_1.Optional)()),
|
|
367
|
+
__param(9, (0, common_1.Inject)(guard_1.SHIELD_MESSAGE_DISPATCHER)),
|
|
332
368
|
__metadata("design:paramtypes", [engine_1.RecordsService,
|
|
333
|
-
grant_ceiling_js_1.GrantCeiling, Object, Object, Object, Object, Object, Object, Object])
|
|
369
|
+
grant_ceiling_js_1.GrantCeiling, Object, Object, Object, Object, Object, Object, Object, Object])
|
|
334
370
|
], UserService);
|
package/dist/index.d.ts
CHANGED
|
@@ -40,6 +40,13 @@ export { buildIdentityAuthKeys, type IdentityAuthKeys } from './auth-facts-keys.
|
|
|
40
40
|
* `identityObjects`, and mounts the CRUD controllers — no separate engine setup needed.
|
|
41
41
|
*/
|
|
42
42
|
export { IdentityFeatureModule } from './features/identity-feature.module.js';
|
|
43
|
+
/**
|
|
44
|
+
* G-010: the administrator invite (`POST /identity/users/:uuid/invite`, option
|
|
45
|
+
* `forRoot({ invite })`) and the computed `signInStatus` on user reads (types only).
|
|
46
|
+
*/
|
|
47
|
+
export type { InviteOptions } from './features/user/invite.config.js';
|
|
48
|
+
export type { InviteResult } from './features/user/invite.service.js';
|
|
49
|
+
export type { SignInStatus, UserRecord } from './features/user/user.service.js';
|
|
43
50
|
/**
|
|
44
51
|
* The org bootstrap (G-009): `runOrgBootstrap(app, { file, apply })` applies a declarative org file
|
|
45
52
|
* (roots, company tree, roles, users, postings) through the framework's own services, as the declared
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;GAIG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AACxF;0DAC0D;AAC1D,OAAO,EAAE,qBAAqB,EAAE,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEpF;;;;;GAKG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;;GAIG;AACH,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,GAC5B,MAAM,+CAA+C,CAAC;AAEvD;;;GAGG;AACH,OAAO,EACL,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,UAAU,GAChB,MAAM,mCAAmC,CAAC;AAU3C;;;GAGG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AAEjF;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4CAA4C,CAAC;AAEpF;;;;;;;GAOG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,6CAA6C,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAC9C,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AACxC,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;GAIG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAE/C;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AACxF;0DAC0D;AAC1D,OAAO,EAAE,qBAAqB,EAAE,KAAK,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAEpF;;;;;GAKG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,uCAAuC,CAAC;AAE9E;;;GAGG;AACH,YAAY,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAC;AACtE,YAAY,EAAE,YAAY,EAAE,MAAM,mCAAmC,CAAC;AACtE,YAAY,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAEhF;;;;GAIG;AACH,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,KAAK,kBAAkB,EACvB,KAAK,sBAAsB,GAC5B,MAAM,+CAA+C,CAAC;AAEvD;;;GAGG;AACH,OAAO,EACL,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,UAAU,GAChB,MAAM,mCAAmC,CAAC;AAU3C;;;GAGG;AACH,OAAO,EAAE,qBAAqB,EAAE,MAAM,0CAA0C,CAAC;AAEjF;;;;;;GAMG;AACH,OAAO,EAAE,sBAAsB,EAAE,MAAM,4CAA4C,CAAC;AAEpF;;;;;;;GAOG;AACH,OAAO,EAAE,mBAAmB,EAAE,MAAM,6CAA6C,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user.object.d.ts","sourceRoot":"","sources":["../src/user.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,
|
|
1
|
+
{"version":3,"file":"user.object.d.ts","sourceRoot":"","sources":["../src/user.object.ts"],"names":[],"mappings":"AAGA;;;;GAIG;AACH,qBA0Ba,IAAI;IAEf,IAAI,EAAG,MAAM,CAAC;IAGd,QAAQ,EAAG,MAAM,CAAC;IAGlB,KAAK,EAAG,MAAM,CAAC;IAGf,MAAM,EAAG,MAAM,CAAC;IAGhB,SAAS,EAAG,MAAM,CAAC;CACpB"}
|