@happyvertical/smrt-users 0.37.0 → 0.37.2
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/AGENTS.md +2 -1
- package/README.md +63 -0
- package/dist/chunks/{TerminalAuthService-bY1oWeAh.js → TerminalAuthService-DcgimQYo.js} +243 -243
- package/dist/chunks/TerminalAuthService-DcgimQYo.js.map +1 -0
- package/dist/chunks/{index-DBpq-WMK.js → index-C3E55ikp.js} +3 -3
- package/dist/chunks/{index-DBpq-WMK.js.map → index-C3E55ikp.js.map} +1 -1
- package/dist/collections/AccessRequestCollection.d.ts +28 -0
- package/dist/collections/AccessRequestCollection.d.ts.map +1 -0
- package/dist/collections/index.d.ts +1 -0
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +748 -39
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +421 -2
- package/dist/models/AccessRequest.d.ts +131 -0
- package/dist/models/AccessRequest.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/services/AccessRequestService.d.ts +326 -0
- package/dist/services/AccessRequestService.d.ts.map +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +122 -8
- package/dist/sveltekit.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +9 -9
- package/dist/chunks/TerminalAuthService-bY1oWeAh.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +1,198 @@
|
|
|
1
|
-
import { D as DEFAULT_ROLES,
|
|
2
|
-
import {
|
|
1
|
+
import { n as normalizeEmail, D as DEFAULT_ROLES, U as UserCollection, T as TenantCollection, M as MembershipCollection, i as isValidEmail, a as DEFAULT_ROLE_SLUGS, P as PermissionCollection, p as parsePermissionSlug, b as isValidPermissionSlug, c as DEFAULT_TENANT_POLICY } from "./chunks/TerminalAuthService-DcgimQYo.js";
|
|
2
|
+
import { d, e, f, g, h, j, G, k, l, m, o, q, r, s, t, u, O, v, w, x, R, y, S, z, A, B, C, E, F, H, I, J, K, d as d2, e as e2, L, N, Q, V, W, X, Y, Z } from "./chunks/TerminalAuthService-DcgimQYo.js";
|
|
3
3
|
import { field, smrt, SmrtObject, SmrtCollection, foreignKey, ObjectRegistry, findManifestEntryByQualifiedName } from "@happyvertical/smrt-core";
|
|
4
|
+
import { AccessRequestStatus, UserStatus, MembershipStatus } from "@happyvertical/smrt-types";
|
|
5
|
+
import { AccessRequestStatus as AccessRequestStatus2, MembershipStatus as MembershipStatus2, OverrideEffect, SessionStatus, TenantPermissionEffect, TenantStatus, UserStatus as UserStatus2 } from "@happyvertical/smrt-types";
|
|
6
|
+
import { createLogger } from "@happyvertical/logger";
|
|
4
7
|
import { getPackageConfig } from "@happyvertical/smrt-config";
|
|
5
8
|
import { createHash } from "node:crypto";
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
var __defProp$2 = Object.defineProperty;
|
|
10
|
+
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
11
|
+
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
12
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
13
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
14
|
+
if (decorator = decorators[i])
|
|
15
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
16
|
+
if (kind && result) __defProp$2(target, key, result);
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
let AccessRequest = class extends SmrtObject {
|
|
20
|
+
email = "";
|
|
21
|
+
name = null;
|
|
22
|
+
status = AccessRequestStatus.REQUESTED;
|
|
23
|
+
source = "";
|
|
24
|
+
requestContext = "{}";
|
|
25
|
+
note = null;
|
|
26
|
+
requestedAt = /* @__PURE__ */ new Date();
|
|
27
|
+
decidedAt = null;
|
|
28
|
+
decidedBy = null;
|
|
29
|
+
resultingUserId = null;
|
|
30
|
+
tenantHint = null;
|
|
31
|
+
constructor(options = {}) {
|
|
32
|
+
super(options);
|
|
33
|
+
if (options.email !== void 0) this.email = normalizeEmail(options.email);
|
|
34
|
+
if (options.name !== void 0) this.name = options.name;
|
|
35
|
+
if (options.status !== void 0) this.status = options.status;
|
|
36
|
+
if (options.source !== void 0) this.source = options.source;
|
|
37
|
+
if (options.requestContext !== void 0)
|
|
38
|
+
this.requestContext = options.requestContext;
|
|
39
|
+
if (options.note !== void 0) this.note = options.note;
|
|
40
|
+
if (options.requestedAt !== void 0)
|
|
41
|
+
this.requestedAt = options.requestedAt;
|
|
42
|
+
if (options.decidedAt !== void 0) this.decidedAt = options.decidedAt;
|
|
43
|
+
if (options.decidedBy !== void 0) this.decidedBy = options.decidedBy;
|
|
44
|
+
if (options.resultingUserId !== void 0)
|
|
45
|
+
this.resultingUserId = options.resultingUserId;
|
|
46
|
+
if (options.tenantHint !== void 0) this.tenantHint = options.tenantHint;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Parse {@link requestContext} into an object. Returns `{}` on missing or
|
|
50
|
+
* malformed JSON (graceful — never throws).
|
|
51
|
+
*/
|
|
52
|
+
getRequestContext() {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(this.requestContext);
|
|
55
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
56
|
+
} catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Serialize and store {@link requestContext} from an object.
|
|
62
|
+
*/
|
|
63
|
+
setRequestContext(value) {
|
|
64
|
+
this.requestContext = JSON.stringify(value ?? {});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse {@link tenantHint} into an object, or `null` when unset / malformed.
|
|
68
|
+
*/
|
|
69
|
+
getTenantHint() {
|
|
70
|
+
if (!this.tenantHint) return null;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(this.tenantHint);
|
|
73
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Serialize and store {@link tenantHint} from an object (or clear with null).
|
|
80
|
+
*/
|
|
81
|
+
setTenantHint(value) {
|
|
82
|
+
this.tenantHint = value ? JSON.stringify(value) : null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Whether the request is still open (awaiting a decision).
|
|
86
|
+
*/
|
|
87
|
+
isOpen() {
|
|
88
|
+
return this.status === AccessRequestStatus.REQUESTED;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Whether the request has been approved (and not yet graduated).
|
|
92
|
+
*/
|
|
93
|
+
isApproved() {
|
|
94
|
+
return this.status === AccessRequestStatus.APPROVED;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Whether the request reached a terminal state (declined, graduated, or
|
|
98
|
+
* canceled) and can no longer transition.
|
|
99
|
+
*/
|
|
100
|
+
isTerminal() {
|
|
101
|
+
return this.status === AccessRequestStatus.DECLINED || this.status === AccessRequestStatus.GRADUATED || this.status === AccessRequestStatus.CANCELED;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
__decorateClass$2([
|
|
105
|
+
field({ required: true, indexed: true })
|
|
106
|
+
], AccessRequest.prototype, "email", 2);
|
|
107
|
+
__decorateClass$2([
|
|
108
|
+
field({ nullable: true })
|
|
109
|
+
], AccessRequest.prototype, "name", 2);
|
|
110
|
+
__decorateClass$2([
|
|
111
|
+
field({ type: "text", indexed: true })
|
|
112
|
+
], AccessRequest.prototype, "status", 2);
|
|
113
|
+
__decorateClass$2([
|
|
114
|
+
field({ indexed: true })
|
|
115
|
+
], AccessRequest.prototype, "source", 2);
|
|
116
|
+
__decorateClass$2([
|
|
117
|
+
field()
|
|
118
|
+
], AccessRequest.prototype, "requestContext", 2);
|
|
119
|
+
__decorateClass$2([
|
|
120
|
+
field({ nullable: true })
|
|
121
|
+
], AccessRequest.prototype, "note", 2);
|
|
122
|
+
__decorateClass$2([
|
|
123
|
+
field()
|
|
124
|
+
], AccessRequest.prototype, "requestedAt", 2);
|
|
125
|
+
__decorateClass$2([
|
|
126
|
+
field({ nullable: true })
|
|
127
|
+
], AccessRequest.prototype, "decidedAt", 2);
|
|
128
|
+
__decorateClass$2([
|
|
129
|
+
field({ nullable: true })
|
|
130
|
+
], AccessRequest.prototype, "decidedBy", 2);
|
|
131
|
+
__decorateClass$2([
|
|
132
|
+
field({ nullable: true })
|
|
133
|
+
], AccessRequest.prototype, "resultingUserId", 2);
|
|
134
|
+
__decorateClass$2([
|
|
135
|
+
field({ nullable: true })
|
|
136
|
+
], AccessRequest.prototype, "tenantHint", 2);
|
|
137
|
+
AccessRequest = __decorateClass$2([
|
|
138
|
+
smrt({
|
|
139
|
+
tableName: "access_requests",
|
|
140
|
+
// Append-style: the natural key is the surrogate id, so a new request never
|
|
141
|
+
// upserts over an existing row. Without this, SMRT defaults to upserting on
|
|
142
|
+
// `slug`/`context`, and `slug` is derived from `name` — two public submissions
|
|
143
|
+
// sharing a display name (e.g. two "Jane Doe"s with different emails) would
|
|
144
|
+
// collide and overwrite each other. Open-request dedup is handled explicitly
|
|
145
|
+
// by AccessRequestService.createAccessRequest, not by the storage conflict key.
|
|
146
|
+
conflictColumns: ["id"],
|
|
147
|
+
// CLOSED generated surface — all access flows through AccessRequestService.
|
|
148
|
+
api: { include: [] },
|
|
149
|
+
mcp: { include: [] },
|
|
150
|
+
cli: { include: [] }
|
|
151
|
+
})
|
|
152
|
+
], AccessRequest);
|
|
153
|
+
class AccessRequestCollection extends SmrtCollection {
|
|
154
|
+
static _itemClass = AccessRequest;
|
|
155
|
+
/**
|
|
156
|
+
* Find all access requests for an email (any status), newest first. The email
|
|
157
|
+
* is normalized before querying so callers can pass any case.
|
|
158
|
+
*/
|
|
159
|
+
async findByEmail(email) {
|
|
160
|
+
return await this.list({
|
|
161
|
+
where: { email: normalizeEmail(email) },
|
|
162
|
+
orderBy: "created_at DESC"
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Find the single open (`REQUESTED`) request for an email, if any. This is
|
|
167
|
+
* the dedup key used by {@link AccessRequestService.createAccessRequest}.
|
|
168
|
+
*/
|
|
169
|
+
async findOpenByEmail(email) {
|
|
170
|
+
const results = await this.list({
|
|
171
|
+
where: {
|
|
172
|
+
email: normalizeEmail(email),
|
|
173
|
+
status: AccessRequestStatus.REQUESTED
|
|
174
|
+
},
|
|
175
|
+
limit: 1,
|
|
176
|
+
orderBy: "created_at DESC"
|
|
177
|
+
});
|
|
178
|
+
return results.length > 0 ? results[0] : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Find access requests by status, newest first.
|
|
182
|
+
*/
|
|
183
|
+
async findByStatus(status) {
|
|
184
|
+
return await this.list({
|
|
185
|
+
where: { status },
|
|
186
|
+
orderBy: "created_at DESC"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Find all open (`REQUESTED`) access requests — the operator triage queue.
|
|
191
|
+
*/
|
|
192
|
+
async findOpen() {
|
|
193
|
+
return await this.findByStatus(AccessRequestStatus.REQUESTED);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
8
196
|
var __defProp$1 = Object.defineProperty;
|
|
9
197
|
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
10
198
|
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
@@ -253,6 +441,521 @@ class RoleCollection extends SmrtCollection {
|
|
|
253
441
|
return roles;
|
|
254
442
|
}
|
|
255
443
|
}
|
|
444
|
+
const logger = createLogger({ level: "info" });
|
|
445
|
+
const ACCESS_REQUEST_CAPABILITIES = {
|
|
446
|
+
/** Read the access-request queue (`list` / `get`). */
|
|
447
|
+
READ: "access-requests:read",
|
|
448
|
+
/** Decide requests (`approve` / `decline` / `cancel` / `graduate`). */
|
|
449
|
+
MANAGE: "access-requests:manage"
|
|
450
|
+
};
|
|
451
|
+
class AccessRequestError extends Error {
|
|
452
|
+
code;
|
|
453
|
+
constructor(message, code) {
|
|
454
|
+
super(message);
|
|
455
|
+
this.name = "AccessRequestError";
|
|
456
|
+
this.code = code;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const APPROVE_FROM = [AccessRequestStatus.REQUESTED];
|
|
460
|
+
const DECLINE_FROM = [
|
|
461
|
+
AccessRequestStatus.REQUESTED,
|
|
462
|
+
AccessRequestStatus.APPROVED
|
|
463
|
+
];
|
|
464
|
+
const CANCEL_FROM = [
|
|
465
|
+
AccessRequestStatus.REQUESTED,
|
|
466
|
+
AccessRequestStatus.APPROVED
|
|
467
|
+
];
|
|
468
|
+
class AccessRequestService {
|
|
469
|
+
#options;
|
|
470
|
+
#authorize;
|
|
471
|
+
#onEvent;
|
|
472
|
+
#requests;
|
|
473
|
+
#users;
|
|
474
|
+
#tenants;
|
|
475
|
+
#memberships;
|
|
476
|
+
#roles;
|
|
477
|
+
#rolesSeeded = false;
|
|
478
|
+
constructor(options) {
|
|
479
|
+
this.#options = options;
|
|
480
|
+
this.#authorize = options.authorize;
|
|
481
|
+
this.#onEvent = options.onEvent;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Initialize the backing collections (creates/verifies their tables).
|
|
485
|
+
*/
|
|
486
|
+
async initialize() {
|
|
487
|
+
this.#requests = await AccessRequestCollection.create(this.#options);
|
|
488
|
+
this.#users = await UserCollection.create(this.#options);
|
|
489
|
+
this.#tenants = await TenantCollection.create(this.#options);
|
|
490
|
+
this.#memberships = await MembershipCollection.create(this.#options);
|
|
491
|
+
this.#roles = await RoleCollection.create(this.#options);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Static factory — construct and initialize in one call.
|
|
495
|
+
*/
|
|
496
|
+
static async create(options) {
|
|
497
|
+
const service = new AccessRequestService(options);
|
|
498
|
+
await service.initialize();
|
|
499
|
+
return service;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* The underlying collection, for advanced read scenarios. Prefer the service
|
|
503
|
+
* methods, which apply normalization, the state machine, capability gating,
|
|
504
|
+
* and events.
|
|
505
|
+
*/
|
|
506
|
+
get collection() {
|
|
507
|
+
return this.#requests;
|
|
508
|
+
}
|
|
509
|
+
// ============= Public-safe creation =============
|
|
510
|
+
/**
|
|
511
|
+
* Create an access request. **Public-safe**: no capability check — meant to be
|
|
512
|
+
* callable unauthenticated by apps (which add their own rate-limiting).
|
|
513
|
+
*
|
|
514
|
+
* Validates and normalizes the email, then de-duplicates: if an open
|
|
515
|
+
* (`REQUESTED`) request already exists for the email, this merges any newly
|
|
516
|
+
* supplied context/name/source/hint into it and returns it instead of
|
|
517
|
+
* creating a duplicate (no second `created` event).
|
|
518
|
+
*
|
|
519
|
+
* @remarks
|
|
520
|
+
* De-duplication is **best-effort, not atomic**: it is a read-then-write
|
|
521
|
+
* (`findOpenByEmail` → `create`) with no DB-level partial-unique constraint
|
|
522
|
+
* (the table is append-style, keyed on `id`, because the same email may
|
|
523
|
+
* accumulate many requests over its lifetime). Two requests for the same
|
|
524
|
+
* email racing concurrently can therefore both create an open row. This is by
|
|
525
|
+
* design — the spec makes dedup configurable and pushes abuse control to the
|
|
526
|
+
* app (rate-limiting on the public endpoint). Operators triaging two open rows
|
|
527
|
+
* for one email is benign; apps needing a hard single-open-request guarantee
|
|
528
|
+
* should add a partial unique index (`UNIQUE(email) WHERE status='requested'`)
|
|
529
|
+
* in their migration.
|
|
530
|
+
*
|
|
531
|
+
* @throws {@link AccessRequestError} (`INVALID_EMAIL`) when the email is invalid.
|
|
532
|
+
*/
|
|
533
|
+
async createAccessRequest(input) {
|
|
534
|
+
const email = normalizeEmail(input.email);
|
|
535
|
+
if (!isValidEmail(email)) {
|
|
536
|
+
throw new AccessRequestError(
|
|
537
|
+
"A valid email address is required to request access.",
|
|
538
|
+
"INVALID_EMAIL"
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const existing = await this.#requests.findOpenByEmail(email);
|
|
542
|
+
if (existing) {
|
|
543
|
+
let changed = false;
|
|
544
|
+
if (input.context && Object.keys(input.context).length > 0) {
|
|
545
|
+
existing.setRequestContext({
|
|
546
|
+
...existing.getRequestContext(),
|
|
547
|
+
...input.context
|
|
548
|
+
});
|
|
549
|
+
changed = true;
|
|
550
|
+
}
|
|
551
|
+
if (input.tenantHint) {
|
|
552
|
+
existing.setTenantHint({
|
|
553
|
+
...existing.getTenantHint() ?? {},
|
|
554
|
+
...input.tenantHint
|
|
555
|
+
});
|
|
556
|
+
changed = true;
|
|
557
|
+
}
|
|
558
|
+
if (input.name && !existing.name) {
|
|
559
|
+
existing.name = input.name;
|
|
560
|
+
changed = true;
|
|
561
|
+
}
|
|
562
|
+
if (input.source && !existing.source) {
|
|
563
|
+
existing.source = input.source;
|
|
564
|
+
changed = true;
|
|
565
|
+
}
|
|
566
|
+
if (changed) await existing.save();
|
|
567
|
+
return existing;
|
|
568
|
+
}
|
|
569
|
+
const request = await this.#requests.create({
|
|
570
|
+
email,
|
|
571
|
+
name: input.name ?? null,
|
|
572
|
+
source: input.source ?? "",
|
|
573
|
+
status: AccessRequestStatus.REQUESTED,
|
|
574
|
+
requestedAt: /* @__PURE__ */ new Date(),
|
|
575
|
+
requestContext: JSON.stringify(input.context ?? {}),
|
|
576
|
+
tenantHint: input.tenantHint ? JSON.stringify(input.tenantHint) : null,
|
|
577
|
+
note: input.note ?? null
|
|
578
|
+
});
|
|
579
|
+
await this.#emit({
|
|
580
|
+
type: "access-request.created",
|
|
581
|
+
accessRequest: request,
|
|
582
|
+
at: /* @__PURE__ */ new Date()
|
|
583
|
+
});
|
|
584
|
+
return request;
|
|
585
|
+
}
|
|
586
|
+
// ============= Operator reads =============
|
|
587
|
+
/**
|
|
588
|
+
* List access requests (operator-facing). Requires the `access-requests:read`
|
|
589
|
+
* capability when an authorizer is configured.
|
|
590
|
+
*/
|
|
591
|
+
async listAccessRequests(filter = {}) {
|
|
592
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.READ, {
|
|
593
|
+
by: filter.by
|
|
594
|
+
});
|
|
595
|
+
const where = {};
|
|
596
|
+
if (filter.status !== void 0) where.status = filter.status;
|
|
597
|
+
if (filter.email !== void 0) where.email = normalizeEmail(filter.email);
|
|
598
|
+
if (filter.source !== void 0) where.source = filter.source;
|
|
599
|
+
return await this.#requests.list({
|
|
600
|
+
where,
|
|
601
|
+
limit: filter.limit,
|
|
602
|
+
offset: filter.offset,
|
|
603
|
+
orderBy: filter.orderBy ?? "created_at DESC"
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Get a single access request by id (operator-facing). Requires the
|
|
608
|
+
* `access-requests:read` capability when an authorizer is configured.
|
|
609
|
+
*/
|
|
610
|
+
async getAccessRequest(id, options = {}) {
|
|
611
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.READ, {
|
|
612
|
+
by: options.by,
|
|
613
|
+
accessRequestId: id
|
|
614
|
+
});
|
|
615
|
+
return await this.#requests.get(id);
|
|
616
|
+
}
|
|
617
|
+
// ============= Operator decisions =============
|
|
618
|
+
/**
|
|
619
|
+
* Approve a request: `REQUESTED → APPROVED`. Idempotent (re-approving an
|
|
620
|
+
* already-`APPROVED` request is a no-op returning it). Requires
|
|
621
|
+
* `access-requests:manage`.
|
|
622
|
+
*
|
|
623
|
+
* @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
|
|
624
|
+
*/
|
|
625
|
+
async approveAccessRequest(id, options = {}) {
|
|
626
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
|
|
627
|
+
by: options.by,
|
|
628
|
+
accessRequestId: id
|
|
629
|
+
});
|
|
630
|
+
const request = await this.#load(id);
|
|
631
|
+
if (request.status === AccessRequestStatus.APPROVED) return request;
|
|
632
|
+
this.#assertTransition(request, APPROVE_FROM, "approve");
|
|
633
|
+
request.status = AccessRequestStatus.APPROVED;
|
|
634
|
+
request.decidedAt = /* @__PURE__ */ new Date();
|
|
635
|
+
if (options.by) request.decidedBy = options.by;
|
|
636
|
+
if (options.note != null) request.note = options.note;
|
|
637
|
+
await request.save();
|
|
638
|
+
await this.#emit({
|
|
639
|
+
type: "access-request.approved",
|
|
640
|
+
accessRequest: request,
|
|
641
|
+
at: /* @__PURE__ */ new Date(),
|
|
642
|
+
by: options.by
|
|
643
|
+
});
|
|
644
|
+
return request;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Decline a request: `REQUESTED | APPROVED → DECLINED`. Idempotent. Requires
|
|
648
|
+
* `access-requests:manage`.
|
|
649
|
+
*
|
|
650
|
+
* @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
|
|
651
|
+
*/
|
|
652
|
+
async declineAccessRequest(id, options = {}) {
|
|
653
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
|
|
654
|
+
by: options.by,
|
|
655
|
+
accessRequestId: id
|
|
656
|
+
});
|
|
657
|
+
const request = await this.#load(id);
|
|
658
|
+
if (request.status === AccessRequestStatus.DECLINED) return request;
|
|
659
|
+
this.#assertTransition(request, DECLINE_FROM, "decline");
|
|
660
|
+
request.status = AccessRequestStatus.DECLINED;
|
|
661
|
+
request.decidedAt = /* @__PURE__ */ new Date();
|
|
662
|
+
if (options.by) request.decidedBy = options.by;
|
|
663
|
+
if (options.reason != null) request.note = options.reason;
|
|
664
|
+
await request.save();
|
|
665
|
+
await this.#emit({
|
|
666
|
+
type: "access-request.declined",
|
|
667
|
+
accessRequest: request,
|
|
668
|
+
at: /* @__PURE__ */ new Date(),
|
|
669
|
+
by: options.by
|
|
670
|
+
});
|
|
671
|
+
return request;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Cancel a request: `REQUESTED | APPROVED → CANCELED`. Idempotent. Requires
|
|
675
|
+
* `access-requests:manage`.
|
|
676
|
+
*
|
|
677
|
+
* @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
|
|
678
|
+
*/
|
|
679
|
+
async cancelAccessRequest(id, options = {}) {
|
|
680
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
|
|
681
|
+
by: options.by,
|
|
682
|
+
accessRequestId: id
|
|
683
|
+
});
|
|
684
|
+
const request = await this.#load(id);
|
|
685
|
+
if (request.status === AccessRequestStatus.CANCELED) return request;
|
|
686
|
+
this.#assertTransition(request, CANCEL_FROM, "cancel");
|
|
687
|
+
request.status = AccessRequestStatus.CANCELED;
|
|
688
|
+
request.decidedAt = /* @__PURE__ */ new Date();
|
|
689
|
+
if (options.by) request.decidedBy = options.by;
|
|
690
|
+
if (options.reason != null) request.note = options.reason;
|
|
691
|
+
await request.save();
|
|
692
|
+
await this.#emit({
|
|
693
|
+
type: "access-request.canceled",
|
|
694
|
+
accessRequest: request,
|
|
695
|
+
at: /* @__PURE__ */ new Date(),
|
|
696
|
+
by: options.by
|
|
697
|
+
});
|
|
698
|
+
return request;
|
|
699
|
+
}
|
|
700
|
+
// ============= Graduation =============
|
|
701
|
+
/**
|
|
702
|
+
* Graduate an approved request into a `User`, optionally attaching a tenant.
|
|
703
|
+
*
|
|
704
|
+
* Valid from `APPROVED` (or from `REQUESTED` when
|
|
705
|
+
* {@link GraduateAccessRequestOptions.allowFromRequested} is set). Creates a
|
|
706
|
+
* user when none exists for the email, or **links** the existing one
|
|
707
|
+
* otherwise (reusing {@link UserCollection}). Idempotent: a second call on an
|
|
708
|
+
* already-`GRADUATED` request returns the same user (and an existing
|
|
709
|
+
* membership for the requested tenant, if any) without re-creating anything.
|
|
710
|
+
*
|
|
711
|
+
* Requires `access-requests:manage`.
|
|
712
|
+
*
|
|
713
|
+
* @throws {@link AccessRequestError} — `NOT_FOUND` (unknown id),
|
|
714
|
+
* `INVALID_TRANSITION` (terminal/declined/canceled or `REQUESTED` without
|
|
715
|
+
* `allowFromRequested`), `TENANT_NOT_FOUND`, or `ROLE_NOT_FOUND`.
|
|
716
|
+
*/
|
|
717
|
+
async graduateAccessRequest(id, options = {}) {
|
|
718
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
|
|
719
|
+
by: options.by,
|
|
720
|
+
accessRequestId: id
|
|
721
|
+
});
|
|
722
|
+
const request = await this.#load(id);
|
|
723
|
+
const tenantOption = options.tenant ?? "none";
|
|
724
|
+
if (request.status === AccessRequestStatus.GRADUATED && request.resultingUserId) {
|
|
725
|
+
const existingUser = await this.#users.get(request.resultingUserId);
|
|
726
|
+
if (existingUser) {
|
|
727
|
+
const membership2 = await this.#resolveExistingMembership(
|
|
728
|
+
existingUser.id,
|
|
729
|
+
tenantOption
|
|
730
|
+
);
|
|
731
|
+
return {
|
|
732
|
+
user: existingUser,
|
|
733
|
+
membership: membership2 ?? void 0,
|
|
734
|
+
accessRequest: request,
|
|
735
|
+
created: false
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
if (request.status !== AccessRequestStatus.GRADUATED) {
|
|
740
|
+
const allowFromRequested = options.allowFromRequested ?? false;
|
|
741
|
+
const canGraduate = request.status === AccessRequestStatus.APPROVED || request.status === AccessRequestStatus.REQUESTED && allowFromRequested;
|
|
742
|
+
if (!canGraduate) {
|
|
743
|
+
const hint = request.status === AccessRequestStatus.REQUESTED ? " (approve it first, or pass allowFromRequested)" : "";
|
|
744
|
+
throw new AccessRequestError(
|
|
745
|
+
`Cannot graduate an access request in status "${request.status}"${hint}.`,
|
|
746
|
+
"INVALID_TRANSITION"
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (tenantOption !== "none") {
|
|
751
|
+
await this.#ensureRolesSeeded();
|
|
752
|
+
await this.#validateTenantOption(tenantOption);
|
|
753
|
+
}
|
|
754
|
+
const email = normalizeEmail(request.email);
|
|
755
|
+
let user = await this.#users.findByEmail(email);
|
|
756
|
+
let created = false;
|
|
757
|
+
if (!user) {
|
|
758
|
+
user = await this.#users.create({
|
|
759
|
+
email,
|
|
760
|
+
status: options.activate ?? UserStatus.ACTIVE
|
|
761
|
+
});
|
|
762
|
+
created = true;
|
|
763
|
+
} else if (options.activate !== void 0 && user.status !== options.activate) {
|
|
764
|
+
user.status = options.activate;
|
|
765
|
+
await user.save();
|
|
766
|
+
}
|
|
767
|
+
let membership;
|
|
768
|
+
let tenant;
|
|
769
|
+
if (tenantOption !== "none") {
|
|
770
|
+
const attached = await this.#attachTenant(
|
|
771
|
+
user.id,
|
|
772
|
+
tenantOption
|
|
773
|
+
);
|
|
774
|
+
membership = attached.membership;
|
|
775
|
+
tenant = attached.tenant;
|
|
776
|
+
}
|
|
777
|
+
request.status = AccessRequestStatus.GRADUATED;
|
|
778
|
+
request.resultingUserId = user.id;
|
|
779
|
+
request.decidedAt = /* @__PURE__ */ new Date();
|
|
780
|
+
if (options.by) request.decidedBy = options.by;
|
|
781
|
+
if (options.note != null) request.note = options.note;
|
|
782
|
+
await request.save();
|
|
783
|
+
await this.#emit({
|
|
784
|
+
type: "access-request.graduated",
|
|
785
|
+
accessRequest: request,
|
|
786
|
+
at: /* @__PURE__ */ new Date(),
|
|
787
|
+
by: options.by,
|
|
788
|
+
user,
|
|
789
|
+
membership,
|
|
790
|
+
tenant
|
|
791
|
+
});
|
|
792
|
+
return { user, membership, tenant, accessRequest: request, created };
|
|
793
|
+
}
|
|
794
|
+
// ============= Internals =============
|
|
795
|
+
/**
|
|
796
|
+
* Load a request or throw `NOT_FOUND`.
|
|
797
|
+
*/
|
|
798
|
+
async #load(id) {
|
|
799
|
+
const request = await this.#requests.get(id);
|
|
800
|
+
if (!request) {
|
|
801
|
+
throw new AccessRequestError("Access request not found.", "NOT_FOUND");
|
|
802
|
+
}
|
|
803
|
+
return request;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Guard a state transition; throw `INVALID_TRANSITION` if the current status
|
|
807
|
+
* is not an allowed source.
|
|
808
|
+
*/
|
|
809
|
+
#assertTransition(request, allowedFrom, action) {
|
|
810
|
+
if (!allowedFrom.includes(request.status)) {
|
|
811
|
+
throw new AccessRequestError(
|
|
812
|
+
`Cannot ${action} an access request in status "${request.status}".`,
|
|
813
|
+
"INVALID_TRANSITION"
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Run the configured authorizer (if any). Absent an authorizer, operator
|
|
819
|
+
* methods are ungated — see the class-level security note.
|
|
820
|
+
*/
|
|
821
|
+
async #requireCapability(capability, context) {
|
|
822
|
+
if (!this.#authorize) return;
|
|
823
|
+
await this.#authorize({
|
|
824
|
+
capability,
|
|
825
|
+
by: context.by ?? null,
|
|
826
|
+
accessRequestId: context.accessRequestId
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Best-effort event delivery — a throwing handler is logged and swallowed so
|
|
831
|
+
* it cannot roll back an already-persisted transition.
|
|
832
|
+
*/
|
|
833
|
+
async #emit(event) {
|
|
834
|
+
if (!this.#onEvent) return;
|
|
835
|
+
try {
|
|
836
|
+
await this.#onEvent(event);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
logger.error(
|
|
839
|
+
`AccessRequest event handler threw for "${event.type}" (request ${event.accessRequest.id})`,
|
|
840
|
+
{ error }
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Throw `ROLE_NOT_FOUND` if no role with `roleSlug` is resolvable (tenant-
|
|
846
|
+
* specific first, then system). Roles must already be seeded.
|
|
847
|
+
*/
|
|
848
|
+
async #assertRoleExists(roleSlug, tenantId) {
|
|
849
|
+
if (!await this.#roles.findBySlug(roleSlug, tenantId)) {
|
|
850
|
+
throw new AccessRequestError(
|
|
851
|
+
`Role "${roleSlug}" not found — seed system roles or pass a valid role slug.`,
|
|
852
|
+
"ROLE_NOT_FOUND"
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Validate a graduation tenant option with NO side effects: the target tenant
|
|
858
|
+
* must exist (existing-tenant variant) and the role slug must resolve. Run
|
|
859
|
+
* before any persistence so a bad option can't leave orphan rows.
|
|
860
|
+
*/
|
|
861
|
+
async #validateTenantOption(option) {
|
|
862
|
+
if ("tenantId" in option) {
|
|
863
|
+
const tenant = await this.#tenants.get(option.tenantId);
|
|
864
|
+
if (!tenant) {
|
|
865
|
+
throw new AccessRequestError(
|
|
866
|
+
`Target tenant "${option.tenantId}" not found.`,
|
|
867
|
+
"TENANT_NOT_FOUND"
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
await this.#assertRoleExists(
|
|
871
|
+
option.role ?? DEFAULT_ROLE_SLUGS.MEMBER,
|
|
872
|
+
option.tenantId
|
|
873
|
+
);
|
|
874
|
+
} else {
|
|
875
|
+
await this.#assertRoleExists(option.role ?? DEFAULT_ROLE_SLUGS.OWNER);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Create-or-attach the requester to a tenant, reusing the tenant / role /
|
|
880
|
+
* membership collections (no duplication of user/membership logic).
|
|
881
|
+
*/
|
|
882
|
+
async #attachTenant(userId, option) {
|
|
883
|
+
await this.#ensureRolesSeeded();
|
|
884
|
+
if ("tenantId" in option) {
|
|
885
|
+
const tenant2 = await this.#tenants.get(option.tenantId);
|
|
886
|
+
if (!tenant2) {
|
|
887
|
+
throw new AccessRequestError(
|
|
888
|
+
`Target tenant "${option.tenantId}" not found.`,
|
|
889
|
+
"TENANT_NOT_FOUND"
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
const membership2 = await this.#getOrCreateMembership(
|
|
893
|
+
userId,
|
|
894
|
+
tenant2.id,
|
|
895
|
+
option.role ?? DEFAULT_ROLE_SLUGS.MEMBER
|
|
896
|
+
);
|
|
897
|
+
return { tenant: tenant2, membership: membership2 };
|
|
898
|
+
}
|
|
899
|
+
const tenant = await this.#tenants.create({
|
|
900
|
+
name: option.create.name,
|
|
901
|
+
slug: option.create.slug,
|
|
902
|
+
description: option.create.description,
|
|
903
|
+
status: option.create.status
|
|
904
|
+
});
|
|
905
|
+
const membership = await this.#getOrCreateMembership(
|
|
906
|
+
userId,
|
|
907
|
+
tenant.id,
|
|
908
|
+
option.role ?? DEFAULT_ROLE_SLUGS.OWNER
|
|
909
|
+
);
|
|
910
|
+
return { tenant, membership };
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Resolve (creating if needed) the user's membership in a tenant with the
|
|
914
|
+
* given role slug. Idempotent — returns any existing membership for the pair.
|
|
915
|
+
*/
|
|
916
|
+
async #getOrCreateMembership(userId, tenantId, roleSlug) {
|
|
917
|
+
const existing = await this.#memberships.findByUserAndTenant(
|
|
918
|
+
userId,
|
|
919
|
+
tenantId
|
|
920
|
+
);
|
|
921
|
+
if (existing) return existing;
|
|
922
|
+
const role = await this.#roles.findBySlug(roleSlug, tenantId);
|
|
923
|
+
if (!role) {
|
|
924
|
+
throw new AccessRequestError(
|
|
925
|
+
`Role "${roleSlug}" not found — seed system roles or pass a valid role slug.`,
|
|
926
|
+
"ROLE_NOT_FOUND"
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
return await this.#memberships.create({
|
|
930
|
+
userId,
|
|
931
|
+
tenantId,
|
|
932
|
+
roleId: role.id,
|
|
933
|
+
status: MembershipStatus.ACTIVE
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* For idempotent re-graduation: find an existing membership for the requested
|
|
938
|
+
* tenant. Only the existing-tenant variant is resolvable (a `{ create }`
|
|
939
|
+
* variant has no known tenant id on a re-call).
|
|
940
|
+
*/
|
|
941
|
+
async #resolveExistingMembership(userId, tenantOption) {
|
|
942
|
+
if (tenantOption === "none" || !("tenantId" in tenantOption)) return null;
|
|
943
|
+
return await this.#memberships.findByUserAndTenant(
|
|
944
|
+
userId,
|
|
945
|
+
tenantOption.tenantId
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Lazily seed the default system roles (owner/admin/member/viewer) the first
|
|
950
|
+
* time graduation attaches a tenant — so the public create path never incurs
|
|
951
|
+
* the write.
|
|
952
|
+
*/
|
|
953
|
+
async #ensureRolesSeeded() {
|
|
954
|
+
if (this.#rolesSeeded) return;
|
|
955
|
+
await this.#roles.seedSystemRoles();
|
|
956
|
+
this.#rolesSeeded = true;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
256
959
|
class MagicLinkError extends Error {
|
|
257
960
|
constructor(message) {
|
|
258
961
|
super(message);
|
|
@@ -299,7 +1002,7 @@ class MagicLinkService {
|
|
|
299
1002
|
* The caller is responsible for emailing the token to the user.
|
|
300
1003
|
*/
|
|
301
1004
|
async generate(email) {
|
|
302
|
-
const { SignJWT } = await import("./chunks/index-
|
|
1005
|
+
const { SignJWT } = await import("./chunks/index-C3E55ikp.js");
|
|
303
1006
|
const key = await this.getSigningKey();
|
|
304
1007
|
const nonce = crypto.randomUUID();
|
|
305
1008
|
const normalizedEmail = normalizeEmail(email);
|
|
@@ -328,7 +1031,7 @@ class MagicLinkService {
|
|
|
328
1031
|
* @throws {MagicLinkError} If the token is invalid, expired, or already used
|
|
329
1032
|
*/
|
|
330
1033
|
async verify(token) {
|
|
331
|
-
const { jwtVerify, errors } = await import("./chunks/index-
|
|
1034
|
+
const { jwtVerify, errors } = await import("./chunks/index-C3E55ikp.js");
|
|
332
1035
|
const key = await this.getSigningKey();
|
|
333
1036
|
let payload;
|
|
334
1037
|
try {
|
|
@@ -1339,63 +2042,69 @@ class TenantService {
|
|
|
1339
2042
|
}
|
|
1340
2043
|
}
|
|
1341
2044
|
export {
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
2045
|
+
ACCESS_REQUEST_CAPABILITIES,
|
|
2046
|
+
AccessRequest,
|
|
2047
|
+
AccessRequestCollection,
|
|
2048
|
+
AccessRequestError,
|
|
2049
|
+
AccessRequestService,
|
|
2050
|
+
AccessRequestStatus2 as AccessRequestStatus,
|
|
2051
|
+
d as CliAuthRequest,
|
|
2052
|
+
e as CliAuthRequestCollection,
|
|
2053
|
+
f as DEFAULT_CLI_AUTH_POLL_INTERVAL_SECONDS,
|
|
2054
|
+
g as DEFAULT_CLI_AUTH_REQUEST_TTL_SECONDS,
|
|
2055
|
+
h as DEFAULT_CLI_SESSION_TTL_SECONDS,
|
|
1347
2056
|
DEFAULT_ROLES,
|
|
1348
2057
|
DEFAULT_ROLE_SLUGS,
|
|
1349
|
-
|
|
2058
|
+
j as DEFAULT_SESSION_TTL,
|
|
1350
2059
|
DEFAULT_TENANT_POLICY,
|
|
1351
2060
|
DEFAULT_TOKEN_EXPIRY_SECONDS,
|
|
1352
2061
|
G as Group,
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
2062
|
+
k as GroupCollection,
|
|
2063
|
+
l as GroupMember,
|
|
2064
|
+
m as GroupMemberCollection,
|
|
2065
|
+
o as GroupRole,
|
|
2066
|
+
q as GroupRoleCollection,
|
|
2067
|
+
r as MAX_TENANT_HIERARCHY_DEPTH,
|
|
1359
2068
|
MagicLinkError,
|
|
1360
2069
|
MagicLinkService,
|
|
1361
2070
|
UsersMagicLinkToken as MagicLinkToken,
|
|
1362
2071
|
UsersMagicLinkTokenCollection as MagicLinkTokenCollection,
|
|
1363
|
-
|
|
2072
|
+
s as Membership,
|
|
1364
2073
|
MembershipCollection,
|
|
1365
|
-
|
|
1366
|
-
|
|
2074
|
+
t as MembershipOverride,
|
|
2075
|
+
u as MembershipOverrideCollection,
|
|
1367
2076
|
MembershipStatus2 as MembershipStatus,
|
|
1368
2077
|
O as OidcLoginError,
|
|
1369
|
-
|
|
2078
|
+
v as OidcLoginService,
|
|
1370
2079
|
OverrideEffect,
|
|
1371
|
-
|
|
2080
|
+
w as Permission,
|
|
1372
2081
|
PermissionCatalogService,
|
|
1373
2082
|
PermissionCollection,
|
|
1374
|
-
|
|
2083
|
+
x as PermissionResolver,
|
|
1375
2084
|
Role,
|
|
1376
2085
|
RoleCollection,
|
|
1377
2086
|
R as RolePermission,
|
|
1378
|
-
|
|
2087
|
+
y as RolePermissionCollection,
|
|
1379
2088
|
S as Session,
|
|
1380
|
-
|
|
1381
|
-
|
|
2089
|
+
z as SessionCollection,
|
|
2090
|
+
A as SessionService,
|
|
1382
2091
|
SessionStatus,
|
|
1383
|
-
|
|
2092
|
+
B as Tenant,
|
|
1384
2093
|
TenantCollection,
|
|
1385
|
-
|
|
2094
|
+
C as TenantHierarchyError,
|
|
1386
2095
|
TenantPermissionEffect,
|
|
1387
|
-
|
|
1388
|
-
|
|
2096
|
+
E as TenantPermissionOverride,
|
|
2097
|
+
F as TenantPermissionOverrideCollection,
|
|
1389
2098
|
TenantService,
|
|
1390
2099
|
TenantStatus,
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
UserStatus,
|
|
1397
|
-
|
|
1398
|
-
|
|
2100
|
+
H as TerminalAuthError,
|
|
2101
|
+
I as TerminalAuthRateLimitError,
|
|
2102
|
+
J as TerminalAuthService,
|
|
2103
|
+
K as User,
|
|
2104
|
+
UserCollection,
|
|
2105
|
+
UserStatus2 as UserStatus,
|
|
2106
|
+
d2 as UsersCliAuthRequest,
|
|
2107
|
+
e2 as UsersCliAuthRequestCollection,
|
|
1399
2108
|
UsersMagicLinkToken,
|
|
1400
2109
|
UsersMagicLinkTokenCollection,
|
|
1401
2110
|
applyPostgresPermissionPolicies,
|